From 5f92851a960b8a9fda28ac12838bbe648733b42e Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 5 Jun 2026 19:46:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(radius):=20RADIUS-Server=20via=20FreeRADIU?= =?UTF-8?q?S=20(PAP/CHAP)=20=E2=80=94=20v1.2.93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 21 +- cmd/edgeguard-ctl/cluster_replication.go | 1 + cmd/edgeguard-ctl/render.go | 5 +- internal/cluster/confighash.go | 5 + internal/database/migrations/0042_radius.sql | 51 +++ internal/firewall/firewall.go | 23 ++ internal/freeradius/freeradius.go | 153 ++++++++ internal/freeradius/freeradius_test.go | 83 +++++ internal/handlers/radius.go | 346 ++++++++++++++++++ internal/models/radius.go | 43 +++ internal/services/radius/radius.go | 239 ++++++++++++ management-ui/src/App.tsx | 2 + .../src/components/Layout/AppLayout.tsx | 1 + .../src/components/Layout/Sidebar.tsx | 2 + management-ui/src/i18n/locales/de/common.json | 36 +- management-ui/src/i18n/locales/en/common.json | 36 +- management-ui/src/pages/RADIUS/index.tsx | 226 ++++++++++++ packaging/debian/edgeguard-api/DEBIAN/control | 2 +- .../debian/edgeguard-api/DEBIAN/postinst | 36 ++ 20 files changed, 1302 insertions(+), 11 deletions(-) create mode 100644 internal/database/migrations/0042_radius.sql create mode 100644 internal/freeradius/freeradius.go create mode 100644 internal/freeradius/freeradius_test.go create mode 100644 internal/handlers/radius.go create mode 100644 internal/models/radius.go create mode 100644 internal/services/radius/radius.go create mode 100644 management-ui/src/pages/RADIUS/index.tsx diff --git a/VERSION b/VERSION index 108609c..b9aea87 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.92 \ No newline at end of file +1.2.93 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index c5b4973..25c4980 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -29,6 +29,7 @@ import ( 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" @@ -61,6 +62,7 @@ import ( 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" ) @@ -453,15 +455,22 @@ func main() { } 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, - "kea": keaReloader, + "nftables": fwReloader, + "wireguard": wgReloader, + "squid": squidReloader, + "unbound": unboundReloader, + "chrony": chronyReloader, + "kea": keaReloader, + "freeradius": radiusReloader, }) // License — node-local key store + DB-mirror of last verify diff --git a/cmd/edgeguard-ctl/cluster_replication.go b/cmd/edgeguard-ctl/cluster_replication.go index c7a16d9..481a9ed 100644 --- a/cmd/edgeguard-ctl/cluster_replication.go +++ b/cmd/edgeguard-ctl/cluster_replication.go @@ -80,6 +80,7 @@ var localOnlyTables = []string{ "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 diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index ff7d5c1..b826938 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -8,6 +8,7 @@ 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" @@ -63,10 +64,12 @@ func cmdRenderConfig(args []string) int { 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 ke.SkipReload = true + fr.SkipReload = true } // keepalived: Node-ID aus node.conf für Prioritäts-Berechnung @@ -75,7 +78,7 @@ func cmdRenderConfig(args []string) int { ka = keepalived.New(pool, lc.NodeID) } - gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke} + gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr} if ka != nil { gens = append(gens, ka) } diff --git a/internal/cluster/confighash.go b/internal/cluster/confighash.go index 177b974..07d60e6 100644 --- a/internal/cluster/confighash.go +++ b/internal/cluster/confighash.go @@ -76,6 +76,11 @@ var hashSpec = []hashTable{ {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 diff --git a/internal/database/migrations/0042_radius.sql b/internal/database/migrations/0042_radius.sql new file mode 100644 index 0000000..1f0372f --- /dev/null +++ b/internal/database/migrations/0042_radius.sql @@ -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 diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index affd899..969843c 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -434,6 +434,29 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule { } } + // 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) + } + } + } + } + return out } diff --git a/internal/freeradius/freeradius.go b/internal/freeradius/freeradius.go new file mode 100644 index 0000000..ab26778 --- /dev/null +++ b/internal/freeradius/freeradius.go @@ -0,0 +1,153 @@ +// Package freeradius renders the FreeRADIUS client + user files from the +// radius_* tables and manages the freeradius service lifecycle. +// +// Two files are rendered (mirrors the multi-file WireGuard renderer): +// - clients.conf — NAS clients (ipaddr + shared secret) +// - authorize — users file ("name" Cleartext-Password := "pw") +// Both managed under /etc/edgeguard/freeradius/ and symlinked from the +// distro paths by postinst. Shared secrets / passwords are decrypted via +// secrets.Box at render time. Service runs ONLY when radius_settings.enabled +// is true on this node (default off). +package freeradius + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" + radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" +) + +const ( + ConfDir = configgen.EtcEdgeguard + "/freeradius" + ClientsPath = ConfDir + "/clients.conf" + AuthorizePath = ConfDir + "/authorize" + serviceName = "freeradius" +) + +type Generator struct { + Pool *pgxpool.Pool + Repo *radiussvc.Repo + Box *secrets.Box + SkipReload bool +} + +func New(pool *pgxpool.Pool, box *secrets.Box) *Generator { + return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box} +} + +func (g *Generator) Name() string { return "freeradius" } + +// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote). +func confEscape(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} + +// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview). +func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) { + clients, err := g.Repo.ListClients(ctx) + if err != nil { + return "", err + } + var b bytes.Buffer + b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n") + for _, c := range clients { + if !c.Active { + continue + } + secret := "***" + if !mask { + pt, err := g.Box.Open(c.SecretEnc) + if err != nil { + return "", fmt.Errorf("open secret for client %s: %w", c.Name, err) + } + secret = string(pt) + } + fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n", + c.Name, c.IPAddr, confEscape(secret), c.Name) + } + return b.String(), nil +} + +// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***. +func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) { + users, err := g.Repo.ListUsers(ctx) + if err != nil { + return "", err + } + var b bytes.Buffer + b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n") + for _, u := range users { + if !u.Active { + continue + } + pw := "***" + if !mask { + pt, err := g.Box.Open(u.PasswordEnc) + if err != nil { + return "", fmt.Errorf("open password for user %s: %w", u.Username, err) + } + pw = string(pt) + } + fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw)) + } + return b.String(), nil +} + +// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview. +func (g *Generator) RenderToString(ctx context.Context) (string, error) { + clients, err := g.buildClients(ctx, true) + if err != nil { + return "", err + } + authorize, err := g.buildAuthorize(ctx, true) + if err != nil { + return "", err + } + return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil +} + +func (g *Generator) Render(ctx context.Context) error { + settings, err := g.Repo.GetSettings(ctx) + if err != nil { + return fmt.Errorf("get radius settings: %w", err) + } + + if !settings.Enabled { + if g.SkipReload { + return nil + } + _ = configgen.DisableService(serviceName) + _ = configgen.StopService(serviceName) + return nil + } + + clients, err := g.buildClients(ctx, false) + if err != nil { + return err + } + authorize, err := g.buildAuthorize(ctx, false) + if err != nil { + return err + } + if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil { + return fmt.Errorf("write clients.conf: %w", err) + } + if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil { + return fmt.Errorf("write authorize: %w", err) + } + if g.SkipReload { + return nil + } + if err := configgen.EnableService(serviceName); err != nil { + return err + } + return configgen.RestartService(serviceName) +} diff --git a/internal/freeradius/freeradius_test.go b/internal/freeradius/freeradius_test.go new file mode 100644 index 0000000..d66d757 --- /dev/null +++ b/internal/freeradius/freeradius_test.go @@ -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) + } +} diff --git a/internal/handlers/radius.go b/internal/handlers/radius.go new file mode 100644 index 0000000..814a4c5 --- /dev/null +++ b/internal/handlers/radius.go @@ -0,0 +1,346 @@ +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 || len(*b.Secret) < 6) { + return errors.New("secret ist erforderlich (mind. 6 Zeichen)") + } + if b.Secret != nil && *b.Secret != "" && len(*b.Secret) < 6 { + return errors.New("secret muss mind. 6 Zeichen haben") + } + 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") + } + 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) +} diff --git a/internal/models/radius.go b/internal/models/radius.go new file mode 100644 index 0000000..a6b7460 --- /dev/null +++ b/internal/models/radius.go @@ -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" } diff --git a/internal/services/radius/radius.go b/internal/services/radius/radius.go new file mode 100644 index 0000000..1519e56 --- /dev/null +++ b/internal/services/radius/radius.go @@ -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 +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 04617df..9fea7ab 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -26,6 +26,7 @@ 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')) @@ -161,6 +162,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 6f7b4af..e664e01 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -23,6 +23,7 @@ const PAGE_TITLES: Record = { '/ntp': 'nav.ntp', '/vpn/wireguard': 'nav.wireguard', '/forward-proxy': 'nav.forwardProxy', + '/radius': 'nav.radius', '/firewall/live': 'nav.firewallLive', '/firewall': 'nav.firewall', '/cluster': 'nav.cluster', diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 73c53ed..d2f2f43 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -18,6 +18,7 @@ import { DatabaseOutlined, FireOutlined, GlobalOutlined, + IdcardOutlined, NodeIndexOutlined, RadarChartOutlined, SafetyCertificateOutlined, @@ -80,6 +81,7 @@ const NAV: NavSection[] = [ { path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: }, { path: '/crowdsec', labelKey: 'nav.crowdsec', icon: }, { path: '/waf', labelKey: 'nav.waf', icon: }, + { path: '/radius', labelKey: 'nav.radius', icon: }, ], }, { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 32a4efc..20189e7 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -36,7 +36,8 @@ "security": "Sicherheit", "system": "System" }, - "waf": "WAF" + "waf": "WAF", + "radius": "RADIUS" }, "fw": { "title": "Firewall", @@ -1855,6 +1856,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.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index bb63e92..7bb48c4 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -36,7 +36,8 @@ "security": "Security", "system": "System" }, - "waf": "WAF" + "waf": "WAF", + "radius": "RADIUS" }, "fw": { "title": "Firewall", @@ -1855,6 +1856,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.", diff --git a/management-ui/src/pages/RADIUS/index.tsx b/management-ui/src/pages/RADIUS/index.tsx new file mode 100644 index 0000000..c3f27ef --- /dev/null +++ b/management-ui/src/pages/RADIUS/index.tsx @@ -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 ( +
+ } title={t('radius.title')} subtitle={t('radius.intro')} /> + }, + { key: 'clients', label: t('radius.tabs.clients'), children: }, + { key: 'users', label: t('radius.tabs.users'), children: }, + ]} + /> +
+ ) +} + +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() + + 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 ( + + {msgCtx} + form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> + + + + + + + + + + + + + + ) +} + +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() + const [editing, setEditing] = useState(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 = { ...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 = [ + { 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 ? : ) }, + { title: t('common.status'), dataIndex: 'active', render: (v: boolean) => {v ? t('common.active') : t('common.inactive')} }, + { + title: t('common.actions'), key: 'a', width: 200, + render: (_, r) => ( + + {!isViewer && } + {!isViewer && del.mutate(r.id)}>} + + ), + }, + ] + + return ( + + {msgCtx} + + + + rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} /> + setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose> + form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> + + + + + + + + + + + ) +} + +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() + const [editing, setEditing] = useState(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 = { ...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 = [ + { title: t('radius.user.username'), dataIndex: 'username' }, + { title: t('radius.user.password'), dataIndex: 'password_configured', render: (v: boolean) => (v ? : ) }, + { title: t('common.status'), dataIndex: 'active', render: (v: boolean) => {v ? t('common.active') : t('common.inactive')} }, + { + title: t('common.actions'), key: 'a', width: 200, + render: (_, r) => ( + + {!isViewer && } + {!isViewer && del.mutate(r.id)}>} + + ), + }, + ] + + return ( + + {msgCtx} + + + + + rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} /> + setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose> + form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> + + + + + + + + + ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/control b/packaging/debian/edgeguard-api/DEBIAN/control index 788deb1..ebd1df5 100644 --- a/packaging/debian/edgeguard-api/DEBIAN/control +++ b/packaging/debian/edgeguard-api/DEBIAN/control @@ -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, kea-dhcp4-server, 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 diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 857ef99..db506fc 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -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). @@ -128,6 +133,15 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable kea-dhcp4-server.servic 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 @@ -251,6 +265,28 @@ SUDOERS 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) ──