feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92

Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony.
- Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations.
- internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK).
- internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists).
- configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables).
- main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable).
- UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'.
- Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-05 18:56:30 +02:00
parent 3a707e2e3f
commit bac7c7e349
24 changed files with 1666 additions and 6 deletions

View File

@@ -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
}

View 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)
}
}

View File

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

View File

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