fix(otbr): bridge the radio from a socat sidecar over a Unix socket #1608

Merged
k.renner merged 1 commits from fix/otbr-no-runtime-install into main 2026-11-08 09:55:21 +00:00
2 changed files with 76 additions and 49 deletions
+44 -49
View File
@@ -29,49 +29,48 @@ spec:
# RCP is reached over the network (slzb-thread.renner.uno).
nodeSelector:
net.renner.uno/iot-vlan: "true"
initContainers:
# The Thread RCP is a network-attached radio (spinel over TCP).
# otbr-agent speaks spinel+hdlc over a Unix stream socket, which this
# native sidecar serves on the shared emptyDir and bridges to the
# radio, one TCP connection per agent connection. A PTY would not
# work here: devpts is per-container.
- name: radio-bridge
image: alpine/socat:1.8.1.3@sha256:466c8a0383e16463fabdb7df01008292fb78d07da60224a46cb5d3142ce122ed
imagePullPolicy: IfNotPresent
restartPolicy: Always
args:
- -d
- UNIX-LISTEN:/run/thread/spinel.sock,fork,unlink-early,mode=600
- TCP:slzb-thread.renner.uno:6638
startupProbe:
exec:
command: ["test", "-S", "/run/thread/spinel.sock"]
periodSeconds: 1
failureThreshold: 30
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: radio
mountPath: /run/thread
containers:
# The Thread RCP is a network-attached radio (Serial-over-IP), so the
# spinel transport needs a TCP-to-PTY bridge. Two obvious approaches
# do not work and should not be retried:
#
# - `spinel+hdlc+forkpty://` needs socat, which the upstream image
# does not ship; the URL fails with a silent ENOENT and otbr-agent
# dies right after logging it.
# - A sidecar cannot provide it either: devpts is per-container, so
# a PTY created next door is invisible here and the symlink dangles.
#
# Hence socat is installed at startup and run inside this container.
- name: otbr
image: openthread/otbr:latest@sha256:51f9af250252f4faae85d0197574a7c17ada873c78799854461a93e3e42cf324
image: openthread/otbr:latest@sha256:cada244b39d1f43cb12d5f93694b4621db3b51c28219a05bbffac0f898d1403e
command:
- bash
- -c
- |
set -euo pipefail
if ! command -v socat >/dev/null; then
echo "otbr: installing socat from apt"
apt-get update -qq
apt-get install -y -qq socat
fi
echo "otbr: starting TCP↔PTY bridge to ${SLZB_HOST}:${SLZB_PORT}"
# Match the HA addon's socat invocation byte-for-byte
# (rootfs/etc/s6-overlay/s6-rc.d/socat-otbr-tcp/run): the
# known-working reference for SMLight network-attached
# Thread radios. ignoreeof keeps the data loop alive when
# otbr-agent transiently closes the PTY (e.g. on agent
# restart from livenessProbe), instead of socat exiting
# and racing to recreate the PTY symlink.
( while true; do
socat -d \
"pty,raw,echo=0,link=/tmp/ttyThread,ignoreeof" \
"tcp:${SLZB_HOST}:${SLZB_PORT}"
echo "otbr: socat bridge exited; restarting in 2s" >&2
sleep 2
done ) &
until [ -e /tmp/ttyThread ]; do
echo "otbr: waiting for serial bridge PTY at /tmp/ttyThread"
sleep 1
done
# SLZB-06P7 firmware crashes otbr-agent if a saved dataset
# is loaded before the radio's PHY is enabled: dataset
# commit triggers KeyManager → otPlatRadioSetMacKey, which
@@ -94,8 +93,8 @@ spec:
/app/etc/docker/test/docker_entrypoint.sh &
ENTRYPOINT_PID=$!
# Wait for the daemon socket to come up. Up to 90s of
# attempts at 2s intervals — covers slow apt installs,
# spinel handshake, and agent init on this radio.
# attempts at 2s intervals — covers the spinel handshake
# and agent init on this radio.
for i in $(seq 1 45); do
if ot-ctl state >/dev/null 2>&1; then
break
@@ -167,16 +166,9 @@ spec:
fi
wait "$ENTRYPOINT_PID"
env:
- name: SLZB_HOST
value: "slzb-thread.renner.uno"
- name: SLZB_PORT
value: "6638"
# Match the HA addon's RADIO_URL byte-for-byte. baud is
# omitted because PTYs ignore termios speed; uart-flow-
# control is a no-op on a PTY but stays in to mirror the
# known-working reference exactly.
# The radio-bridge sidecar's socket.
- name: RADIO_URL
value: "spinel+hdlc+uart:///tmp/ttyThread?uart-flow-control"
value: "spinel+hdlc+unix:///run/thread/spinel.sock"
# Use the macvlan attachment as the backbone interface. mDNS
# advertisements, SRP server listening, and RA reception all
# happen on this NIC. eth0 (the Cilium pod CIDR) is the
@@ -249,6 +241,8 @@ spec:
# and re-election on every restart).
- name: data
mountPath: /var/lib/thread
- name: radio
mountPath: /run/thread
# Probes must watch the *protocol*, not just the process.
#
# The upstream entrypoint backgrounds otbr-agent and ends in
@@ -266,8 +260,7 @@ spec:
# from this image, so probes go through ot-ctl, not REST.
#
# startupProbe: the agent's control socket answers. Covers the
# apt install, the socat TCP↔PTY bridge to the SLZB radio, and
# the spinel handshake. Generous: 10s × 60 = 10 minutes. While
# spinel handshake with the SLZB radio and agent init. Generous: 10s × 60 = 10 minutes. While
# it runs, the other two probes are suspended, so the entrypoint
# gets to finish activating Thread before liveness may act.
startupProbe:
@@ -340,3 +333,5 @@ spec:
- name: data
persistentVolumeClaim:
claimName: otbr-thread
- name: radio
emptyDir: {}
@@ -541,6 +541,38 @@ spec:
- port: "443"
protocol: TCP
---
# otbr: the radio-bridge sidecar's spinel link to the SLZB-06P7 Thread radio.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: otbr-egress
namespace: home-assistant
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: otbr
egress:
# rules.dns lets Cilium learn the toFQDNs mapping
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "slzb-thread.renner.uno"
toPorts:
- ports:
- port: "6638"
protocol: TCP
---
# mqtt-account-controller: credential delivery to deliver.esp32 targets in mqtt-accounts.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy