Merge pull request 'Disable HA hook dedicated listener; route HA via ctrl-agent' (#8) from benvin/ha-listener-bind into main
ci/woodpecker/tag/docker Pipeline was successful

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-29 12:03:26 +10:00
2 changed files with 64 additions and 1 deletions
+10
View File
@@ -241,6 +241,16 @@ func hooks(in RenderInput) []hookLib {
"max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000), "max-response-delay": firstNonZero(ha.MaxResponseDelay, 60000),
"max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000), "max-ack-delay": firstNonZero(ha.MaxAckDelay, 5000),
"max-unacked-clients": firstNonZero(ha.MaxUnackedClients, 5), "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, "peers": peers,
} }
libs = append(libs, hookLib{ libs = append(libs, hookLib{
+53
View File
@@ -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 // TestSubnetWithoutPoolIsDeclared verifies the pool-less subnet still appears
// (Kea must know the subnet to service relayed requests) but carries no pools. // (Kea must know the subnet to service relayed requests) but carries no pools.
func TestSubnetWithoutPoolIsDeclared(t *testing.T) { func TestSubnetWithoutPoolIsDeclared(t *testing.T) {
@@ -231,6 +278,12 @@ func TestRenderCtrlAgent(t *testing.T) {
t.Errorf("ctrl-agent config missing %q", m) 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). // Kea 2.6+ only accepts unix socket paths under /var/run/kea (exact string).
if !strings.Contains(out, `"socket-name": "/var/run/kea/`) { if !strings.Contains(out, `"socket-name": "/var/run/kea/`) {
t.Errorf("ctrl-agent socket-name must be under /var/run/kea, got: %s", out) t.Errorf("ctrl-agent socket-name must be under /var/run/kea, got: %s", out)