shelly-manager: bind HTTP to the pod address, not 0.0.0.0 #142

Merged
k.renner merged 1 commits from fix/shelly-manager-bind-eth0 into master 2026-07-10 13:01:01 +00:00
Owner

The hole

The Authentik gate from #138 guards only the Traefik ingress path. shelly-manager also has a Multus macvlan (net1) on the IoT VLAN for mDNS discovery, binds 0.0.0.0 (image default ASPNETCORE_URLS=http://0.0.0.0:8080), and macvlan traffic bypasses Cilium policy entirely. So the unauthenticated admin UI was reachable directly on the IoT VLAN, past the gate.

Verified today from edge-1 (which sits on v30iot):

$ curl http://10.2.30.122:8080/
200  —  <title>Inventory</title>  Shelly Manager   (0.007s)

An untrusted IoT client — the segment where the Shelly devices themselves live — could rewrite device config, push scripts, and read stored device admin passwords. Exactly the capability the gate was meant to close.

The macvlan predates the gate (it carried the old static .228). #138 closed the front door and left this side door open. I found it auditing yesterday's work, so this is a follow-up to my own incomplete fix, not a new discovery about someone else's.

The fix

Pin Kestrel to the pod's eth0 address via the downward API:

- name: POD_IP
  valueFrom: { fieldRef: { fieldPath: status.podIP } }
- name: ASPNETCORE_URLS
  value: "http://$(POD_IP):8080"

:8080 is then served only on the cluster pod CIDR, not on net1. A deployment env var overrides the Dockerfile ENV.

Why nothing breaks

consumer path still works?
Authentik outpost → IPv4 ClusterIP 10.109.176.52 → podIP:8080 yes
kubelet probes httpGet named port → podIP:8080 yes
mDNS discovery ZeroconfResolver multicast sockets on net1 independent of Kestrel

The Service is single-stack IPv4 (ipFamilies: [IPv4]), so binding the IPv4 pod IP loses no consumer. status.podIP = the eth0 address (10.244.2.65), distinct from net1's 10.2.30.122. And MdnsResolver (src/ShellyManager.Web/Shelly/MdnsResolver.cs) uses the ZeroconfResolver library, which binds its own per-interface multicast sockets — moving the HTTP listener off net1 doesn't touch it. net1 keeps its L2 presence for _shelly._tcp.local. browsing; it just stops carrying the HTTP server.

Also corrects the stale "static IP" comment on the NAD — net1 has been a DHCP lease since #135.

Verified so far

  • kubectl kustomize builds; rendered env has the downward-API POD_IP and ASPNETCORE_URLS=http://$(POD_IP):8080.
  • Probes unchanged (httpGet on the named port, which resolves to the pod IP Kestrel now binds).

