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:
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, ",")
|
||||
}
|
||||
Reference in New Issue
Block a user