3 Commits

Author SHA1 Message Date
Debian
df31bfa720 fix: Audit-Bugfixes (Auth/WAF/Firewall/Cluster/Renderer) — v1.2.94
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>
2026-06-06 10:55:21 +02:00
Debian
5f92851a96 feat(radius): RADIUS-Server via FreeRADIUS (PAP/CHAP) — v1.2.93
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>
2026-06-05 19:46:42 +02:00
Debian
bac7c7e349 feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony.
- Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations.
- internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK).
- internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists).
- configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables).
- main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable).
- UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'.
- Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:56:30 +02:00
42 changed files with 3291 additions and 99 deletions

View File

@@ -1 +1 @@
1.2.91
1.2.94

View File

@@ -28,6 +28,8 @@ import (
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
@@ -58,7 +60,9 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
)
@@ -445,14 +449,28 @@ func main() {
}
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
// DHCP (Kea) — re-render kea-dhcp4.conf + manage service lifecycle.
keaReloader := func(ctx context.Context) error {
return kearender.New(pool).Render(ctx)
}
handlers.NewDHCPHandler(dhcpsvc.New(pool), auditRepo, nodeID, withFW(keaReloader)).Register(authed)
// RADIUS (FreeRADIUS) — re-render clients.conf + authorize + service lifecycle.
radiusReloader := func(ctx context.Context) error {
return radiusrender.New(pool, secretsBox).Render(ctx)
}
handlers.NewRADIUSHandler(radiussvc.New(pool, secretsBox), auditRepo, nodeID, withFW(radiusReloader)).Register(authed)
// Wire all service reloaders into systemHdl so RenderConfigs
// re-renders every service from DB state in one shot.
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
"nftables": fwReloader,
"wireguard": wgReloader,
"squid": squidReloader,
"unbound": unboundReloader,
"chrony": chronyReloader,
"nftables": fwReloader,
"wireguard": wgReloader,
"squid": squidReloader,
"unbound": unboundReloader,
"chrony": chronyReloader,
"kea": keaReloader,
"freeradius": radiusReloader,
})
// License — node-local key store + DB-mirror of last verify

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/exec"
@@ -79,6 +80,8 @@ var localOnlyTables = []string{
"cluster_settings", // VIP-Interface kann pro Node unterschiedlich sein
"dns_settings", // listen_addresses ist node-spezifisch
"ntp_settings", // listen_addresses ist node-spezifisch
"dhcp_settings", // ob DIESE Node DHCP betreibt (Dual-DHCP vermeiden)
"radius_settings", // ob DIESE Node RADIUS betreibt + Listen-Adressen
"system_settings", // Hostname, Maintenance-Mode etc.
"join_tokens_used", // Token-Tracking nur auf Primary relevant
"audit_log", // Lokales Audit-Protokoll
@@ -357,7 +360,9 @@ END $$;`, egSubName, egSubName, egSubName, egSubName)
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
egSubName, connStr, egPubName,
)
if err := psqlDBExec("edgeguard", createSQL); err != nil {
// Via stdin (nicht -c), damit das Replikations-Passwort nicht in der
// Prozess-Argv (ps/proc) oder in PG-log_statement landet.
if err := psqlDBExecStdin("edgeguard", createSQL); err != nil {
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
return 1
}
@@ -468,7 +473,7 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
},
}
url := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort)
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
@@ -514,7 +519,7 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
},
},
}
url := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort)
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
@@ -566,6 +571,17 @@ func psqlDBExec(db, sql string) error {
return err
}
// psqlDBExecStdin führt SQL über stdin (`-f -`) aus statt `-c`, damit
// Secrets im SQL nicht in der Prozess-Argv / PG-Statement-Logs erscheinen.
func psqlDBExecStdin(db, sql string) error {
cmd := buildPsqlCmd([]string{"-d", db, "-v", "ON_ERROR_STOP=1", "-f", "-"})
cmd.Stdin = strings.NewReader(sql)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
func psqlDBRun(db string, args []string) ([]byte, error) {
baseArgs := []string{"-d", db}

View File

@@ -8,6 +8,8 @@ import (
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
@@ -61,9 +63,17 @@ func cmdRenderConfig(args []string) int {
wg := wireguard.New(pool, secrets.New(""))
ub := unbound.New(pool)
cn := chrony.New(pool)
ke := kea.New(pool)
fr := freeradius.New(pool, secrets.New(""))
if skipReload {
hap.SkipReload = true
fw.SkipReload = true
sq.SkipReload = true
wg.SkipReload = true
ub.SkipReload = true
cn.SkipReload = true
ke.SkipReload = true
fr.SkipReload = true
}
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
@@ -72,7 +82,7 @@ func cmdRenderConfig(args []string) int {
ka = keepalived.New(pool, lc.NodeID)
}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
if ka != nil {
gens = append(gens, ka)
}

View File

@@ -71,6 +71,16 @@ var hashSpec = []hashTable{
{Name: "ntp_pools", MigrationDefault: true},
// DHCP: Subnets + Reservierungen sind geteilte Config (repliziert).
// dhcp_settings ist node-lokal (ob DIESE Node DHCP betreibt) → NICHT hier.
{Name: "dhcp_subnets"},
{Name: "dhcp_reservations"},
// RADIUS: Clients + Users sind geteilte Config (repliziert).
// radius_settings ist node-lokal → NICHT hier.
{Name: "radius_clients"},
{Name: "radius_users"},
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
// repliziert und sind per Design node-spezifisch (jede Node hat eigene

View File

@@ -99,6 +99,33 @@ func RestartService(name string) error {
return nil
}
// StopService runs `sudo -n systemctl stop <name>.service`.
func StopService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "stop", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl stop %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// EnableService runs `sudo -n systemctl enable <name>.service` (boot-persistent).
func EnableService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl enable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// DisableService runs `sudo -n systemctl disable <name>.service`.
func DisableService(name string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", name+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("sudo systemctl disable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// EtcEdgeguard is the on-target config root. Templated path used by
// all renderers — never let renderers hard-code their own.
const EtcEdgeguard = "/etc/edgeguard"

View File

@@ -0,0 +1,62 @@
-- +goose Up
-- +goose StatementBegin
-- DHCP (Kea) — globale Singleton-Settings (node-lokal, wie dns_settings/
-- ntp_settings: ob DIESE Node DHCP betreibt). Subnets/Reservierungen sind
-- geteilte Config und werden repliziert.
CREATE TABLE IF NOT EXISTS dhcp_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
default_lease INTEGER NOT NULL DEFAULT 3600,
max_lease INTEGER NOT NULL DEFAULT 7200,
domain_name TEXT NOT NULL DEFAULT '',
dns_servers TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_settings_singleton CHECK (id = 1)
);
INSERT INTO dhcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Subnets: an ein Interface per NAME gebunden (nicht per node-lokaler FK,
-- damit die Replikation nicht an divergierenden interface_id bricht).
CREATE TABLE IF NOT EXISTS dhcp_subnets (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
interface_name TEXT NOT NULL,
subnet_cidr TEXT NOT NULL,
pool_start TEXT NOT NULL DEFAULT '',
pool_end TEXT NOT NULL DEFAULT '',
gateway TEXT NOT NULL DEFAULT '',
dns_servers TEXT NOT NULL DEFAULT '',
lease_time INTEGER,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_subnets_name_unique UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS dhcp_reservations (
id BIGSERIAL PRIMARY KEY,
subnet_id BIGINT NOT NULL REFERENCES dhcp_subnets(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
mac_address TEXT NOT NULL,
ip_address TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dhcp_reservations_subnet_mac_unique UNIQUE (subnet_id, mac_address)
);
CREATE INDEX IF NOT EXISTS idx_dhcp_reservations_subnet ON dhcp_reservations(subnet_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS dhcp_reservations;
DROP TABLE IF EXISTS dhcp_subnets;
DROP TABLE IF EXISTS dhcp_settings;
-- +goose StatementEnd

View File

@@ -0,0 +1,51 @@
-- +goose Up
-- +goose StatementBegin
-- RADIUS (FreeRADIUS) — node-lokale Singleton-Settings (ob DIESE Node
-- RADIUS betreibt + Listen). Clients/Users sind geteilte Config (repliziert).
CREATE TABLE IF NOT EXISTS radius_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
listen_addresses TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_settings_singleton CHECK (id = 1)
);
INSERT INTO radius_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- NAS-Clients (Geräte, die RADIUS-Requests senden): IP/CIDR + Shared Secret
-- (verschlüsselt via secrets.Box).
CREATE TABLE IF NOT EXISTS radius_clients (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
ipaddr TEXT NOT NULL,
secret_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_clients_name_unique UNIQUE (name)
);
-- Benutzer (PAP/CHAP): Name + Passwort (verschlüsselt; Cleartext nur zur
-- Render-Zeit in die freeradius-lesbare authorize-Datei).
CREATE TABLE IF NOT EXISTS radius_users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
password_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_users_username_unique UNIQUE (username)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS radius_users;
DROP TABLE IF EXISTS radius_clients;
DROP TABLE IF EXISTS radius_settings;
-- +goose StatementEnd

View File

@@ -150,6 +150,8 @@ type AutoFWRule struct {
Proto string
Port int
DstIP string
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
Comment string
}
@@ -417,7 +419,60 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
return out
// DHCP (Kea): wenn auf DIESER Node aktiviert → udp/67 pro aktivem
// Subnet-Interface (gescopt auf die LAN-iface, NICHT global/WAN).
var dhcpEnabled bool
if err := g.Pool.QueryRow(ctx, `SELECT enabled FROM dhcp_settings WHERE id=1`).Scan(&dhcpEnabled); err == nil && dhcpEnabled {
rows, err := g.Pool.Query(ctx, `SELECT DISTINCT interface_name FROM dhcp_subnets WHERE active AND interface_name <> ''`)
if err == nil {
defer rows.Close()
for rows.Next() {
var iface string
if rows.Scan(&iface) == nil && iface != "" {
out = append(out, AutoFWRule{Proto: "udp", Port: 67, Iface: iface, Comment: "DHCP (Kea) auf " + iface})
}
}
}
}
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
var radiusEnabled bool
var radiusListen string
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
ips := splitCSV(radiusListen)
emit := func(ip string) {
out = append(out,
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
)
}
if len(ips) == 0 {
emit("")
} else {
for _, ip := range ips {
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
emit(ip)
}
}
}
}
// Familien-Tag (ip/ip6) für DstIP-basierte Auto-Rules setzen; eine
// IPv6-Listen-Adresse muss `ip6 daddr` ergeben (sonst lehnt nft das
// gesamte Ruleset ab). Unparsebare DstIPs werden verworfen.
tagged := out[:0]
for _, r := range out {
if r.DstIP != "" {
fam := addrFamily(r.DstIP)
if fam == "" {
continue
}
r.L3 = fam
}
tagged = append(tagged, r)
}
return tagged
}
// splitCSV — wie in den Service-renderern.

View File

@@ -0,0 +1,64 @@
package firewall
import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
)
// TestTemplate_autoRuleIface prüft, dass eine Auto-Rule mit Iface als
// `iifname "<x>"`-gescopte Zeile rendert (DHCP udp/67 auf LAN) und dass
// DstIP-basierte Auto-Rules unverändert bleiben.
func TestTemplate_autoRuleIface(t *testing.T) {
view := &View{
AutoRules: []AutoFWRule{
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", L3: "ip", Comment: "DNS"},
{Proto: "udp", Port: 53, DstIP: "2001:db8::1", L3: "ip6", Comment: "DNS v6"},
},
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, view); err != nil {
t.Fatalf("template execute: %v", err)
}
out := buf.String()
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
}
// v4-DstIP-Auto-Rule: ip daddr.
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
t.Errorf("v4 DstIP auto-rule wrong\n----\n%s", out)
}
// Fix #5: v6-DstIP muss `ip6 daddr` ergeben (sonst bricht nft das Ruleset).
if !strings.Contains(out, `ip6 daddr 2001:db8::1 udp dport 53 accept`) {
t.Errorf("v6 DstIP auto-rule must use ip6 daddr\n----\n%s", out)
}
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
nft, err := exec.LookPath("nft")
if err != nil {
t.Skip("nft not in PATH")
}
f, err := os.CreateTemp(t.TempDir(), "autorule-*.nft")
if err != nil {
t.Fatal(err)
}
_, _ = f.WriteString(out)
f.Close()
var cmd *exec.Cmd
if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name())
} else {
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
}
if combined, err := cmd.CombinedOutput(); err != nil {
msg := string(combined)
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
t.Skipf("nft -c needs root: %s", strings.TrimSpace(msg))
}
t.Fatalf("nft -c rejected ruleset: %v\n%s\n----\n%s", err, combined, out)
}
}

View File

@@ -6,6 +6,7 @@ import (
"os/exec"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
@@ -19,8 +20,17 @@ func TestE2E_IPv6Render(t *testing.T) {
t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test")
}
ctx := context.Background()
if err := database.Migrate(ctx, dsn); err != nil {
t.Fatalf("migrate: %v", err)
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {

View File

@@ -61,7 +61,7 @@ table inet edgeguard {
# editiert diese nicht. Wenn der Service entfernt/disabled
# wird, ist die Rule beim nächsten Render weg.
{{range .AutoRules}}
{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
{{end}}
# ── Operator-defined rules ──

View File

@@ -0,0 +1,157 @@
// 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)
}

View File

@@ -0,0 +1,83 @@
package freeradius
import (
"context"
"os"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func TestRender_ClientsAndUsers(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the freeradius renderer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, q := range []string{`DELETE FROM radius_clients`, `DELETE FROM radius_users`} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatalf("clean: %v", err)
}
}
box := secrets.New(t.TempDir() + "/master_key")
repo := radiussvc.New(pool, box)
if _, err := repo.CreateClient(ctx, "testnas", "10.0.0.0/24", `s3c"ret\x`, true, "lab"); err != nil {
t.Fatalf("create client: %v", err)
}
if _, err := repo.CreateUser(ctx, "alice", "alicepw", true); err != nil {
t.Fatalf("create user: %v", err)
}
g := New(pool, box)
clients, err := g.buildClients(ctx, false)
if err != nil {
t.Fatalf("buildClients: %v", err)
}
for _, want := range []string{
"client testnas {",
"ipaddr = 10.0.0.0/24",
`secret = "s3c\"ret\\x"`, // " und \ escaped → Secret-Roundtrip + Escaping
"shortname = testnas",
} {
if !strings.Contains(clients, want) {
t.Errorf("clients.conf missing %q\n----\n%s", want, clients)
}
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
t.Fatalf("buildAuthorize: %v", err)
}
if !strings.Contains(authorize, `"alice" Cleartext-Password := "alicepw"`) {
t.Errorf("authorize missing alice entry\n----\n%s", authorize)
}
// Maskierte Preview enthält keine echten Secrets.
masked, _ := g.buildClients(ctx, true)
if strings.Contains(masked, "s3c") {
t.Errorf("masked preview leaked secret:\n%s", masked)
}
}

View File

@@ -110,6 +110,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
actor, role := "", "admin"
remote := c.ClientIP()
var totpEnabled bool
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
// 1. Try DB users table first.
if h.Users != nil {
@@ -134,6 +135,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
actor = ai.Email
role = ai.Role
totpEnabled = ai.TOTPEnabled
viaDB = true
h.Users.RecordLogin(c.Request.Context(), ai.ID)
}
}
@@ -168,6 +170,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
if actor != "" && !viaDB && h.Users != nil {
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
role = ai.Role
totpEnabled = ai.TOTPEnabled
}
}
// TOTP gate: password OK but 2FA required → issue a short-lived pending
// cookie and tell the UI to show the TOTP input.
if totpEnabled {

View File

@@ -81,7 +81,14 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
return
}
if h.nodeHasPublication(ctx) {
isPrimary, err := h.nodeHasPublication(ctx)
if err != nil {
// Primary/Subscriber-Status nicht ermittelbar → NICHT raten
// (sonst Resync auf dem falschen Node). Abbrechen.
response.Internal(c, fmt.Errorf("primary-status nicht ermittelbar: %w", err))
return
}
if isPrimary {
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
if h.Aggregator == nil {
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
@@ -162,7 +169,12 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er
}
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
if h.nodeHasPublication(ctx) {
// Bei Statusfehler fail-closed (NICHT resyncen).
isPrimary, err := h.nodeHasPublication(ctx)
if err != nil {
return fmt.Errorf("publication-status nicht ermittelbar: %w", err)
}
if isPrimary {
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
}
if st := repairUnitState(); st == "activating" || st == "active" {
@@ -201,9 +213,9 @@ rm -f %[2]s
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
// DB-User lesbar (anders als pg_subscription).
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) (bool, error) {
if h.Store == nil || h.Store.Pool == nil {
return false
return false, errors.New("no db pool")
}
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
@@ -211,9 +223,9 @@ func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
if err := h.Store.Pool.QueryRow(cctx,
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
).Scan(&exists); err != nil {
return false
return false, err
}
return exists
return exists, nil
}
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
@@ -231,7 +243,9 @@ type repairStatusResponse struct {
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
ctx := c.Request.Context()
if h.Store != nil && h.nodeHasPublication(ctx) && h.Aggregator != nil {
// Status-Poll: bei Fehler kein 500 — einfach lokalen Status liefern.
isPrimary, _ := h.nodeHasPublication(ctx)
if h.Store != nil && isPrimary && h.Aggregator != nil {
if all, err := h.Store.List(ctx); err == nil {
if peer := findOtherPeer(all, h.LocalID); peer != nil {
results := h.Aggregator.FanOut(ctx,

346
internal/handlers/dhcp.go Normal file
View File

@@ -0,0 +1,346 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
)
// DHCPHandler exposes /api/v1/dhcp/{settings,subnets,reservations} for
// the Kea DHCPv4 server.
type DHCPHandler struct {
Repo *dhcpsvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewDHCPHandler(repo *dhcpsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DHCPHandler {
return &DHCPHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *DHCPHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("kea: reload after mutation failed", "op", op, "error", err)
}
}
func (h *DHCPHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/dhcp")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
s := g.Group("/subnets")
s.GET("", h.ListSubnets)
s.POST("", h.CreateSubnet)
s.GET("/:id", h.GetSubnet)
s.PUT("/:id", h.UpdateSubnet)
s.DELETE("/:id", h.DeleteSubnet)
s.GET("/:id/reservations", h.ListReservationsForSubnet)
s.POST("/:id/reservations", h.CreateReservation)
r := g.Group("/reservations")
r.GET("", h.ListAllReservations)
r.GET("/:id", h.GetReservation)
r.PUT("/:id", h.UpdateReservation)
r.DELETE("/:id", h.DeleteReservation)
}
// ── Settings ─────────────────────────────────────────────────────────
func (h *DHCPHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *DHCPHandler) UpdateSettings(c *gin.Context) {
var req models.DHCPSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.DefaultLease <= 0 {
req.DefaultLease = 3600
}
if req.MaxLease < req.DefaultLease {
req.MaxLease = req.DefaultLease
}
if err := validateIPList(req.DNSServers); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Subnets ──────────────────────────────────────────────────────────
func (h *DHCPHandler) ListSubnets(c *gin.Context) {
out, err := h.Repo.ListSubnets(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"subnets": out})
}
func (h *DHCPHandler) GetSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
s, err := h.Repo.GetSubnet(c.Request.Context(), id)
if err != nil {
h.subnetErr(c, err)
return
}
response.OK(c, s)
}
func (h *DHCPHandler) CreateSubnet(c *gin.Context) {
var req models.DHCPSubnet
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.validateSubnet(c, &req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateSubnet(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "subnet.create")
}
func (h *DHCPHandler) UpdateSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DHCPSubnet
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.validateSubnet(c, &req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSubnet(c.Request.Context(), id, req)
if err != nil {
h.subnetErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "subnet.update")
}
func (h *DHCPHandler) DeleteSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteSubnet(c.Request.Context(), id); err != nil {
h.subnetErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "subnet.delete")
}
// ── Reservations ─────────────────────────────────────────────────────
func (h *DHCPHandler) ListAllReservations(c *gin.Context) {
out, err := h.Repo.ListAllReservations(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"reservations": out})
}
func (h *DHCPHandler) ListReservationsForSubnet(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
out, err := h.Repo.ListReservationsForSubnet(c.Request.Context(), id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"reservations": out})
}
func (h *DHCPHandler) GetReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
v, err := h.Repo.GetReservation(c.Request.Context(), id)
if err != nil {
h.resvErr(c, err)
return
}
response.OK(c, v)
}
func (h *DHCPHandler) CreateReservation(c *gin.Context) {
subnetID, ok := parseID(c)
if !ok {
return
}
var req models.DHCPReservation
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
req.SubnetID = subnetID
if err := validateReservation(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateReservation(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.create", out.MACAddress, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "reservation.create")
}
func (h *DHCPHandler) UpdateReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DHCPReservation
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateReservation(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateReservation(c.Request.Context(), id, req)
if err != nil {
h.resvErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.update", out.MACAddress, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "reservation.update")
}
func (h *DHCPHandler) DeleteReservation(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteReservation(c.Request.Context(), id); err != nil {
h.resvErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "reservation.delete")
}
// ── Validation + error mapping ───────────────────────────────────────
func (h *DHCPHandler) validateSubnet(c *gin.Context, s *models.DHCPSubnet) error {
s.Name = strings.TrimSpace(s.Name)
s.InterfaceName = strings.TrimSpace(s.InterfaceName)
if s.Name == "" {
return errors.New("name ist erforderlich")
}
if s.InterfaceName == "" {
return errors.New("interface_name ist erforderlich")
}
if exists, err := h.Repo.InterfaceExists(c.Request.Context(), s.InterfaceName); err == nil && !exists {
return errors.New("interface_name existiert nicht: " + s.InterfaceName)
}
if _, _, err := net.ParseCIDR(s.SubnetCIDR); err != nil {
return errors.New("subnet_cidr ist kein gültiges CIDR: " + s.SubnetCIDR)
}
if (s.PoolStart == "") != (s.PoolEnd == "") {
return errors.New("pool_start und pool_end müssen beide gesetzt sein (oder beide leer)")
}
for _, ip := range []string{s.PoolStart, s.PoolEnd, s.Gateway} {
if ip != "" && net.ParseIP(ip) == nil {
return errors.New("ungültige IP-Adresse: " + ip)
}
}
return validateIPList(s.DNSServers)
}
func validateReservation(r *models.DHCPReservation) error {
r.MACAddress = strings.TrimSpace(strings.ToLower(r.MACAddress))
r.IPAddress = strings.TrimSpace(r.IPAddress)
if _, err := net.ParseMAC(r.MACAddress); err != nil {
return errors.New("mac_address ist ungültig: " + r.MACAddress)
}
if net.ParseIP(r.IPAddress) == nil {
return errors.New("ip_address ist ungültig: " + r.IPAddress)
}
return nil
}
// validateIPList prüft eine optionale Komma-Liste von IPs.
func validateIPList(csv string) error {
for _, p := range strings.Split(csv, ",") {
p = strings.TrimSpace(p)
if p != "" && net.ParseIP(p) == nil {
return errors.New("ungültige IP in dns_servers: " + p)
}
}
return nil
}
func (h *DHCPHandler) subnetErr(c *gin.Context, err error) {
if errors.Is(err, dhcpsvc.ErrSubnetNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *DHCPHandler) resvErr(c *gin.Context, err error) {
if errors.Is(err, dhcpsvc.ErrReservationNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

359
internal/handlers/radius.go Normal file
View File

@@ -0,0 +1,359 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
)
// RADIUSHandler exposes /api/v1/radius/{settings,clients,users} for the
// FreeRADIUS server (files-based PAP/CHAP).
type RADIUSHandler struct {
Repo *radiussvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewRADIUSHandler(repo *radiussvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *RADIUSHandler {
return &RADIUSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *RADIUSHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("freeradius: reload after mutation failed", "op", op, "error", err)
}
}
func (h *RADIUSHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/radius")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
c := g.Group("/clients")
c.GET("", h.ListClients)
c.POST("", h.CreateClient)
c.GET("/:id", h.GetClient)
c.PUT("/:id", h.UpdateClient)
c.DELETE("/:id", h.DeleteClient)
u := g.Group("/users")
u.GET("", h.ListUsers)
u.POST("", h.CreateUser)
u.GET("/:id", h.GetUser)
u.PUT("/:id", h.UpdateUser)
u.DELETE("/:id", h.DeleteUser)
}
var validClientName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
// ── Settings ─────────────────────────────────────────────────────────
func (h *RADIUSHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *RADIUSHandler) UpdateSettings(c *gin.Context) {
var req models.RADIUSSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateIPList(req.ListenAddresses); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Clients ──────────────────────────────────────────────────────────
type clientView struct {
models.RADIUSClient
SecretConfigured bool `json:"secret_configured"`
}
func clientToView(c models.RADIUSClient) clientView {
return clientView{RADIUSClient: c, SecretConfigured: len(c.SecretEnc) > 0}
}
func (h *RADIUSHandler) ListClients(c *gin.Context) {
list, err := h.Repo.ListClients(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]clientView, 0, len(list))
for _, cl := range list {
out = append(out, clientToView(cl))
}
response.OK(c, gin.H{"clients": out})
}
func (h *RADIUSHandler) GetClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cl, err := h.Repo.GetClient(c.Request.Context(), id)
if err != nil {
h.clientErr(c, err)
return
}
response.OK(c, clientToView(*cl))
}
type clientBody struct {
Name string `json:"name"`
IPAddr string `json:"ipaddr"`
Secret *string `json:"secret"` // create: required; update: nil=unchanged
Active bool `json:"active"`
Description string `json:"description"`
}
func (b *clientBody) validate(creating bool) error {
b.Name = strings.TrimSpace(b.Name)
b.IPAddr = strings.TrimSpace(b.IPAddr)
if !validClientName.MatchString(b.Name) {
return errors.New("name darf nur Buchstaben/Ziffern/._- enthalten")
}
if net.ParseIP(b.IPAddr) == nil {
if _, _, err := net.ParseCIDR(b.IPAddr); err != nil {
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
}
}
if creating && b.Secret == nil {
return errors.New("secret ist erforderlich")
}
if b.Secret != nil {
if len(*b.Secret) < 6 {
return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)")
}
if strings.ContainsAny(*b.Secret, "\r\n") {
return errors.New("secret darf keine Zeilenumbrüche enthalten")
}
}
return nil
}
func (h *RADIUSHandler) CreateClient(c *gin.Context) {
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateClient(c.Request.Context(), b.Name, b.IPAddr, *b.Secret, b.Active, b.Description)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.create", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.Created(c, clientToView(*out))
h.reload(c.Request.Context(), "client.create")
}
func (h *RADIUSHandler) UpdateClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateClient(c.Request.Context(), id, b.Name, b.IPAddr, b.Secret, b.Active, b.Description)
if err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.update", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.OK(c, clientToView(*out))
h.reload(c.Request.Context(), "client.update")
}
func (h *RADIUSHandler) DeleteClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteClient(c.Request.Context(), id); err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "client.delete")
}
// ── Users ────────────────────────────────────────────────────────────
type userView struct {
models.RADIUSUser
PasswordConfigured bool `json:"password_configured"`
}
func userToView(u models.RADIUSUser) userView {
return userView{RADIUSUser: u, PasswordConfigured: len(u.PasswordEnc) > 0}
}
func (h *RADIUSHandler) ListUsers(c *gin.Context) {
list, err := h.Repo.ListUsers(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]userView, 0, len(list))
for _, u := range list {
out = append(out, userToView(u))
}
response.OK(c, gin.H{"users": out})
}
func (h *RADIUSHandler) GetUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
u, err := h.Repo.GetUser(c.Request.Context(), id)
if err != nil {
h.userErr(c, err)
return
}
response.OK(c, userToView(*u))
}
type userBody struct {
Username string `json:"username"`
Password *string `json:"password"`
Active bool `json:"active"`
}
func (b *userBody) validate(creating bool) error {
b.Username = strings.TrimSpace(b.Username)
if b.Username == "" || strings.ContainsAny(b.Username, "\r\n") {
return errors.New("username ist erforderlich (ohne Zeilenumbrüche)")
}
if creating && (b.Password == nil || *b.Password == "") {
return errors.New("password ist erforderlich")
}
if b.Password != nil {
if *b.Password == "" {
return errors.New("password darf nicht leer sein (löscht es nicht)")
}
if strings.ContainsAny(*b.Password, "\r\n") {
return errors.New("password darf keine Zeilenumbrüche enthalten")
}
}
return nil
}
func (h *RADIUSHandler) CreateUser(c *gin.Context) {
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateUser(c.Request.Context(), b.Username, *b.Password, b.Active)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.create", out.Username, nil, h.NodeID)
response.Created(c, userToView(*out))
h.reload(c.Request.Context(), "user.create")
}
func (h *RADIUSHandler) UpdateUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateUser(c.Request.Context(), id, b.Username, b.Password, b.Active)
if err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.update", out.Username, nil, h.NodeID)
response.OK(c, userToView(*out))
h.reload(c.Request.Context(), "user.update")
}
func (h *RADIUSHandler) DeleteUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteUser(c.Request.Context(), id); err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "user.delete")
}
// ── error mapping ────────────────────────────────────────────────────
func (h *RADIUSHandler) clientErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrClientNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *RADIUSHandler) userErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrUserNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

View File

@@ -4,8 +4,11 @@ import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -15,6 +18,10 @@ import (
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
)
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
// "942100-942999") als Exclusion — verhindert SecLang-Direktiven-Injection.
var wafRuleIDRe = regexp.MustCompile(`^[0-9]{1,9}(-[0-9]{1,9})?$`)
// WafHandler exposes the per-domain WAF configuration REST API:
//
// GET /waf/configs — list all configs (one per domain)
@@ -110,6 +117,27 @@ func (h *WafHandler) Upsert(c *gin.Context) {
if body.ExclusionNotes == nil {
body.ExclusionNotes = map[string]string{}
}
// Exclusions müssen reine Rule-IDs/Ranges sein (sonst Direktiven-Injection
// in die SecLang-Config via Newline).
for _, ex := range body.RuleExclusions {
if !wafRuleIDRe.MatchString(strings.TrimSpace(ex)) {
response.BadRequest(c, errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): "+ex))
return
}
}
// Trusted-Proxies müssen gültige IPs/CIDRs sein.
for _, p := range body.TrustedProxies {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if net.ParseIP(p) == nil {
if _, _, err := net.ParseCIDR(p); err != nil {
response.BadRequest(c, errors.New("ungültiger Trusted-Proxy (IP/CIDR): "+p))
return
}
}
}
cfg := models.WafConfig{
DomainID: domainID,
Enabled: body.Enabled,

235
internal/kea/kea.go Normal file
View File

@@ -0,0 +1,235 @@
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
// tables and manages the kea-dhcp4-server service lifecycle.
//
// The config is built as a Go struct and json-marshalled (NOT a text
// template) so the output is always syntactically valid JSON. Managed
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
// symlinks /etc/kea/kea-dhcp4.conf to it.
//
// Safety: the service runs ONLY when dhcp_settings.enabled is true on
// THIS node (a DHCP server is network-sensitive; default off). enabled
// → enable + restart; disabled → disable + stop.
package kea
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
)
const (
ConfPath = configgen.EtcEdgeguard + "/kea/kea-dhcp4.conf"
serviceName = "kea-dhcp4-server"
leaseFile = "/var/lib/kea/kea-leases4.csv"
keaBinary = "/usr/sbin/kea-dhcp4"
)
type Generator struct {
Pool *pgxpool.Pool
Repo *dhcpsvc.Repo
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator {
return &Generator{Pool: pool, Repo: dhcpsvc.New(pool)}
}
func (g *Generator) Name() string { return "kea" }
// ── Kea config JSON shape ────────────────────────────────────────────
type keaConfig struct {
Dhcp4 dhcp4 `json:"Dhcp4"`
}
type dhcp4 struct {
InterfacesConfig ifcfg `json:"interfaces-config"`
LeaseDatabase leaseDB `json:"lease-database"`
ValidLifetime int `json:"valid-lifetime"`
MaxValidLifetime int `json:"max-valid-lifetime"`
OptionData []optionData `json:"option-data,omitempty"`
Subnet4 []subnet4 `json:"subnet4"`
Loggers []logger `json:"loggers"`
}
type ifcfg struct {
Interfaces []string `json:"interfaces"`
}
type leaseDB struct {
Type string `json:"type"`
Persist bool `json:"persist"`
Name string `json:"name"`
}
type optionData struct {
Name string `json:"name"`
Data string `json:"data"`
}
type subnet4 struct {
ID int64 `json:"id"`
Subnet string `json:"subnet"`
Pools []pool `json:"pools,omitempty"`
OptionData []optionData `json:"option-data,omitempty"`
Reservations []reservation `json:"reservations,omitempty"`
}
type pool struct {
Pool string `json:"pool"`
}
type reservation struct {
HWAddress string `json:"hw-address"`
IPAddress string `json:"ip-address"`
Hostname string `json:"hostname,omitempty"`
}
type logger struct {
Name string `json:"name"`
Severity string `json:"severity"`
OutputOptions []outOpt `json:"output_options"`
}
type outOpt struct {
Output string `json:"output"`
}
// buildConfig assembliert die Kea-Config aus dem DB-State.
func (g *Generator) buildConfig(ctx context.Context) (*keaConfig, *bool, error) {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return nil, nil, fmt.Errorf("get dhcp settings: %w", err)
}
subnets, err := g.Repo.ListSubnets(ctx)
if err != nil {
return nil, nil, fmt.Errorf("list subnets: %w", err)
}
resv, err := g.Repo.ListAllReservations(ctx)
if err != nil {
return nil, nil, fmt.Errorf("list reservations: %w", err)
}
bySubnet := map[int64][]reservation{}
for _, r := range resv {
if !r.Active {
continue
}
bySubnet[r.SubnetID] = append(bySubnet[r.SubnetID], reservation{
HWAddress: r.MACAddress, IPAddress: r.IPAddress, Hostname: r.Hostname,
})
}
ifaceSet := map[string]bool{}
ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab)
var sn4 []subnet4
for _, s := range subnets {
if !s.Active {
continue
}
if !ifaceSet[s.InterfaceName] {
ifaceSet[s.InterfaceName] = true
ifaces = append(ifaces, s.InterfaceName)
}
sub := subnet4{ID: s.ID, Subnet: s.SubnetCIDR}
if s.PoolStart != "" && s.PoolEnd != "" {
sub.Pools = []pool{{Pool: s.PoolStart + " - " + s.PoolEnd}}
}
if s.Gateway != "" {
sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: s.Gateway})
}
dns := s.DNSServers
if dns == "" {
dns = settings.DNSServers
}
if dns != "" {
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(dns)})
}
sub.Reservations = bySubnet[s.ID]
sn4 = append(sn4, sub)
}
d := dhcp4{
InterfacesConfig: ifcfg{Interfaces: ifaces},
LeaseDatabase: leaseDB{Type: "memfile", Persist: true, Name: leaseFile},
ValidLifetime: settings.DefaultLease,
MaxValidLifetime: settings.MaxLease,
Subnet4: sn4,
Loggers: []logger{{
Name: "kea-dhcp4", Severity: "INFO",
OutputOptions: []outOpt{{Output: "stdout"}},
}},
}
if settings.DNSServers != "" {
d.OptionData = append(d.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(settings.DNSServers)})
}
if settings.DomainName != "" {
d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: settings.DomainName})
}
if d.Subnet4 == nil {
d.Subnet4 = []subnet4{}
}
return &keaConfig{Dhcp4: d}, &settings.Enabled, nil
}
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
cfg, _, err := g.buildConfig(ctx)
if err != nil {
return "", err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", err
}
return string(b) + "\n", nil
}
func (g *Generator) Render(ctx context.Context) error {
cfg, enabled, err := g.buildConfig(ctx)
if err != nil {
return err
}
// Default-off / disabled: Service stoppen + disablen, nichts weiter.
if enabled == nil || !*enabled {
if g.SkipReload {
return nil
}
_ = configgen.DisableService(serviceName)
_ = configgen.StopService(serviceName)
return nil
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := configgen.AtomicWrite(ConfPath, append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("write kea config: %w", err)
}
if g.SkipReload {
return nil
}
// Best-effort Config-Test (verhindert Restart mit kaputter Semantik).
if _, statErr := os.Stat(keaBinary); statErr == nil {
if out, terr := exec.Command(keaBinary, "-t", ConfPath).CombinedOutput(); terr != nil {
return fmt.Errorf("kea-dhcp4 -t rejected config: %w (output: %s)", terr, strings.TrimSpace(string(out)))
}
}
if err := configgen.EnableService(serviceName); err != nil {
return err
}
return configgen.RestartService(serviceName)
}
// normalizeCSV trimmt Whitespace um Komma-getrennte Werte (Kea will
// "a,b,c" ohne Leerzeichen-Toleranz-Probleme).
func normalizeCSV(s string) string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return strings.Join(out, ",")
}

