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)