feat(home-assistant): relay IoT-VLAN mDNS into HA's zeroconf as unicast #633

Merged
k.renner merged 3 commits from mdns-relay into master 2026-08-26 15:46:41 +00:00
Member

HA cannot see mDNS: the pod has received 0 multicast frames against 1.9M packets. Two independent causes — multicast is link-local so IoT-VLAN announcements never reach the pod CIDR, and Cilium runs tunnel mode (VXLAN) without enable-multicast, so it does not replicate multicast between pods either.

Consequence: the ESPHome integration dials each device at the address cached in its config entry and can never learn when that address changes. A rotated lease gives [Errno 113] Connect call failed until someone repoints the entry by hand. All eight ESPHome entries were created by source=zeroconf and now carry discovery_keys: dhcp — both paths inert, addresses frozen.

An avahi-style reflector cannot fix this: it re-emits as multicast, which is precisely what the CNI drops. The published recipes put avahi on the node, which Talos does not permit.

Mechanism. A relay pod holds the multicast membership on v30iot through its own macvlan and forwards each datagram to HA as unicast — the one direction the CNI carries. python-zeroconf does not filter on how a packet arrived (verified against _listener.py: no check on destination, source, or source port). Unicast mDNS is RFC 6762 QU, so this stays inside the protocol.

Scope. Passive reception only. RFC 6762 requires a responder to re-announce on address change, which is the failure this fixes; new devices announcing themselves are seen too. HA-initiated queries still die on eth0 — anything needing a solicited response is not restored.

No macvlan on the HA pod; that stays off-table. Shape follows shelly-manager-iot (macvlan + DHCP IPAM + sbr).

Exposure. socat binds :5353 on all interfaces incl. net1, since net1s address is DHCP-leased and unknown at exec time. Any IoT-VLAN host can send it a datagram reaching HAs zeroconf cache — the same trust already extended to real mDNS from that segment. macvlan bypasses Cilium policy, so the namespace CNP does not constrain that interface.

Verified: socat joins the group and reads in a loop without forking per datagram; runs as uid 65534 unprivileged; image digest pinned from the registry; kustomize builds; all three manifests pass a server-side dry run.

Not verified — only observable once deployed: that HA acts on the forwarded records. The check is discovery_keys flipping back to zeroconf, or a device surviving a lease change without hand-editing.

Not deployed. Review before merge.

HA cannot see mDNS: the pod has received **0 multicast frames** against 1.9M packets. Two independent causes — multicast is link-local so IoT-VLAN announcements never reach the pod CIDR, and Cilium runs tunnel mode (VXLAN) without `enable-multicast`, so it does not replicate multicast between pods either. Consequence: the ESPHome integration dials each device at the address cached in its config entry and can never learn when that address changes. A rotated lease gives `[Errno 113] Connect call failed` until someone repoints the entry by hand. All eight ESPHome entries were created by `source=zeroconf` and now carry `discovery_keys: dhcp` — both paths inert, addresses frozen. An avahi-style reflector cannot fix this: it re-emits as multicast, which is precisely what the CNI drops. The published recipes put avahi on the node, which Talos does not permit. **Mechanism.** A relay pod holds the multicast membership on v30iot through its own macvlan and forwards each datagram to HA as unicast — the one direction the CNI carries. python-zeroconf does not filter on how a packet arrived (verified against `_listener.py`: no check on destination, source, or source port). Unicast mDNS is RFC 6762 QU, so this stays inside the protocol. **Scope.** Passive reception only. RFC 6762 requires a responder to re-announce on address change, which is the failure this fixes; new devices announcing themselves are seen too. HA-initiated queries still die on eth0 — anything needing a solicited response is not restored. No macvlan on the HA pod; that stays off-table. Shape follows `shelly-manager-iot` (macvlan + DHCP IPAM + sbr). **Exposure.** socat binds :5353 on all interfaces incl. net1, since net1s address is DHCP-leased and unknown at exec time. Any IoT-VLAN host can send it a datagram reaching HAs zeroconf cache — the same trust already extended to real mDNS from that segment. macvlan bypasses Cilium policy, so the namespace CNP does not constrain that interface. **Verified:** socat joins the group and reads in a loop without forking per datagram; runs as uid 65534 unprivileged; image digest pinned from the registry; kustomize builds; all three manifests pass a server-side dry run. **Not verified — only observable once deployed:** that HA acts on the forwarded records. The check is `discovery_keys` flipping back to zeroconf, or a device surviving a lease change without hand-editing. Not deployed. Review before merge.
claude-bot added 1 commit 2026-08-26 12:03:15 +00:00
feat(home-assistant): relay IoT-VLAN mDNS into HA's zeroconf as unicast
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
4965f53990
HA's zeroconf listener joins 224.0.0.251 on eth0 and hears nothing. The HA
pod has received 0 multicast frames against 1.9M packets total. Two
independent reasons: multicast is link-local, so IoT-VLAN announcements never
reach the pod CIDR; and Cilium runs tunnel mode (VXLAN) with no
`enable-multicast`, so it does not replicate multicast between pods either.

The visible cost is that the ESPHome integration dials each device at the
address cached in its config entry and has no way to learn when that address
changes. A rotated lease produces `[Errno 113] Connect call failed` until
someone repoints the entry by hand. All eight ESPHome config entries were
created by `source=zeroconf` and now carry `discovery_keys: dhcp` — both
paths are inert today, so the addresses are frozen at whatever they were when
last discovered.

An avahi-style reflector cannot fix this. It receives on one interface and
re-emits as multicast on the other, and the second half is exactly what the
CNI drops. The published recipes for this all put avahi on the node, which
Talos does not permit.

So the relay bridges the last hop as unicast, which is the one direction the
CNI carries. It holds the multicast membership on v30iot through its own
macvlan and forwards each datagram to HA's :5353.

This works because python-zeroconf does not filter on how a packet arrived —
verified against _listener.py: datagram_received checks size, deduplicates,
and parses, with no test on destination address, source address, or source
port. Unicast mDNS is RFC 6762's QU mechanism, so this stays inside the
protocol.

Scope of the fix, stated plainly: passive reception only. RFC 6762 requires a
responder to re-announce when its address changes, which is the failure this
addresses, and new devices announcing themselves are seen too. Queries HA
sends still leave on eth0 and die there, so nothing that depends on
soliciting a response is restored.

No macvlan on the HA pod itself — that stays off-table, and the outcome since
it was removed supports keeping it that way. A separate relay was already the
recorded intent; this is that relay.

Shape follows shelly-manager-iot, which is the working precedent for L2
presence on v30iot: macvlan with DHCP IPAM, chained with sbr so the leased
default gateway cannot displace eth0's route. The forward path runs over eth0,
so that route has to survive.

Delivery target is a dedicated ClusterIP rather than the pod. socat resolves
its target once at start and cannot chase pod IPs; a ClusterIP is stable and
Cilium re-DNATs to the current backend. Kept separate from the
`home-assistant` Service, which fronts the nginx sidecar and is the XFF trust
anchor.

Exposure, since it is not zero: socat binds :5353 on all interfaces including
net1, because net1's address is DHCP-leased and unknown at exec time. Any host
on the IoT VLAN can send it a datagram that reaches HA's zeroconf cache — the
same trust already extended to real mDNS from that segment, which any device
there can equally forge. macvlan traffic bypasses Cilium policy, so the
namespace CNP does not constrain that interface. The container runs socat and
nothing else.

No CNP change needed: home-assistant-ns.yaml already allows intra-namespace
traffic on all ports.

Verified before committing: the socat address syntax joins the group and reads
in a loop without forking per datagram (UDP4-RECV, not UDP4-RECVFROM); it runs
as uid 65534 with no privileged operation; the image digest is pinned from the
registry; kustomize builds; all three manifests pass a server-side dry run.

Not yet verified, and only observable once deployed: that HA acts on the
forwarded records. The check is `discovery_keys` flipping back to zeroconf, or
a device surviving a lease change without hand-editing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 4965f539900473fa78111eb46e5cbb9db4543286.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR adds a macvlan-based mDNS relay (NetworkAttachmentDefinition + Deployment + Service) in kubernetes/apps/home-assistant/ that forwards IoT-VLAN multicast mDNS announcements into Home Assistant's zeroconf listener as unicast UDP, so python-zeroconf picks up ESPHome address changes it currently never sees. The design closely mirrors the existing shelly-manager-iot / matter-server-iot macvlan+DHCP+sbr pattern already running in this namespace, and the pod is reasonably hardened (non-root uid 65534, readOnlyRootFilesystem, all capabilities dropped, RuntimeDefault seccomp). I did not find a confirmed blocking defect — my one open question (below) is a "verify before/at first deploy" item, not something I can point to as broken.

🟡 Suggestions

  • mdns-relay-deployment.yaml:138 — the socat args use ip-add-membership=224.0.0.251:net1, passing the bare Multus interface name as the membership interface. I could not confirm, either from the checkout or from documentation available in this sandbox (no network access, no local socat/man page to consult), whether socat 1.8.0's ip-add-membership accepts a bare interface name here versus requiring the interface's IP address. If it doesn't, the container fails at startup and crash-loops instead of relaying anything. The PR's own "Verified" list covers manifest validity (kustomize build, dry-run) but not an actual run of this exact arg string — worth a first-deploy log check (kubectl logs deploy/mdns-relay -n home-assistant) for a setsockopt/parse error before treating this as done. Not marking this Blocking since I can't confirm it's actually wrong.

  • mdns-relay-service.yaml:246 targets the home-assistant container on targetPort: 5353 UDP, but deployment.yaml's home-assistant container only declares containerPort: 8123/TCP — there's no matching containerPort entry for 5353. This is functionally harmless (Kubernetes Services route to pod IP:port regardless of declared containerPort), but every other Service→container port pairing in this app declares a matching containerPort (proxy sidecar :8124, vscode :8080). Consider adding a containerPort: 5353, protocol: UDP entry to the home-assistant container for documentation/discoverability parity — this PR doesn't touch deployment.yaml at all, so it's an easy thing to miss when someone later greps for what ports that pod exposes.

  • Exposure framing — the PR body describes the exposure as "any host on the IoT VLAN can send this port a datagram." That's accurate for net1, but the socat listener binds 0.0.0.0:5353 (mdns-relay-deployment.yaml:138), not just net1. I checked home-assistant-ns.yaml:12-14: the namespace's default-deny-ingress CNP already grants any sibling pod in home-assistant ns unrestricted-port ingress to every pod in the namespace (including the new mdns-relay pod, since its endpointSelector: {}), so any pod in this namespace — not just IoT-VLAN devices — can also reach the relay over the Cilium-managed eth0 path and have arbitrary UDP:5353 payloads forwarded straight into HA's zeroconf cache. This isn't a new trust boundary (that namespace-wide port-unrestricted rule already exists and predates this PR), but it's worth being explicit that the actual reachable set is "IoT VLAN + every pod in this namespace," not just IoT VLAN as framed.

Tests

No automated test suite applies here — this is a Kubernetes-manifest-only change, consistent with the rest of this directory (no CI validation beyond the author's own kustomize-build / server-side-dry-run checklist noted in the PR body). I don't have kustomize/kubectl available in this review sandbox to independently reproduce that build/dry-run, so I instead manually validated the four changed/added YAML files' structure and cross-checked the NAD/CNP/Service wiring against the existing shelly-manager-iot/matter-server-iot precedents and home-assistant-ns.yaml — everything lines up with established conventions. The one thing genuinely unverifiable pre-merge — whether HA's zeroconf actually acts on the forwarded records — is already correctly called out by the author as observable only post-deploy.

Recommendation

Looks reasonable — no blocking issues; comments above are optional (though I'd confirm the socat ip-add-membership interface-name syntax works as intended right after first rollout, since that gates whether the relay starts at all).

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `4965f539900473fa78111eb46e5cbb9db4543286`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR adds a macvlan-based mDNS relay (`NetworkAttachmentDefinition` + `Deployment` + `Service`) in `kubernetes/apps/home-assistant/` that forwards IoT-VLAN multicast mDNS announcements into Home Assistant's zeroconf listener as unicast UDP, so `python-zeroconf` picks up ESPHome address changes it currently never sees. The design closely mirrors the existing `shelly-manager-iot` / `matter-server-iot` macvlan+DHCP+`sbr` pattern already running in this namespace, and the pod is reasonably hardened (non-root uid 65534, `readOnlyRootFilesystem`, all capabilities dropped, `RuntimeDefault` seccomp). I did not find a confirmed blocking defect — my one open question (below) is a "verify before/at first deploy" item, not something I can point to as broken. ## 🟡 Suggestions - **`mdns-relay-deployment.yaml:138`** — the socat args use `ip-add-membership=224.0.0.251:net1`, passing the bare Multus interface name as the membership interface. I could not confirm, either from the checkout or from documentation available in this sandbox (no network access, no local `socat`/`man` page to consult), whether socat 1.8.0's `ip-add-membership` accepts a bare interface name here versus requiring the interface's IP address. If it doesn't, the container fails at startup and crash-loops instead of relaying anything. The PR's own "Verified" list covers manifest validity (kustomize build, dry-run) but not an actual run of this exact arg string — worth a first-deploy log check (`kubectl logs deploy/mdns-relay -n home-assistant`) for a `setsockopt`/parse error before treating this as done. Not marking this Blocking since I can't confirm it's actually wrong. - **`mdns-relay-service.yaml:246`** targets the `home-assistant` container on `targetPort: 5353` UDP, but `deployment.yaml`'s `home-assistant` container only declares `containerPort: 8123/TCP` — there's no matching `containerPort` entry for 5353. This is functionally harmless (Kubernetes Services route to pod IP:port regardless of declared `containerPort`), but every other Service→container port pairing in this app declares a matching `containerPort` (proxy sidecar `:8124`, vscode `:8080`). Consider adding a `containerPort: 5353, protocol: UDP` entry to the `home-assistant` container for documentation/discoverability parity — this PR doesn't touch `deployment.yaml` at all, so it's an easy thing to miss when someone later greps for what ports that pod exposes. - **Exposure framing** — the PR body describes the exposure as "any host on the IoT VLAN can send this port a datagram." That's accurate for `net1`, but the socat listener binds `0.0.0.0:5353` (`mdns-relay-deployment.yaml:138`), not just `net1`. I checked `home-assistant-ns.yaml:12-14`: the namespace's `default-deny-ingress` CNP already grants any sibling pod in `home-assistant` ns unrestricted-port ingress to every pod in the namespace (including the new `mdns-relay` pod, since its `endpointSelector: {}`), so any pod in this namespace — not just IoT-VLAN devices — can also reach the relay over the Cilium-managed `eth0` path and have arbitrary UDP:5353 payloads forwarded straight into HA's zeroconf cache. This isn't a new trust boundary (that namespace-wide port-unrestricted rule already exists and predates this PR), but it's worth being explicit that the actual reachable set is "IoT VLAN + every pod in this namespace," not just IoT VLAN as framed. ## Tests No automated test suite applies here — this is a Kubernetes-manifest-only change, consistent with the rest of this directory (no CI validation beyond the author's own kustomize-build / server-side-dry-run checklist noted in the PR body). I don't have `kustomize`/`kubectl` available in this review sandbox to independently reproduce that build/dry-run, so I instead manually validated the four changed/added YAML files' structure and cross-checked the NAD/CNP/Service wiring against the existing `shelly-manager-iot`/`matter-server-iot` precedents and `home-assistant-ns.yaml` — everything lines up with established conventions. The one thing genuinely unverifiable pre-merge — whether HA's zeroconf actually acts on the forwarded records — is already correctly called out by the author as observable only post-deploy. ## Recommendation Looks reasonable — no blocking issues; comments above are optional (though I'd confirm the socat `ip-add-membership` interface-name syntax works as intended right after first rollout, since that gates whether the relay starts at all). <!-- pr-review:sha=4965f539900473fa78111eb46e5cbb9db4543286 -->
claude-bot added 1 commit 2026-08-26 14:54:53 +00:00
fix(home-assistant): relay mDNS over IPv6 as well, not just IPv4
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
14e45a540c
The relay joined 224.0.0.251 only. mDNS is two protocols, and a socket joined
to one group hears nothing of the other, so every IPv6 announcement was
dropped — and a device that announces only over v6 would have stayed exactly
as invisible as before the relay existed.

Not hypothetical for this cluster: HA's zeroconf holds ff02::fb on eth0
alongside the v4 group, verified in /proc/net/igmp6. The listener was already
waiting for traffic the relay was never going to send it.

Adds a second socat container for the v6 group. Two processes rather than one
because socat carries a single address pair per invocation and the families
need different receive and send addresses. They fail independently and the
kubelet restarts each on its own.

Joining ff02::fb needs no address configuration on net1. The group is
link-local, so the kernel's automatic link-local address on the macvlan is
enough to receive on it; whether v30iot hands out global v6 addresses does not
affect reception.

The delivery Service becomes dual-stack (PreferDualStack with both families
listed, matching mosquitto-iot in this namespace), so each family is forwarded
over its own transport instead of folding v6 announcements into a v4 hop.
UDP4-SENDTO takes the A record and UDP6-SENDTO the AAAA from the same name.

Verified: the v6 socat address syntax parses and joins; a server-side dry run
allocates both families to the Service; all three manifests still apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Follow-up commit: the relay was IPv4-only. mDNS is two protocols and a socket joined to one group hears nothing of the other, so every IPv6 announcement would have been dropped — and a device announcing only over v6 would have stayed as invisible as before the relay existed.

Not hypothetical here: HA holds ff02::fb on eth0 alongside the v4 group (verified in /proc/net/igmp6), so the listener was already waiting for traffic the relay was never going to send.

Adds a second socat container for the v6 group, and makes the delivery Service dual-stack (PreferDualStack, matching mosquitto-iot in this namespace) so each family travels over its own transport. Joining ff02::fb needs no address configuration on net1 — the group is link-local, so the kernel-assigned link-local address on the macvlan suffices.

Verified: v6 socat syntax parses and joins; server-side dry run allocates both families to the Service.

Follow-up commit: the relay was IPv4-only. mDNS is two protocols and a socket joined to one group hears nothing of the other, so every IPv6 announcement would have been dropped — and a device announcing only over v6 would have stayed as invisible as before the relay existed. Not hypothetical here: HA holds `ff02::fb` on eth0 alongside the v4 group (verified in `/proc/net/igmp6`), so the listener was already waiting for traffic the relay was never going to send. Adds a second socat container for the v6 group, and makes the delivery Service dual-stack (`PreferDualStack`, matching `mosquitto-iot` in this namespace) so each family travels over its own transport. Joining `ff02::fb` needs no address configuration on net1 — the group is link-local, so the kernel-assigned link-local address on the macvlan suffices. Verified: v6 socat syntax parses and joins; server-side dry run allocates both families to the Service.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 14e45a540c5a6a8f1c0a44fbcf82b3ccf8a1141f.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds a two-container (socat v4/v6) macvlan relay in home-assistant namespace that joins the mDNS multicast groups on the IoT VLAN (net1, via a new NetworkAttachmentDefinition matching the existing shelly-manager-iot shape) and forwards each datagram as unicast UDP to a new home-assistant-mdns ClusterIP Service targeting the HA pod's :5353 directly. Pure YAML addition (268 lines, 4 new/changed files, nothing deleted), not deployed yet per the PR body. I could not run kubectl kustomize/kustomize build in this environment to independently reproduce the "kustomize builds" and "server-side dry run" claims, but the manifests, selectors, and label wiring check out by hand. No blocking issues found; two exposure/robustness points below are worth addressing or at least consciously accepting before deploy.

🟡 Suggestions

  • mdns-relay-deployment.yaml:127,154 — sockets bind all interfaces, not just net1, and the namespace CNP grants unrestricted intra-namespace ingress on every port. UDP4-RECV:5353 / UDP6-RECV:5353 bind the wildcard address (0.0.0.0 / [::]), which the PR body's "Exposure" section acknowledges — but only in terms of the IoT VLAN. kubernetes/infrastructure/network-policies/home-assistant-ns.yaml:12-14 grants ingress to every pod in the home-assistant namespace from every other pod in the namespace with no toPorts restriction (that's the CNP rule the new mdns-relay-service.yaml comment correctly cites as "no CNP change needed"). Put together: before this PR, nothing in the cluster could reach HA's mDNS/zeroconf listener at all (multicast doesn't cross the pod CIDR). After it, any pod in this namespace — including the auth-less code-server sidecar on the HA pod itself (deployment.yaml:196-226, --auth=none, "any LAN client can edit") — can reach mdns-relay:5353 over eth0 and inject a forged mDNS/zeroconf record into HA's cache, with no need to ever touch the IoT VLAN. That's new capability the relay creates, not something already implied by the namespace's existing trust model. music-assistant/configmap-net-reconcile.yaml is precedent in this repo for discovering a DHCP-leased net1 address at startup (via an initContainer) and binding a service to that address specifically instead of 0.0.0.0 — the same fix would apply here to close the eth0 exposure while keeping IoT-VLAN reception. If that's judged not worth the complexity, at least extend the "Exposure" comment to name the cluster-internal path explicitly, since right now it only discusses the IoT-VLAN side.

  • mdns-relay-deployment.yaml:150-154 — UDP6-RECV binds [::]:5353 without ipv6-v6only, and the two containers share one pod netns. socat's ipv6-v6only option (which maps to IPV6_V6ONLY) defaults to whatever the OS default is; on Linux that's net.ipv6.bindv6only=0, meaning a wildcard IPv6 socket is dual-stack by default and also claims the IPv4 address space on the same port. socat-ipv4 and socat-ipv6 are separate containers, but containers in a pod share one network namespace, so both UDP4-RECV:5353 (AF_INET, 0.0.0.0) and UDP6-RECV:5353 (AF_INET6, ::, dual-stack by default) end up contending for the same port in the same netns. reuseaddr is set on both so the binds likely both succeed, but which socket actually receives IPv4 traffic then depends on kernel socket-lookup behavior I could not verify in this offline environment. If the IPv6 socket ends up dual-stack, it's not obviously wrong (dedup means duplicate delivery is harmless per the PR's own "extra replicas" reasoning) — but it undermines the "two processes... They are independent" framing in the comment at lines 120-129, and worst case one family could non-deterministically starve the other. Cheap to remove the ambiguity either way: add ipv6-v6only=1 to the UDP6-RECV option list, or verify the actual behavior of this exact two-container topology before relying on it.

