From bac7c7e3498156fb3414e979ae2b553eb947e515 Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 5 Jun 2026 18:56:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(dhcp):=20DHCPv4-Server=20via=20Kea=20(kea-?= =?UTF-8?q?dhcp4-server)=20=E2=80=94=20v1.2.92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony. - Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations. - internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK). - internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists). - configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables). - main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable). - UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'. - Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 9 + cmd/edgeguard-ctl/cluster_replication.go | 1 + cmd/edgeguard-ctl/render.go | 5 +- internal/cluster/confighash.go | 5 + internal/configgen/configgen.go | 27 ++ internal/database/migrations/0041_dhcp.sql | 62 +++ internal/firewall/firewall.go | 17 + internal/firewall/firewall_autorule_test.go | 59 +++ internal/firewall/firewall_e2e_test.go | 14 +- internal/firewall/ruleset.nft.tpl | 2 +- internal/handlers/dhcp.go | 346 ++++++++++++++++ internal/kea/kea.go | 235 +++++++++++ internal/kea/kea_test.go | 89 ++++ internal/models/dhcp.go | 53 +++ internal/services/dhcp/dhcp.go | 238 +++++++++++ management-ui/src/App.tsx | 2 + .../src/components/Layout/AppLayout.tsx | 1 + .../src/components/Layout/Sidebar.tsx | 1 + management-ui/src/i18n/locales/de/common.json | 47 +++ management-ui/src/i18n/locales/en/common.json | 47 +++ management-ui/src/pages/DHCP/index.tsx | 385 ++++++++++++++++++ packaging/debian/edgeguard-api/DEBIAN/control | 2 +- .../debian/edgeguard-api/DEBIAN/postinst | 23 ++ 24 files changed, 1666 insertions(+), 6 deletions(-) create mode 100644 internal/database/migrations/0041_dhcp.sql create mode 100644 internal/firewall/firewall_autorule_test.go create mode 100644 internal/handlers/dhcp.go create mode 100644 internal/kea/kea.go create mode 100644 internal/kea/kea_test.go create mode 100644 internal/models/dhcp.go create mode 100644 internal/services/dhcp/dhcp.go create mode 100644 management-ui/src/pages/DHCP/index.tsx diff --git a/VERSION b/VERSION index da5c718..108609c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.91 \ No newline at end of file +1.2.92 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 4b94a63..c5b4973 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -28,6 +28,7 @@ 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" "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 +59,7 @@ 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" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" @@ -445,6 +447,12 @@ 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) + // 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{ @@ -453,6 +461,7 @@ func main() { "squid": squidReloader, "unbound": unboundReloader, "chrony": chronyReloader, + "kea": keaReloader, }) // License — node-local key store + DB-mirror of last verify diff --git a/cmd/edgeguard-ctl/cluster_replication.go b/cmd/edgeguard-ctl/cluster_replication.go index 05c3231..c7a16d9 100644 --- a/cmd/edgeguard-ctl/cluster_replication.go +++ b/cmd/edgeguard-ctl/cluster_replication.go @@ -79,6 +79,7 @@ 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) "system_settings", // Hostname, Maintenance-Mode etc. "join_tokens_used", // Token-Tracking nur auf Primary relevant "audit_log", // Lokales Audit-Protokoll diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index 6a557e7..ff7d5c1 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -8,6 +8,7 @@ import ( "time" "git.netcell-it.de/projekte/edgeguard-native/internal/chrony" + "git.netcell-it.de/projekte/edgeguard-native/internal/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 +62,11 @@ func cmdRenderConfig(args []string) int { wg := wireguard.New(pool, secrets.New("")) ub := unbound.New(pool) cn := chrony.New(pool) + ke := kea.New(pool) if skipReload { hap.SkipReload = true fw.SkipReload = true + ke.SkipReload = true } // keepalived: Node-ID aus node.conf für Prioritäts-Berechnung @@ -72,7 +75,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} if ka != nil { gens = append(gens, ka) } diff --git a/internal/cluster/confighash.go b/internal/cluster/confighash.go index 7595096..177b974 100644 --- a/internal/cluster/confighash.go +++ b/internal/cluster/confighash.go @@ -71,6 +71,11 @@ var hashSpec = []hashTable{ {Name: "ntp_pools", MigrationDefault: true}, + // DHCP: Subnets + Reservierungen sind geteilte Config (repliziert). + // dhcp_settings ist node-lokal (ob DIESE Node DHCP betreibt) → NICHT hier. + {Name: "dhcp_subnets"}, + {Name: "dhcp_reservations"}, + // network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash. // Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT // repliziert und sind per Design node-spezifisch (jede Node hat eigene diff --git a/internal/configgen/configgen.go b/internal/configgen/configgen.go index 409f966..254d78d 100644 --- a/internal/configgen/configgen.go +++ b/internal/configgen/configgen.go @@ -99,6 +99,33 @@ func RestartService(name string) error { return nil } +// StopService runs `sudo -n systemctl stop .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 .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 .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" diff --git a/internal/database/migrations/0041_dhcp.sql b/internal/database/migrations/0041_dhcp.sql new file mode 100644 index 0000000..c12fdcd --- /dev/null +++ b/internal/database/migrations/0041_dhcp.sql @@ -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 diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 2c40265..affd899 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -150,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 } @@ -417,6 +418,22 @@ 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}) + } + } + } + } + return out } diff --git a/internal/firewall/firewall_autorule_test.go b/internal/firewall/firewall_autorule_test.go new file mode 100644 index 0000000..3a5d32d --- /dev/null +++ b/internal/firewall/firewall_autorule_test.go @@ -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 ""`-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) + } +} diff --git a/internal/firewall/firewall_e2e_test.go b/internal/firewall/firewall_e2e_test.go index 66fd84a..c792ad6 100644 --- a/internal/firewall/firewall_e2e_test.go +++ b/internal/firewall/firewall_e2e_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "strings" "testing" + "time" "git.netcell-it.de/projekte/edgeguard-native/internal/database" ) @@ -19,8 +20,17 @@ func TestE2E_IPv6Render(t *testing.T) { t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test") } ctx := context.Background() - if err := database.Migrate(ctx, dsn); err != nil { - t.Fatalf("migrate: %v", err) + // Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere + // guarded Test-Pakete dieselbe frische DB parallel migrieren. + var mErr error + for i := 0; i < 3; i++ { + if mErr = database.Migrate(ctx, dsn); mErr == nil { + break + } + time.Sleep(700 * time.Millisecond) + } + if mErr != nil { + t.Fatalf("migrate: %v", mErr) } pool, err := database.Open(ctx, dsn) if err != nil { diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index 9d994d8..c512b74 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -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 ── diff --git a/internal/handlers/dhcp.go b/internal/handlers/dhcp.go new file mode 100644 index 0000000..988891a --- /dev/null +++ b/internal/handlers/dhcp.go @@ -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) +} diff --git a/internal/kea/kea.go b/internal/kea/kea.go new file mode 100644 index 0000000..e56f375 --- /dev/null +++ b/internal/kea/kea.go @@ -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, ",") +} diff --git a/internal/kea/kea_test.go b/internal/kea/kea_test.go new file mode 100644 index 0000000..44b2270 --- /dev/null +++ b/internal/kea/kea_test.go @@ -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) + } + } +} diff --git a/internal/models/dhcp.go b/internal/models/dhcp.go new file mode 100644 index 0000000..8f2398f --- /dev/null +++ b/internal/models/dhcp.go @@ -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" } diff --git a/internal/services/dhcp/dhcp.go b/internal/services/dhcp/dhcp.go new file mode 100644 index 0000000..1b0e3f8 --- /dev/null +++ b/internal/services/dhcp/dhcp.go @@ -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 +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 0c3c674..04617df 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -27,6 +27,7 @@ const FirewallPage = lazy(() => import('./pages/Firewall')) const WireguardPage = lazy(() => import('./pages/Wireguard')) const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy')) 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')) @@ -161,6 +162,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index cf5e2a9..6f7b4af 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -19,6 +19,7 @@ const PAGE_TITLES: Record = { '/ip-addresses': 'nav.ipAddresses', '/ssl': 'nav.ssl', '/dns': 'nav.dns', + '/dhcp': 'nav.dhcp', '/ntp': 'nav.ntp', '/vpn/wireguard': 'nav.wireguard', '/forward-proxy': 'nav.forwardProxy', diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 04793c0..73c53ed 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -67,6 +67,7 @@ const NAV: NavSection[] = [ { path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: }, { path: '/ssl', labelKey: 'nav.ssl', icon: }, { path: '/dns', labelKey: 'nav.dns', icon: }, + { path: '/dhcp', labelKey: 'nav.dhcp', icon: }, { path: '/ntp', labelKey: 'nav.ntp', icon: }, ], }, diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 9e26415..32a4efc 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -15,6 +15,7 @@ "wireguard": "WireGuard", "forwardProxy": "Forward-Proxy", "dns": "DNS", + "dhcp": "DHCP", "ntp": "Zeit (NTP)", "firewall": "Firewall", "firewallLive": "Firewall-Log", @@ -1075,6 +1076,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.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 3f78320..bb63e92 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -15,6 +15,7 @@ "wireguard": "WireGuard", "forwardProxy": "Forward proxy", "dns": "DNS", + "dhcp": "DHCP", "ntp": "Time (NTP)", "firewall": "Firewall", "firewallLive": "Firewall log", @@ -1075,6 +1076,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.", diff --git a/management-ui/src/pages/DHCP/index.tsx b/management-ui/src/pages/DHCP/index.tsx new file mode 100644 index 0000000..98ce135 --- /dev/null +++ b/management-ui/src/pages/DHCP/index.tsx @@ -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 ( +
+ } title={t('dhcp.title')} subtitle={t('dhcp.intro')} /> + }, + { key: 'subnets', label: t('dhcp.tabs.subnets'), children: }, + ]} + /> +
+ ) +} + +// ── 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() + + 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 ( + + {msgCtx} + + form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> + + + + + + + + + + + + + + + + + + + + + + + ) +} + +// ── 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() + const [editing, setEditing] = useState(null) + const [open, setOpen] = useState(false) + const [resvFor, setResvFor] = useState(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) + setOpen(true) + } + const openEdit = (s: DHCPSubnet) => { + setEditing(s) + form.setFieldsValue(s) + setOpen(true) + } + + const cols: ColumnsType = [ + { 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) => {v ? t('common.active') : t('common.inactive')}, + }, + { + title: t('common.actions'), key: 'actions', width: 280, + render: (_, r) => ( + + + {!isViewer && } + {!isViewer && ( + del.mutate(r.id)}> + + + )} + + ), + }, + ] + + return ( + + {msgCtx} + + + + rowKey="id" size="small" columns={cols} dataSource={subnets ?? []} pagination={false} /> + + setOpen(false)} + onOk={() => form.submit()} + confirmLoading={save.isPending} + destroyOnClose + > + form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> + + + + + + + + + + + + + + + + + + + + + + + + + + + {resvFor && ( + setResvFor(null)} /> + )} + + ) +} + +// ── 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() + const [editing, setEditing] = useState(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 = [ + { 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) => ( + + {!isViewer && } + {!isViewer && ( + del.mutate(r.id)}> + + + )} + + ), + }, + ] + + return ( + + {msgCtx} + + + + rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} /> + + setFormOpen(false)} + onOk={() => form.submit()} + confirmLoading={save.isPending} + destroyOnClose + > + form={form} layout="vertical" onFinish={(v) => save.mutate(v)}> + + + + + + + + + + + + + + + + + + {subnet.subnet_cidr} + + ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/control b/packaging/debian/edgeguard-api/DEBIAN/control index 03133e6..788deb1 100644 --- a/packaging/debian/edgeguard-api/DEBIAN/control +++ b/packaging/debian/edgeguard-api/DEBIAN/control @@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir PG Streaming Replication + provider Floating-IP for HTTP ingress). . This package ships the management API, scheduler and CLI. -Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, 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, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban Section: admin Priority: optional diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index aea4913..857ef99 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -48,6 +48,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 +119,15 @@ 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 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 +238,19 @@ 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 + chmod 0440 /etc/sudoers.d/edgeguard # ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──