Files-basierter RADIUS-Server (Clients + Users), managed analog DHCP/WireGuard.
- Migration 0042: radius_settings (singleton, node-lokal), radius_clients (secret_enc), radius_users (password_enc) — Secrets via secrets.Box verschlüsselt.
- internal/freeradius: Multi-File-Renderer (clients.conf + authorize) via Box.Open, Secret-Escaping (" \), Service default-off/an enabled gekoppelt. internal/services/radius + internal/handlers/radius.go: Settings + Client/User-CRUD, write-only Secret-Semantik, Validierung (IP/CIDR, name-charset), GET liefert secret_configured statt Secret.
- Firewall: udp 1812/1813 Auto-Rule bei enabled. Cluster: clients/users repliziert (hashSpec), radius_settings node-lokal.
- main.go + render.go + WithAllReloaders. Packaging: freeradius Dependency, setgid-Dir /etc/edgeguard/freeradius (Gruppe freeradius), Symlinks clients.conf+authorize, disable-on-install, sudoers.
- UI: RADIUS-Seite (Einstellungen + Clients + Benutzer) unter Sicherheit, Route/Nav/i18n de/en.
- Tests (guarded): Renderer-Inhalt + Secret-Escaping/Roundtrip + Masking. Scope v1: PAP/CHAP files-based (kein EAP/802.1X).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.4 KiB
Go
154 lines
4.4 KiB
Go
// Package freeradius renders the FreeRADIUS client + user files from the
|
|
// radius_* tables and manages the freeradius service lifecycle.
|
|
//
|
|
// Two files are rendered (mirrors the multi-file WireGuard renderer):
|
|
// - clients.conf — NAS clients (ipaddr + shared secret)
|
|
// - authorize — users file ("name" Cleartext-Password := "pw")
|
|
// Both managed under /etc/edgeguard/freeradius/ and symlinked from the
|
|
// distro paths by postinst. Shared secrets / passwords are decrypted via
|
|
// secrets.Box at render time. Service runs ONLY when radius_settings.enabled
|
|
// is true on this node (default off).
|
|
package freeradius
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
|
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
|
)
|
|
|
|
const (
|
|
ConfDir = configgen.EtcEdgeguard + "/freeradius"
|
|
ClientsPath = ConfDir + "/clients.conf"
|
|
AuthorizePath = ConfDir + "/authorize"
|
|
serviceName = "freeradius"
|
|
)
|
|
|
|
type Generator struct {
|
|
Pool *pgxpool.Pool
|
|
Repo *radiussvc.Repo
|
|
Box *secrets.Box
|
|
SkipReload bool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
|
return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box}
|
|
}
|
|
|
|
func (g *Generator) Name() string { return "freeradius" }
|
|
|
|
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote).
|
|
func confEscape(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
|
return s
|
|
}
|
|
|
|
// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview).
|
|
func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) {
|
|
clients, err := g.Repo.ListClients(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var b bytes.Buffer
|
|
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
|
|
for _, c := range clients {
|
|
if !c.Active {
|
|
continue
|
|
}
|
|
secret := "***"
|
|
if !mask {
|
|
pt, err := g.Box.Open(c.SecretEnc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("open secret for client %s: %w", c.Name, err)
|
|
}
|
|
secret = string(pt)
|
|
}
|
|
fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n",
|
|
c.Name, c.IPAddr, confEscape(secret), c.Name)
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***.
|
|
func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) {
|
|
users, err := g.Repo.ListUsers(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var b bytes.Buffer
|
|
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
|
|
for _, u := range users {
|
|
if !u.Active {
|
|
continue
|
|
}
|
|
pw := "***"
|
|
if !mask {
|
|
pt, err := g.Box.Open(u.PasswordEnc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("open password for user %s: %w", u.Username, err)
|
|
}
|
|
pw = string(pt)
|
|
}
|
|
fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw))
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview.
|
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
|
clients, err := g.buildClients(ctx, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
authorize, err := g.buildAuthorize(ctx, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil
|
|
}
|
|
|
|
func (g *Generator) Render(ctx context.Context) error {
|
|
settings, err := g.Repo.GetSettings(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("get radius settings: %w", err)
|
|
}
|
|
|
|
if !settings.Enabled {
|
|
if g.SkipReload {
|
|
return nil
|
|
}
|
|
_ = configgen.DisableService(serviceName)
|
|
_ = configgen.StopService(serviceName)
|
|
return nil
|
|
}
|
|
|
|
clients, err := g.buildClients(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
authorize, err := g.buildAuthorize(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil {
|
|
return fmt.Errorf("write clients.conf: %w", err)
|
|
}
|
|
if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil {
|
|
return fmt.Errorf("write authorize: %w", err)
|
|
}
|
|
if g.SkipReload {
|
|
return nil
|
|
}
|
|
if err := configgen.EnableService(serviceName); err != nil {
|
|
return err
|
|
}
|
|
return configgen.RestartService(serviceName)
|
|
}
|