otbr: bind the REST API to the pod address, not 0.0.0.0 #145

Merged
k.renner merged 1 commits from fix/otbr-rest-bind-eth0 into master 2026-07-10 14:50:51 +00:00
Owner

The hole (last of three — #142 shelly-manager, #144 matter-server)

otbr's net1 is a macvlan on the IoT VLAN (the Thread backbone interface), macvlan bypasses Cilium policy, and REST_HOST=0.0.0.0 bound otbr-agent's REST API on :8081 to all interfaces. So the unauthenticated OTBR REST API was reachable from the whole IoT VLAN.

Verified today from edge-1 (on v30iot):

$ curl http://<net1 ip>:8081/
404   ← otbr-agent REST answering (404 at /, but it's the live listener)

That API reads and modifies Thread network config including the network key — enough to join or disrupt the mesh. Worst of the three exposures in capability.

Reality vs. the old wiki note: otbr-agent REST listens on :8081 (governed by REST_HOST); otbr-web is on 127.0.0.1:80 (loopback, not exposed); the container's declared rest:8080 port has no listener. REST_HOST is the whole exposure.

The fix

- name: POD_IP                       # declared BEFORE REST_HOST —
  valueFrom: { fieldRef: { fieldPath: status.podIP } }
- name: REST_HOST                    # k8s $(VAR) only sees earlier entries
  value: "$(POD_IP)"

REST binds the pod eth0 address; net1 no longer carries it. HA's OTBR integration keeps working via the IPv4 ClusterIP Service (otbr.home-assistant.svc:8081 → podIP:8081).

Why the border router is unaffected

REST_HOST (--rest-listen-address) governs only the REST control API. The Thread border router uses net1 through BACKBONE_INTERFACE for mDNS/RA/backbone — a separate, unchanged mechanism. Baseline before the change: state=leader, br=running, mesh 2 routers / 8 children / 8 neighbors.

Verified so far

kubectl kustomize builds; rendered env has POD_IP (downward API) ordered before REST_HOST=$(POD_IP); BACKBONE_INTERFACE=net1 unchanged.

⚠️ This restarts the Thread border router

Expect ~60s of self-healing CASE-session churn on Thread/Matter end devices as the mesh re-forms (per the #141 verification). The dataset-restore poll (#139), CRLF fix (#140), and protocol-aware probes (#141) mean the pod should return to leader/running on its own and only report Ready once Thread is up. I'll confirm reconvergence before calling it done.

Post-merge checks

  1. curl http://<net1 ip>:8081/ from edge-1 now fails.
  2. REST listens on the pod IP only.
  3. HA reaches otbr:8081 via ClusterIP.
  4. otbr returns to leader / br running; mesh reconverges.
  5. matter-server's Thread nodes re-subscribe (they'll churn during the mesh re-form, same as any otbr restart).
## The hole (last of three — #142 shelly-manager, #144 matter-server) otbr's `net1` is a macvlan on the **IoT VLAN** (the Thread backbone interface), macvlan bypasses Cilium policy, and `REST_HOST=0.0.0.0` bound `otbr-agent`'s REST API on `:8081` to all interfaces. So the unauthenticated OTBR REST API was reachable from the whole IoT VLAN. Verified today from `edge-1` (on v30iot): ``` $ curl http://<net1 ip>:8081/ 404 ← otbr-agent REST answering (404 at /, but it's the live listener) ``` That API reads and modifies Thread network config **including the network key** — enough to join or disrupt the mesh. Worst of the three exposures in capability. **Reality vs. the old wiki note:** `otbr-agent` REST listens on `:8081` (governed by `REST_HOST`); `otbr-web` is on `127.0.0.1:80` (loopback, not exposed); the container's declared `rest:8080` port has no listener. `REST_HOST` is the whole exposure. ## The fix ```yaml - name: POD_IP # declared BEFORE REST_HOST — valueFrom: { fieldRef: { fieldPath: status.podIP } } - name: REST_HOST # k8s $(VAR) only sees earlier entries value: "$(POD_IP)" ``` REST binds the pod eth0 address; `net1` no longer carries it. HA's OTBR integration keeps working via the IPv4 ClusterIP Service (`otbr.home-assistant.svc:8081` → podIP:8081). ## Why the border router is unaffected `REST_HOST` (`--rest-listen-address`) governs only the REST control API. The Thread border router uses `net1` through `BACKBONE_INTERFACE` for mDNS/RA/backbone — a separate, unchanged mechanism. Baseline before the change: `state=leader`, `br=running`, mesh 2 routers / 8 children / 8 neighbors. ## Verified so far `kubectl kustomize` builds; rendered env has `POD_IP` (downward API) ordered **before** `REST_HOST=$(POD_IP)`; `BACKBONE_INTERFACE=net1` unchanged. ## ⚠️ This restarts the Thread border router Expect **~60s of self-healing CASE-session churn** on Thread/Matter end devices as the mesh re-forms (per the #141 verification). The dataset-restore poll (#139), CRLF fix (#140), and protocol-aware probes (#141) mean the pod should return to `leader`/`running` on its own and only report `Ready` once Thread is up. I'll confirm reconvergence before calling it done. ## Post-merge checks 1. `curl http://<net1 ip>:8081/` from `edge-1` now **fails**. 2. REST listens on the pod IP only. 3. HA reaches `otbr:8081` via ClusterIP. 4. otbr returns to `leader` / `br running`; mesh reconverges. 5. matter-server's Thread nodes re-subscribe (they'll churn during the mesh re-form, same as any otbr restart).
k.renner added 1 commit 2026-07-10 14:44:13 +00:00
Last of the three IoT-VLAN gate/bind bypasses (shelly-manager #142,
matter-server #144). otbr's net1 is a macvlan on the IoT VLAN (backbone
interface), macvlan bypasses Cilium policy, and REST_HOST=0.0.0.0 bound
otbr-agent's REST API on :8081 to all interfaces — so the unauthenticated
OTBR REST API was reachable from the whole IoT VLAN.

Verified 2026-07-10 from edge-1 (on v30iot): GET http://<net1 ip>:8081/
answered (404 at /, but it is the live otbr-agent REST listener). That API
can read and modify Thread network configuration, including the network
key — i.e. enough to join or disrupt the Thread mesh.

Note the reality vs. the old comment: otbr-agent's REST listens on :8081
(governed by REST_HOST / --rest-listen-address); otbr-web sits on
127.0.0.1:80 (loopback, not exposed); the container's declared rest:8080
port has no listener. So REST_HOST is the whole exposure.

Set REST_HOST to the pod eth0 address via the downward API
(POD_IP=status.podIP; REST_HOST=$(POD_IP)). POD_IP is declared BEFORE
REST_HOST because k8s $(VAR) substitution only sees earlier env entries.
HA's OTBR integration keeps working via the IPv4 ClusterIP Service
(otbr.home-assistant.svc:8081 -> podIP:8081). net1 no longer carries REST.

The border router itself is unaffected: it uses net1 through
BACKBONE_INTERFACE for mDNS/RA/backbone, independent of where REST listens.

Verified: kubectl kustomize builds; rendered env has POD_IP (downward API)
before REST_HOST=$(POD_IP); BACKBONE_INTERFACE=net1 unchanged. Baseline
before change: state=leader, br=running, mesh 2 routers/8 children/8
neighbors.

CAVEAT: this restarts the Thread border router. Expect ~60s of
self-healing CASE-session churn on Thread/Matter end devices as the mesh
re-forms (per the #141 verification). The dataset-restore race and the
protocol-aware probes from #139/#140/#141 mean the pod should come back to
leader/running on its own and only report Ready once Thread is up.

Post-merge checks to run: GET net1:8081 from the IoT VLAN now fails; REST
listens on the pod IP only; HA reaches otbr:8081 via ClusterIP; otbr
returns to leader/br running and the mesh reconverges; matter-server's
Thread nodes re-subscribe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
k.renner merged commit 701df19bae into master 2026-07-10 14:50:51 +00:00
Sign in to join this conversation.