Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
4.6 KiB
Go
158 lines
4.6 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)
|
|
// 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)
|
|
}
|