From 3b542f8a85a092e9af45386e1908a0298dccc18c Mon Sep 17 00:00:00 2001 From: Mathew Beane Date: Fri, 2 Oct 2026 16:13:10 -0400 Subject: [PATCH] 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 --- README.md | 5 +++- docker/docker-compose.yml | 25 +++++++++++++++++ docker/docker-entrypoint.sh | 46 +++++++++++++++++++++++++++++++ docs/user-guide.md | 55 +++++++++++++++++++++++++++++++------ 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8c81c7e25..1799e5a48 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f45f3d78d..947ebad12 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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= 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 _default \ + # --format '{{(index .IPAM.Config 0).Gateway}}' + # RUVIEW_UDP_ALLOW=/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: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 192746c51..12533bef7 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -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 --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= (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 /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 \ diff --git a/docs/user-guide.md b/docs/user-guide.md index 1d79d9416..f0f8a2193 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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= -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= \ + 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=` | 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=` | 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 (`_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= \ + -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= \ + 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= # macOS Docker: /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)