package kea import ( "encoding/json" "fmt" "sort" "strings" v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" ) // ThisServerPlaceholder is substituted by each pod's entrypoint with its // ordinal-derived HA peer name (e.g. "server0"). Keeping it a placeholder in // the shared config means the ConfigMap is identical across pods and carries // no pod IPs, so the config hash never triggers a restart loop. const ThisServerPlaceholder = "@@THIS_SERVER_NAME@@" // Peer is a stable HA peer identity (no pod IPs — DNS names only). type Peer struct { Name string URL string Role string } // RenderInput aggregates a KeaCluster with its matching subnets and client // classes into everything needed to render the kea configs. type RenderInput struct { Cluster v1alpha1.KeaCluster Subnets []v1alpha1.KeaSubnet ClientClasses []v1alpha1.KeaClientClass Peers []Peer } // ---- kea-dhcp4.conf model (field order = JSON key order, deterministic) ---- type dhcp4Root struct { Dhcp4 dhcp4 `json:"Dhcp4"` } type dhcp4 struct { InterfacesConfig map[string]any `json:"interfaces-config"` ControlSocket map[string]any `json:"control-socket"` LeaseDatabase map[string]any `json:"lease-database"` ValidLifetime int `json:"valid-lifetime"` MaxValidLifetime int `json:"max-valid-lifetime"` Authoritative bool `json:"authoritative"` DDNSSendUpdates bool `json:"ddns-send-updates"` OptionDef []optionDef `json:"option-def,omitempty"` OptionData []optionData `json:"option-data,omitempty"` ClientClasses []clientClass `json:"client-classes,omitempty"` HooksLibraries []hookLib `json:"hooks-libraries"` Subnet4 []subnet4 `json:"subnet4"` Loggers []logger `json:"loggers"` } type optionDef struct { Name string `json:"name"` Code int `json:"code"` Type string `json:"type"` Space string `json:"space"` Array bool `json:"array,omitempty"` RecordTypes string `json:"record-types,omitempty"` Encapsulate string `json:"encapsulate,omitempty"` } type optionData struct { Name string `json:"name,omitempty"` Code int `json:"code,omitempty"` Space string `json:"space,omitempty"` Data string `json:"data"` CSVFormat *bool `json:"csv-format,omitempty"` } type clientClass struct { Name string `json:"name"` Test string `json:"test,omitempty"` BootFileName string `json:"boot-file-name,omitempty"` NextServer string `json:"next-server,omitempty"` ServerHostname string `json:"server-hostname,omitempty"` OptionData []optionData `json:"option-data,omitempty"` } type pool struct { Pool string `json:"pool"` } type subnet4 struct { ID int `json:"id"` Subnet string `json:"subnet"` Pools []pool `json:"pools,omitempty"` NextServer string `json:"next-server,omitempty"` BootFileName string `json:"boot-file-name,omitempty"` ValidLifetime int `json:"valid-lifetime,omitempty"` ClientClass string `json:"client-class,omitempty"` OptionData []optionData `json:"option-data,omitempty"` } type hookLib struct { Library string `json:"library"` Parameters map[string]any `json:"parameters,omitempty"` } type logger struct { Name string `json:"name"` Severity string `json:"severity"` OutputOptions []map[string]any `json:"output_options"` } // RenderDHCP4 renders the deterministic kea-dhcp4.conf JSON. func RenderDHCP4(in RenderInput) (string, error) { in = sortInput(in) spec := in.Cluster.Spec valid := spec.DefaultLeaseTime if valid == 0 { valid = 1200 } maxValid := spec.MaxLeaseTime if maxValid == 0 { maxValid = 86400 } d := dhcp4{ InterfacesConfig: map[string]any{"interfaces": []string{"*"}}, ControlSocket: map[string]any{"socket-type": "unix", "socket-name": CtrlSocketPath}, LeaseDatabase: map[string]any{"type": "memfile", "persist": false}, ValidLifetime: valid, MaxValidLifetime: maxValid, Authoritative: true, DDNSSendUpdates: false, HooksLibraries: hooks(in), Subnet4: renderSubnets(in), Loggers: loggers("kea-dhcp4"), } for _, od := range spec.OptionDefs { space := od.Space if space == "" { space = "dhcp4" } d.OptionDef = append(d.OptionDef, optionDef{ Name: od.Name, Code: od.Code, Type: od.Type, Space: space, Array: od.Array, RecordTypes: od.RecordTypes, Encapsulate: od.Encapsulate, }) } // Global options shared by every subnet. if spec.DomainName != "" { d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: spec.DomainName}) } if len(spec.NTPServers) > 0 { d.OptionData = append(d.OptionData, optionData{Name: "ntp-servers", Data: strings.Join(spec.NTPServers, ",")}) } d.ClientClasses = renderClasses(in) return marshal(dhcp4Root{Dhcp4: d}) } func renderSubnets(in RenderInput) []subnet4 { out := make([]subnet4, 0, len(in.Subnets)) for _, s := range in.Subnets { sub := subnet4{ ID: s.Spec.ID, Subnet: s.Spec.Subnet, NextServer: s.Spec.NextServer, BootFileName: s.Spec.BootFileName, ValidLifetime: s.Spec.ValidLifetime, } for _, p := range s.Spec.Pools { sub.Pools = append(sub.Pools, pool{Pool: normalizePool(p)}) } if len(s.Spec.ClientClasses) == 1 { sub.ClientClass = s.Spec.ClientClasses[0] } if len(s.Spec.Routers) > 0 { sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: strings.Join(s.Spec.Routers, ",")}) } if len(s.Spec.DNSServers) > 0 { sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: strings.Join(s.Spec.DNSServers, ",")}) } if s.Spec.DomainName != "" { sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name", Data: s.Spec.DomainName}) } sub.OptionData = append(sub.OptionData, convertOptionData(s.Spec.OptionData)...) out = append(out, sub) } return out } func renderClasses(in RenderInput) []clientClass { out := make([]clientClass, 0, len(in.ClientClasses)) for _, c := range in.ClientClasses { cc := clientClass{ Name: c.Name, Test: classTest(c.Spec), BootFileName: c.Spec.BootFileName, NextServer: c.Spec.NextServer, ServerHostname: c.Spec.ServerHostname, OptionData: convertOptionData(c.Spec.OptionData), } out = append(out, cc) } return out } // classTest returns the raw test if set, else builds one from ArchHex. func classTest(spec v1alpha1.KeaClientClassSpec) string { if spec.Test != "" { return spec.Test } terms := make([]string, 0, len(spec.ArchHex)) for _, a := range spec.ArchHex { terms = append(terms, fmt.Sprintf("option[%d].hex == %s", ClientArchOption, a)) } return strings.Join(terms, " or ") } func hooks(in RenderInput) []hookLib { libs := []hookLib{{Library: LeaseCmdsLibrary}} peers := make([]map[string]any, 0, len(in.Peers)) for _, p := range in.Peers { peers = append(peers, map[string]any{ "name": p.Name, "url": p.URL, "role": p.Role, "auto-failover": true, }) } mode := string(in.Cluster.Spec.HA.Mode) if mode == "" { mode = string(v1alpha1.HAHotStandby) } ha := in.Cluster.Spec.HA rel := map[string]any{ "this-server-name": ThisServerPlaceholder, "mode": mode, "heartbeat-delay": firstNonZero(ha.HeartbeatDelay, 10000), "max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000), "max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000), "max-unacked-clients": firstNonZero(ha.MaxUnackedClients, 5), // With core multi-threading enabled (Kea 2.6 default) the HA hook would // open a dedicated HTTP listener bound to this server's peer url address — // here a per-pod ClusterIP (virtual, kube-proxy DNAT) that is not // assignable on the pod, so the bind fails. Disable it so inbound HA // traffic flows via kea-ctrl-agent, which binds 0.0.0.0:CtrlAgentPort; // peers stay reachable at their ClusterIP:CtrlAgentPort via the per-pod Service. "multi-threading": map[string]any{ "enable-multi-threading": true, "http-dedicated-listener": false, }, "peers": peers, } libs = append(libs, hookLib{ Library: HALibrary, Parameters: map[string]any{"high-availability": []any{rel}}, }) return libs } func loggers(name string) []logger { return []logger{{ Name: name, Severity: "INFO", OutputOptions: []map[string]any{ {"output": "stdout"}, }, }} } // AssignSubnetIDs stamps a stable numeric id on every subnet that lacks one, // choosing the smallest unused positive integer in sorted-CIDR order. func AssignSubnetIDs(subnets []v1alpha1.KeaSubnet) { sort.SliceStable(subnets, func(i, j int) bool { return subnets[i].Spec.Subnet < subnets[j].Spec.Subnet }) used := map[int]bool{} for i := range subnets { if subnets[i].Spec.ID > 0 { used[subnets[i].Spec.ID] = true } } next := 1 for i := range subnets { if subnets[i].Spec.ID == 0 { for used[next] { next++ } subnets[i].Spec.ID = next used[next] = true } } } // sortInput sorts subnets and classes deterministically and assigns subnet ids. func sortInput(in RenderInput) RenderInput { subs := make([]v1alpha1.KeaSubnet, len(in.Subnets)) copy(subs, in.Subnets) AssignSubnetIDs(subs) sort.SliceStable(subs, func(i, j int) bool { return subs[i].Spec.ID < subs[j].Spec.ID }) in.Subnets = subs classes := make([]v1alpha1.KeaClientClass, len(in.ClientClasses)) copy(classes, in.ClientClasses) sort.SliceStable(classes, func(i, j int) bool { return classes[i].Name < classes[j].Name }) in.ClientClasses = classes peers := make([]Peer, len(in.Peers)) copy(peers, in.Peers) sort.SliceStable(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) in.Peers = peers return in } func convertOptionData(in []v1alpha1.OptionData) []optionData { out := make([]optionData, 0, len(in)) for _, o := range in { out = append(out, optionData{ Name: o.Name, Code: o.Code, Space: o.Space, Data: o.Data, CSVFormat: o.CSVFormat, }) } return out } // normalizePool ensures the "start - end" spacing Kea expects. func normalizePool(p string) string { if strings.Contains(p, "-") && !strings.Contains(p, " - ") { parts := strings.SplitN(p, "-", 2) return strings.TrimSpace(parts[0]) + " - " + strings.TrimSpace(parts[1]) } return strings.TrimSpace(p) } func firstNonZero(v, def int) int { if v != 0 { return v } return def } func marshal(v any) (string, error) { b, err := json.MarshalIndent(v, "", " ") if err != nil { return "", err } return string(b) + "\n", nil }