Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f92851a96 | ||
|
|
bac7c7e349 | ||
|
|
3a707e2e3f | ||
|
|
f85552a475 | ||
|
|
92c5e25557 | ||
|
|
9383b870b0 | ||
|
|
4416d361a0 | ||
|
|
58e42eb269 |
@@ -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,6 +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"
|
||||
)
|
||||
@@ -316,6 +321,14 @@ func main() {
|
||||
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
||||
setupHdl.RegisterAuthed(authed)
|
||||
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
|
||||
|
||||
// OIDC/Keycloak SSO — public Flow-Endpoints auf v1 (hinter SetupGate),
|
||||
// Admin-Settings auf authed (PUT nur admin via RequireAdminForMutations).
|
||||
oidcRepo := oidcsvc.New(pool, secretsBox)
|
||||
oidcHdl := handlers.NewOIDCHandler(oidcRepo, oidcsvc.NewClient(oidcRepo), usersRepo, signer, setupStore).
|
||||
WithAudit(auditRepo, nodeID)
|
||||
oidcHdl.RegisterPublic(v1)
|
||||
oidcHdl.RegisterAdmin(authed)
|
||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
@@ -436,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
|
||||
|
||||
@@ -79,6 +79,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
|
||||
|
||||
@@ -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,13 @@ 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
|
||||
ke.SkipReload = true
|
||||
fr.SkipReload = true
|
||||
}
|
||||
|
||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||
@@ -72,7 +78,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)
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,6 +4,7 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/corazawaf/coraza/v3 v3.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/dropmorepackets/haproxy-go v0.0.8
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
@@ -16,6 +17,7 @@ require (
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
4
go.sum
4
go.sum
@@ -19,6 +19,8 @@ github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+Q
|
||||
github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo=
|
||||
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
|
||||
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -190,6 +192,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
|
||||
@@ -71,33 +71,26 @@ var hashSpec = []hashTable{
|
||||
|
||||
{Name: "ntp_pools", MigrationDefault: true},
|
||||
|
||||
// network_interfaces + ip_addresses werden seit 0030 repliziert —
|
||||
// VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary
|
||||
// für Failover bereitstehen. Ethernet-IPs werden im Secondary-Renderer
|
||||
// herausgefiltert (eth0 = cloud-init / Keepalived).
|
||||
//
|
||||
// ip_addresses.interface_id ist ein node-lokaler Autoincrement-PK, der
|
||||
// zwischen zwei unabhängigen DBs divergiert (utm-1: eth0=6, utm-2: eth0=1).
|
||||
// Wir hashen daher semantisch: address + prefix + flags + interface_name
|
||||
// statt interface_id — sonst False-Positive-Drift auf logisch identischen Nodes.
|
||||
{Name: "network_interfaces"},
|
||||
{Name: "ip_addresses", CustomSQL: `
|
||||
SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '')
|
||||
FROM (
|
||||
SELECT md5(jsonb_build_object(
|
||||
'address', ia.address,
|
||||
'prefix', ia.prefix,
|
||||
'is_vip', ia.is_vip,
|
||||
'active', ia.active,
|
||||
'vip_priority', ia.vip_priority,
|
||||
'description', ia.description,
|
||||
'iface', ni.name
|
||||
)::text) AS rh
|
||||
FROM ip_addresses ia
|
||||
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
||||
) sub`},
|
||||
// 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"},
|
||||
|
||||
// static_routes, dns_settings, ntp_settings bleiben node-spezifisch.
|
||||
// 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
|
||||
// Mgmt-/Host-IPs, z.B. utm-1=.6, utm-2=.8). Würde man sie hashen, wäre
|
||||
// der config_hash zwischen zwei Nodes ZWANGSLÄUFIG dauerhaft verschieden
|
||||
// → Drift-Banner, das kein Resync je beheben kann (Resync kopiert nur
|
||||
// replizierte Tabellen). Migration 0030 wollte sie zwar replizieren,
|
||||
// localOnlyTables schließt sie aber weiter aus → wir hashen sie nicht.
|
||||
//
|
||||
// static_routes, dns_settings, ntp_settings bleiben ebenfalls node-spezifisch.
|
||||
}
|
||||
|
||||
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- network_interfaces und ip_addresses werden in die Cluster-Replikation
|
||||
-- aufgenommen. Das ALTER PUBLICATION erfordert den Superuser (postgres),
|
||||
-- daher läuft es im postinst via `sudo -u postgres psql`, nicht hier.
|
||||
-- Diese Migration dient nur als Versions-Marker für goose.
|
||||
-- HINWEIS (korrigiert v1.2.89): Diese Migration war urspr. dafür gedacht,
|
||||
-- network_interfaces und ip_addresses in die Cluster-Replikation aufzunehmen.
|
||||
-- Das wurde NICHT umgesetzt und ist auch NICHT gewollt: beide Tabellen sind
|
||||
-- node-spezifisch (jede Node hat eigene Mgmt-/Host-IPs) und stehen weiterhin
|
||||
-- in cluster_replication.go localOnlyTables → sie werden bewusst NICHT
|
||||
-- repliziert. Sie sind auch aus dem Drift-Hash (confighash.go) entfernt,
|
||||
-- da sie sonst dauerhaften False-Positive-Drift erzeugen.
|
||||
-- Diese Migration ist ein No-op / reiner Versions-Marker für goose.
|
||||
SELECT 1;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
35
internal/database/migrations/0040_oidc_settings.sql
Normal file
35
internal/database/migrations/0040_oidc_settings.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- OIDC / Keycloak SSO — Singleton-Settings (analog forward_proxy_settings).
|
||||
-- client_secret_enc: secrets.Box.Seal-Output (AES-256-GCM), NULL = nicht gesetzt.
|
||||
-- Rolle kommt bewusst NICHT aus dem Token, daher keine group/role-claim-Spalten.
|
||||
CREATE TABLE IF NOT EXISTS oidc_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
issuer_url TEXT NOT NULL DEFAULT '',
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
client_secret_enc BYTEA,
|
||||
scopes TEXT NOT NULL DEFAULT 'openid email profile',
|
||||
email_claim TEXT NOT NULL DEFAULT 'email',
|
||||
button_label TEXT NOT NULL DEFAULT 'Sign in with SSO',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT oidc_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
INSERT INTO oidc_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Opportunistisches Linking: beim ersten SSO-Login wird der OIDC-'sub'
|
||||
-- gespeichert; weicht er später ab, wird der Login abgelehnt. Nullable,
|
||||
-- kein Backfill (Match-Schlüssel bleibt die verifizierte E-Mail).
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject TEXT;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS oidc_subject;
|
||||
DROP TABLE IF EXISTS oidc_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
62
internal/database/migrations/0041_dhcp.sql
Normal file
62
internal/database/migrations/0041_dhcp.sql
Normal 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
|
||||
51
internal/database/migrations/0042_radius.sql
Normal file
51
internal/database/migrations/0042_radius.sql
Normal 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
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -138,6 +139,7 @@ type View struct {
|
||||
type WGSiteMasqEntry struct {
|
||||
Iface string // wg interface name, e.g. "wg7"
|
||||
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
|
||||
L3 string // "ip" oder "ip6" — Familie von VPNNet
|
||||
}
|
||||
|
||||
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
|
||||
@@ -148,6 +150,7 @@ type AutoFWRule struct {
|
||||
Proto string
|
||||
Port int
|
||||
DstIP string
|
||||
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
|
||||
Comment string
|
||||
}
|
||||
|
||||
@@ -162,7 +165,11 @@ type RuleLeg struct {
|
||||
DstIfaces []string
|
||||
SrcAddrs []string
|
||||
DstAddrs []string
|
||||
Service ResolvedService // Proto="" → no service match (any)
|
||||
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching —
|
||||
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
|
||||
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
|
||||
L3 string
|
||||
Service ResolvedService // Proto="" → no service match (any)
|
||||
}
|
||||
|
||||
// ResolvedRule has all addresses + services already expanded so the
|
||||
@@ -195,6 +202,12 @@ type ResolvedNATRule struct {
|
||||
DPortStart, DPortEnd int
|
||||
TargetAddr string
|
||||
TargetPortStart, TargetPortEnd int
|
||||
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
|
||||
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
|
||||
// IPv6 MIT Port in eckigen Klammern ("[2001:db8::1]") für korrekte
|
||||
// nft-dnat-Syntax.
|
||||
L3 string
|
||||
TargetHost string
|
||||
Comment string
|
||||
}
|
||||
|
||||
@@ -280,26 +293,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Expand to one Leg per (rule × service); rules without a service
|
||||
// produce one leg with empty Proto.
|
||||
// Expand to one Leg per (rule × service × address-family). Rules
|
||||
// without a service produce one leg-set with empty Proto.
|
||||
for _, r := range rules {
|
||||
if len(r.Services) == 0 {
|
||||
view.Legs = append(view.Legs, RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
|
||||
Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
|
||||
})
|
||||
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
|
||||
continue
|
||||
}
|
||||
for _, svc := range r.Services {
|
||||
view.Legs = append(view.Legs, RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
|
||||
Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
|
||||
Service: svc,
|
||||
})
|
||||
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +325,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if wgRows.Scan(&name, &cidr) == nil {
|
||||
view.WGServerIfaces = append(view.WGServerIfaces, name)
|
||||
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
|
||||
l3 := addrFamily(ipNet.String())
|
||||
if l3 == "" {
|
||||
l3 = "ip"
|
||||
}
|
||||
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
|
||||
Iface: name,
|
||||
VPNNet: ipNet.String(),
|
||||
L3: l3,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -411,6 +418,45 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -435,6 +481,131 @@ func isLoopback(ip string) bool {
|
||||
return strings.HasPrefix(ip, "127.")
|
||||
}
|
||||
|
||||
// addrFamily klassifiziert einen nft-Adressausdruck (host, CIDR oder
|
||||
// range "a-b") als "ip" (IPv4), "ip6" (IPv6) oder "" (unbestimmt, z.B.
|
||||
// FQDN-Platzhalter). Adressen enthalten selbst kein '-', daher trennt der
|
||||
// erste Bindestrich sicher eine Range in ihr erstes Element.
|
||||
func addrFamily(expr string) string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(expr, '-'); i > 0 {
|
||||
expr = strings.TrimSpace(expr[:i])
|
||||
}
|
||||
if i := strings.IndexByte(expr, '/'); i > 0 {
|
||||
expr = expr[:i]
|
||||
}
|
||||
ip := net.ParseIP(expr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
return "ip"
|
||||
}
|
||||
return "ip6"
|
||||
}
|
||||
|
||||
// splitByFamily teilt eine Liste von nft-Adressausdrücken in v4 und v6.
|
||||
// Unbestimmte (FQDN o.ä.) werden verworfen.
|
||||
func splitByFamily(exprs []string) (v4, v6 []string) {
|
||||
for _, e := range exprs {
|
||||
switch addrFamily(e) {
|
||||
case "ip":
|
||||
v4 = append(v4, e)
|
||||
case "ip6":
|
||||
v6 = append(v6, e)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch.
|
||||
func serviceL3(svc ResolvedService) string {
|
||||
switch svc.Proto {
|
||||
case "icmp":
|
||||
return "ip"
|
||||
case "icmpv6":
|
||||
return "ip6"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// natFamily ermittelt die Adressfamilie einer NAT-Regel aus ihren
|
||||
// Adressen. ok=false bei gemischten v4/v6-Adressen (ungültig → die Regel
|
||||
// muss übersprungen werden, sonst bricht `nft -f` das gesamte Ruleset).
|
||||
func natFamily(r ResolvedNATRule) (fam string, ok bool) {
|
||||
for _, a := range []string{r.SrcCIDR, r.DstCIDR, r.TargetAddr} {
|
||||
f := addrFamily(a)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if fam == "" {
|
||||
fam = f
|
||||
} else if fam != f {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if fam == "" {
|
||||
fam = "ip" // keine Adressen (reine iface/proto-Regel) → v4-Default
|
||||
}
|
||||
return fam, true
|
||||
}
|
||||
|
||||
// expandFamilyLegs materialisiert die nft-Zeilen für eine Regel + optional
|
||||
// einen Service, getrennt nach Adressfamilie. Adresslose Regeln ergeben eine
|
||||
// einzige familienagnostische Zeile (unverändertes v4-Verhalten, greift
|
||||
// zugleich für v6). Regeln mit Adressen werden pro Familie als separate
|
||||
// Zeile emittiert — ein nft-Paket ist immer entweder v4 oder v6.
|
||||
func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLeg {
|
||||
base := RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
}
|
||||
if hasSvc {
|
||||
base.Service = svc
|
||||
}
|
||||
|
||||
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
|
||||
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
|
||||
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
|
||||
return []RuleLeg{base}
|
||||
}
|
||||
|
||||
src4, src6 := splitByFamily(r.SrcAddrs)
|
||||
dst4, dst6 := splitByFamily(r.DstAddrs)
|
||||
svcFam := ""
|
||||
if hasSvc {
|
||||
svcFam = serviceL3(svc)
|
||||
}
|
||||
|
||||
var legs []RuleLeg
|
||||
for _, fam := range []string{"ip", "ip6"} {
|
||||
if svcFam != "" && svcFam != fam {
|
||||
continue // icmp nur auf v4, icmpv6 nur auf v6
|
||||
}
|
||||
srcF, dstF := src4, dst4
|
||||
if fam == "ip6" {
|
||||
srcF, dstF = src6, dst6
|
||||
}
|
||||
// Eine eingeschränkte Seite ohne Mitglied dieser Familie → die
|
||||
// Zeile würde nichts (oder Falsches) matchen → überspringen.
|
||||
if len(r.SrcAddrs) > 0 && len(srcF) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(r.DstAddrs) > 0 && len(dstF) == 0 {
|
||||
continue
|
||||
}
|
||||
leg := base
|
||||
leg.L3 = fam
|
||||
leg.SrcAddrs = srcF
|
||||
leg.DstAddrs = dstF
|
||||
legs = append(legs, leg)
|
||||
}
|
||||
return legs
|
||||
}
|
||||
|
||||
// addrObjMap is keyed by id; value is the nft expression for that
|
||||
// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10").
|
||||
type addrObjMap map[int64]string
|
||||
@@ -693,6 +864,19 @@ ORDER BY priority DESC, id ASC`)
|
||||
if outZone != nil {
|
||||
r.OutIfaces = zoneIfaces[*outZone]
|
||||
}
|
||||
fam, ok := natFamily(r)
|
||||
if !ok {
|
||||
// Gemischte v4/v6-Adressen → ungültige NAT-Regel. Überspringen
|
||||
// statt das gesamte Ruleset mit `nft -f` zu brechen.
|
||||
slog.Warn("firewall: NAT-Regel mit gemischten v4/v6-Adressen übersprungen", "id", r.ID)
|
||||
continue
|
||||
}
|
||||
r.L3 = fam
|
||||
r.TargetHost = r.TargetAddr
|
||||
if fam == "ip6" && r.TargetAddr != "" && r.TargetPortStart > 0 {
|
||||
// nft braucht [v6]:port für dnat-Targets mit Port.
|
||||
r.TargetHost = "[" + r.TargetAddr + "]"
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
59
internal/firewall/firewall_autorule_test.go
Normal file
59
internal/firewall/firewall_autorule_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
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", Comment: "DNS"},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Regression: DstIP-Auto-Rule ohne Iface bleibt unverändert.
|
||||
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
|
||||
t.Errorf("DstIP auto-rule changed\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)
|
||||
}
|
||||
}
|
||||
142
internal/firewall/firewall_e2e_test.go
Normal file
142
internal/firewall/firewall_e2e_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
)
|
||||
|
||||
// TestE2E_IPv6Render fährt den ECHTEN Generator gegen eine Test-DB:
|
||||
// alle Migrations + v4/v6-Seed + RenderToString + nft -c. Nur aktiv, wenn
|
||||
// EG_FWTEST_DSN gesetzt ist (sonst Skip — `go test ./...` bleibt DB-frei).
|
||||
func TestE2E_IPv6Render(t *testing.T) {
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// 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 {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
for _, tbl := range []string{
|
||||
"firewall_nat_rules", "firewall_rules",
|
||||
"firewall_address_group_members", "firewall_address_groups",
|
||||
"firewall_address_objects", "network_interfaces",
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, "DELETE FROM "+tbl); err != nil {
|
||||
t.Fatalf("clean %s: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustExec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("seed failed (%s): %v", sql, err)
|
||||
}
|
||||
}
|
||||
insID := func(sql string, args ...any) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
|
||||
t.Fatalf("seed-id failed (%s): %v", sql, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
mustExec(`INSERT INTO network_interfaces (name,type,role) VALUES ('eth0','ethernet','wan'),('eth1','ethernet','lan')`)
|
||||
|
||||
v4net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v4net','network','10.0.0.0/24') RETURNING id`)
|
||||
v6net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6net','network','2001:db8:1::/64') RETURNING id`)
|
||||
v6host := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6host','host','2001:db8:2::5') RETURNING id`)
|
||||
v6range := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6range','range','2001:db8:3::1-2001:db8:3::9') RETURNING id`)
|
||||
|
||||
// Gemischte Gruppe (v4 + v6) → muss in zwei Familien-Zeilen splitten.
|
||||
grp := insID(`INSERT INTO firewall_address_groups (name) VALUES ('mixed') RETURNING id`)
|
||||
mustExec(`INSERT INTO firewall_address_group_members (group_id,object_id) VALUES ($1,$2),($1,$3)`, grp, v4net, v6net)
|
||||
|
||||
httpsSvc := insID(`INSERT INTO firewall_services (name,proto,port_start,port_end,builtin,description) VALUES ('t-https','tcp',443,443,false,'')
|
||||
ON CONFLICT (name) DO UPDATE SET proto=excluded.proto RETURNING id`)
|
||||
var pingV6 int64
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM firewall_services WHERE proto='icmpv6' LIMIT 1`).Scan(&pingV6)
|
||||
|
||||
// (1) gemischte Gruppe + tcp443 → je eine ip- und ip6-Zeile.
|
||||
mustExec(`INSERT INTO firewall_rules (name,action,src_zone,src_address_group_id,service_object_id) VALUES ('mixed-https','accept','any',$1,$2)`, grp, httpsSvc)
|
||||
// (2) v6-host + icmpv6 → eine ip6-Zeile.
|
||||
if pingV6 != 0 {
|
||||
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,service_object_id) VALUES ('v6-ping','accept',$1,$2)`, v6host, pingV6)
|
||||
}
|
||||
// (3) v6-range src + v6net dst (kein Service).
|
||||
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,dst_address_object_id) VALUES ('v6-range','drop',$1,$2)`, v6range, v6net)
|
||||
|
||||
// (a) v6-DNAT mit Port → dnat to [..]:port.
|
||||
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v6-dnat','dnat','tcp','2001:db8:9::/64',80,'2001:db8:9::2',8080)`)
|
||||
// (b) v4-DNAT (Regression).
|
||||
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v4-dnat','dnat','tcp','1.2.3.4',80,'10.0.0.5',80)`)
|
||||
// (c) gemischte Familie (v4 src, v6 target) → MUSS übersprungen werden.
|
||||
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_src_cidr,target_addr) VALUES ('mixed-snat','snat','any','10.0.0.0/24','2001:db8::99')`)
|
||||
|
||||
out, err := New(pool).RenderToString(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
|
||||
for _, w := range []string{
|
||||
"ip saddr { 10.0.0.0/24 }",
|
||||
"ip6 saddr { 2001:db8:1::/64 }",
|
||||
"ip6 nexthdr icmpv6",
|
||||
"ip6 saddr { 2001:db8:3::1-2001:db8:3::9 }",
|
||||
"dnat to [2001:db8:9::2]:8080",
|
||||
"dnat to 10.0.0.5:80",
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("rendered output missing %q\n----\n%s", w, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "2001:db8::99") {
|
||||
t.Errorf("mixed-family NAT rule was not skipped\n----\n%s", out)
|
||||
}
|
||||
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
t.Skip("nft not in PATH — skipping syntax check")
|
||||
}
|
||||
f, err := os.CreateTemp(t.TempDir(), "e2e-*.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 (no usable sudo): %s", strings.TrimSpace(msg))
|
||||
}
|
||||
t.Fatalf("nft -c rejected the real-rendered ruleset: %v\n%s\n----\n%s", err, combined, out)
|
||||
}
|
||||
}
|
||||
183
internal/firewall/firewall_ipv6_test.go
Normal file
183
internal/firewall/firewall_ipv6_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddrFamily(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.2.3.4": "ip",
|
||||
"10.0.0.0/24": "ip",
|
||||
"1.2.3.4-1.2.3.10": "ip",
|
||||
"2001:db8::1": "ip6",
|
||||
"fd00::/64": "ip6",
|
||||
"2001:db8::1-2001:db8::5": "ip6",
|
||||
"example.com": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := addrFamily(in); got != want {
|
||||
t.Errorf("addrFamily(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_splitsByFamily(t *testing.T) {
|
||||
r := ResolvedRule{
|
||||
ID: 1, Action: "accept",
|
||||
SrcAddrs: []string{"10.0.0.0/24", "fd00::/64"},
|
||||
DstAddrs: []string{"1.2.3.4", "2001:db8::1"},
|
||||
}
|
||||
legs := expandFamilyLegs(r, ResolvedService{}, false)
|
||||
if len(legs) != 2 {
|
||||
t.Fatalf("want 2 legs (v4+v6), got %d", len(legs))
|
||||
}
|
||||
var v4, v6 *RuleLeg
|
||||
for i := range legs {
|
||||
switch legs[i].L3 {
|
||||
case "ip":
|
||||
v4 = &legs[i]
|
||||
case "ip6":
|
||||
v6 = &legs[i]
|
||||
}
|
||||
}
|
||||
if v4 == nil || v6 == nil {
|
||||
t.Fatalf("missing family leg: %+v", legs)
|
||||
}
|
||||
if len(v4.SrcAddrs) != 1 || v4.SrcAddrs[0] != "10.0.0.0/24" || v4.DstAddrs[0] != "1.2.3.4" {
|
||||
t.Errorf("v4 leg wrong: src=%v dst=%v", v4.SrcAddrs, v4.DstAddrs)
|
||||
}
|
||||
if len(v6.SrcAddrs) != 1 || v6.SrcAddrs[0] != "fd00::/64" || v6.DstAddrs[0] != "2001:db8::1" {
|
||||
t.Errorf("v6 leg wrong: src=%v dst=%v", v6.SrcAddrs, v6.DstAddrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_addresslessIsAgnostic(t *testing.T) {
|
||||
legs := expandFamilyLegs(ResolvedRule{ID: 2, Action: "accept"}, ResolvedService{}, false)
|
||||
if len(legs) != 1 || legs[0].L3 != "" {
|
||||
t.Fatalf("addressless rule must be a single agnostic leg, got %d legs L3=%q", len(legs), legs[0].L3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_oneFamilyOnly(t *testing.T) {
|
||||
// src nur v4, dst nur v4 → genau eine v4-Zeile (kein leerer v6-Leg).
|
||||
r := ResolvedRule{ID: 3, Action: "drop", SrcAddrs: []string{"10.0.0.0/8"}}
|
||||
legs := expandFamilyLegs(r, ResolvedService{}, false)
|
||||
if len(legs) != 1 || legs[0].L3 != "ip" {
|
||||
t.Fatalf("v4-only rule want 1 ip leg, got %+v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_icmpFamilyMatch(t *testing.T) {
|
||||
r6 := ResolvedRule{ID: 4, Action: "accept", SrcAddrs: []string{"fd00::/64"}}
|
||||
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmpv6"}, true); len(legs) != 1 || legs[0].L3 != "ip6" {
|
||||
t.Fatalf("icmpv6+v6 want 1 ip6 leg, got %+v", legs)
|
||||
}
|
||||
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmp"}, true); len(legs) != 0 {
|
||||
t.Fatalf("icmp on v6-only addrs want 0 legs, got %+v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatFamily(t *testing.T) {
|
||||
if _, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24", TargetAddr: "2001:db8::1"}); ok {
|
||||
t.Error("mixed v4/v6 NAT must be rejected (ok=false)")
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{TargetAddr: "2001:db8::1"}); !ok || fam != "ip6" {
|
||||
t.Errorf("v6 NAT: fam=%q ok=%v want ip6/true", fam, ok)
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24"}); !ok || fam != "ip" {
|
||||
t.Errorf("v4 NAT: fam=%q ok=%v want ip/true", fam, ok)
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{}); !ok || fam != "ip" {
|
||||
t.Errorf("addressless NAT: fam=%q ok=%v want ip/true (v4 default)", fam, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// renderView ist ein gemischter v4/v6-View, der alle geänderten
|
||||
// Template-Zweige berührt.
|
||||
func renderView(t *testing.T) string {
|
||||
t.Helper()
|
||||
view := &View{
|
||||
PeerIPv4: []string{"10.0.0.1"},
|
||||
PeerIPv6: []string{"fd00::1"},
|
||||
Legs: []RuleLeg{
|
||||
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
|
||||
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
|
||||
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostisch
|
||||
},
|
||||
NATRules: []ResolvedNATRule{
|
||||
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
|
||||
{ID: 6, Kind: "snat", L3: "ip6", SrcCIDR: "fd00::/64", TargetAddr: "2001:db8::99"},
|
||||
{ID: 7, Kind: "dnat", L3: "ip", DstCIDR: "1.2.3.4", Proto: "tcp", DPortStart: 80, TargetAddr: "10.0.0.5", TargetHost: "10.0.0.5", TargetPortStart: 80},
|
||||
},
|
||||
WGSiteMasq: []WGSiteMasqEntry{{Iface: "wg7", VPNNet: "fd00:99::/64", L3: "ip6"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, view); err != nil {
|
||||
t.Fatalf("template execute: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestTemplate_v6AndV4Render(t *testing.T) {
|
||||
out := renderView(t)
|
||||
mustContain := []string{
|
||||
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
|
||||
"ip6 saddr { fd00::/64 }", // v6-Regel
|
||||
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
|
||||
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
|
||||
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
|
||||
"ip6 saddr fd00::/64 snat to 2001:db8::99",
|
||||
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
|
||||
}
|
||||
for _, w := range mustContain {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("output missing %q\n----\n%s", w, out)
|
||||
}
|
||||
}
|
||||
// v6-Adressen dürfen NIEMALS in einem ip-saddr/daddr-Set landen.
|
||||
if strings.Contains(out, "ip saddr { fd00") || strings.Contains(out, "ip daddr { fd00") ||
|
||||
strings.Contains(out, "ip saddr { 2001") {
|
||||
t.Errorf("v6 address leaked into IPv4 match\n----\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplate_nftSyntax validiert das gerenderte Ruleset mit `nft -c -f`
|
||||
// (Check-Modus, kein Apply). Wird übersprungen, wenn nft nicht installiert
|
||||
// ist (z.B. CI ohne nft).
|
||||
func TestTemplate_nftSyntax(t *testing.T) {
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
t.Skip("nft binary not available — skipping syntax check")
|
||||
}
|
||||
out := renderView(t)
|
||||
f, err := os.CreateTemp(t.TempDir(), "ruleset-*.nft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
|
||||
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
|
||||
// (auf den Nodes rendert/prüft edgeguard ohnehin als root).
|
||||
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())
|
||||
}
|
||||
combined, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := string(combined)
|
||||
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "a password is required") || strings.Contains(msg, "may not run sudo") {
|
||||
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
|
||||
}
|
||||
t.Fatalf("nft -c -f rejected the generated ruleset: %v\n%s\n----\n%s", err, combined, out)
|
||||
}
|
||||
}
|
||||
@@ -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}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||
{{end}}
|
||||
|
||||
# ── Operator-defined rules ──
|
||||
@@ -71,7 +71,7 @@ table inet edgeguard {
|
||||
die Comment-Zeile angehängt — sonst frisst nft die rule
|
||||
als Teil des # Kommentars). */ -}}
|
||||
{{""}}
|
||||
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
|
||||
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}{{.L3}} saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}{{.L3}} daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
|
||||
{{end}}
|
||||
|
||||
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
||||
@@ -101,7 +101,7 @@ table inet edgeguard {
|
||||
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
||||
# gehen via ct state established schon durch.
|
||||
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
|
||||
ip saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
||||
{{.L3}} saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
|
||||
@@ -128,7 +128,7 @@ table inet edgeguard {
|
||||
{{""}}
|
||||
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
||||
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
|
||||
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}ip daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
|
||||
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}{{.L3}} daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetHost}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
|
||||
{{end}}{{end}}
|
||||
}
|
||||
|
||||
@@ -152,16 +152,16 @@ table inet edgeguard {
|
||||
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
|
||||
# findet so den Weg zurück durch den Tunnel.
|
||||
{{range .WGSiteMasq}}
|
||||
oifname "{{.Iface}}" ip saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||
oifname "{{.Iface}}" {{.L3}} saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||
{{end}}
|
||||
{{range .NATRules}}{{if eq .Kind "snat"}}
|
||||
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
||||
{{""}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
|
||||
{{end}}{{if eq .Kind "masquerade"}}
|
||||
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
|
||||
{{""}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}masquerade
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}masquerade
|
||||
{{end}}{{end}}
|
||||
}
|
||||
}
|
||||
|
||||
153
internal/freeradius/freeradius.go
Normal file
153
internal/freeradius/freeradius.go
Normal file
@@ -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)
|
||||
}
|
||||
83
internal/freeradius/freeradius_test.go
Normal file
83
internal/freeradius/freeradius_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
346
internal/handlers/dhcp.go
Normal file
346
internal/handlers/dhcp.go
Normal 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)
|
||||
}
|
||||
349
internal/handlers/oidc.go
Normal file
349
internal/handlers/oidc.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// OIDC / Keycloak SSO. Additiv zum lokalen Passwort-Login. Regeln:
|
||||
// - kein Auto-Provisioning (E-Mail muss als User existieren),
|
||||
// - Rolle kommt aus der DB-Row (nie aus dem Token),
|
||||
// - lokaler Login + TOTP bleiben unangetastet.
|
||||
//
|
||||
// Flow-State (state/PKCE-verifier/nonce) liegt stateless in einem 5-min
|
||||
// signierten HttpOnly-Cookie (SameSite=Lax, da der IdP-Redirect ein
|
||||
// top-level cross-site GET ist). Nach Erfolg wird dieselbe Session wie
|
||||
// beim lokalen Login ausgestellt (setSessionCookie + Signer).
|
||||
|
||||
const (
|
||||
oidcFlowCookie = "edgeguard_oidc_flow"
|
||||
oidcFlowTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
type OIDCHandler struct {
|
||||
Repo *oidcsvc.Repo
|
||||
Auth oidcsvc.Authenticator
|
||||
Users *usersvc.Repo
|
||||
Signer *session.Signer
|
||||
Setup *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewOIDCHandler(repo *oidcsvc.Repo, auth oidcsvc.Authenticator, users *usersvc.Repo, signer *session.Signer, setupStore *setup.Store) *OIDCHandler {
|
||||
return &OIDCHandler{Repo: repo, Auth: auth, Users: users, Signer: signer, Setup: setupStore}
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) WithAudit(a *audit.Repo, nodeID string) *OIDCHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die unauth. Endpoints (auf v1, hinter SetupGate).
|
||||
func (h *OIDCHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/auth/oidc")
|
||||
g.GET("/settings", h.PublicSettings)
|
||||
g.GET("/login", h.Login)
|
||||
g.GET("/callback", h.Callback)
|
||||
}
|
||||
|
||||
// RegisterAdmin mountet die Admin-Endpoints (auf authed: requireAuth +
|
||||
// RequireAdminForMutations → GET für alle, PUT nur admin).
|
||||
func (h *OIDCHandler) RegisterAdmin(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/oidc")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
}
|
||||
|
||||
// PublicSettings: nur, was die Login-Seite braucht.
|
||||
func (h *OIDCHandler) PublicSettings(c *gin.Context) {
|
||||
s, err := h.Repo.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
// Kein Datensatz/kein DB → SSO einfach „aus".
|
||||
response.OK(c, gin.H{"enabled": false, "button_label": ""})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": s.Enabled, "button_label": s.ButtonLabel})
|
||||
}
|
||||
|
||||
// GetSettings: Admin-Sicht ohne Secret, mit secret_configured + redirect_uri.
|
||||
func (h *OIDCHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
|
||||
response.OK(c, gin.H{
|
||||
"enabled": s.Enabled,
|
||||
"issuer_url": s.IssuerURL,
|
||||
"client_id": s.ClientID,
|
||||
"scopes": s.Scopes,
|
||||
"email_claim": s.EmailClaim,
|
||||
"button_label": s.ButtonLabel,
|
||||
"secret_configured": hasSecret,
|
||||
"redirect_uri": h.redirectURI(c),
|
||||
})
|
||||
}
|
||||
|
||||
type oidcUpdateBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IssuerURL string `json:"issuer_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret *string `json:"client_secret"` // nil = unverändert, "" = löschen
|
||||
Scopes string `json:"scopes"`
|
||||
EmailClaim string `json:"email_claim"`
|
||||
ButtonLabel string `json:"button_label"`
|
||||
}
|
||||
|
||||
// UpdateSettings: PUT (admin via RequireAdminForMutations).
|
||||
func (h *OIDCHandler) UpdateSettings(c *gin.Context) {
|
||||
var body oidcUpdateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
body.IssuerURL = strings.TrimSpace(body.IssuerURL)
|
||||
body.ClientID = strings.TrimSpace(body.ClientID)
|
||||
if body.Scopes == "" {
|
||||
body.Scopes = "openid email profile"
|
||||
}
|
||||
if body.EmailClaim == "" {
|
||||
body.EmailClaim = "email"
|
||||
}
|
||||
if body.ButtonLabel == "" {
|
||||
body.ButtonLabel = "Sign in with SSO"
|
||||
}
|
||||
|
||||
if body.Enabled {
|
||||
if body.IssuerURL == "" || body.ClientID == "" {
|
||||
response.BadRequest(c, errors.New("issuer_url und client_id sind erforderlich, wenn OIDC aktiviert ist"))
|
||||
return
|
||||
}
|
||||
if u, err := url.Parse(body.IssuerURL); err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
response.BadRequest(c, errors.New("issuer_url muss eine gültige https-URL sein"))
|
||||
return
|
||||
}
|
||||
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
|
||||
providing := body.ClientSecret != nil && *body.ClientSecret != ""
|
||||
if !hasSecret && !providing {
|
||||
response.BadRequest(c, errors.New("client_secret ist erforderlich (noch keins gespeichert)"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Repo.Update(c.Request.Context(), oidcsvc.UpdateInput{
|
||||
Enabled: body.Enabled,
|
||||
IssuerURL: body.IssuerURL,
|
||||
ClientID: body.ClientID,
|
||||
Scopes: body.Scopes,
|
||||
EmailClaim: body.EmailClaim,
|
||||
ButtonLabel: body.ButtonLabel,
|
||||
ClientSecret: body.ClientSecret,
|
||||
}); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, actorOf(c), "oidc.settings.updated", actorOf(c),
|
||||
gin.H{"enabled": body.Enabled, "issuer": body.IssuerURL})
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Login: 302 zum IdP. Setzt das signierte Flow-Cookie.
|
||||
func (h *OIDCHandler) Login(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
s, err := h.Repo.Get(ctx)
|
||||
if err != nil || !s.Enabled {
|
||||
h.fail(c, "disabled")
|
||||
return
|
||||
}
|
||||
state, err1 := randToken(24)
|
||||
nonce, err2 := randToken(24)
|
||||
if err1 != nil || err2 != nil {
|
||||
h.fail(c, "server")
|
||||
return
|
||||
}
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
redirectURI := h.redirectURI(c)
|
||||
|
||||
authURL, err := h.Auth.AuthCodeURL(ctx, redirectURI, state, nonce, verifier)
|
||||
if err != nil {
|
||||
h.fail(c, "config")
|
||||
return
|
||||
}
|
||||
|
||||
blob, _ := json.Marshal(oidcFlow{State: state, Verifier: verifier, Nonce: nonce})
|
||||
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
|
||||
if err != nil {
|
||||
h.fail(c, "server")
|
||||
return
|
||||
}
|
||||
h.setFlowCookie(c, signed)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// Callback: verifiziert Flow + Token, mappt auf DB-User, stellt Session aus.
|
||||
func (h *OIDCHandler) Callback(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Flow-Cookie lesen + sofort entwerten (single-use).
|
||||
rawFlow, _ := c.Cookie(oidcFlowCookie)
|
||||
h.clearFlowCookie(c)
|
||||
if rawFlow == "" {
|
||||
h.fail(c, "expired")
|
||||
return
|
||||
}
|
||||
payload, err := h.Signer.VerifyBlob(rawFlow)
|
||||
if err != nil {
|
||||
h.fail(c, "expired")
|
||||
return
|
||||
}
|
||||
var flow oidcFlow
|
||||
if json.Unmarshal(payload, &flow) != nil {
|
||||
h.fail(c, "expired")
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("error") != "" {
|
||||
h.fail(c, "denied")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(flow.State)) != 1 {
|
||||
h.fail(c, "state")
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
h.fail(c, "exchange")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.Auth.Exchange(ctx, h.redirectURI(c), code, flow.Verifier)
|
||||
if err != nil {
|
||||
h.fail(c, "token")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(flow.Nonce)) != 1 {
|
||||
h.fail(c, "nonce")
|
||||
return
|
||||
}
|
||||
if !claims.EmailVerified || claims.Email == "" {
|
||||
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
|
||||
gin.H{"via": "oidc", "reason": "email_unverified", "remote": c.ClientIP()})
|
||||
h.fail(c, "unverified")
|
||||
return
|
||||
}
|
||||
|
||||
u, _, err := h.Users.FindByEmail(ctx, claims.Email)
|
||||
if err != nil {
|
||||
reason := "oidc_no_account"
|
||||
if !errors.Is(err, usersvc.ErrNotFound) {
|
||||
reason = "server"
|
||||
}
|
||||
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
|
||||
gin.H{"via": "oidc", "reason": reason, "remote": c.ClientIP()})
|
||||
h.fail(c, map[bool]string{true: "no_account", false: "server"}[reason == "oidc_no_account"])
|
||||
return
|
||||
}
|
||||
if !u.Active {
|
||||
h.audit(c, u.Email, "auth.login.failed", u.Email,
|
||||
gin.H{"via": "oidc", "reason": "account_disabled", "remote": c.ClientIP()})
|
||||
h.fail(c, "disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Opportunistisches sub-Linking + Schutz gegen E-Mail-Reassignment.
|
||||
if stored, err := h.Users.GetOIDCSubject(ctx, u.ID); err == nil {
|
||||
if stored != "" && stored != claims.Subject {
|
||||
h.audit(c, u.Email, "auth.login.failed", u.Email,
|
||||
gin.H{"via": "oidc", "reason": "subject_mismatch", "remote": c.ClientIP()})
|
||||
h.fail(c, "subject_mismatch")
|
||||
return
|
||||
}
|
||||
if stored == "" {
|
||||
_ = h.Users.SetOIDCSubject(ctx, u.ID, claims.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
h.Users.RecordLogin(ctx, u.ID)
|
||||
|
||||
// Rolle STRIKT aus der DB-Row (nie aus Claims).
|
||||
raw, tok, err := h.Signer.IssueWithRole(u.Email, u.Role)
|
||||
if err != nil {
|
||||
h.fail(c, "server")
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, raw, tok.Exp)
|
||||
h.audit(c, u.Email, "auth.login.success", u.Email,
|
||||
gin.H{"via": "oidc", "role": u.Role, "remote": c.ClientIP()})
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type oidcFlow struct {
|
||||
State string `json:"s"`
|
||||
Verifier string `json:"v"`
|
||||
Nonce string `json:"n"`
|
||||
}
|
||||
|
||||
// redirectURI = https://<FQDN>/api/v1/auth/oidc/callback (FQDN aus setup.json,
|
||||
// Fallback Request-Host). Muss im IdP als Redirect-URI registriert sein.
|
||||
func (h *OIDCHandler) redirectURI(c *gin.Context) string {
|
||||
host := ""
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
host = strings.TrimSpace(st.FQDN)
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
return "https://" + host + "/api/v1/auth/oidc/callback"
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) fail(c *gin.Context, reason string) {
|
||||
c.Redirect(http.StatusFound, "/login?sso_error="+url.QueryEscape(reason))
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) audit(c *gin.Context, actor, action, subject string, detail any) {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actor, action, subject, detail, h.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) setFlowCookie(c *gin.Context, raw string) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(oidcFlowCookie, raw, int(oidcFlowTTL.Seconds()), "/", "", true, true)
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) clearFlowCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(oidcFlowCookie, "", -1, "/", "", true, true)
|
||||
}
|
||||
|
||||
func randToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
202
internal/handlers/oidc_test.go
Normal file
202
internal/handlers/oidc_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims —
|
||||
// kein echter IdP nötig.
|
||||
type mockAuth struct {
|
||||
claims *oidcsvc.Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) {
|
||||
return "https://idp.example/authorize?state=" + state, nil
|
||||
}
|
||||
func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) {
|
||||
return m.claims, m.err
|
||||
}
|
||||
|
||||
func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the oidc handler test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// 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 {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0)
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email)
|
||||
if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
blob, _ := json.Marshal(flow)
|
||||
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
|
||||
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed})
|
||||
c.Request = req
|
||||
h.Callback(c)
|
||||
return rec
|
||||
}
|
||||
|
||||
func sessionCookie(rec *httptest.ResponseRecorder) string {
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code")
|
||||
|
||||
if loc := rec.Header().Get("Location"); loc != "/dashboard" {
|
||||
t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc)
|
||||
}
|
||||
raw := sessionCookie(rec)
|
||||
if raw == "" {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
tok, err := signer.Verify(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("session token invalid: %v", err)
|
||||
}
|
||||
// Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims.
|
||||
if tok.Role != "viewer" {
|
||||
t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role)
|
||||
}
|
||||
if tok.Actor != "sso-viewer@test.local" {
|
||||
t.Errorf("token actor = %q", tok.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_UnknownEmail_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local")
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") {
|
||||
t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if sessionCookie(rec) != "" {
|
||||
t.Fatal("no session cookie expected for unknown user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_InactiveUser_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-disabled@test.local", "admin", false)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") {
|
||||
t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if sessionCookie(rec) != "" {
|
||||
t.Fatal("no session cookie expected for inactive user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_EmailUnverified_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-unverified@test.local", "admin", true)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") {
|
||||
t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if sessionCookie(rec) != "" {
|
||||
t.Fatal("no session cookie expected for unverified email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_NonceMismatch_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-nonce@test.local", "admin", true)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") {
|
||||
t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_StateMismatch_Rejected(t *testing.T) {
|
||||
users, _, signer := oidcTestSetup(t)
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") {
|
||||
t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
346
internal/handlers/radius.go
Normal file
346
internal/handlers/radius.go
Normal file
@@ -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)
|
||||
}
|
||||
235
internal/kea/kea.go
Normal file
235
internal/kea/kea.go
Normal 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{}
|
||||
var ifaces []string
|
||||
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
89
internal/kea/kea_test.go
Normal 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
53
internal/models/dhcp.go
Normal 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" }
|
||||
21
internal/models/oidc_settings.go
Normal file
21
internal/models/oidc_settings.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OIDCSettings ist die Singleton-Konfiguration für OIDC/Keycloak-SSO.
|
||||
// ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box)
|
||||
// und wird NIE serialisiert (json:"-").
|
||||
type OIDCSettings struct {
|
||||
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||
Enabled bool `gorm:"column:enabled" json:"enabled"`
|
||||
IssuerURL string `gorm:"column:issuer_url" json:"issuer_url"`
|
||||
ClientID string `gorm:"column:client_id" json:"client_id"`
|
||||
ClientSecretEnc []byte `gorm:"column:client_secret_enc" json:"-"`
|
||||
Scopes string `gorm:"column:scopes" json:"scopes"`
|
||||
EmailClaim string `gorm:"column:email_claim" json:"email_claim"`
|
||||
ButtonLabel string `gorm:"column:button_label" json:"button_label"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (OIDCSettings) TableName() string { return "oidc_settings" }
|
||||
43
internal/models/radius.go
Normal file
43
internal/models/radius.go
Normal 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" }
|
||||
238
internal/services/dhcp/dhcp.go
Normal file
238
internal/services/dhcp/dhcp.go
Normal 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
|
||||
}
|
||||
181
internal/services/oidc/client.go
Normal file
181
internal/services/oidc/client.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Claims sind die aus dem ID-Token extrahierten Felder, die der Login-
|
||||
// Flow braucht. Bewusst minimal — Rolle kommt NIE aus dem Token.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Nonce string
|
||||
}
|
||||
|
||||
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
|
||||
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der
|
||||
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
|
||||
type Authenticator interface {
|
||||
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).
|
||||
AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error)
|
||||
// Exchange tauscht den Code (PKCE), verifiziert das ID-Token und gibt
|
||||
// die Claims zurück. Prüft Issuer/Audience/Signatur/Expiry.
|
||||
Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error)
|
||||
}
|
||||
|
||||
// Client implementiert Authenticator gegen einen echten OIDC-Provider.
|
||||
// Provider+Verifier werden lazy aufgebaut und gecached; bei geänderten
|
||||
// Settings (Fingerprint) neu aufgebaut.
|
||||
type Client struct {
|
||||
repo *Repo
|
||||
|
||||
mu sync.Mutex
|
||||
cacheKey string
|
||||
provider *gooidc.Provider
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewClient(repo *Repo) *Client { return &Client{repo: repo} }
|
||||
|
||||
// loaded baut (oder reused) Provider+Verifier aus den aktuellen Settings.
|
||||
// Cache-Key = Fingerprint(issuer, client_id, scopes); Rebuild bei Änderung.
|
||||
func (c *Client) loaded(ctx context.Context) (*gooidc.Provider, *gooidc.IDTokenVerifier, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !s.Enabled {
|
||||
return nil, nil, ErrDisabled
|
||||
}
|
||||
if strings.TrimSpace(s.IssuerURL) == "" || strings.TrimSpace(s.ClientID) == "" {
|
||||
return nil, nil, fmt.Errorf("oidc: issuer_url and client_id required")
|
||||
}
|
||||
key := fingerprint(s.IssuerURL, s.ClientID, s.Scopes)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.provider == nil || c.cacheKey != key {
|
||||
prov, err := gooidc.NewProvider(ctx, s.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("oidc: discovery: %w", err)
|
||||
}
|
||||
c.provider = prov
|
||||
c.verifier = prov.Verifier(&gooidc.Config{ClientID: s.ClientID})
|
||||
c.cacheKey = key
|
||||
}
|
||||
return c.provider, c.verifier, nil
|
||||
}
|
||||
|
||||
func (c *Client) oauthConfig(prov *gooidc.Provider, clientID, secret, redirectURI, scopes string) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: secret,
|
||||
Endpoint: prov.Endpoint(),
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: splitScopes(scopes),
|
||||
}
|
||||
}
|
||||
|
||||
// AuthCodeURL implementiert Authenticator.
|
||||
func (c *Client) AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prov, _, err := c.loaded(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, _ := c.repo.ClientSecret(ctx)
|
||||
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
|
||||
return cfg.AuthCodeURL(state,
|
||||
gooidc.Nonce(nonce),
|
||||
oauth2.S256ChallengeOption(pkceVerifier),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Exchange implementiert Authenticator.
|
||||
func (c *Client) Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prov, verifier, err := c.loaded(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, _ := c.repo.ClientSecret(ctx)
|
||||
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
|
||||
|
||||
tok, err := cfg.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: code exchange: %w", err)
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawID == "" {
|
||||
return nil, errors.New("oidc: no id_token in response")
|
||||
}
|
||||
idToken, err := verifier.Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: id_token verify: %w", err)
|
||||
}
|
||||
return extractClaims(idToken, s.EmailClaim)
|
||||
}
|
||||
|
||||
// extractClaims liest E-Mail (via konfigurierbarem Claim), email_verified,
|
||||
// sub und nonce aus dem verifizierten ID-Token.
|
||||
func extractClaims(idToken *gooidc.IDToken, emailClaim string) (*Claims, error) {
|
||||
var raw map[string]any
|
||||
if err := idToken.Claims(&raw); err != nil {
|
||||
return nil, fmt.Errorf("oidc: decode claims: %w", err)
|
||||
}
|
||||
if emailClaim == "" {
|
||||
emailClaim = "email"
|
||||
}
|
||||
out := &Claims{Subject: idToken.Subject}
|
||||
if v, ok := raw[emailClaim].(string); ok {
|
||||
out.Email = strings.TrimSpace(strings.ToLower(v))
|
||||
}
|
||||
// email_verified kann bool oder "true"/"false" sein.
|
||||
switch ev := raw["email_verified"].(type) {
|
||||
case bool:
|
||||
out.EmailVerified = ev
|
||||
case string:
|
||||
out.EmailVerified = ev == "true"
|
||||
}
|
||||
if n, ok := raw["nonce"].(string); ok {
|
||||
out.Nonce = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitScopes(s string) []string {
|
||||
out := []string{}
|
||||
for _, p := range strings.Fields(s) {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = []string{gooidc.ScopeOpenID, "email", "profile"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fingerprint(parts ...string) string {
|
||||
h := sha256.New()
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
111
internal/services/oidc/settings.go
Normal file
111
internal/services/oidc/settings.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton-
|
||||
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
|
||||
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
|
||||
// Cookie im Handler), daher hält dieses Paket keinen Request-State.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ErrDisabled signalisiert, dass OIDC nicht aktiviert/konfiguriert ist.
|
||||
var ErrDisabled = errors.New("oidc: not enabled")
|
||||
|
||||
// Repo liest/schreibt die oidc_settings-Singleton-Row und ver-/entschlüsselt
|
||||
// das Client-Secret via secrets.Box.
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
box *secrets.Box
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo {
|
||||
return &Repo{pool: pool, box: box}
|
||||
}
|
||||
|
||||
// Get liefert die Settings (client_secret_enc als Bytes, NULL → nil).
|
||||
func (r *Repo) Get(ctx context.Context) (*models.OIDCSettings, error) {
|
||||
var s models.OIDCSettings
|
||||
if err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, enabled, issuer_url, client_id, client_secret_enc,
|
||||
scopes, email_claim, button_label, created_at, updated_at
|
||||
FROM oidc_settings WHERE id = 1`).Scan(
|
||||
&s.ID, &s.Enabled, &s.IssuerURL, &s.ClientID, &s.ClientSecretEnc,
|
||||
&s.Scopes, &s.EmailClaim, &s.ButtonLabel, &s.CreatedAt, &s.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ClientSecret entschlüsselt das gespeicherte Client-Secret ("" wenn keins).
|
||||
func (r *Repo) ClientSecret(ctx context.Context) (string, error) {
|
||||
s, err := r.Get(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(s.ClientSecretEnc) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
pt, err := r.box.Open(s.ClientSecretEnc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
// HasSecret meldet, ob ein Client-Secret hinterlegt ist (für die Admin-UI,
|
||||
// ohne das Secret selbst preiszugeben).
|
||||
func (r *Repo) HasSecret(ctx context.Context) (bool, error) {
|
||||
var present bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT client_secret_enc IS NOT NULL FROM oidc_settings WHERE id = 1`).Scan(&present)
|
||||
return present, err
|
||||
}
|
||||
|
||||
// UpdateInput beschreibt eine Settings-Änderung. ClientSecret nutzt
|
||||
// write-only-Semantik: nil = unverändert, "" = löschen, sonst neu sealen.
|
||||
type UpdateInput struct {
|
||||
Enabled bool
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
Scopes string
|
||||
EmailClaim string
|
||||
ButtonLabel string
|
||||
ClientSecret *string
|
||||
}
|
||||
|
||||
// Update schreibt die Settings. Das Secret wird nur angefasst, wenn
|
||||
// ClientSecret != nil.
|
||||
func (r *Repo) Update(ctx context.Context, in UpdateInput) error {
|
||||
if in.ClientSecret == nil {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE oidc_settings
|
||||
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
|
||||
email_claim=$5, button_label=$6, updated_at=NOW()
|
||||
WHERE id=1`,
|
||||
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel)
|
||||
return err
|
||||
}
|
||||
|
||||
var enc []byte
|
||||
if *in.ClientSecret != "" {
|
||||
sealed, err := r.box.Seal([]byte(*in.ClientSecret))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc = sealed
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE oidc_settings
|
||||
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
|
||||
email_claim=$5, button_label=$6, client_secret_enc=$7, updated_at=NOW()
|
||||
WHERE id=1`,
|
||||
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel, enc)
|
||||
return err
|
||||
}
|
||||
90
internal/services/oidc/settings_test.go
Normal file
90
internal/services/oidc/settings_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// migrateRetry umgeht die goose-Erst-Apply-Race, wenn mehrere guarded
|
||||
// Test-Pakete dieselbe frische DB parallel migrieren.
|
||||
func migrateRetry(ctx context.Context, dsn string) error {
|
||||
var err error
|
||||
for i := 0; i < 3; i++ {
|
||||
if err = database.Migrate(ctx, dsn); err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
|
||||
func testRepo(t *testing.T) *Repo {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the oidc settings test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := migrateRetry(ctx, dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
box := secrets.New(t.TempDir() + "/master_key")
|
||||
// Settings auf einen sauberen Default zurücksetzen.
|
||||
if _, err := pool.Exec(ctx, `UPDATE oidc_settings SET enabled=false, issuer_url='', client_id='', client_secret_enc=NULL WHERE id=1`); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
return New(pool, box)
|
||||
}
|
||||
|
||||
func TestSettings_SecretWriteOnly(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
ctx := context.Background()
|
||||
str := func(s string) *string { return &s }
|
||||
|
||||
// 1) Neues Secret setzen.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg", ClientSecret: str("s3cr3t")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has, _ := r.HasSecret(ctx); !has {
|
||||
t.Fatal("HasSecret should be true after setting a secret")
|
||||
}
|
||||
got, err := r.ClientSecret(ctx)
|
||||
if err != nil || got != "s3cr3t" {
|
||||
t.Fatalf("ClientSecret = %q, %v; want s3cr3t", got, err)
|
||||
}
|
||||
|
||||
// 2) Update mit nil → Secret bleibt unverändert.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg2", ClientSecret: nil}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = r.ClientSecret(ctx)
|
||||
if got != "s3cr3t" {
|
||||
t.Fatalf("secret should be preserved on nil update, got %q", got)
|
||||
}
|
||||
if s, _ := r.Get(ctx); s.ClientID != "eg2" {
|
||||
t.Fatalf("client_id should update to eg2, got %q", s.ClientID)
|
||||
}
|
||||
|
||||
// 3) Update mit "" → Secret gelöscht.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: false, IssuerURL: "", ClientID: "", ClientSecret: str("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has, _ := r.HasSecret(ctx); has {
|
||||
t.Fatal("HasSecret should be false after clearing the secret")
|
||||
}
|
||||
got, _ = r.ClientSecret(ctx)
|
||||
if got != "" {
|
||||
t.Fatalf("secret should be empty after clear, got %q", got)
|
||||
}
|
||||
}
|
||||
239
internal/services/radius/radius.go
Normal file
239
internal/services/radius/radius.go
Normal 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
|
||||
}
|
||||
@@ -174,3 +174,67 @@ var (
|
||||
ErrInvalidToken = errors.New("invalid session token")
|
||||
ErrExpiredToken = errors.New("session token expired")
|
||||
)
|
||||
|
||||
// blobEnvelope umhüllt eine beliebige Payload mit einem Ablaufzeitpunkt.
|
||||
type blobEnvelope struct {
|
||||
Exp int64 `json:"exp"`
|
||||
Payload []byte `json:"p"`
|
||||
}
|
||||
|
||||
// SignBlob signiert beliebige Bytes mit dem Session-Secret (HMAC-SHA256,
|
||||
// gleiches Format wie Tokens: base64url(json).base64url(sig)) und einer
|
||||
// TTL. Für stateless, cluster-sichere Kurzzeit-Cookies (z.B. der
|
||||
// OIDC-Flow-State). Das Secret ist clusterweit synchron (.jwt_fingerprint).
|
||||
func (s *Signer) SignBlob(payload []byte, ttl time.Duration) (string, error) {
|
||||
env := blobEnvelope{
|
||||
Exp: s.Now().Add(ttl).Unix(),
|
||||
Payload: payload,
|
||||
}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
mac.Write(data)
|
||||
return base64.RawURLEncoding.EncodeToString(data) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifyBlob prüft Signatur + Ablauf und gibt die ursprüngliche Payload
|
||||
// zurück. ErrInvalidToken / ErrExpiredToken bei Fehlern.
|
||||
func (s *Signer) VerifyBlob(raw string) ([]byte, error) {
|
||||
if raw == "" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
dot := -1
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '.' {
|
||||
dot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dot <= 0 || dot >= len(raw)-1 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw[:dot])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(raw[dot+1:])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
mac.Write(payload)
|
||||
if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
var env blobEnvelope
|
||||
if err := json.Unmarshal(payload, &env); err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if s.Now().Unix() >= env.Exp {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return env.Payload, nil
|
||||
}
|
||||
|
||||
@@ -240,6 +240,21 @@ func (r *Repo) RecordLogin(ctx context.Context, id int64) {
|
||||
_, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id)
|
||||
}
|
||||
|
||||
// GetOIDCSubject liefert den gespeicherten OIDC-'sub' des Users ("" wenn
|
||||
// noch nicht verknüpft).
|
||||
func (r *Repo) GetOIDCSubject(ctx context.Context, id int64) (string, error) {
|
||||
var sub string
|
||||
err := r.pool.QueryRow(ctx, `SELECT COALESCE(oidc_subject, '') FROM users WHERE id=$1`, id).Scan(&sub)
|
||||
return sub, err
|
||||
}
|
||||
|
||||
// SetOIDCSubject speichert den OIDC-'sub' beim ersten erfolgreichen
|
||||
// SSO-Login (opportunistisches Linking).
|
||||
func (r *Repo) SetOIDCSubject(ctx context.Context, id int64, sub string) error {
|
||||
_, err := r.pool.Exec(ctx, `UPDATE users SET oidc_subject=$1, updated_at=NOW() WHERE id=$2`, sub, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyPassword is a constant-time bcrypt compare.
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
|
||||
import { Suspense, lazy, useEffect, useState, type ReactNode } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ConfigProvider, Spin } from 'antd'
|
||||
@@ -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'))
|
||||
@@ -79,11 +81,37 @@ const antdTheme = {
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const setUser = useAuthStore((s) => s.set)
|
||||
const location = useLocation()
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
// Wenn kein Store-User da ist (z.B. direkt nach SSO-Callback: Cookie
|
||||
// gesetzt, sessionStorage leer — oder Hard-Refresh), einmal /auth/me
|
||||
// probieren, bevor wir nach /login umleiten.
|
||||
const [checking, setChecking] = useState(user === null)
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null) {
|
||||
setChecking(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
apiClient.get('/auth/me')
|
||||
.then((r) => {
|
||||
if (!cancelled && isEnvelope(r.data)) setUser(r.data.data as SessionUser)
|
||||
})
|
||||
.catch(() => { /* 401 → Interceptor leitet auf /login */ })
|
||||
.finally(() => { if (!cancelled) setChecking(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [user, setUser])
|
||||
|
||||
if (user) return <>{children}</>
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <>{children}</>
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
function SetupGate({ children }: { children: ReactNode }) {
|
||||
@@ -134,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 />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
@@ -289,6 +291,24 @@
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"viewerBadge": "Nur lesen",
|
||||
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
|
||||
"sso": {
|
||||
"login": "Mit SSO anmelden",
|
||||
"err": {
|
||||
"no_account": "Kein EdgeGuard-Konto für diese E-Mail. Bitte wende dich an einen Administrator.",
|
||||
"disabled": "Dieses Konto ist deaktiviert.",
|
||||
"unverified": "Die E-Mail-Adresse ist beim Identity-Provider nicht verifiziert.",
|
||||
"state": "Sicherheitsprüfung fehlgeschlagen (State). Bitte erneut versuchen.",
|
||||
"nonce": "Sicherheitsprüfung fehlgeschlagen (Nonce). Bitte erneut versuchen.",
|
||||
"token": "Das Token vom Identity-Provider konnte nicht verifiziert werden.",
|
||||
"exchange": "Code-Austausch mit dem Identity-Provider fehlgeschlagen.",
|
||||
"expired": "Die Anmeldesitzung ist abgelaufen. Bitte erneut versuchen.",
|
||||
"denied": "Anmeldung beim Identity-Provider abgebrochen.",
|
||||
"subject_mismatch": "Die Identität passt nicht zum hinterlegten Konto.",
|
||||
"config": "SSO ist nicht korrekt konfiguriert.",
|
||||
"server": "Interner Fehler bei der SSO-Anmeldung.",
|
||||
"generic": "SSO-Anmeldung fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"verify": "Code bestätigen",
|
||||
@@ -907,6 +927,24 @@
|
||||
"configPreviewBtn": "Vorschau laden",
|
||||
"configPreviewHint": "Rendert die gewählte Service-Config aus dem aktuellen DB-State. Nur Lesezugriff — es wird nichts auf Disk geschrieben oder neu geladen.",
|
||||
"configCopied": "Config in Zwischenablage kopiert",
|
||||
"oidc": {
|
||||
"title": "Single Sign-On (OIDC)",
|
||||
"intro": "SSO-Login per OpenID Connect (z. B. Keycloak), zusätzlich zum lokalen Login. Anmeldung gelingt nur für bereits angelegte Benutzer; die Rolle wird in EdgeGuard verwaltet.",
|
||||
"enabled": "SSO aktiviert",
|
||||
"issuerUrl": "Issuer-URL",
|
||||
"issuerHint": "Basis-URL des Realms, z. B. https://keycloak.example.com/realms/edgeguard",
|
||||
"clientId": "Client-ID",
|
||||
"clientSecret": "Client-Secret",
|
||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||
"secretUnset": "Noch kein Secret gespeichert.",
|
||||
"scopes": "Scopes",
|
||||
"emailClaim": "E-Mail-Claim",
|
||||
"buttonLabel": "Button-Beschriftung",
|
||||
"redirectUri": "Redirect-URI",
|
||||
"redirectHint": "Diese URL im Keycloak-Client als gültige Redirect-URI eintragen (pro Cluster-Node die jeweilige FQDN).",
|
||||
"saved": "OIDC-Einstellungen gespeichert",
|
||||
"saveFailed": "Speichern der OIDC-Einstellungen fehlgeschlagen"
|
||||
},
|
||||
"passwordCardTitle": "Admin-Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
@@ -1039,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.",
|
||||
@@ -1287,6 +1371,7 @@
|
||||
"common": {
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"or": "oder",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Lädt …",
|
||||
@@ -1771,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.",
|
||||
|
||||
@@ -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",
|
||||
@@ -289,6 +291,24 @@
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"viewerBadge": "Read-only",
|
||||
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
|
||||
"sso": {
|
||||
"login": "Sign in with SSO",
|
||||
"err": {
|
||||
"no_account": "No EdgeGuard account for this email. Please contact an administrator.",
|
||||
"disabled": "This account is disabled.",
|
||||
"unverified": "The email address is not verified at the identity provider.",
|
||||
"state": "Security check failed (state). Please try again.",
|
||||
"nonce": "Security check failed (nonce). Please try again.",
|
||||
"token": "Could not verify the token from the identity provider.",
|
||||
"exchange": "Code exchange with the identity provider failed.",
|
||||
"expired": "The sign-in session expired. Please try again.",
|
||||
"denied": "Sign-in at the identity provider was cancelled.",
|
||||
"subject_mismatch": "The identity does not match the linked account.",
|
||||
"config": "SSO is not configured correctly.",
|
||||
"server": "Internal error during SSO sign-in.",
|
||||
"generic": "SSO sign-in failed."
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||
"verify": "Verify code",
|
||||
@@ -907,6 +927,24 @@
|
||||
"configPreviewBtn": "Load preview",
|
||||
"configPreviewHint": "Renders the selected service config from the current DB state. Read-only — nothing is written to disk or reloaded.",
|
||||
"configCopied": "Config copied to clipboard",
|
||||
"oidc": {
|
||||
"title": "Single Sign-On (OIDC)",
|
||||
"intro": "SSO login via OpenID Connect (e.g. Keycloak), in addition to local login. Only existing users can sign in; the role is managed in EdgeGuard.",
|
||||
"enabled": "SSO enabled",
|
||||
"issuerUrl": "Issuer URL",
|
||||
"issuerHint": "Realm base URL, e.g. https://keycloak.example.com/realms/edgeguard",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client secret",
|
||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||
"secretUnset": "No secret stored yet.",
|
||||
"scopes": "Scopes",
|
||||
"emailClaim": "Email claim",
|
||||
"buttonLabel": "Button label",
|
||||
"redirectUri": "Redirect URI",
|
||||
"redirectHint": "Register this URL as a valid redirect URI in the Keycloak client (per cluster node its own FQDN).",
|
||||
"saved": "OIDC settings saved",
|
||||
"saveFailed": "Failed to save OIDC settings"
|
||||
},
|
||||
"passwordCardTitle": "Change admin password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
@@ -1039,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.",
|
||||
@@ -1287,6 +1371,7 @@
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"or": "or",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading …",
|
||||
@@ -1771,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.",
|
||||
|
||||
385
management-ui/src/pages/DHCP/index.tsx
Normal file
385
management-ui/src/pages/DHCP/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { KeyOutlined } from '@ant-design/icons'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Divider, Form, Input, message, Typography } from 'antd'
|
||||
import { KeyOutlined, LoginOutlined } from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -22,6 +22,28 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
const [totpRequired, setTotpRequired] = useState(false)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||
const [ssoLabel, setSsoLabel] = useState('')
|
||||
|
||||
// SSO-Verfügbarkeit prüfen + evtl. ?sso_error vom Callback anzeigen.
|
||||
useEffect(() => {
|
||||
apiClient.get('/auth/oidc/settings')
|
||||
.then((r) => {
|
||||
if (!isEnvelope(r.data)) return
|
||||
const d = r.data.data as { enabled?: boolean; button_label?: string }
|
||||
setSsoEnabled(!!d.enabled)
|
||||
setSsoLabel(d.button_label || '')
|
||||
})
|
||||
.catch(() => { /* SSO optional */ })
|
||||
|
||||
const reason = new URLSearchParams(window.location.search).get('sso_error')
|
||||
if (reason) {
|
||||
const key = `auth.sso.err.${reason}`
|
||||
const txt = t(key)
|
||||
message.error(txt === key ? t('auth.sso.err.generic') : txt)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const onFinish = async (vals: LoginValues) => {
|
||||
try {
|
||||
@@ -106,6 +128,20 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!totpRequired && ssoEnabled && (
|
||||
<>
|
||||
<Divider plain style={{ fontSize: 12, color: '#94a3b8' }}>{t('common.or')}</Divider>
|
||||
<Button
|
||||
block
|
||||
icon={<LoginOutlined />}
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => { window.location.href = '/api/v1/auth/oidc/login' }}
|
||||
>
|
||||
{ssoLabel || t('auth.sso.login')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!totpRequired && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||
|
||||
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
226
management-ui/src/pages/RADIUS/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,27 @@ interface VIPSettingsValues {
|
||||
gw_check_ip?: string
|
||||
}
|
||||
|
||||
interface OIDCSettingsView {
|
||||
enabled: boolean
|
||||
issuer_url: string
|
||||
client_id: string
|
||||
scopes: string
|
||||
email_claim: string
|
||||
button_label: string
|
||||
secret_configured: boolean
|
||||
redirect_uri: string
|
||||
}
|
||||
|
||||
interface OIDCFormValues {
|
||||
enabled: boolean
|
||||
issuer_url: string
|
||||
client_id: string
|
||||
client_secret?: string
|
||||
scopes: string
|
||||
email_claim: string
|
||||
button_label: string
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -93,6 +114,41 @@ export default function SettingsPage() {
|
||||
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const [oidcForm] = Form.useForm<OIDCFormValues>()
|
||||
const { data: oidc } = useQuery({
|
||||
queryKey: ['oidc', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/oidc/settings')
|
||||
return isEnvelope(r.data) ? r.data.data as OIDCSettingsView : null
|
||||
},
|
||||
})
|
||||
useEffect(() => {
|
||||
if (oidc) {
|
||||
oidcForm.setFieldsValue({
|
||||
enabled: oidc.enabled,
|
||||
issuer_url: oidc.issuer_url,
|
||||
client_id: oidc.client_id,
|
||||
scopes: oidc.scopes,
|
||||
email_claim: oidc.email_claim,
|
||||
button_label: oidc.button_label,
|
||||
client_secret: '',
|
||||
})
|
||||
}
|
||||
}, [oidc, oidcForm])
|
||||
const updateOIDC = useMutation({
|
||||
mutationFn: async (v: OIDCFormValues) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
// leeres Secret = unverändert → Feld weglassen (Backend: nil)
|
||||
if (!v.client_secret) delete body.client_secret
|
||||
return apiClient.put('/oidc/settings', body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.oidc.saved'))
|
||||
void qc.invalidateQueries({ queryKey: ['oidc', 'settings'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('settings.oidc.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const [emailForm] = Form.useForm<ContactEmailValues>()
|
||||
const updateEmails = useMutation({
|
||||
mutationFn: async (v: ContactEmailValues) => {
|
||||
@@ -879,6 +935,49 @@ export default function SettingsPage() {
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title={<><GlobalOutlined /> {t('settings.oidc.title')}</>} className="mb-12" size="small">
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{t('settings.oidc.intro')}
|
||||
</Typography.Paragraph>
|
||||
<Form<OIDCFormValues> form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}>
|
||||
<Form.Item label={t('settings.oidc.enabled')} name="enabled" valuePropName="checked">
|
||||
<Switch disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.issuerUrl')} name="issuer_url" extra={t('settings.oidc.issuerHint')}>
|
||||
<Input placeholder="https://keycloak.example.com/realms/edgeguard" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.clientId')} name="client_id">
|
||||
<Input disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('settings.oidc.clientSecret')}
|
||||
name="client_secret"
|
||||
extra={oidc?.secret_configured ? t('settings.oidc.secretSet') : t('settings.oidc.secretUnset')}
|
||||
>
|
||||
<Input.Password placeholder={oidc?.secret_configured ? '••••••••' : ''} autoComplete="new-password" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.scopes')} name="scopes">
|
||||
<Input placeholder="openid email profile" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.emailClaim')} name="email_claim">
|
||||
<Input placeholder="email" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.buttonLabel')} name="button_label">
|
||||
<Input disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('settings.oidc.redirectUri')} extra={t('settings.oidc.redirectHint')}>
|
||||
<Typography.Text copyable code style={{ fontSize: 12 }}>{oidc?.redirect_uri || ''}</Typography.Text>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" htmlType="submit" loading={updateOIDC.isPending} disabled={isViewer}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
|
||||
<Form<ChangePasswordValues>
|
||||
form={pwForm}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
Reference in New Issue
Block a user