Post-merge checks (I'll run these before calling it done)

  1. curl http://<net1 ip>:8080/ from edge-1 now fails (connection refused).
  2. Discovery still lists all 32 devices in the UI.
  3. The gate still serves through Traefik, and the pod stays Ready (probe still passes on the pod IP).

Not in scope (reported, not fixed — per owner)

The wider 2026-07-10 audit also found: two in-cluster bypasses of this same gate (any monitoring or home-assistant pod can reach :8080 — the namespace-wide CNP allows it); matter-server:5580 and otbr:8081 similarly exposed on the IoT VLAN; unauthenticated Alertmanager (writable silence API), Prometheus, and evcc; and a batch of process-only/no readiness probes. These are being written up as a tracking note, not touched here.

## The hole The Authentik gate from #138 guards only the **Traefik ingress path**. `shelly-manager` also has a Multus macvlan (`net1`) on the **IoT VLAN** for mDNS discovery, binds `0.0.0.0` (image default `ASPNETCORE_URLS=http://0.0.0.0:8080`), and **macvlan traffic bypasses Cilium policy entirely**. So the unauthenticated admin UI was reachable directly on the IoT VLAN, past the gate. Verified today from `edge-1` (which sits on v30iot): ``` $ curl http://10.2.30.122:8080/ 200 — <title>Inventory</title> Shelly Manager (0.007s) ``` An untrusted IoT client — the segment where the Shelly devices themselves live — could rewrite device config, push scripts, and read stored device admin passwords. Exactly the capability the gate was meant to close. The macvlan predates the gate (it carried the old static `.228`). #138 closed the front door and left this side door open. I found it auditing yesterday's work, so this is a follow-up to my own incomplete fix, not a new discovery about someone else's. ## The fix Pin Kestrel to the pod's **eth0** address via the downward API: ```yaml - name: POD_IP valueFrom: { fieldRef: { fieldPath: status.podIP } } - name: ASPNETCORE_URLS value: "http://$(POD_IP):8080" ``` `:8080` is then served only on the cluster pod CIDR, not on `net1`. A deployment env var overrides the Dockerfile `ENV`. ## Why nothing breaks | consumer | path | still works? | |---|---|---| | Authentik outpost | → IPv4 ClusterIP `10.109.176.52` → podIP:8080 | yes | | kubelet probes | httpGet named port → podIP:8080 | yes | | mDNS discovery | `ZeroconfResolver` multicast sockets on `net1` | **independent of Kestrel** | The Service is single-stack IPv4 (`ipFamilies: [IPv4]`), so binding the IPv4 pod IP loses no consumer. `status.podIP` = the eth0 address (`10.244.2.65`), distinct from `net1`'s `10.2.30.122`. And `MdnsResolver` (`src/ShellyManager.Web/Shelly/MdnsResolver.cs`) uses the `ZeroconfResolver` library, which binds its own per-interface multicast sockets — moving the HTTP listener off `net1` doesn't touch it. `net1` keeps its L2 presence for `_shelly._tcp.local.` browsing; it just stops carrying the HTTP server. Also corrects the stale "static IP" comment on the NAD — `net1` has been a DHCP lease since #135. ## Verified so far - `kubectl kustomize` builds; rendered env has the downward-API `POD_IP` and `ASPNETCORE_URLS=http://$(POD_IP):8080`. - Probes unchanged (httpGet on the named port, which resolves to the pod IP Kestrel now binds). ## Post-merge checks (I'll run these before calling it done) 1. `curl http://<net1 ip>:8080/` from `edge-1` now **fails** (connection refused). 2. Discovery still lists all 32 devices in the UI. 3. The gate still serves through Traefik, and the pod stays `Ready` (probe still passes on the pod IP). ## Not in scope (reported, not fixed — per owner) The wider 2026-07-10 audit also found: two in-cluster bypasses of this same gate (any `monitoring` or `home-assistant` pod can reach `:8080` — the namespace-wide CNP allows it); `matter-server:5580` and `otbr:8081` similarly exposed on the IoT VLAN; unauthenticated Alertmanager (writable silence API), Prometheus, and evcc; and a batch of process-only/no readiness probes. These are being written up as a tracking note, not touched here.
k.renner added 1 commit 2026-07-10 11:30:20 +00:00
The Authentik gate added in #138 only guards the Traefik ingress path.
shelly-manager also has a Multus macvlan (net1) on the IoT VLAN for mDNS
discovery, it binds 0.0.0.0 (image Dockerfile default
ASPNETCORE_URLS=http://0.0.0.0:8080), and macvlan traffic bypasses Cilium
policy entirely. So the unauthenticated admin UI was reachable directly on
the IoT VLAN, past the gate.

Verified 2026-07-10 from edge-1 (on v30iot):

  GET http://10.2.30.122:8080/  ->  200, full Shelly Manager UI

An untrusted IoT client could rewrite Shelly device config, push scripts,
and read stored device admin passwords — exactly what the gate was meant
to prevent. The macvlan predates the gate; #138 closed the front door and
left this side door open.

Pin Kestrel to the pod's eth0 address via the downward API
(ASPNETCORE_URLS=http://$(POD_IP):8080, POD_IP=status.podIP) so :8080 is
served only on the cluster pod CIDR, not on net1. Everything that should
reach the app still does — the IPv4 ClusterIP Service, the Authentik
outpost through it, and the kubelet httpGet probes all target the pod IP.
The Service is single-stack IPv4, so no IPv6 consumer is lost.

mDNS discovery is unaffected: the resolver is a separate ZeroconfResolver
socket set that binds its own multicast sockets on net1, independent of
Kestrel (src/ShellyManager.Web/Shelly/MdnsResolver.cs). net1 keeps L2
presence for `_shelly._tcp.local.` browsing; it just no longer carries the
HTTP server.

Also corrects the stale "static IP" NAD comment — net1 is a DHCP lease
since #135.

Verified: kubectl kustomize builds; rendered env has POD_IP (downward API)
and ASPNETCORE_URLS=http://$(POD_IP):8080; probes still httpGet the named
port, which resolves to the pod IP Kestrel now binds. Post-merge checks to
run: discovery still lists all devices, GET to net1:8080 from the IoT VLAN
now fails, gate still serves via Traefik, pod stays Ready.

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