fix(docker): make UDP CSI ingest posture explicit in the container

Since ADR-296 the sensing server binds its UDP receiver to 127.0.0.1 by
default, and docker-entrypoint.sh never set a UDP bind. Inside a
container that address cannot receive from the published 5005/udp port,
so the default image never shows a node (#2089, follow-up to #1394).

The entrypoint now binds UDP on 0.0.0.0 only when the operator has set
an ADR-296 source guard (RUVIEW_UDP_ALLOW, RUVIEW_UDP_INSECURE_LAN, or
the matching CLI flags). Without a guard it keeps the loopback bind and
prints a note explaining why no node will appear and what to set,
including the macOS bridge-gateway allowlist. An explicit RUVIEW_UDP_BIND
or --udp-bind still wins.

I chose this over defaulting the bind to 0.0.0.0 unconditionally. With
no guard that would make the server refuse to start (exit 1) in auto
mode, which binds UDP, and the guard would still have to be supplied. The
change keeps ADR-296's fail-closed rule as is: a routable bind never
happens without an allowlist or the explicit insecure opt-in. Auth
defaults are unchanged.

The entrypoint also normalizes RUVIEW_UDP_INSECURE_LAN=1/yes/on to true,
since clap rejects 1 for that flag.

The compose service passes RUVIEW_API_TOKEN, the RUVIEW_UDP_* variables
and SENSING_ALLOWED_HOSTS through from the shell, with notes on the
exit-64 auth check, the macOS gateway allowlist and the 421 Host check.
The README and user-guide Docker quickstarts now set a token, and the
user guide gains a "Receiving ESP32 frames in Docker" section.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-02 16:13:10 -04:00
1 parent 03a897938f
commit 3b542f8a85
4 files changed
+122 -9

No files matched your search

+4 -1
View File
@@ -141,8 +141,11 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
```bash
# Option 1: Docker (simulated data, no hardware needed)
docker pull ruvnet/wifi-densepose:latest
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
export RUVIEW_API_TOKEN=$(openssl rand -hex 32) # required; the container exits 64 without it
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open http://localhost:3000
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
# see docs/user-guide.md "Receiving ESP32 frames in Docker".
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
# Flash firmware, provision WiFi, and start sensing:
+25
View File
@@ -37,6 +37,31 @@ services:
# volumes: ["/path/to/models:/app/models"]
# MODELS_DIR=/app/models
- MODELS_DIR=${MODELS_DIR:-data/models}
# Auth (issue #864). The entrypoint refuses to start (exit 64) unless
# RUVIEW_API_TOKEN is set. Export it before `docker compose up`:
# export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
# Bare names pass the host value through only when it is set.
- RUVIEW_API_TOKEN
# ESP32 UDP ingest (ADR-296, issue #2089). The UDP receiver defaults to
# 127.0.0.1, which cannot receive from the published 5005/udp port.
# Set ONE source guard and the entrypoint binds 0.0.0.0:
# RUVIEW_UDP_ALLOW=<node-ip-or-cidr> restrict sources (preferred)
# RUVIEW_UDP_INSECURE_LAN=true accept the spoofing risk
# Docker Desktop and OrbStack on macOS rewrite the source address to the
# bridge gateway, so a LAN-subnet allowlist drops every frame. There,
# allow the gateway instead:
# docker network inspect <project>_default \
# --format '{{(index .IPAM.Config 0).Gateway}}'
# RUVIEW_UDP_ALLOW=<gateway>/32
# The gateway admits anything that reaches the published port, so keep
# 5005/udp off untrusted networks.
- RUVIEW_UDP_ALLOW
- RUVIEW_UDP_INSECURE_LAN
- RUVIEW_UDP_BIND
# Browsing the UI from another machine also needs the host name or IP
# in SENSING_ALLOWED_HOSTS (otherwise 421), and the TCP ports above
# published beyond 127.0.0.1.
- SENSING_ALLOWED_HOSTS
security_opt:
- no-new-privileges:true
cap_drop:
+46
View File
@@ -22,6 +22,10 @@
# Default is `auto`. Set CSI_SOURCE=simulated when you want
# fake data tagged as such; never set it implicitly.
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
# RUVIEW_UDP_ALLOW / RUVIEW_UDP_INSECURE_LAN — ADR-296 UDP source guard.
# Setting either makes the entrypoint bind UDP on 0.0.0.0 so
# ESP32 frames on the published 5005/udp port can arrive.
# RUVIEW_UDP_BIND — explicit UDP bind; overrides the choice above.
set -e
# ── Issue #864: fail-closed on default posture ───────────────────────────────
@@ -97,6 +101,48 @@ esac
# server binary so users can just pass flags:
# docker run <image> --source esp32 --tick-ms 500
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
# ── Issue #2089: UDP CSI ingest posture inside a container ───────────────
# ADR-296 defaults the UDP receiver to 127.0.0.1. Inside a container that
# bind can never receive from a published port, so no node ever appears.
# We bind 0.0.0.0 only when the operator has configured an ADR-296 guard
# (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server
# would refuse a routable bind, so we keep loopback and say why instead.
# An explicit RUVIEW_UDP_BIND or --udp-bind always wins.
case "${RUVIEW_UDP_INSECURE_LAN:-}" in
1|yes|YES|on|ON|TRUE|True) export RUVIEW_UDP_INSECURE_LAN=true ;;
0|no|NO|off|OFF|FALSE|False) export RUVIEW_UDP_INSECURE_LAN=false ;;
esac
__udp_bind_arg=0
__udp_guard_arg=0
for __arg in "$@"; do
case "$__arg" in
--udp-bind|--udp-bind=*) __udp_bind_arg=1 ;;
--udp-allow|--udp-allow=*|--udp-insecure-lan) __udp_guard_arg=1 ;;
esac
done
if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then
if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "${RUVIEW_UDP_INSECURE_LAN:-}" = true ] \
|| [ "$__udp_guard_arg" = 1 ]; then
export RUVIEW_UDP_BIND=0.0.0.0
echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2
echo "[entrypoint] (a source guard is set: RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN)." >&2
else
case "${CSI_SOURCE:-auto}" in
simulated|simulate) ;;
*)
echo "[entrypoint] NOTE: UDP CSI receiver stays on 127.0.0.1 (ADR-296 default)." >&2
echo "[entrypoint] Inside a container that bind cannot receive ESP32 frames from" >&2
echo "[entrypoint] a published 5005/udp port, so no node will appear. To ingest:" >&2
echo "[entrypoint] -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> (restrict sources), or" >&2
echo "[entrypoint] -e RUVIEW_UDP_INSECURE_LAN=true (accept spoofing risk)" >&2
echo "[entrypoint] Docker Desktop/OrbStack on macOS rewrite the source to the bridge" >&2
echo "[entrypoint] gateway, so the allowlist must name <gateway>/32, not the LAN subnet." >&2
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/2089" >&2
;;
esac
fi
fi
set -- /app/sensing-server \
--source "${CSI_SOURCE:-auto}" \
--tick-ms 100 \
+47 -8
View File
@@ -109,7 +109,7 @@ Multi-architecture image (amd64 + arm64). Works on Intel/AMD and Apple Silicon M
| `simulated` | Generate synthetic CSI frames (no hardware required) |
| `wifi` | Host Wi-Fi RSSI (not available inside containers) |
Example: `docker run -e CSI_SOURCE=esp32 -p 3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`
Example: `docker run -e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> -p 127.0.0.1:3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`. The container exits with code 64 unless `RUVIEW_API_TOKEN` is set, and receives no ESP32 frames until a UDP source guard is set; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
### From Source (Rust)
@@ -261,8 +261,9 @@ Non-interactive:
### 30-Second Demo (Docker)
```bash
# Pull and run
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
# Pull and run (the container exits with code 64 without an API token)
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open the UI in your browser
# http://localhost:3000
@@ -365,11 +366,39 @@ Real Channel State Information at 20 Hz with 56-192 subcarriers. Required for po
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
# Docker (use CSI_SOURCE environment variable)
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
ruvnet/wifi-densepose:latest
```
The ESP32 nodes stream binary CSI frames over UDP to port 5005. See [Hardware Setup](#esp32-s3-mesh) for flashing instructions.
#### Receiving ESP32 frames in Docker
The sensing server binds its UDP receiver to `127.0.0.1` by default (ADR-296). Inside a container that address cannot receive anything from a published `5005/udp` port, so no node ever appears. The image's entrypoint binds UDP on `0.0.0.0` only when you set a source guard, and prints a note at startup when you have not:
| Variable | Effect |
|----------|--------|
| `RUVIEW_API_TOKEN` | Required. Without it (or `RUVIEW_ALLOW_UNAUTHENTICATED=1`) the container exits with code 64. |
| `RUVIEW_UDP_ALLOW=<ip-or-cidr>` | Accept frames only from these sources. Preferred. |
| `RUVIEW_UDP_INSECURE_LAN=true` | Accept frames from any source. The UDP data plane is not authenticated, so only use this on a trusted network. |
| `RUVIEW_UDP_BIND` | Explicit UDP bind address. Overrides the entrypoint's choice. |
| `SENSING_ALLOWED_HOSTS=<host-ip>` | Needed to open the UI from another machine; otherwise the server answers 421. That also requires publishing the TCP ports beyond `127.0.0.1`. |
**macOS (Docker Desktop, OrbStack):** datagrams reach the container from the Docker bridge gateway, not from the node's LAN address, so an allowlist that names your LAN subnet drops every frame. Allow the gateway instead:
```bash
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
GW=$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')
docker run --rm -d --name ruview \
-p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW="$GW/32" \
ruvnet/wifi-densepose:latest
curl -s -H "Authorization: Bearer $RUVIEW_API_TOKEN" localhost:3000/api/v1/nodes
```
Allowing the gateway admits anything that can reach the published port, so keep `5005/udp` off untrusted networks. Node identity still comes from the frame payload. With Docker Compose the service runs on the project network (`<project>_default`), so inspect that network's gateway rather than `bridge`. Docker Desktop on Windows has a related source-address problem; see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
### ESP32 Multistatic Mesh (Advanced)
For higher accuracy with through-wall tracking, deploy 3-6 ESP32-S3 nodes in a **multistatic mesh** configuration. Each node acts as both transmitter and receiver, creating multiple sensing paths through the environment.
@@ -407,13 +436,17 @@ cargo run -p wifi-densepose-sensing-server -- \
# Docker
docker run --rm \
-e RUVIEW_API_TOKEN \
-e CSI_SOURCE=esp32 \
-p 3000:3000 \
-p 3001:3001 \
-e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
-p 127.0.0.1:3000:3000 \
-p 127.0.0.1:3001:3001 \
-p 5005:5005/udp \
ruvnet/wifi-densepose:latest
```
On macOS Docker the allowlist must name the bridge gateway; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
Open the UI from the sensing server, not from a local file:
| View | URL |
@@ -2047,8 +2080,10 @@ Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmW
# From source
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
# Docker (use CSI_SOURCE environment variable)
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
# Docker (see "Receiving ESP32 frames in Docker" for the UDP source guard)
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
ruvnet/wifi-densepose:latest
```
See [ADR-018](../docs/adr/ADR-018-esp32-dev-implementation.md), [ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md), and [Tutorial #34](https://github.com/ruvnet/RuView/issues/34).
@@ -2377,6 +2412,8 @@ For production deployments with both Rust and Python services:
```bash
cd docker
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
export RUVIEW_UDP_ALLOW=<node-ip-or-cidr> # macOS Docker: <gateway>/32
docker compose up
```
@@ -2384,6 +2421,8 @@ This starts:
- Rust sensing server on ports 3000 (HTTP), 3001 (WS), 5005 (UDP)
- Python legacy server on ports 8080 (HTTP), 8765 (WS)
The sensing server exits with code 64 when `RUVIEW_API_TOKEN` is unset. Without `RUVIEW_UDP_ALLOW` or `RUVIEW_UDP_INSECURE_LAN=true` it starts but receives no ESP32 frames. `docker/docker-compose.yml` passes these variables through from your shell, along with `RUVIEW_UDP_BIND` and `SENSING_ALLOWED_HOSTS`. See [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
---
## Testing Firmware Without Hardware (QEMU)