Files
kea-operator/internal/kea/config.go
T
unkin-agent b0e3e31a6b
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Disable HA hook dedicated listener; route HA via ctrl-agent
## Why
After v0.1.4 pointed HA peer URLs at per-pod ClusterIP Services, kea-dhcp4 2.6
starts the HA service but then crashes at hook load:

  DHCP4_CONFIG_LOAD_FAIL ... Error initializing hooks: CmdHttpListener::run
  failed: unable to setup TCP acceptor for listening to the incoming HTTP
  requests: bind: Cannot assign requested address

With core multi-threading enabled (Kea 2.6 default), the HA hook opens a
dedicated HTTP listener bound to *this* server's peer url address. That address
is now a per-pod ClusterIP — virtual (kube-proxy DNAT), not assignable on the
pod — so the bind fails. Peers must be reached via ClusterIP, but the local
listener must bind a pod-local address.

## How
- Set the HA relationship's multi-threading block http-dedicated-listener:false
  (enable-multi-threading:true). Per Kea 2.6 docs this makes inbound HA traffic
  flow through kea-ctrl-agent instead of a hook-owned listener. The ctrl-agent
  sidecar already binds 0.0.0.0:8000, and the per-pod Service targetPort 8000
  routes ClusterIP:8000 to that container, so remote peers keep reaching this
  server at its ClusterIP while nothing binds the virtual address locally.
- Regression tests: rendered config disables the dedicated listener (string +
  parsed high-availability.multi-threading assertion); ctrl-agent binds 0.0.0.0
  (the pod-local address the CA-mediated route depends on).
2026-08-27 00:21:20 +10:00

348 lines
10 KiB
Go

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
}