Remove k8s node host addressing on the IoT VLAN (security + hairpin fix) #369

Merged
k.renner merged 3 commits from claude-bot/iot-nic-unaddress into master 2026-07-23 11:42:10 +00:00
8 changed files with 114 additions and 33 deletions
@@ -50,7 +50,7 @@ spec:
# above is additive. No explicit edge exclusion is needed: the
# `workload-class=pinned:NoSchedule` taint on k8s-edge already
# keeps this pod off there (we never tolerate it), and the NAD's
# `master: eth2` lines up with v30iot on every k8s-{1,2,3} worker.
# `master: eth1` lines up with v30iot on every k8s-{1,2,3} worker.
enableServiceLinks: false
# Grants shelly-manager the in-cluster identity used by the firmware
# pipeline to create esphome flash Jobs and stream their logs. RBAC is
@@ -28,9 +28,11 @@ data:
The v30iot IP is DHCP and node-dependent (MA is not node-pinned), so a stored
IP is a stale assumption the moment MA reschedules or the lease changes -
exactly the failure this replaces. We therefore recompute it from the live
interface on every start. hostNetwork means this initContainer sees eth1
directly. Fail closed if no v30iot address is present, so MA can never come
up advertising a wrong IP.
interface on every start. The macvlan child net1 (see network-attachment.yaml)
is attached by Multus with its DHCP lease before any container runs, so this
initContainer sees the pod's own v30iot address by scanning its interfaces.
Fail closed if no v30iot address is present, so MA can never come up
advertising a wrong IP.
"""
import fcntl
import ipaddress
+17 -16
View File
@@ -16,19 +16,19 @@ spec:
metadata:
labels:
app.kubernetes.io/name: music-assistant
annotations:
# Multus attaches net1 (macvlan on v30iot, DHCP-leased) for direct
# L2 adjacency to the audio clients (Snapcast / SlimProto /
# Sendspin) — those protocols sync audio across group members and
# require a shared L2 broadcast domain; routing the audio path
# through the CNI overlay breaks the sync timing budget. This
# replaces the former hostNetwork attachment (see
# network-attachment.yaml) so the NODE no longer holds an address
# on the compromised IoT VLAN; MA gets its own leased IoT IP on
# net1 instead. master: eth1 is v30iot on every k8s-{1,2,3}/edge
# worker.
k8s.v1.cni.cncf.io/networks: music-assistant-iot
spec:
# Host networking attaches MA to the node's L2 VLANs directly.
# Every node's second NIC (eth1) is on v30iot, so the pod sits
# on the IoT VLAN with the audio clients (Snapcast / SlimProto /
# Chromecast). Those protocols sync audio across group members
# and require all members to share an L2 broadcast domain;
# routing the audio path through the CNI overlay breaks the
# sync timing budget. The visibility / discovery side-effects
# (mDNS, uPnP, DLNA browse) are byproducts of being on the
# VLAN, not the reason for hostNetwork — the reason is L2
# adjacency for audio sync.
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
enableServiceLinks: false
# No nodeSelector/toleration: MA only needs a node on v30iot (all
# of them) for audio-sync L2, not the edge USB devices. It's the
@@ -47,10 +47,11 @@ spec:
# stale on the next reschedule or lease change (the Sendspin server
# then fails to bind and dies, with no fallback).
# So we recompute the v30iot IP from the live interface every boot and
# write it into /data/settings.json before MA reads it. Runs in the
# pod's hostNetwork namespace, so it sees eth1 directly. Reuses the MA
# image (python + stdlib only; Flux keeps the tag in lockstep via the
# shared image policy) — no extra dependency.
# write it into /data/settings.json before MA reads it. Multus attaches
# net1 (with its DHCP lease) before any container runs, so this sees the
# pod's own v30iot interface directly. Reuses the MA image (python +
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.9.9 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
@@ -1,6 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- network-attachment.yaml
- configmap-net-reconcile.yaml
- deployment.yaml
- ingress.yaml
@@ -0,0 +1,56 @@
---
# music-assistant's second NIC: macvlan attached to the node's v30iot
# interface (eth1 — the connected 10.2.30.0/24 on every k8s-{1,2,3}/edge
# worker). Gives the pod a real leased IP on the IoT VLAN with full L2
# presence, which the audio-sync protocols (Snapcast / SlimProto /
# Sendspin) require: group members must share an L2 broadcast domain or
# the sync timing budget breaks. macvlan bridge mode puts net1 frames
# straight onto the v30iot wire — the same direct-L2 property MA
# previously got from hostNetwork, without the node itself holding an
# address on the (compromised) IoT VLAN.
#
# This replaces the former hostNetwork attachment. The reconcile
# initContainer (configmap-net-reconcile.yaml) discovers net1's leased
# address the same way it used to discover the host's eth1 address —
# scanning the pod's own interfaces for a 10.2.30.0/24 address — and
# writes it as MA's publish_ip. bind_ip stays 0.0.0.0, which now binds
# eth0 (pod) + net1; players reach the stream/slimproto/sendspin servers
# on net1. Requires the cni-dhcp-daemon DaemonSet in infrastructure/multus.
#
# Mirrors matter-server-iot; the only difference is MA does not speak
# Thread, so the matter NAD's accept_ra_rt_info_max_plen tuning (for the
# OTBR OMR RIO) is omitted here.
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: music-assistant-iot
namespace: music-assistant
spec:
# Chained CNI: macvlan creates net1 with a DHCP-leased v30iot address,
# then `sbr` confines net1's routes to a source-based routing table.
#
# sbr is not optional: a leased address arrives with a default gateway
# (DHCP option 3, 10.2.30.1), and the dhcp IPAM plugin installs it as a
# second default route at eth0's metric. Replies to cluster-internal
# peers (the ClusterIP Service, the HTTP ingress path) would then egress
# the IoT VLAN and vanish, and the pod would go NotReady. sbr keeps IoT
# traffic sourced from net1 on net1 while the pod default route stays on
# eth0. Same rationale as matter-server-iot.
config: |
{
"cniVersion": "0.3.1",
"name": "music-assistant-iot",
"plugins": [
{
"type": "macvlan",
"master": "eth1",
"mode": "bridge",
"ipam": {
"type": "dhcp"
}
},
{
"type": "sbr"
}
]
}
+24 -7
View File
@@ -17,15 +17,20 @@ machine:
dhcp: true
vip:
ip: 10.2.40.60
# v30iot: deprioritise its default route so eth0's default
# (metric 1024) wins normal egress and v30iot's only feeds the
# connected /24 used by Cilium L2 announce for the IoT-pool LBs.
# Same pattern as k8s-edge — see patches/k8s-edge.yaml.
# v30iot: LINK ONLY — no host addressing. The NIC is the macvlan
# parent for the v30iot pod attachments (matter/otbr/shelly-manager),
# whose children do their own DHCP on the L2; host addresses were
# never needed for that. Host DHCP (formerly dhcp + routeMetric 4096)
# installed a connected 10.2.30.0/24 route, so node replies to
# IoT-subnet clients bypassed the UDM and broke every UDM-DNATed
# flow (hairpin NAT: un-translated SYN-ACK -> client kernel RST,
# verified live 2026-07-23). The purpose the old comment claimed —
# Cilium L2 announce for an IoT LB pool — does not exist (no
# L2AnnouncementPolicy, no such pool). v6 RA addressing on this NIC
# is disabled via machine.sysctls for the same reason.
- deviceSelector:
busPath: "0000:06:13.0"
dhcp: true
dhcpOptions:
routeMetric: 4096
dhcp: false
# Dedicated Ceph cluster network on the X710 SFP+ port (PCI passthrough,
# mapping x710-port1 in opentofu/vm_150_k8s_1.tf). Separate /29 on VLAN 49,
# MTU 9000, no default route — used by Rook for OSD↔OSD replication via
@@ -35,6 +40,18 @@ machine:
addresses:
- 10.99.0.1/29
mtu: 9000
sysctls:
# v30iot NIC carries no host addressing (see interfaces above); this
# disables v6 entirely (incl. link-local) so the host is unreachable on that L2.
#
# NAME DEPENDENCY: the interface above is selected by busPath, but a
# sysctl can only key on the kernel name. `0000:06:13.0` -> `eth1` is
# verified on k8s-{1,2,3} (same fact the three IoT NADs rely on). If PCI
# probe order ever renames it, this sysctl no-ops SILENTLY and the host
# keeps a v6 address on IoT — so the rollout MUST confirm `eth1` is
# addressless per node before trusting it (a wrong name here fails quiet,
# unlike the NADs which fail loud with a NotReady pod).
net.ipv6.conf.eth1.disable_ipv6: "1"
install:
disk: /dev/vda
certSANs:
+5 -3
View File
@@ -8,17 +8,19 @@ machine:
dhcp: true
vip:
ip: 10.2.40.60
# v30iot: link only, no host addressing — see k8s-1.yaml.
- deviceSelector:
busPath: "0000:06:13.0"
dhcp: true
dhcpOptions:
routeMetric: 4096
dhcp: false
# See k8s-1.yaml for context on the dedicated Ceph cluster network.
- deviceSelector:
driver: i40e
addresses:
- 10.99.0.2/29
mtu: 9000
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
net.ipv6.conf.eth1.disable_ipv6: "1"
install:
disk: /dev/vda
certSANs:
+5 -3
View File
@@ -8,17 +8,19 @@ machine:
dhcp: true
vip:
ip: 10.2.40.60
# v30iot: link only, no host addressing — see k8s-1.yaml.
- deviceSelector:
busPath: "0000:06:13.0"
dhcp: true
dhcpOptions:
routeMetric: 4096
dhcp: false
# See k8s-1.yaml for context on the dedicated Ceph cluster network.
- deviceSelector:
driver: i40e
addresses:
- 10.99.0.3/29
mtu: 9000
sysctls:
# v30iot NIC carries no host addressing — see k8s-1.yaml.
net.ipv6.conf.eth1.disable_ipv6: "1"
install:
disk: /dev/vda
certSANs: