fix(home-assistant): confine the mDNS relay's receive sockets to net1
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>
This commit is contained in:
@@ -48,16 +48,32 @@
|
||||
#
|
||||
# EXPOSURE
|
||||
#
|
||||
# socat binds :5353 on all interfaces, net1 included, because net1's address
|
||||
# is DHCP-leased and unknown at exec time — there is nothing to bind to. Any
|
||||
# host on the IoT VLAN can therefore send this port a datagram and have it
|
||||
# forwarded into HA's zeroconf cache. That is the same trust already extended
|
||||
# to genuine mDNS from that segment, which any device there can equally forge;
|
||||
# the relay widens who can reach HA's listener, not what can be claimed. Note
|
||||
# that macvlan traffic bypasses Cilium policy entirely, so the namespace CNP
|
||||
# does not constrain this interface.
|
||||
# The receive sockets carry so-bindtodevice=net1, so only datagrams that
|
||||
# actually arrived on the IoT VLAN interface are delivered. Binding to net1's
|
||||
# address instead is not possible — it is DHCP-leased and unknown at exec time
|
||||
# — but binding to the device achieves the same confinement without needing to
|
||||
# know the address, and needs no capability: SO_BINDTODEVICE succeeds as an
|
||||
# unprivileged uid here.
|
||||
#
|
||||
# Nothing else is exposed: the container runs socat and only socat, with no
|
||||
# Without it socat would bind the wildcard, and since the namespace CNP grants
|
||||
# every pod in home-assistant unrestricted access to every other pod on every
|
||||
# port, any sibling pod could reach :5353 over the cluster network and have
|
||||
# records forwarded into HA's zeroconf cache.
|
||||
#
|
||||
# What remains, and cannot be closed here: any host on the IoT VLAN can send
|
||||
# this port a datagram. That is the same trust already extended to genuine
|
||||
# mDNS from that segment, which any device there can equally forge — the relay
|
||||
# widens who can reach HA's listener, not what can be claimed. macvlan traffic
|
||||
# bypasses Cilium policy entirely, so the namespace CNP does not constrain
|
||||
# this interface either way.
|
||||
#
|
||||
# Note that the relay is not what makes HA's zeroconf reachable from inside
|
||||
# the cluster: HA binds :5353 on the pod address (v4) and the wildcard (v6),
|
||||
# and the namespace CNP already permits any sibling pod to reach it directly.
|
||||
# Confining the relay removes a second route to that listener; it does not
|
||||
# remove the first.
|
||||
#
|
||||
# Nothing else is exposed: the containers run socat and only socat, with no
|
||||
# second listener to reach.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -124,7 +140,7 @@ spec:
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,reuseaddr"
|
||||
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr"
|
||||
- "UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
# No probes. socat is a single process with no health surface: if it
|
||||
# exits, the container exits and the kubelet restarts it, which is the
|
||||
@@ -146,12 +162,19 @@ spec:
|
||||
# link-local and the kernel's automatic link-local address on the
|
||||
# macvlan is sufficient to receive on it. Whether v30iot hands out
|
||||
# global v6 addresses does not affect reception here.
|
||||
#
|
||||
# ipv6-v6only=1 keeps this socket to v6 only. Both containers share the
|
||||
# pod's network namespace, and a dual-stack v6 socket would overlap the
|
||||
# v4 container's port. Measured behaviour without it was no contention —
|
||||
# the v4 receiver kept taking packets while the v6 one ran — but that
|
||||
# depends on the kernel's bindv6only default rather than on anything
|
||||
# stated here, so the roles are made explicit instead of inherited.
|
||||
- name: socat-ipv6
|
||||
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- -u
|
||||
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,reuseaddr"
|
||||
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr"
|
||||
- "UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
|
||||
resources:
|
||||
requests:
|
||||
|
||||
Reference in New Issue
Block a user