2 Commits

Author SHA1 Message Date
benvin 0567505a51 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
2026-08-29 12:03:26 +10:00
unkin-agent b0e3e31a6b Disable HA hook dedicated listener; route HA via ctrl-agent
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
## 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
2 changed files with 64 additions and 1 deletions
+11 -1
View File
@@ -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,
+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
// (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)