// 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{} ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab) 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, ",") }