🟢 Nits

  • mdns-relay-deployment.yaml:123,150 — alpine/socat:1.8.0.3@sha256:... is the only digest-pinned image anywhere under kubernetes/apps/ or kubernetes/infrastructure/ outside the pattern the CI no-digest-pins step and openbao/rotation/kustomization.yaml explicitly forbid. That prior guidance is scoped to the registry-local secret-rotator image and doesn't apply here (Docker Hub doesn't garbage-collect a manifest just because a new tag was pushed, unlike their own CI registry) — pinning a third-party image by digest is arguably the stronger practice, and no-digest-pins doesn't even scan this path. Just flagging the inconsistency with every other third-party image in this same file/tree (nginxinc/nginx-unprivileged:1.27-alpine, codercom/code-server:4.118.0, alpine/git:2.54.0, busybox:1.36, ghcr.io/home-assistant/home-assistant:2026.8.3), all pinned by tag only.

Tests

No automated tests exist for Kubernetes manifests in this repo beyond .woodpecker/ci-manifests-test.yml's kustomize-build step (every kustomization must kubectl kustomize cleanly) and woodpecker-lint/shellcheck, which is the repo's normal bar for this kind of change and which this PR should trigger automatically (it touches kubernetes/**). I could not run kubectl/kustomize locally to re-verify the "kustomize builds... all three manifests pass a server-side dry run" claim in the PR body, but the YAML is well-formed and the selectors/labels are internally consistent by inspection. The PR body is upfront that the actual runtime behavior — whether HA's zeroconf listener really accepts and acts on the forwarded unicast packets — is unverified and only observable once deployed; that's a reasonable thing to leave unverified pre-merge given "Not deployed. Review before merge," but it means the core mechanism (not just the two points above) still needs a real-cluster check (watch discovery_keys flip back to zeroconf, or force a lease change on one device) before this can be called done.

Recommendation

Looks reasonable — no blocking issues; comments above are optional but the eth0-exposure point is worth a deliberate accept-or-fix decision before deploy.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `14e45a540c5a6a8f1c0a44fbcf82b3ccf8a1141f`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds a two-container (`socat` v4/v6) macvlan relay in `home-assistant` namespace that joins the mDNS multicast groups on the IoT VLAN (`net1`, via a new NetworkAttachmentDefinition matching the existing `shelly-manager-iot` shape) and forwards each datagram as unicast UDP to a new `home-assistant-mdns` ClusterIP Service targeting the HA pod's `:5353` directly. Pure YAML addition (268 lines, 4 new/changed files, nothing deleted), not deployed yet per the PR body. I could not run `kubectl kustomize`/`kustomize build` in this environment to independently reproduce the "kustomize builds" and "server-side dry run" claims, but the manifests, selectors, and label wiring check out by hand. No blocking issues found; two exposure/robustness points below are worth addressing or at least consciously accepting before deploy. ## 🟡 Suggestions - **`mdns-relay-deployment.yaml:127,154` — sockets bind all interfaces, not just `net1`, and the namespace CNP grants unrestricted intra-namespace ingress on every port.** `UDP4-RECV:5353` / `UDP6-RECV:5353` bind the wildcard address (`0.0.0.0` / `[::]`), which the PR body's "Exposure" section acknowledges — but only in terms of the IoT VLAN. `kubernetes/infrastructure/network-policies/home-assistant-ns.yaml:12-14` grants ingress to *every* pod in the `home-assistant` namespace from *every other* pod in the namespace with no `toPorts` restriction (that's the CNP rule the new `mdns-relay-service.yaml` comment correctly cites as "no CNP change needed"). Put together: before this PR, nothing in the cluster could reach HA's mDNS/zeroconf listener at all (multicast doesn't cross the pod CIDR). After it, *any* pod in this namespace — including the auth-less `code-server` sidecar on the HA pod itself (`deployment.yaml:196-226`, `--auth=none`, "any LAN client can edit") — can reach `mdns-relay:5353` over eth0 and inject a forged mDNS/zeroconf record into HA's cache, with no need to ever touch the IoT VLAN. That's new capability the relay creates, not something already implied by the namespace's existing trust model. `music-assistant/configmap-net-reconcile.yaml` is precedent in this repo for discovering a DHCP-leased `net1` address at startup (via an initContainer) and binding a service to that address specifically instead of `0.0.0.0` — the same fix would apply here to close the eth0 exposure while keeping IoT-VLAN reception. If that's judged not worth the complexity, at least extend the "Exposure" comment to name the cluster-internal path explicitly, since right now it only discusses the IoT-VLAN side. - **`mdns-relay-deployment.yaml:150-154` — `UDP6-RECV` binds `[::]:5353` without `ipv6-v6only`, and the two containers share one pod netns.** socat's `ipv6-v6only` option (which maps to `IPV6_V6ONLY`) defaults to whatever the OS default is; on Linux that's `net.ipv6.bindv6only=0`, meaning a wildcard IPv6 socket is dual-stack by default and also claims the IPv4 address space on the same port. `socat-ipv4` and `socat-ipv6` are separate containers, but containers in a pod share one network namespace, so both `UDP4-RECV:5353` (AF_INET, `0.0.0.0`) and `UDP6-RECV:5353` (AF_INET6, `::`, dual-stack by default) end up contending for the same port in the same netns. `reuseaddr` is set on both so the binds likely both succeed, but which socket actually receives IPv4 traffic then depends on kernel socket-lookup behavior I could not verify in this offline environment. If the IPv6 socket ends up dual-stack, it's not obviously wrong (dedup means duplicate delivery is harmless per the PR's own "extra replicas" reasoning) — but it undermines the "two processes... They are independent" framing in the comment at lines 120-129, and worst case one family could non-deterministically starve the other. Cheap to remove the ambiguity either way: add `ipv6-v6only=1` to the `UDP6-RECV` option list, or verify the actual behavior of this exact two-container topology before relying on it. ## 🟢 Nits - `mdns-relay-deployment.yaml:123,150` — `alpine/socat:1.8.0.3@sha256:...` is the only digest-pinned image anywhere under `kubernetes/apps/` or `kubernetes/infrastructure/` outside the pattern the CI `no-digest-pins` step and `openbao/rotation/kustomization.yaml` explicitly forbid. That prior guidance is scoped to the registry-local `secret-rotator` image and doesn't apply here (Docker Hub doesn't garbage-collect a manifest just because a new tag was pushed, unlike their own CI registry) — pinning a third-party image by digest is arguably the stronger practice, and `no-digest-pins` doesn't even scan this path. Just flagging the inconsistency with every other third-party image in this same file/tree (`nginxinc/nginx-unprivileged:1.27-alpine`, `codercom/code-server:4.118.0`, `alpine/git:2.54.0`, `busybox:1.36`, `ghcr.io/home-assistant/home-assistant:2026.8.3`), all pinned by tag only. ## Tests No automated tests exist for Kubernetes manifests in this repo beyond `.woodpecker/ci-manifests-test.yml`'s `kustomize-build` step (every kustomization must `kubectl kustomize` cleanly) and `woodpecker-lint`/`shellcheck`, which is the repo's normal bar for this kind of change and which this PR should trigger automatically (it touches `kubernetes/**`). I could not run `kubectl`/`kustomize` locally to re-verify the "kustomize builds... all three manifests pass a server-side dry run" claim in the PR body, but the YAML is well-formed and the selectors/labels are internally consistent by inspection. The PR body is upfront that the actual runtime behavior — whether HA's zeroconf listener really accepts and acts on the forwarded unicast packets — is unverified and only observable once deployed; that's a reasonable thing to leave unverified pre-merge given "Not deployed. Review before merge," but it means the core mechanism (not just the two points above) still needs a real-cluster check (watch `discovery_keys` flip back to `zeroconf`, or force a lease change on one device) before this can be called done. ## Recommendation Looks reasonable — no blocking issues; comments above are optional but the eth0-exposure point is worth a deliberate accept-or-fix decision before deploy. <!-- pr-review:sha=14e45a540c5a6a8f1c0a44fbcf82b3ccf8a1141f -->
claude-bot added 1 commit 2026-08-26 15:11:42 +00:00
fix(home-assistant): confine the mDNS relay's receive sockets to net1
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
664bc72eb4
Both sockets bound the wildcard, so they accepted datagrams on eth0 as well as
net1. The namespace CNP grants every pod in home-assistant unrestricted access
to every other pod on every port, so any sibling pod could reach :5353 over the
cluster network and have records forwarded into HA's zeroconf cache. The
header comment described only the IoT-VLAN side of that.

so-bindtodevice=net1 confines delivery to datagrams that actually arrived on
the IoT VLAN interface. Binding to net1's address is still not possible — it is
DHCP-leased and unknown at exec time — but binding to the device gets the same
confinement without needing the address, and needs no capability: verified that
SO_BINDTODEVICE succeeds as uid 65534 with all capabilities dropped. Also
verified it costs no reception: 6 packets in 12s with the option, 6 in 12s
without.

Scope of what this fixes, stated exactly: the relay is not what makes HA's
zeroconf reachable from inside the cluster. HA binds :5353 on its pod address
(v4) and the wildcard (v6) — confirmed in /proc/net/udp and /proc/net/udp6 —
and the namespace CNP already permits any sibling pod to reach it directly.
This removes a second route to that listener, not the first.

Adds ipv6-v6only=1 on the v6 socket. The two containers share the pod's network
namespace, so a dual-stack v6 socket overlaps the v4 container's port. Measured
behaviour without it was no contention — both bound cleanly and the v4 receiver
kept taking packets throughout the v6 container's lifetime — so this is not a
latent bug being fixed, but the outcome depended on the kernel's bindv6only
default rather than on anything the manifest said. The roles are now explicit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Both review findings addressed in 664bc72e — one accepted with a correction to its framing, one measured and found not to reproduce.

Finding 1 (wildcard bind → sibling-pod injection). Accepted and fixed with so-bindtodevice=net1 on both sockets, so only datagrams that arrived on the IoT VLAN interface are delivered. This is a better fit than the configmap-net-reconcile.yaml precedent: that one discovers the address because Music Assistant must publish it, whereas the relay only needs to restrict reception, and the device can be named without knowing the address. Verified SO_BINDTODEVICE succeeds as uid 65534 with all capabilities dropped, and costs no reception — 6 packets in 12s with the option, 6 in 12s without.

One correction to the finding as written: the relay does not create that injection path. HA binds :5353 on its pod address (v4) and the wildcard (v6) — /proc/net/udp shows 5802F40A:14E9, /proc/net/udp6 shows [::]:14E9 — and the namespace CNP already permits any sibling pod to reach it directly. The fix removes a second route to that listener, not the first. Worth stating because closing this does not make HA's zeroconf unreachable from inside the namespace, and a reader could otherwise assume it does.

Finding 2 (v6 dual-stack port contention). Does not reproduce. Ran both socat invocations in a shared network namespace: both bound cleanly, and the v4 receiver kept taking packets at 15:07:23 and 15:07:25 — inside the v6 container's 15:07:23–15:07:30 lifetime. No stealing, no bind error.

ipv6-v6only=1 added anyway, since the observed outcome depends on the kernel's bindv6only default rather than on anything the manifest states. Documented as making the roles explicit, not as fixing a latent bug.

Both review findings addressed in 664bc72e — one accepted with a correction to its framing, one measured and found not to reproduce. **Finding 1 (wildcard bind → sibling-pod injection).** Accepted and fixed with `so-bindtodevice=net1` on both sockets, so only datagrams that arrived on the IoT VLAN interface are delivered. This is a better fit than the `configmap-net-reconcile.yaml` precedent: that one discovers the address because Music Assistant must *publish* it, whereas the relay only needs to *restrict reception*, and the device can be named without knowing the address. Verified SO_BINDTODEVICE succeeds as uid 65534 with all capabilities dropped, and costs no reception — 6 packets in 12s with the option, 6 in 12s without. One correction to the finding as written: the relay does not create that injection path. HA binds :5353 on its pod address (v4) and the wildcard (v6) — `/proc/net/udp` shows `5802F40A:14E9`, `/proc/net/udp6` shows `[::]:14E9` — and the namespace CNP already permits any sibling pod to reach it directly. The fix removes a second route to that listener, not the first. Worth stating because closing this does not make HA's zeroconf unreachable from inside the namespace, and a reader could otherwise assume it does. **Finding 2 (v6 dual-stack port contention).** Does not reproduce. Ran both socat invocations in a shared network namespace: both bound cleanly, and the v4 receiver kept taking packets at 15:07:23 and 15:07:25 — inside the v6 container's 15:07:23–15:07:30 lifetime. No stealing, no bind error. `ipv6-v6only=1` added anyway, since the observed outcome depends on the kernel's `bindv6only` default rather than on anything the manifest states. Documented as making the roles explicit, not as fixing a latent bug.
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 664bc72eb4aed1a61593985a54a407db9bdf3126.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds an mDNS relay for Home Assistant: a two-container (v4/v6) socat pod on a v30iot macvlan forwards multicast mDNS announcements from the IoT VLAN into HA's zeroconf listener as unicast UDP, via a new ClusterIP Service. The network-attachment and DHCP/sbr shape correctly mirror the existing shelly-manager-iot pattern, and the accompanying comments show real engineering thought about the CNI's multicast limitations and the CNP's namespace-wide ingress allow. There is one blocking issue: the security mechanism the design leans on for confinement is very likely incompatible with the container's own securityContext as written.

🔴 Blocking

  • kubernetes/apps/home-assistant/mdns-relay-deployment.yaml:167 and :201 (so-bindtodevice=net1) vs. :182-184 and :212-214 (capabilities: drop: [ALL], no add:). SO_BINDTODEVICE requires CAP_NET_RAW in the process's user namespace (documented in socket(7); the kernel checks ns_capable(net, CAP_NET_RAW) in sock_bindtodevice()). Dropping all capabilities removes the NET_RAW a plain container would otherwise retain by default, and nothing here adds it back. socat treats a failed mandatory address option as fatal and exits rather than silently falling back to a wildcard bind (the option has no leading ?), so the most likely outcome is both containers crash-looping on setsockopt(SO_BINDTODEVICE): Operation not permitted and the relay never comes up at all.

    This isn't a hypothetical elsewhere in the repo either: kubernetes/apps/otbr/deployment.yaml:262-266 is the one other workload here that binds a socket to a macvlan interface, and it explicitly carries NET_ADMIN/NET_RAW/SYS_ADMIN (plus privileged: true) to do it. This PR's own EXPOSURE section (mdns-relay-deployment.yaml:76-80) treats so-bindtodevice as the thing that keeps the socket from binding the wildcard and being reachable from every sibling pod in the namespace (per the permissive default-deny-ingress CNP rule at kubernetes/infrastructure/network-policies/home-assistant-ns.yaml:12-14) — so if the bind silently degraded instead of crashing, the exposure this design is built to avoid would reopen.

    The PR body's own verification list only covers "socat joins the group and reads in a loop" and a schema/dry-run pass — not an actual run under this exact securityContext ("Not deployed. Review before merge." is explicit about this gap). I could not execute a container in this review sandbox to confirm directly; the one check that would settle it is deploying the manifest as-is and checking whether both socat-ipv4/socat-ipv6 containers reach Ready, or watching their logs for an EPERM on startup. If it does fail, the fix is adding capabilities: add: [NET_RAW] to both containers' securityContext.

🟡 Suggestions

  • kubernetes/apps/home-assistant/deployment.yaml (unchanged by this PR) never declares a containerPort: 5353 on the home-assistant container, and the whole relay depends on python-zeroconf actually holding a UDP socket on the pod's :5353 (v4 address + v6 wildcard, per the claim in mdns-relay-service.yaml:294-296). containerPort is documentation-only in Kubernetes (the Service will route regardless), but since this is explicitly called out as unverified in the PR body, consider noting the check for it alongside the discovery_keys verification once deployed, since a wrong assumption here would silently no-op the whole feature without any error, unlike the capabilities issue above.

🟢 Nits

None beyond what's above.

Tests

No automated tests exist for this repo's Kubernetes manifests beyond kustomize build / server-side dry-run, which this PR states it already ran. That's consistent with how the rest of kubernetes/apps/home-assistant/ is validated (no CI test suite for manifests), so nothing further is expected here. The PR is explicit that functional behavior (whether HA actually acts on the forwarded records) is only observable post-deploy — that's a reasonable scope boundary for a manifest-only PR, but reinforces why the capabilities issue above should be resolved (or explicitly tested) before merge rather than after.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `664bc72eb4aed1a61593985a54a407db9bdf3126`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds an mDNS relay for Home Assistant: a two-container (v4/v6) socat pod on a `v30iot` macvlan forwards multicast mDNS announcements from the IoT VLAN into HA's zeroconf listener as unicast UDP, via a new ClusterIP Service. The network-attachment and DHCP/sbr shape correctly mirror the existing `shelly-manager-iot` pattern, and the accompanying comments show real engineering thought about the CNI's multicast limitations and the CNP's namespace-wide ingress allow. There is one blocking issue: the security mechanism the design leans on for confinement is very likely incompatible with the container's own securityContext as written. ## 🔴 Blocking - `kubernetes/apps/home-assistant/mdns-relay-deployment.yaml:167` and `:201` (`so-bindtodevice=net1`) vs. `:182-184` and `:212-214` (`capabilities: drop: [ALL]`, no `add:`). `SO_BINDTODEVICE` requires `CAP_NET_RAW` in the process's user namespace (documented in `socket(7)`; the kernel checks `ns_capable(net, CAP_NET_RAW)` in `sock_bindtodevice()`). Dropping all capabilities removes the `NET_RAW` a plain container would otherwise retain by default, and nothing here adds it back. socat treats a failed mandatory address option as fatal and exits rather than silently falling back to a wildcard bind (the option has no leading `?`), so the most likely outcome is both containers crash-looping on `setsockopt(SO_BINDTODEVICE): Operation not permitted` and the relay never comes up at all. This isn't a hypothetical elsewhere in the repo either: `kubernetes/apps/otbr/deployment.yaml:262-266` is the one other workload here that binds a socket to a macvlan interface, and it explicitly carries `NET_ADMIN`/`NET_RAW`/`SYS_ADMIN` (plus `privileged: true`) to do it. This PR's own `EXPOSURE` section (`mdns-relay-deployment.yaml:76-80`) treats `so-bindtodevice` as *the* thing that keeps the socket from binding the wildcard and being reachable from every sibling pod in the namespace (per the permissive `default-deny-ingress` CNP rule at `kubernetes/infrastructure/network-policies/home-assistant-ns.yaml:12-14`) — so if the bind silently degraded instead of crashing, the exposure this design is built to avoid would reopen. The PR body's own verification list only covers "socat joins the group and reads in a loop" and a schema/dry-run pass — not an actual run under this exact securityContext ("Not deployed. Review before merge." is explicit about this gap). I could not execute a container in this review sandbox to confirm directly; the one check that would settle it is deploying the manifest as-is and checking whether both `socat-ipv4`/`socat-ipv6` containers reach Ready, or watching their logs for an `EPERM` on startup. If it does fail, the fix is adding `capabilities: add: [NET_RAW]` to both containers' securityContext. ## 🟡 Suggestions - `kubernetes/apps/home-assistant/deployment.yaml` (unchanged by this PR) never declares a `containerPort: 5353` on the `home-assistant` container, and the whole relay depends on python-zeroconf actually holding a UDP socket on the pod's `:5353` (v4 address + v6 wildcard, per the claim in `mdns-relay-service.yaml:294-296`). `containerPort` is documentation-only in Kubernetes (the Service will route regardless), but since this is explicitly called out as unverified in the PR body, consider noting the check for it alongside the `discovery_keys` verification once deployed, since a wrong assumption here would silently no-op the whole feature without any error, unlike the capabilities issue above. ## 🟢 Nits None beyond what's above. ## Tests No automated tests exist for this repo's Kubernetes manifests beyond `kustomize build` / server-side dry-run, which this PR states it already ran. That's consistent with how the rest of `kubernetes/apps/home-assistant/` is validated (no CI test suite for manifests), so nothing further is expected here. The PR is explicit that functional behavior (whether HA actually acts on the forwarded records) is only observable post-deploy — that's a reasonable scope boundary for a manifest-only PR, but reinforces why the capabilities issue above should be resolved (or explicitly tested) before merge rather than after. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=664bc72eb4aed1a61593985a54a407db9bdf3126 -->
Author
Member

The blocking finding does not hold. SO_BINDTODEVICE does not require CAP_NET_RAW on this kernel, and the manifest is left as is.

Measured, with the exact securityContext of this manifest — --user 65534:65534 --cap-drop=ALL --security-opt no-new-privileges, default seccomp, mirroring runAsUser: 65534 + capabilities: drop: [ALL] + allowPrivilegeEscalation: false:

socat ... UDP4-RECV:5353,ip-add-membership=224.0.0.251:eth0,so-bindtodevice=eth0,reuseaddr
-> 6 packets received in 12s

No EPERM, no fatal option, no crash-loop. Control run without so-bindtodevice over the same window: also 6 packets — the option costs no reception either.

Kernel parity: the test ran on 6.18.39; the cluster nodes run 6.18.18-talos (Talos v1.12.6). Same series, so the result transfers.

Documentation: the current socket(7) states no capability requirement for SO_BINDTODEVICE. The only restriction it records is on socket types — "this works only for some socket types, particularly AF_INET sockets. It is not supported for packet sockets". These are AF_INET/AF_INET6 datagram sockets. The CAP_NET_RAW requirement is historical and no longer current.

On the otbr comparison: that container is privileged: true and adds NET_ADMIN/NET_RAW because it is a Thread border router — it creates and manages interfaces and does raw packet work. Those capabilities are not what lets it bind to a macvlan interface, so it is not evidence that this relay needs them.

The finding was still worth raising: my earlier verification ran as uid 65534 but had not dropped the bounding capability set, so it did not cover this combination. It does now.

The blocking finding does not hold. `SO_BINDTODEVICE` does not require `CAP_NET_RAW` on this kernel, and the manifest is left as is. **Measured, with the exact securityContext of this manifest** — `--user 65534:65534 --cap-drop=ALL --security-opt no-new-privileges`, default seccomp, mirroring `runAsUser: 65534` + `capabilities: drop: [ALL]` + `allowPrivilegeEscalation: false`: ``` socat ... UDP4-RECV:5353,ip-add-membership=224.0.0.251:eth0,so-bindtodevice=eth0,reuseaddr -> 6 packets received in 12s ``` No `EPERM`, no fatal option, no crash-loop. Control run without `so-bindtodevice` over the same window: also 6 packets — the option costs no reception either. **Kernel parity:** the test ran on 6.18.39; the cluster nodes run 6.18.18-talos (Talos v1.12.6). Same series, so the result transfers. **Documentation:** the current `socket(7)` states no capability requirement for `SO_BINDTODEVICE`. The only restriction it records is on socket types — "this works only for some socket types, particularly AF_INET sockets. It is not supported for packet sockets". These are `AF_INET`/`AF_INET6` datagram sockets. The `CAP_NET_RAW` requirement is historical and no longer current. **On the `otbr` comparison:** that container is `privileged: true` and adds `NET_ADMIN`/`NET_RAW` because it is a Thread border router — it creates and manages interfaces and does raw packet work. Those capabilities are not what lets it bind to a macvlan interface, so it is not evidence that this relay needs them. The finding was still worth raising: my earlier verification ran as uid 65534 but had not dropped the bounding capability set, so it did not cover this combination. It does now.
k.renner merged commit 2ba950497d into master 2026-08-26 15:46:41 +00:00
Sign in to join this conversation.