// 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) // und strippt Steuerzeichen (CR/LF) als Defense-in-Depth gegen Zeilen- // Injection — die Werte werden zwar schon im Handler validiert. func confEscape(s string) string { s = strings.ReplaceAll(s, "\r", "") s = strings.ReplaceAll(s, "\n", "") 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) }