89
internal/kea/kea_test.go Normal file
View File

@@ -0,0 +1,89 @@
package kea
import (
"context"
"encoding/json"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func TestRender_DHCPConfig(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the kea renderer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, q := range []string{
`DELETE FROM dhcp_reservations`,
`DELETE FROM dhcp_subnets`,
`UPDATE dhcp_settings SET enabled=true, default_lease=3600, max_lease=7200, domain_name='lan', dns_servers='1.1.1.1, 8.8.8.8' WHERE id=1`,
} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatalf("seed (%s): %v", q, err)
}
}
var subID int64
if err := pool.QueryRow(ctx, `
INSERT INTO dhcp_subnets (name, interface_name, subnet_cidr, pool_start, pool_end, gateway, dns_servers, active)
VALUES ('lan','eth1','10.0.0.0/24','10.0.0.100','10.0.0.200','10.0.0.1','',true) RETURNING id`).Scan(&subID); err != nil {
t.Fatalf("seed subnet: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO dhcp_reservations (subnet_id, mac_address, ip_address, hostname, active)
VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil {
t.Fatalf("seed reservation: %v", err)
}
out, err := New(pool).RenderToString(ctx)
if err != nil {
t.Fatalf("render: %v", err)
}
if !json.Valid([]byte(out)) {
t.Fatalf("rendered config is not valid JSON:\n%s", out)
}
for _, want := range []string{
`"10.0.0.0/24"`,
`"10.0.0.100 - 10.0.0.200"`,
`"aa:bb:cc:dd:ee:ff"`,
`"routers"`,
`"eth1"`,
`"hw-address"`,
`"valid-lifetime": 3600`,
} {
if !strings.Contains(out, want) {
t.Errorf("rendered config missing %q\n----\n%s", want, out)
}
}
// Best-effort: echte Kea-Validierung, falls die Binary da ist.
if _, statErr := os.Stat(keaBinary); statErr == nil {
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
_, _ = f.WriteString(out)
f.Close()
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil {
t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
}
}
}

53
internal/models/dhcp.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import "time"
// DHCPSettings ist die node-lokale Singleton-Konfiguration des Kea-DHCPv4-
// Servers (ob diese Node DHCP betreibt + globale Defaults).
type DHCPSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
DefaultLease int `gorm:"column:default_lease" json:"default_lease"`
MaxLease int `gorm:"column:max_lease" json:"max_lease"`
DomainName string `gorm:"column:domain_name" json:"domain_name"`
DNSServers string `gorm:"column:dns_servers" json:"dns_servers"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DHCPSettings) TableName() string { return "dhcp_settings" }
// DHCPSubnet ist ein vom DHCP-Server bedientes Subnetz (an ein Interface
// per NAME gebunden — cluster-sicher, da interface_id node-lokal wäre).
type DHCPSubnet struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
InterfaceName string `gorm:"column:interface_name" json:"interface_name"`
SubnetCIDR string `gorm:"column:subnet_cidr" json:"subnet_cidr"`
PoolStart string `gorm:"column:pool_start" json:"pool_start"`
PoolEnd string `gorm:"column:pool_end" json:"pool_end"`
Gateway string `gorm:"column:gateway" json:"gateway"`
DNSServers string `gorm:"column:dns_servers" json:"dns_servers"`
LeaseTime *int `gorm:"column:lease_time" json:"lease_time,omitempty"`
Active bool `gorm:"column:active" json:"active"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DHCPSubnet) TableName() string { return "dhcp_subnets" }
// DHCPReservation ist eine statische MAC→IP-Zuordnung innerhalb eines Subnets.
type DHCPReservation struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
SubnetID int64 `gorm:"column:subnet_id" json:"subnet_id"`
Name string `gorm:"column:name" json:"name"`
MACAddress string `gorm:"column:mac_address" json:"mac_address"`
IPAddress string `gorm:"column:ip_address" json:"ip_address"`
Hostname string `gorm:"column:hostname" json:"hostname"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DHCPReservation) TableName() string { return "dhcp_reservations" }

43
internal/models/radius.go Normal file
View File

@@ -0,0 +1,43 @@
package models
import "time"
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
type RADIUSSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSSettings) TableName() string { return "radius_settings" }
// RADIUSClient ist ein NAS-Client (IP/CIDR + Shared Secret). SecretEnc
// wird via secrets.Box verschlüsselt und nie serialisiert.
type RADIUSClient struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
IPAddr string `gorm:"column:ipaddr" json:"ipaddr"`
SecretEnc []byte `gorm:"column:secret_enc" json:"-"`
Active bool `gorm:"column:active" json:"active"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSClient) TableName() string { return "radius_clients" }
// RADIUSUser ist ein PAP/CHAP-Benutzer. PasswordEnc wird via secrets.Box
// verschlüsselt und nie serialisiert.
type RADIUSUser struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Username string `gorm:"column:username" json:"username"`
PasswordEnc []byte `gorm:"column:password_enc" json:"-"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSUser) TableName() string { return "radius_users" }

View File

@@ -44,6 +44,7 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
whitelist[n] = true
}
out := make([]Result, 0, len(gens))
var errs []error
for _, g := range gens {
if len(whitelist) > 0 && !whitelist[g.Name()] {
out = append(out, Result{Name: g.Name(), Skipped: true})
@@ -52,11 +53,14 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
err := g.Render(ctx)
out = append(out, Result{Name: g.Name(), Err: err})
if err != nil && !errors.Is(err, configgen.ErrNotImplemented) {
// hard failure — surface it but return what's done so far
return out, fmt.Errorf("%s: %w", g.Name(), err)
// Weitermachen: die Generatoren sind unabhängig und reloaden
// inline (nft/Service-Reload sind atomar). Abbrechen würde die
// restlichen Dienste auf altem Stand lassen → halb angewandt.
// Stattdessen alle versuchen und Fehler gesammelt zurückgeben.
errs = append(errs, fmt.Errorf("%s: %w", g.Name(), err))
}
}
return out, nil
return out, errors.Join(errs...)
}
// Summarise turns the result slice into a human-readable multiline

View File

@@ -0,0 +1,238 @@
// Package dhcp provides CRUD against the dhcp_settings (singleton),
// dhcp_subnets, and dhcp_reservations tables. The Kea config renderer
// in internal/kea consumes these.
package dhcp
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var (
ErrSubnetNotFound = errors.New("dhcp subnet not found")
ErrReservationNotFound = errors.New("dhcp reservation not found")
)
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
// ── Settings (singleton id=1) ────────────────────────────────────────
func (r *Repo) GetSettings(ctx context.Context) (*models.DHCPSettings, error) {
var s models.DHCPSettings
if err := r.Pool.QueryRow(ctx, `
SELECT id, enabled, default_lease, max_lease, domain_name, dns_servers, created_at, updated_at
FROM dhcp_settings WHERE id=1`).Scan(
&s.ID, &s.Enabled, &s.DefaultLease, &s.MaxLease, &s.DomainName, &s.DNSServers,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) UpdateSettings(ctx context.Context, s models.DHCPSettings) (*models.DHCPSettings, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE dhcp_settings
SET enabled=$1, default_lease=$2, max_lease=$3, domain_name=$4, dns_servers=$5, updated_at=NOW()
WHERE id=1
RETURNING id, enabled, default_lease, max_lease, domain_name, dns_servers, created_at, updated_at`,
s.Enabled, s.DefaultLease, s.MaxLease, s.DomainName, s.DNSServers)
var out models.DHCPSettings
if err := row.Scan(&out.ID, &out.Enabled, &out.DefaultLease, &out.MaxLease,
&out.DomainName, &out.DNSServers, &out.CreatedAt, &out.UpdatedAt); err != nil {
return nil, err
}
return &out, nil
}
// ── Subnets ──────────────────────────────────────────────────────────
const subnetCols = `id, name, interface_name, subnet_cidr, pool_start, pool_end,
gateway, dns_servers, lease_time, active, description, created_at, updated_at`
func scanSubnet(row pgx.Row) (*models.DHCPSubnet, error) {
var s models.DHCPSubnet
if err := row.Scan(&s.ID, &s.Name, &s.InterfaceName, &s.SubnetCIDR, &s.PoolStart,
&s.PoolEnd, &s.Gateway, &s.DNSServers, &s.LeaseTime, &s.Active, &s.Description,
&s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) ListSubnets(ctx context.Context) ([]models.DHCPSubnet, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+subnetCols+` FROM dhcp_subnets ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DHCPSubnet, 0, 8)
for rows.Next() {
s, err := scanSubnet(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func (r *Repo) GetSubnet(ctx context.Context, id int64) (*models.DHCPSubnet, error) {
s, err := scanSubnet(r.Pool.QueryRow(ctx, `SELECT `+subnetCols+` FROM dhcp_subnets WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSubnetNotFound
}
return nil, err
}
return s, nil
}
func (r *Repo) CreateSubnet(ctx context.Context, s models.DHCPSubnet) (*models.DHCPSubnet, error) {
return scanSubnet(r.Pool.QueryRow(ctx, `
INSERT INTO dhcp_subnets (name, interface_name, subnet_cidr, pool_start, pool_end,
gateway, dns_servers, lease_time, active, description)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING `+subnetCols,
s.Name, s.InterfaceName, s.SubnetCIDR, s.PoolStart, s.PoolEnd,
s.Gateway, s.DNSServers, s.LeaseTime, s.Active, s.Description))
}
func (r *Repo) UpdateSubnet(ctx context.Context, id int64, s models.DHCPSubnet) (*models.DHCPSubnet, error) {
out, err := scanSubnet(r.Pool.QueryRow(ctx, `
UPDATE dhcp_subnets
SET name=$1, interface_name=$2, subnet_cidr=$3, pool_start=$4, pool_end=$5,
gateway=$6, dns_servers=$7, lease_time=$8, active=$9, description=$10, updated_at=NOW()
WHERE id=$11
RETURNING `+subnetCols,
s.Name, s.InterfaceName, s.SubnetCIDR, s.PoolStart, s.PoolEnd,
s.Gateway, s.DNSServers, s.LeaseTime, s.Active, s.Description, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSubnetNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) DeleteSubnet(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM dhcp_subnets WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrSubnetNotFound
}
return nil
}
// ── Reservations ─────────────────────────────────────────────────────
const resvCols = `id, subnet_id, name, mac_address, ip_address, hostname, active, created_at, updated_at`
func scanResv(row pgx.Row) (*models.DHCPReservation, error) {
var r models.DHCPReservation
if err := row.Scan(&r.ID, &r.SubnetID, &r.Name, &r.MACAddress, &r.IPAddress,
&r.Hostname, &r.Active, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
return &r, nil
}
func (r *Repo) ListAllReservations(ctx context.Context) ([]models.DHCPReservation, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+resvCols+` FROM dhcp_reservations ORDER BY subnet_id, ip_address`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DHCPReservation, 0, 16)
for rows.Next() {
v, err := scanResv(rows)
if err != nil {
return nil, err
}
out = append(out, *v)
}
return out, rows.Err()
}
func (r *Repo) ListReservationsForSubnet(ctx context.Context, subnetID int64) ([]models.DHCPReservation, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+resvCols+` FROM dhcp_reservations WHERE subnet_id=$1 ORDER BY ip_address`, subnetID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.DHCPReservation, 0, 8)
for rows.Next() {
v, err := scanResv(rows)
if err != nil {
return nil, err
}
out = append(out, *v)
}
return out, rows.Err()
}
func (r *Repo) GetReservation(ctx context.Context, id int64) (*models.DHCPReservation, error) {
v, err := scanResv(r.Pool.QueryRow(ctx, `SELECT `+resvCols+` FROM dhcp_reservations WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrReservationNotFound
}
return nil, err
}
return v, nil
}
func (r *Repo) CreateReservation(ctx context.Context, v models.DHCPReservation) (*models.DHCPReservation, error) {
return scanResv(r.Pool.QueryRow(ctx, `
INSERT INTO dhcp_reservations (subnet_id, name, mac_address, ip_address, hostname, active)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING `+resvCols,
v.SubnetID, v.Name, v.MACAddress, v.IPAddress, v.Hostname, v.Active))
}
func (r *Repo) UpdateReservation(ctx context.Context, id int64, v models.DHCPReservation) (*models.DHCPReservation, error) {
out, err := scanResv(r.Pool.QueryRow(ctx, `
UPDATE dhcp_reservations
SET name=$1, mac_address=$2, ip_address=$3, hostname=$4, active=$5, updated_at=NOW()
WHERE id=$6
RETURNING `+resvCols,
v.Name, v.MACAddress, v.IPAddress, v.Hostname, v.Active, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrReservationNotFound
}
return nil, err
}
return out, nil
}
func (r *Repo) DeleteReservation(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM dhcp_reservations WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrReservationNotFound
}
return nil
}
// InterfaceExists prüft, ob ein Interface-Name in network_interfaces existiert
// (für die Validierung beim Anlegen/Ändern von Subnets).
func (r *Repo) InterfaceExists(ctx context.Context, name string) (bool, error) {
var exists bool
err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM network_interfaces WHERE name=$1)`, name).Scan(&exists)
return exists, err
}

View File

@@ -0,0 +1,239 @@
// Package radius provides CRUD against radius_settings (singleton),
// radius_clients and radius_users. Shared secrets / user passwords are
// sealed at rest via secrets.Box. The FreeRADIUS renderer in
// internal/freeradius consumes these.
package radius
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
var (
ErrClientNotFound = errors.New("radius client not found")
ErrUserNotFound = errors.New("radius user not found")
)
type Repo struct {
Pool *pgxpool.Pool
Box *secrets.Box
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo { return &Repo{Pool: pool, Box: box} }
// ── Settings ─────────────────────────────────────────────────────────
func (r *Repo) GetSettings(ctx context.Context) (*models.RADIUSSettings, error) {
var s models.RADIUSSettings
if err := r.Pool.QueryRow(ctx, `
SELECT id, enabled, listen_addresses, created_at, updated_at FROM radius_settings WHERE id=1`).Scan(
&s.ID, &s.Enabled, &s.ListenAddresses, &s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
return &s, nil
}
func (r *Repo) UpdateSettings(ctx context.Context, s models.RADIUSSettings) (*models.RADIUSSettings, error) {
var out models.RADIUSSettings
if err := r.Pool.QueryRow(ctx, `
UPDATE radius_settings SET enabled=$1, listen_addresses=$2, updated_at=NOW() WHERE id=1
RETURNING id, enabled, listen_addresses, created_at, updated_at`,
s.Enabled, s.ListenAddresses).Scan(
&out.ID, &out.Enabled, &out.ListenAddresses, &out.CreatedAt, &out.UpdatedAt); err != nil {
return nil, err
}
return &out, nil
}
// ── Clients ──────────────────────────────────────────────────────────
const clientCols = `id, name, ipaddr, secret_enc, active, description, created_at, updated_at`
func scanClient(row pgx.Row) (*models.RADIUSClient, error) {
var c models.RADIUSClient
if err := row.Scan(&c.ID, &c.Name, &c.IPAddr, &c.SecretEnc, &c.Active, &c.Description,
&c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
return &c, nil
}
func (r *Repo) ListClients(ctx context.Context) ([]models.RADIUSClient, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+clientCols+` FROM radius_clients ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.RADIUSClient, 0, 8)
for rows.Next() {
c, err := scanClient(rows)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
func (r *Repo) GetClient(ctx context.Context, id int64) (*models.RADIUSClient, error) {
c, err := scanClient(r.Pool.QueryRow(ctx, `SELECT `+clientCols+` FROM radius_clients WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrClientNotFound
}
return nil, err
}
return c, nil
}
func (r *Repo) CreateClient(ctx context.Context, name, ipaddr, secret string, active bool, desc string) (*models.RADIUSClient, error) {
enc, err := r.Box.Seal([]byte(secret))
if err != nil {
return nil, err
}
return scanClient(r.Pool.QueryRow(ctx, `
INSERT INTO radius_clients (name, ipaddr, secret_enc, active, description)
VALUES ($1,$2,$3,$4,$5) RETURNING `+clientCols,
name, ipaddr, enc, active, desc))
}
// UpdateClient: secret==nil → unverändert, ""→löschen, sonst neu sealen.
func (r *Repo) UpdateClient(ctx context.Context, id int64, name, ipaddr string, secret *string, active bool, desc string) (*models.RADIUSClient, error) {
if secret == nil {
out, err := scanClient(r.Pool.QueryRow(ctx, `
UPDATE radius_clients SET name=$1, ipaddr=$2, active=$3, description=$4, updated_at=NOW()
WHERE id=$5 RETURNING `+clientCols, name, ipaddr, active, desc, id))
return mapClientErr(out, err)
}
var enc []byte
if *secret != "" {
sealed, err := r.Box.Seal([]byte(*secret))
if err != nil {
return nil, err
}
enc = sealed
}
out, err := scanClient(r.Pool.QueryRow(ctx, `
UPDATE radius_clients SET name=$1, ipaddr=$2, secret_enc=$3, active=$4, description=$5, updated_at=NOW()
WHERE id=$6 RETURNING `+clientCols, name, ipaddr, enc, active, desc, id))
return mapClientErr(out, err)
}
func mapClientErr(c *models.RADIUSClient, err error) (*models.RADIUSClient, error) {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrClientNotFound
}
return nil, err
}
return c, nil
}
func (r *Repo) DeleteClient(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_clients WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrClientNotFound
}
return nil
}
// ── Users ────────────────────────────────────────────────────────────
const userCols = `id, username, password_enc, active, created_at, updated_at`
func scanUser(row pgx.Row) (*models.RADIUSUser, error) {
var u models.RADIUSUser
if err := row.Scan(&u.ID, &u.Username, &u.PasswordEnc, &u.Active, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (r *Repo) ListUsers(ctx context.Context) ([]models.RADIUSUser, error) {
rows, err := r.Pool.Query(ctx, `SELECT `+userCols+` FROM radius_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.RADIUSUser, 0, 8)
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (r *Repo) GetUser(ctx context.Context, id int64) (*models.RADIUSUser, error) {
u, err := scanUser(r.Pool.QueryRow(ctx, `SELECT `+userCols+` FROM radius_users WHERE id=$1`, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return u, nil
}
func (r *Repo) CreateUser(ctx context.Context, username, password string, active bool) (*models.RADIUSUser, error) {
enc, err := r.Box.Seal([]byte(password))
if err != nil {
return nil, err
}
return scanUser(r.Pool.QueryRow(ctx, `
INSERT INTO radius_users (username, password_enc, active) VALUES ($1,$2,$3) RETURNING `+userCols,
username, enc, active))
}
func (r *Repo) UpdateUser(ctx context.Context, id int64, username string, password *string, active bool) (*models.RADIUSUser, error) {
if password == nil {
u, err := scanUser(r.Pool.QueryRow(ctx, `
UPDATE radius_users SET username=$1, active=$2, updated_at=NOW() WHERE id=$3 RETURNING `+userCols,
username, active, id))
return mapUserErr(u, err)
}
var enc []byte
if *password != "" {
sealed, err := r.Box.Seal([]byte(*password))
if err != nil {
return nil, err
}
enc = sealed
}
u, err := scanUser(r.Pool.QueryRow(ctx, `
UPDATE radius_users SET username=$1, password_enc=$2, active=$3, updated_at=NOW() WHERE id=$4 RETURNING `+userCols,
username, enc, active, id))
return mapUserErr(u, err)
}
func mapUserErr(u *models.RADIUSUser, err error) (*models.RADIUSUser, error) {
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return u, nil
}
func (r *Repo) DeleteUser(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_users WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}

View File

@@ -96,14 +96,15 @@ func loadOrCreateSecret(path string) ([]byte, error) {
return secret, nil
}
// IssueWithRole returns a signed token for the given actor + role.
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
// issue builds + signs a token with an explicit TTL. No shared-state
// mutation — safe for concurrent use of the shared Signer singleton.
func (s *Signer) issue(actor, role string, ttl time.Duration) (string, *Token, error) {
now := s.Now()
t := Token{
Actor: actor,
Role: role,
Iat: now.Unix(),
Exp: now.Add(s.TTL).Unix(),
Exp: now.Add(ttl).Unix(),
}
data, err := json.Marshal(t)
if err != nil {
@@ -117,18 +118,20 @@ func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
return encoded, &t, nil
}
// Issue is IssueWithRole with empty role.
func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.IssueWithRole(actor, "")
// IssueWithRole returns a signed token for the given actor + role.
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
return s.issue(actor, role, s.TTL)
}
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
// Issue is IssueWithRole with empty role.
func (s *Signer) Issue(actor string) (string, *Token, error) {
return s.issue(actor, "", s.TTL)
}
// IssueWithRoleTTL issues a token with a custom TTL — no longer mutates
// the shared Signer (previously a data race under concurrent logins).
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
orig := s.TTL
s.TTL = ttl
raw, tok, err := s.IssueWithRole(actor, role)
s.TTL = orig
return raw, tok, err
return s.issue(actor, role, ttl)
}
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.

View File

@@ -0,0 +1,47 @@
package session
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// TestSigner_TTLNotShared beweist Fix #1: IssueWithRoleTTL darf das geteilte
// s.TTL nicht mehr mutieren. Unter `go test -race` schlägt die alte Version
// als Data-Race an; zusätzlich prüfen wir, dass parallele normale Logins nie
// die kurze TOTP-TTL erben.
func TestSigner_TTLNotShared(t *testing.T) {
s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour)
var wg sync.WaitGroup
var bad int32
for i := 0; i < 200; i++ {
wg.Add(2)
go func() {
defer wg.Done()
_, _, _ = s.IssueWithRoleTTL("a", "totp_pending", 2*time.Minute)
}()
go func() {
defer wg.Done()
_, tok, err := s.IssueWithRole("b", "admin")
if err != nil {
atomic.AddInt32(&bad, 1)
return
}
// Normale Session muss ~1h gelten, nie die 2-Min-TOTP-TTL.
if tok.Exp-tok.Iat < int64((30 * time.Minute).Seconds()) {
atomic.AddInt32(&bad, 1)
}
}()
}
wg.Wait()
if bad > 0 {
t.Fatalf("%d normale Tokens bekamen eine zu kurze TTL → geteilter Zustand", bad)
}
// TTL-Override wirkt weiterhin korrekt für den TOTP-Token.
_, ptok, _ := s.IssueWithRoleTTL("x", "totp_pending", 2*time.Minute)
if d := ptok.Exp - ptok.Iat; d > int64((3 * time.Minute).Seconds()) {
t.Fatalf("totp-pending TTL = %ds, want ~120s", d)
}
}

View File

@@ -66,14 +66,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
}
}
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
for _, ip := range cfg.TrustedProxies {
ip = strings.TrimSpace(ip)
if ip != "" {
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
_ = ip // used in custom rules below if needed
}
}
// Trusted proxies are NOT a SecLang directive — they are applied in the
// SPOE agent (spoe.go): when the connection source is a trusted proxy,
// the real client IP is taken from X-Forwarded-For before Coraza sees
// it. (Previously this loop emitted a bogus, unrelated directive.)
// Custom rules (appended last so they can override CRS).
if strings.TrimSpace(cfg.CustomRules) != "" {

View File

@@ -3,6 +3,7 @@ package waf
import (
"fmt"
"log/slog"
"net"
"sync"
"github.com/corazawaf/coraza/v3"
@@ -12,8 +13,9 @@ import (
// DomainEngine bundles a Coraza WAF with its operating mode.
type DomainEngine struct {
WAF coraza.WAF
Mode string // "detection" | "blocking"
WAF coraza.WAF
Mode string // "detection" | "blocking"
TrustedProxies []string // wenn src ∈ diese → echte Client-IP aus X-Forwarded-For
}
// Manager holds per-domain Coraza engine instances. Engines are
@@ -90,7 +92,7 @@ func (m *Manager) Reload(domains []DomainConfig) error {
if err != nil {
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
}
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode, TrustedProxies: dc.Config.TrustedProxies}
slog.Info("waf: engine (re)loaded",
"host", dc.Hostname,
"mode", dc.Config.Mode,
@@ -110,8 +112,9 @@ func (m *Manager) Reload(domains []DomainConfig) error {
// (nil, false) when the domain has no WAF or WAF is disabled.
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
// Strip port if present (e.g. "example.com:443" → "example.com").
if i := lastColon(host); i >= 0 {
host = host[:i]
// SplitHostPort errors for a bare host or bare IPv6 literal → keep as-is.
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
m.mu.RLock()
de, ok := m.engines[host]
@@ -122,36 +125,3 @@ func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
return de, true
}
// lastColon returns the index of the last ':' in s that looks like a
// port separator (after the final ']' for IPv6), or -1.
func lastColon(s string) int {
// IPv6 addresses in brackets: "[::1]:443"
if len(s) > 0 && s[0] == '[' {
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
return rb + 1
}
return -1
}
// Plain host — only strip port if there's exactly one colon.
count := 0
idx := -1
for i, c := range s {
if c == ':' {
count++
idx = i
}
}
if count == 1 {
return idx
}
return -1
}
func lastByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}

View File

@@ -3,6 +3,7 @@ package waf
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
@@ -75,6 +76,15 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
return // WAF not configured or disabled for this domain
}
// Trusted-Proxy-Handling: stammt die Verbindung von einem konfigurierten
// Trusted-Proxy, ist die echte Client-IP das letzte X-Forwarded-For-Glied
// (das der Proxy angehängt hat), nicht die Proxy-IP selbst.
if clientIP != "" && len(de.TrustedProxies) > 0 && ipMatchesAny(clientIP, de.TrustedProxies) {
if real := rightmostXFF(rawHdrs); real != "" {
clientIP = real
}
}
tx := de.WAF.NewTransaction()
defer func() {
tx.ProcessLogging()
@@ -165,6 +175,53 @@ func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.Match
})
}
// rightmostXFF gibt den letzten (vom nächstgelegenen Proxy angehängten)
// X-Forwarded-For-Eintrag zurück, sofern es eine gültige IP ist.
func rightmostXFF(rawHdrs string) string {
var val string
for _, line := range strings.Split(rawHdrs, "\n") {
line = strings.TrimRight(line, "\r")
idx := strings.IndexByte(line, ':')
if idx <= 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(line[:idx]), "x-forwarded-for") {
val = strings.TrimSpace(line[idx+1:]) // letzter XFF-Header gewinnt
}
}
if val == "" {
return ""
}
parts := strings.Split(val, ",")
cand := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(cand) == nil {
return ""
}
return cand
}
// ipMatchesAny prüft, ob ip exakt einer IP oder einem CIDR aus list entspricht.
func ipMatchesAny(ip string, list []string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, e := range list {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if strings.Contains(e, "/") {
if _, n, err := net.ParseCIDR(e); err == nil && n.Contains(parsed) {
return true
}
} else if pe := net.ParseIP(e); pe != nil && pe.Equal(parsed) {
return true
}
}
return false
}
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
// calls fn for each valid header line.
func parseHeaders(raw string, fn func(name, val string)) {

37
internal/waf/spoe_test.go Normal file
View File

@@ -0,0 +1,37 @@
package waf
import "testing"
// Beweist Fix #2: Trusted-Proxy-XFF-Auflösung.
func TestRightmostXFF(t *testing.T) {
cases := map[string]string{
"X-Forwarded-For: 203.0.113.7": "203.0.113.7",
"X-Forwarded-For: 203.0.113.7, 10.0.0.1": "10.0.0.1", // rightmost
"x-forwarded-for: 1.2.3.4 , 5.6.7.8": "5.6.7.8",
"Host: x\r\nX-Forwarded-For: 2001:db8::1": "2001:db8::1",
"X-Forwarded-For: not-an-ip": "",
"User-Agent: foo": "",
"": "",
}
for raw, want := range cases {
if got := rightmostXFF(raw); got != want {
t.Errorf("rightmostXFF(%q) = %q, want %q", raw, got, want)
}
}
}
func TestIPMatchesAny(t *testing.T) {
list := []string{"10.0.0.5", "192.168.0.0/16", "2001:db8::/32"}
yes := []string{"10.0.0.5", "192.168.4.7", "2001:db8::abcd"}
no := []string{"10.0.0.6", "172.16.0.1", "2002::1", "garbage"}
for _, ip := range yes {
if !ipMatchesAny(ip, list) {
t.Errorf("ipMatchesAny(%q) = false, want true", ip)
}
}
for _, ip := range no {
if ipMatchesAny(ip, list) {
t.Errorf("ipMatchesAny(%q) = true, want false", ip)
}
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
@@ -27,10 +28,11 @@ import (
const ConfDir = "/etc/edgeguard/wireguard"
type Generator struct {
Pool *pgxpool.Pool
Box *secrets.Box
Ifaces *wgsvc.InterfacesRepo
Peers *wgsvc.PeersRepo
Pool *pgxpool.Pool
Box *secrets.Box
Ifaces *wgsvc.InterfacesRepo
Peers *wgsvc.PeersRepo
SkipReload bool // nur Configs schreiben, keine wg-quick@-Service-Aktionen
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
@@ -151,8 +153,10 @@ func (g *Generator) Render(ctx context.Context) error {
continue
}
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
if !g.SkipReload {
_ = stopWGQuick(ifaceName)
_ = disableWGQuick(ifaceName)
}
}
}
return nil
@@ -228,21 +232,31 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
}
path := filepath.Join(ConfDir, ifc.Name+".conf")
// Config (enthält den Private Key) ZUERST atomar schreiben — vorher
// keinen Symlink/Service auf eine evtl. fehlende/abgeschnittene Datei
// zeigen lassen. AtomicWrite = temp+fsync+rename, 0600.
changed := true
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
changed = false
}
if changed {
if err := configgen.AtomicWrite(path, body.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
if g.SkipReload {
return nil
}
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
// /etc/edgeguard/wireguard/ und symlinken via sudo — /etc/wireguard/
// ist root:root 700, daher braucht es sudo /bin/ln. Das sudoers-Entry
// wird von postinst angelegt.
// Default), nicht unseren ConfDir. Symlink via sudo (/etc/wireguard/
// ist root:root 700). Das sudoers-Entry wird von postinst angelegt.
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
return fmt.Errorf("symlink: %w", err)
}
_ = enableWGQuick(ifc.Name)
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
if !changed {
return startWGQuick(ifc.Name)
}
if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return restartWGQuick(ifc.Name)
}

View File

@@ -26,7 +26,9 @@ const SSLPage = lazy(() => import('./pages/SSL'))
const FirewallPage = lazy(() => import('./pages/Firewall'))
const WireguardPage = lazy(() => import('./pages/Wireguard'))
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
const RADIUSPage = lazy(() => import('./pages/RADIUS'))
const DNSPage = lazy(() => import('./pages/DNS'))
const DHCPPage = lazy(() => import('./pages/DHCP'))
const NTPPage = lazy(() => import('./pages/NTP'))
const ClusterPage = lazy(() => import('./pages/Cluster'))
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
@@ -160,7 +162,9 @@ export default function App() {
<Route path="/firewall/live" element={<FirewallLivePage />} />
<Route path="/vpn/wireguard" element={<WireguardPage />} />
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
<Route path="/radius" element={<RADIUSPage />} />
<Route path="/dns" element={<DNSPage />} />
<Route path="/dhcp" element={<DHCPPage />} />
<Route path="/ntp" element={<NTPPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/logs" element={<LogsPage />} />

View File

@@ -19,9 +19,11 @@ const PAGE_TITLES: Record<string, string> = {
'/ip-addresses': 'nav.ipAddresses',
'/ssl': 'nav.ssl',
'/dns': 'nav.dns',
'/dhcp': 'nav.dhcp',
'/ntp': 'nav.ntp',
'/vpn/wireguard': 'nav.wireguard',
'/forward-proxy': 'nav.forwardProxy',
'/radius': 'nav.radius',
'/firewall/live': 'nav.firewallLive',
'/firewall': 'nav.firewall',
'/cluster': 'nav.cluster',

View File

@@ -18,6 +18,7 @@ import {
DatabaseOutlined,
FireOutlined,
GlobalOutlined,
IdcardOutlined,
NodeIndexOutlined,
RadarChartOutlined,
SafetyCertificateOutlined,
@@ -67,6 +68,7 @@ const NAV: NavSection[] = [
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
{ path: '/dhcp', labelKey: 'nav.dhcp', icon: <CloudServerOutlined /> },
{ path: '/ntp', labelKey: 'nav.ntp', icon: <ClockCircleOutlined /> },
],
},
@@ -79,6 +81,7 @@ const NAV: NavSection[] = [
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
{ path: '/radius', labelKey: 'nav.radius', icon: <IdcardOutlined /> },
],
},
{

View File

@@ -15,6 +15,7 @@
"wireguard": "WireGuard",
"forwardProxy": "Forward-Proxy",
"dns": "DNS",
"dhcp": "DHCP",
"ntp": "Zeit (NTP)",
"firewall": "Firewall",
"firewallLive": "Firewall-Log",
@@ -35,7 +36,8 @@
"security": "Sicherheit",
"system": "System"
},
"waf": "WAF"
"waf": "WAF",
"radius": "RADIUS"
},
"fw": {
"title": "Firewall",
@@ -1075,6 +1077,52 @@
"failed": "Import fehlgeschlagen"
}
},
"dhcp": {
"title": "DHCP (Kea)",
"intro": "DHCPv4-Server: globale Optionen, pro-Subnet-Pools und statische Reservierungen.",
"csvHint": "Komma-getrennt.",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"deleted": "Gelöscht",
"deleteFailed": "Löschen fehlgeschlagen",
"reservations": "Reservierungen",
"haWarnTitle": "Nur auf EINER Node aktivieren",
"haWarnDesc": "Zwei DHCP-Server im selben LAN führen zu Adresskonflikten. Diese Einstellung ist node-lokal — aktiviere DHCP nur auf einer Cluster-Node.",
"tabs": {
"settings": "Einstellungen",
"subnets": "Subnets"
},
"settings": {
"enabled": "DHCP auf dieser Node aktiv",
"defaultLease": "Standard-Lease-Zeit (Sekunden)",
"maxLease": "Max-Lease-Zeit (Sekunden)",
"domainName": "Domain-Name",
"dnsServers": "DNS-Server (global)"
},
"subnet": {
"name": "Name",
"interface": "Interface",
"cidr": "Subnetz (CIDR)",
"pool": "Pool",
"poolStart": "Pool-Start",
"poolEnd": "Pool-Ende",
"gateway": "Gateway",
"dnsServers": "DNS-Server",
"active": "Aktiv",
"add": "Subnet hinzufügen",
"edit": "Subnet bearbeiten",
"deleteConfirm": "Subnet \"{{name}}\" wirklich löschen?"
},
"reservation": {
"name": "Name",
"mac": "MAC-Adresse",
"ip": "IP-Adresse",
"hostname": "Hostname",
"add": "Reservierung hinzufügen",
"edit": "Reservierung bearbeiten",
"deleteConfirm": "Reservierung wirklich löschen?"
}
},
"ntp": {
"title": "Zeitserver (Chrony)",
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",
@@ -1324,6 +1372,7 @@
"yes": "Ja",
"no": "Nein",
"or": "oder",
"status": "Status",
"save": "Speichern",
"cancel": "Abbrechen",
"loading": "Lädt …",
@@ -1808,6 +1857,39 @@
"confirmRemove": "Collection {{name}} wirklich entfernen?"
}
},
"radius": {
"title": "RADIUS (FreeRADIUS)",
"intro": "RADIUS-Authentifizierung (PAP/CHAP): NAS-Clients + Benutzer für VPN-/Switch-/AP-Login.",
"csvHint": "Komma-getrennt; leer = alle Adressen.",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"deleted": "Gelöscht",
"deleteFailed": "Löschen fehlgeschlagen",
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
"secretUnset": "Noch nichts gespeichert.",
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
"settings": {
"enabled": "RADIUS auf dieser Node aktiv",
"listen": "Listen-Adressen"
},
"client": {
"name": "Name",
"ipaddr": "IP / CIDR",
"secret": "Shared Secret",
"description": "Beschreibung",
"add": "Client hinzufügen",
"edit": "Client bearbeiten",
"deleteConfirm": "Client \"{{name}}\" wirklich löschen?"
},
"user": {
"username": "Benutzername",
"password": "Passwort",
"papHint": "Benutzer-Passwörter werden für PAP/CHAP als Cleartext in der FreeRADIUS-Datei hinterlegt (verschlüsselt at-rest in der DB, Datei nur für root/freeradius lesbar).",
"add": "Benutzer hinzufügen",
"edit": "Benutzer bearbeiten",
"deleteConfirm": "Benutzer \"{{name}}\" wirklich löschen?"
}
},
"waf": {
"title": "Web Application Firewall",
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",

View File

@@ -15,6 +15,7 @@
"wireguard": "WireGuard",
"forwardProxy": "Forward proxy",
"dns": "DNS",
"dhcp": "DHCP",
"ntp": "Time (NTP)",
"firewall": "Firewall",
"firewallLive": "Firewall log",
@@ -35,7 +36,8 @@
"security": "Security",
"system": "System"
},
"waf": "WAF"
"waf": "WAF",
"radius": "RADIUS"
},
"fw": {
"title": "Firewall",
@@ -1075,6 +1077,52 @@
"failed": "Import failed"
}
},
"dhcp": {
"title": "DHCP (Kea)",
"intro": "DHCPv4 server: global options, per-subnet pools and static reservations.",
"csvHint": "Comma-separated.",
"saved": "Saved",
"saveFailed": "Save failed",
"deleted": "Deleted",
"deleteFailed": "Delete failed",
"reservations": "Reservations",
"haWarnTitle": "Enable on ONE node only",
"haWarnDesc": "Two DHCP servers on the same LAN cause address conflicts. This setting is node-local — enable DHCP on a single cluster node only.",
"tabs": {
"settings": "Settings",
"subnets": "Subnets"
},
"settings": {
"enabled": "DHCP active on this node",
"defaultLease": "Default lease time (seconds)",
"maxLease": "Max lease time (seconds)",
"domainName": "Domain name",
"dnsServers": "DNS servers (global)"
},
"subnet": {
"name": "Name",
"interface": "Interface",
"cidr": "Subnet (CIDR)",
"pool": "Pool",
"poolStart": "Pool start",
"poolEnd": "Pool end",
"gateway": "Gateway",
"dnsServers": "DNS servers",
"active": "Active",
"add": "Add subnet",
"edit": "Edit subnet",
"deleteConfirm": "Really delete subnet \"{{name}}\"?"
},
"reservation": {
"name": "Name",
"mac": "MAC address",
"ip": "IP address",
"hostname": "Hostname",
"add": "Add reservation",
"edit": "Edit reservation",
"deleteConfirm": "Really delete this reservation?"
}
},
"ntp": {
"title": "Time server (Chrony)",
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",
@@ -1324,6 +1372,7 @@
"yes": "Yes",
"no": "No",
"or": "or",
"status": "Status",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading …",
@@ -1808,6 +1857,39 @@
"confirmRemove": "Really remove collection {{name}}?"
}
},
"radius": {
"title": "RADIUS (FreeRADIUS)",
"intro": "RADIUS authentication (PAP/CHAP): NAS clients + users for VPN/switch/AP login.",
"csvHint": "Comma-separated; empty = all addresses.",
"saved": "Saved",
"saveFailed": "Save failed",
"deleted": "Deleted",
"deleteFailed": "Delete failed",
"secretSet": "Stored — leave empty to keep unchanged.",
"secretUnset": "Nothing stored yet.",
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
"settings": {
"enabled": "RADIUS active on this node",
"listen": "Listen addresses"
},
"client": {
"name": "Name",
"ipaddr": "IP / CIDR",
"secret": "Shared secret",
"description": "Description",
"add": "Add client",
"edit": "Edit client",
"deleteConfirm": "Really delete client \"{{name}}\"?"
},
"user": {
"username": "Username",
"password": "Password",
"papHint": "User passwords are stored as cleartext in the FreeRADIUS file for PAP/CHAP (encrypted at-rest in the DB; the file is readable only by root/freeradius).",
"add": "Add user",
"edit": "Edit user",
"deleteConfirm": "Really delete user \"{{name}}\"?"
}
},
"waf": {
"title": "Web Application Firewall",
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",

View File

@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react'
import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Space,
Switch, Table, Tabs, Tag, Tooltip, Typography, message, Select,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
interface DHCPSettings {
id: number
enabled: boolean
default_lease: number
max_lease: number
domain_name: string
dns_servers: string
}
interface DHCPSubnet {
id: number
name: string
interface_name: string
subnet_cidr: string
pool_start: string
pool_end: string
gateway: string
dns_servers: string
lease_time?: number | null
active: boolean
description: string
}
interface DHCPReservation {
id: number
subnet_id: number
name: string
mac_address: string
ip_address: string
hostname: string
active: boolean
}
interface NetIface { name: string; role: string }
export default function DHCPPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader icon={<CloudServerOutlined />} title={t('dhcp.title')} subtitle={t('dhcp.intro')} />
<Tabs
defaultActiveKey="settings"
items={[
{ key: 'settings', label: t('dhcp.tabs.settings'), children: <SettingsTab /> },
{ key: 'subnets', label: t('dhcp.tabs.subnets'), children: <SubnetsTab /> },
]}
/>
</div>
)
}
// ── Settings ─────────────────────────────────────────────────────────
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPSettings>()
const { data } = useQuery({
queryKey: ['dhcp', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/dhcp/settings')
return isEnvelope(r.data) ? (r.data.data as DHCPSettings) : null
},
})
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
const save = useMutation({
mutationFn: async (v: DHCPSettings) => apiClient.put('/dhcp/settings', v),
onSuccess: () => {
msg.success(t('dhcp.saved'))
void qc.invalidateQueries({ queryKey: ['dhcp', 'settings'] })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
return (
<Card size="small">
{msgCtx}
<Alert
type="warning"
showIcon
className="mb-16"
message={t('dhcp.haWarnTitle')}
description={t('dhcp.haWarnDesc')}
/>
<Form<DHCPSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.settings.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.defaultLease')} name="default_lease">
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.maxLease')} name="max_lease">
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.domainName')} name="domain_name">
<Input placeholder="lan" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
<Input placeholder="1.1.1.1, 8.8.8.8" disabled={isViewer} />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
)
}
// ── Subnets ──────────────────────────────────────────────────────────
function SubnetsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPSubnet>()
const [editing, setEditing] = useState<DHCPSubnet | null>(null)
const [open, setOpen] = useState(false)
const [resvFor, setResvFor] = useState<DHCPSubnet | null>(null)
const { data: subnets } = useQuery({
queryKey: ['dhcp', 'subnets'],
queryFn: async () => {
const r = await apiClient.get('/dhcp/subnets')
return isEnvelope(r.data) ? ((r.data.data as { subnets?: DHCPSubnet[] }).subnets ?? []) : []
},
})
const { data: ifaces } = useQuery({
queryKey: ['network-interfaces'],
queryFn: async () => {
const r = await apiClient.get('/network-interfaces')
return isEnvelope(r.data) ? ((r.data.data as { interfaces?: NetIface[] }).interfaces ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: DHCPSubnet) => {
if (editing) return apiClient.put(`/dhcp/subnets/${editing.id}`, v)
return apiClient.post('/dhcp/subnets', v)
},
onSuccess: () => {
msg.success(t('dhcp.saved'))
setOpen(false); setEditing(null)
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/dhcp/subnets/${id}`),
onSuccess: () => {
msg.success(t('dhcp.deleted'))
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
},
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
})
const openNew = () => {
setEditing(null)
form.resetFields()
form.setFieldsValue({ active: true } as Partial<DHCPSubnet>)
setOpen(true)
}
const openEdit = (s: DHCPSubnet) => {
setEditing(s)
form.setFieldsValue(s)
setOpen(true)
}
const cols: ColumnsType<DHCPSubnet> = [
{ title: t('dhcp.subnet.name'), dataIndex: 'name' },
{ title: t('dhcp.subnet.interface'), dataIndex: 'interface_name' },
{ title: t('dhcp.subnet.cidr'), dataIndex: 'subnet_cidr' },
{
title: t('dhcp.subnet.pool'), key: 'pool',
render: (_, r) => (r.pool_start && r.pool_end ? `${r.pool_start} ${r.pool_end}` : '—'),
},
{ title: t('dhcp.subnet.gateway'), dataIndex: 'gateway', render: (v) => v || '—' },
{
title: t('common.status'), dataIndex: 'active',
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag>,
},
{
title: t('common.actions'), key: 'actions', width: 280,
render: (_, r) => (
<Space>
<Button size="small" onClick={() => setResvFor(r)}>{t('dhcp.reservations')}</Button>
{!isViewer && <Button size="small" onClick={() => openEdit(r)}>{t('common.edit')}</Button>}
{!isViewer && (
<Popconfirm title={t('dhcp.subnet.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} disabled={isViewer}>
{t('dhcp.subnet.add')}
</Button>
</Space>
<Table<DHCPSubnet> rowKey="id" size="small" columns={cols} dataSource={subnets ?? []} pagination={false} />
<Modal
title={editing ? t('dhcp.subnet.edit') : t('dhcp.subnet.add')}
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
confirmLoading={save.isPending}
destroyOnClose
>
<Form<DHCPSubnet> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.subnet.name')} name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label={t('dhcp.subnet.interface')} name="interface_name" rules={[{ required: true }]}>
<Select
options={(ifaces ?? []).map((i) => ({ value: i.name, label: `${i.name} (${i.role})` }))}
showSearch
/>
</Form.Item>
<Form.Item label={t('dhcp.subnet.cidr')} name="subnet_cidr" rules={[{ required: true }]}>
<Input placeholder="10.0.0.0/24" />
</Form.Item>
<Space>
<Form.Item label={t('dhcp.subnet.poolStart')} name="pool_start">
<Input placeholder="10.0.0.100" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.poolEnd')} name="pool_end">
<Input placeholder="10.0.0.200" />
</Form.Item>
</Space>
<Form.Item label={t('dhcp.subnet.gateway')} name="gateway">
<Input placeholder="10.0.0.1" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
<Input placeholder="1.1.1.1, 8.8.8.8" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
{resvFor && (
<ReservationsModal subnet={resvFor} onClose={() => setResvFor(null)} />
)}
</Card>
)
}
// ── Reservations (per subnet) ────────────────────────────────────────
function ReservationsModal({ subnet, onClose }: { subnet: DHCPSubnet; onClose: () => void }) {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPReservation>()
const [editing, setEditing] = useState<DHCPReservation | null>(null)
const [formOpen, setFormOpen] = useState(false)
const key = ['dhcp', 'reservations', subnet.id]
const { data } = useQuery({
queryKey: key,
queryFn: async () => {
const r = await apiClient.get(`/dhcp/subnets/${subnet.id}/reservations`)
return isEnvelope(r.data) ? ((r.data.data as { reservations?: DHCPReservation[] }).reservations ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: DHCPReservation) => {
if (editing) return apiClient.put(`/dhcp/reservations/${editing.id}`, v)
return apiClient.post(`/dhcp/subnets/${subnet.id}/reservations`, v)
},
onSuccess: () => {
msg.success(t('dhcp.saved'))
setFormOpen(false); setEditing(null)
void qc.invalidateQueries({ queryKey: key })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/dhcp/reservations/${id}`),
onSuccess: () => {
msg.success(t('dhcp.deleted'))
void qc.invalidateQueries({ queryKey: key })
},
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<DHCPReservation> = [
{ title: t('dhcp.reservation.mac'), dataIndex: 'mac_address' },
{ title: t('dhcp.reservation.ip'), dataIndex: 'ip_address' },
{ title: t('dhcp.reservation.hostname'), dataIndex: 'hostname', render: (v) => v || '—' },
{
title: t('common.actions'), key: 'actions', width: 180,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue(r); setFormOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && (
<Popconfirm title={t('dhcp.reservation.deleteConfirm')} onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<Modal
title={`${t('dhcp.reservations')}${subnet.name}`}
open
onCancel={onClose}
footer={null}
width={640}
>
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button
type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<DHCPReservation>); setFormOpen(true) }}
>
{t('dhcp.reservation.add')}
</Button>
</Space>
<Table<DHCPReservation> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal
title={editing ? t('dhcp.reservation.edit') : t('dhcp.reservation.add')}
open={formOpen}
onCancel={() => setFormOpen(false)}
onOk={() => form.submit()}
confirmLoading={save.isPending}
destroyOnClose
>
<Form<DHCPReservation> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.reservation.name')} name="name">
<Input />
</Form.Item>
<Form.Item label={t('dhcp.reservation.mac')} name="mac_address" rules={[{ required: true }]}>
<Input placeholder="aa:bb:cc:dd:ee:ff" />
</Form.Item>
<Form.Item label={t('dhcp.reservation.ip')} name="ip_address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.50" />
</Form.Item>
<Form.Item label={t('dhcp.reservation.hostname')} name="hostname">
<Input />
</Form.Item>
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Text type="secondary" style={{ fontSize: 12 }}>{subnet.subnet_cidr}</Text>
</Modal>
)
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import {
Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Switch,
Table, Tabs, Tag, Tooltip, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { IdcardOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import PageHeader from '../../components/PageHeader'
interface RADIUSSettings { id: number; enabled: boolean; listen_addresses: string }
interface RADIUSClient {
id: number; name: string; ipaddr: string; active: boolean; description: string; secret_configured: boolean
}
interface RADIUSUser { id: number; username: string; active: boolean; password_configured: boolean }
export default function RADIUSPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader icon={<IdcardOutlined />} title={t('radius.title')} subtitle={t('radius.intro')} />
<Tabs
defaultActiveKey="settings"
items={[
{ key: 'settings', label: t('radius.tabs.settings'), children: <SettingsTab /> },
{ key: 'clients', label: t('radius.tabs.clients'), children: <ClientsTab /> },
{ key: 'users', label: t('radius.tabs.users'), children: <UsersTab /> },
]}
/>
</div>
)
}
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<RADIUSSettings>()
const { data } = useQuery({
queryKey: ['radius', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/radius/settings')
return isEnvelope(r.data) ? (r.data.data as RADIUSSettings) : null
},
})
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
const save = useMutation({
mutationFn: async (v: RADIUSSettings) => apiClient.put('/radius/settings', v),
onSuccess: () => { msg.success(t('radius.saved')); void qc.invalidateQueries({ queryKey: ['radius', 'settings'] }) },
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
})
return (
<Card size="small">
{msgCtx}
<Form<RADIUSSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('radius.settings.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('radius.settings.listen')} name="listen_addresses" extra={t('radius.csvHint')}>
<Input placeholder="0.0.0.0" disabled={isViewer} />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>{t('common.save')}</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
)
}
interface ClientForm { name: string; ipaddr: string; secret?: string; active: boolean; description: string }
function ClientsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<ClientForm>()
const [editing, setEditing] = useState<RADIUSClient | null>(null)
const [open, setOpen] = useState(false)
const { data } = useQuery({
queryKey: ['radius', 'clients'],
queryFn: async () => {
const r = await apiClient.get('/radius/clients')
return isEnvelope(r.data) ? ((r.data.data as { clients?: RADIUSClient[] }).clients ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: ClientForm) => {
const body: Record<string, unknown> = { ...v }
if (!v.secret) delete body.secret // leer = unverändert
return editing ? apiClient.put(`/radius/clients/${editing.id}`, body) : apiClient.post('/radius/clients', body)
},
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/radius/clients/${id}`),
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<RADIUSClient> = [
{ title: t('radius.client.name'), dataIndex: 'name' },
{ title: t('radius.client.ipaddr'), dataIndex: 'ipaddr' },
{ title: t('radius.client.secret'), dataIndex: 'secret_configured', render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>) },
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
{
title: t('common.actions'), key: 'a', width: 200,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ name: r.name, ipaddr: r.ipaddr, active: r.active, description: r.description, secret: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && <Popconfirm title={t('radius.client.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<ClientForm>); setOpen(true) }}>
{t('radius.client.add')}
</Button>
</Space>
<Table<RADIUSClient> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal title={editing ? t('radius.client.edit') : t('radius.client.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
<Form<ClientForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('radius.client.name')} name="name" rules={[{ required: true }]}><Input placeholder="switch-core" /></Form.Item>
<Form.Item label={t('radius.client.ipaddr')} name="ipaddr" rules={[{ required: true }]}><Input placeholder="10.0.0.0/24" /></Form.Item>
<Form.Item label={t('radius.client.secret')} name="secret" extra={editing ? (editing.secret_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
<Input.Password autoComplete="new-password" placeholder={editing?.secret_configured ? '••••••••' : ''} />
</Form.Item>
<Form.Item label={t('radius.client.description')} name="description"><Input /></Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>
)
}
interface UserForm { username: string; password?: string; active: boolean }
function UsersTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<UserForm>()
const [editing, setEditing] = useState<RADIUSUser | null>(null)
const [open, setOpen] = useState(false)
const { data } = useQuery({
queryKey: ['radius', 'users'],
queryFn: async () => {
const r = await apiClient.get('/radius/users')
return isEnvelope(r.data) ? ((r.data.data as { users?: RADIUSUser[] }).users ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: UserForm) => {
const body: Record<string, unknown> = { ...v }
if (!v.password) delete body.password
return editing ? apiClient.put(`/radius/users/${editing.id}`, body) : apiClient.post('/radius/users', body)
},
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/radius/users/${id}`),
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<RADIUSUser> = [
{ title: t('radius.user.username'), dataIndex: 'username' },
{ title: t('radius.user.password'), dataIndex: 'password_configured', render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>) },
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
{
title: t('common.actions'), key: 'a', width: 200,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ username: r.username, active: r.active, password: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && <Popconfirm title={t('radius.user.deleteConfirm', { name: r.username })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Alert type="info" showIcon className="mb-16" message={t('radius.user.papHint')} />
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<UserForm>); setOpen(true) }}>
{t('radius.user.add')}
</Button>
</Space>
<Table<RADIUSUser> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal title={editing ? t('radius.user.edit') : t('radius.user.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
<Form<UserForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('radius.user.username')} name="username" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label={t('radius.user.password')} name="password" extra={editing ? (editing.password_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
<Input.Password autoComplete="new-password" placeholder={editing?.password_configured ? '••••••••' : ''} />
</Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>
)
}

View File

@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
PG Streaming Replication + provider Floating-IP for HTTP ingress).
.
This package ships the management API, scheduler and CLI.
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, kea-dhcp4-server, freeradius, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
Section: admin
Priority: optional

View File

@@ -31,6 +31,11 @@ case "$1" in
if getent group unbound >/dev/null; then
usermod -a -G unbound "$EG_USER" || true
fi
# freeradius: edgeguard muss die gerenderten clients.conf/authorize
# ins freeradius-lesbare Verzeichnis schreiben (setgid-Dir unten).
if getent group freeradius >/dev/null; then
usermod -a -G freeradius "$EG_USER" || true
fi
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
# (zentrale Log-Übersicht über alle Services).
@@ -48,6 +53,7 @@ case "$1" in
# gespeichert, alles sind Renderer-Outputs.
for d in /etc/edgeguard /etc/edgeguard/haproxy /etc/edgeguard/squid \
/etc/edgeguard/wireguard /etc/edgeguard/unbound \
/etc/edgeguard/kea \
/etc/edgeguard/nftables.d; do
install -d -m 0755 -o "$EG_USER" -g "$EG_USER" "$d"
done
@@ -118,6 +124,24 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload chrony.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload chrony.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart chrony.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart chrony.service
# DHCP (Kea): Service-Lifecycle an dhcp_settings.enabled gekoppelt.
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable kea-dhcp4-server.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable kea-dhcp4-server.service
# RADIUS (FreeRADIUS): Service-Lifecycle an radius_settings.enabled gekoppelt.
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable freeradius.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable freeradius.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
# Auto-Update-Toggle (Settings → Automatische Updates) schreibt/entfernt
@@ -228,6 +252,41 @@ SUDOERS
fi
chown "$EG_USER":"$EG_USER" /etc/chrony/conf.d/edgeguard.conf
chmod 0644 /etc/chrony/conf.d/edgeguard.conf
# Kea DHCPv4: managed config unter /etc/edgeguard/kea, vom Distro-
# Pfad symlinked (Distro-Original wird gesichert). Der Dienst läuft
# NUR, wenn der Operator DHCP aktiviert (Default AUS) — kein
# rogue DHCP-Server nach Install. Der Renderer enabled+restartet
# kea-dhcp4-server, sobald dhcp_settings.enabled=true gesetzt wird.
install -d /etc/kea
if [ -f /etc/kea/kea-dhcp4.conf ] && [ ! -L /etc/kea/kea-dhcp4.conf ]; then
mv /etc/kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf.distro-bak
fi
ln -sfn /etc/edgeguard/kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf
systemctl disable --now kea-dhcp4-server.service 2>/dev/null || true
# FreeRADIUS: managed clients.conf + authorize unter /etc/edgeguard/
# freeradius (setgid → Dateien erben Gruppe freeradius, 0640 → der
# freeradius-Dienst kann lesen). Symlinks von den Distro-Pfaden.
# Dienst default AUS (kein RADIUS bis Operator aktiviert).
if getent group freeradius >/dev/null; then
install -d -m 2750 -o "$EG_USER" -g freeradius /etc/edgeguard/freeradius
else
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" /etc/edgeguard/freeradius
fi
if [ -d /etc/freeradius/3.0 ]; then
if [ -e /etc/freeradius/3.0/clients.conf ] && [ ! -L /etc/freeradius/3.0/clients.conf ]; then
mv /etc/freeradius/3.0/clients.conf /etc/freeradius/3.0/clients.conf.distro-bak
fi
ln -sfn /etc/edgeguard/freeradius/clients.conf /etc/freeradius/3.0/clients.conf
if [ -e /etc/freeradius/3.0/mods-config/files/authorize ] && [ ! -L /etc/freeradius/3.0/mods-config/files/authorize ]; then
mv /etc/freeradius/3.0/mods-config/files/authorize /etc/freeradius/3.0/mods-config/files/authorize.distro-bak
fi
install -d /etc/freeradius/3.0/mods-config/files
ln -sfn /etc/edgeguard/freeradius/authorize /etc/freeradius/3.0/mods-config/files/authorize
fi
systemctl disable --now freeradius.service 2>/dev/null || true
chmod 0440 /etc/sudoers.d/edgeguard
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──