From b0e3e31a6b44338daf4cce2e77847caf14d4175e Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Thu, 27 Aug 2026 00:21:20 +1000 Subject: [PATCH] Disable HA hook dedicated listener; route HA via ctrl-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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). --- internal/kea/config.go | 12 ++++++++- internal/kea/config_test.go | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/internal/kea/config.go b/internal/kea/config.go index acb4859..1e6ebb0 100644 --- a/internal/kea/config.go +++ b/internal/kea/config.go @@ -241,7 +241,17 @@ func hooks(in RenderInput) []hookLib { "max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000), "max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000), "max-unacked-clients": firstNonZero(ha.MaxUnackedClients, 5), - "peers": peers, + // 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, diff --git a/internal/kea/config_test.go b/internal/kea/config_test.go index 419360e..e9eef0b 100644 --- a/internal/kea/config_test.go +++ b/internal/kea/config_test.go @@ -114,6 +114,53 @@ func TestRenderDHCP4ReferenceSemantics(t *testing.T) { } } +// TestHADedicatedListenerDisabled guards the HA listener/bind fix: with Kea 2.6 +// core multi-threading on by default, the HA hook would otherwise open a +// dedicated HTTP listener bound to this server's peer url — a per-pod ClusterIP +// that is virtual (kube-proxy DNAT) and unassignable on the pod, failing with +// "Cannot assign requested address". The rendered config must disable the +// dedicated listener so inbound HA traffic is served by kea-ctrl-agent +// (0.0.0.0:8000) while peers stay reachable at their ClusterIP:8000. +func TestHADedicatedListenerDisabled(t *testing.T) { + out, err := RenderDHCP4(referenceInput()) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(out, `"http-dedicated-listener": false`) { + t.Errorf("HA hook must disable the dedicated HTTP listener (it binds this server's ClusterIP peer url, unassignable on the pod); got:\n%s", out) + } + if !strings.Contains(out, `"enable-multi-threading": true`) { + t.Errorf("HA multi-threading must stay enabled (CA-mediated HA traffic); got:\n%s", out) + } + + // The CA-mediated route only works because the dedicated listener stays off + // on the same port the ctrl-agent binds: parse out the HA hook and assert it. + var root dhcp4Root + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var haParams map[string]any + for _, h := range root.Dhcp4.HooksLibraries { + if strings.Contains(h.Library, "libdhcp_ha.so") { + haParams = h.Parameters + } + } + if haParams == nil { + t.Fatal("HA hook library not present in rendered config") + } + rels, ok := haParams["high-availability"].([]any) + if !ok || len(rels) != 1 { + t.Fatalf("high-availability block malformed: %#v", haParams["high-availability"]) + } + mt, ok := rels[0].(map[string]any)["multi-threading"].(map[string]any) + if !ok { + t.Fatalf("HA relationship missing multi-threading block: %#v", rels[0]) + } + if mt["http-dedicated-listener"] != false { + t.Errorf("http-dedicated-listener must be false, got %#v", mt["http-dedicated-listener"]) + } +} + // TestSubnetWithoutPoolIsDeclared verifies the pool-less subnet still appears // (Kea must know the subnet to service relayed requests) but carries no pools. func TestSubnetWithoutPoolIsDeclared(t *testing.T) { @@ -231,6 +278,12 @@ func TestRenderCtrlAgent(t *testing.T) { t.Errorf("ctrl-agent config missing %q", m) } } + // The HA hook runs with the dedicated listener disabled, so inbound HA traffic + // is served by the ctrl-agent; it must bind a pod-local address (0.0.0.0), not + // a ClusterIP, so peers reaching the per-pod Service's ClusterIP:8000 land here. + if !strings.Contains(out, `"http-host": "0.0.0.0"`) { + t.Errorf("ctrl-agent must bind 0.0.0.0 (pod-local) for CA-mediated HA, got: %s", out) + } // Kea 2.6+ only accepts unix socket paths under /var/run/kea (exact string). if !strings.Contains(out, `"socket-name": "/var/run/kea/`) { t.Errorf("ctrl-agent socket-name must be under /var/run/kea, got: %s", out) -- 2.47.3