b0e3e31a6b
## 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).
348 lines
10 KiB
Go
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
|
|
}
|