diff --git a/.github/workflows/sensing-server-docker.yml b/.github/workflows/sensing-server-docker.yml index 02b0c766..77bbedb3 100644 --- a/.github/workflows/sensing-server-docker.yml +++ b/.github/workflows/sensing-server-docker.yml @@ -115,11 +115,12 @@ jobs: # RUN guard catches missing ones at build time, this re-checks the # pushed artifact post-hoc as belt-and-braces). # 2. /health is up. - # 3. /api/v1/info returns 200 with no auth (LAN-mode default). - # 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a + # 3. Default Docker start refuses the unsafe 0.0.0.0 unauthenticated mode. + # 4. Explicit LAN opt-in keeps /api/v1/info available without auth. + # 5. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a # Bearer header, 200 with the correct one (the #443 auth middleware). # --------------------------------------------------------------------- - - name: Smoke-test image assets + LAN-mode HTTP + - name: Smoke-test image assets + secure Docker defaults run: | set -euo pipefail IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}" @@ -128,7 +129,17 @@ jobs: 'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null' docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null' - docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE" + set +e + docker run --rm -e CSI_SOURCE=simulated "$IMAGE" >/tmp/ruview-default-start.log 2>&1 + code=$? + set -e + test "$code" != "0" || { echo "expected unsafe default start to fail"; exit 1; } + grep -q 'RUVIEW_API_TOKEN' /tmp/ruview-default-start.log + + docker run -d --name sm -p 3000:3000 \ + -e CSI_SOURCE=simulated \ + -e RUVIEW_ALLOW_UNAUTH_LAN=1 \ + "$IMAGE" # Wait up to 30 s for /health. for _ in $(seq 1 30); do if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust index 8418ac03..15a92663 100644 --- a/docker/Dockerfile.rust +++ b/docker/Dockerfile.rust @@ -60,8 +60,9 @@ RUN set -e; \ test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \ echo "image assets OK" -# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default), -# set to enforce `Authorization: Bearer ` (see bearer_auth module, #443). +# Bearer-token auth on /api/v1/*. Docker refuses a 0.0.0.0 sensing-server +# bind when this is empty unless RUVIEW_ALLOW_UNAUTH_LAN=1 is set explicitly +# for a trusted LAN deployment. # docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ... ENV RUVIEW_API_TOKEN= diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b511141d..377295fd 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -23,6 +23,12 @@ services: - "5005:5005/udp" environment: - RUST_LOG=info + # Secure default: docker-entrypoint.sh refuses to bind the sensing + # server on 0.0.0.0 unless a token is configured, or unauthenticated + # trusted-LAN mode is explicitly acknowledged. + - RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN:-} + # Uncomment only for intentionally trusted LAN deployments: + # - RUVIEW_ALLOW_UNAUTH_LAN=1 # CSI_SOURCE controls the data source for the sensing server. # Options: auto (default) — probe for ESP32 UDP then fall back to simulation # esp32 — receive real CSI frames from an ESP32 on UDP port 5005 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index e0851137..4f1aae7b 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -13,8 +13,69 @@ # Environment variables: # CSI_SOURCE — data source: auto (default), esp32, wifi, simulated # MODELS_DIR — directory to scan for .rvf model files (default: data/models) +# RUVIEW_API_TOKEN — bearer token for /api/v1/* when binding to the network +# RUVIEW_ALLOW_UNAUTH_LAN=1 — explicit opt-in for unauthenticated LAN mode set -e +is_truthy() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +is_unspecified_bind_addr() { + case "${1:-}" in + 0.0.0.0|::|\[::\]) return 0 ;; + *) return 1 ;; + esac +} + +sensing_bind_addr() { + bind="${SENSING_BIND_ADDR:-127.0.0.1}" + expect_value= + for arg in "$@"; do + if [ "$expect_value" = "--bind-addr" ]; then + bind="$arg" + expect_value= + continue + fi + case "$arg" in + --bind-addr=*) bind="${arg#--bind-addr=}" ;; + --bind-addr) expect_value="--bind-addr" ;; + esac + done + printf '%s\n' "$bind" +} + +guard_unauthenticated_network_bind() { + case "${1:-}" in + /app/sensing-server|sensing-server) ;; + *) return 0 ;; + esac + + bind_addr="$(sensing_bind_addr "$@")" + if ! is_unspecified_bind_addr "$bind_addr"; then + return 0 + fi + if [ -n "${RUVIEW_API_TOKEN:-}" ]; then + return 0 + fi + if is_truthy "${RUVIEW_ALLOW_UNAUTH_LAN:-}"; then + echo "WARN: starting unauthenticated LAN mode on ${bind_addr} because RUVIEW_ALLOW_UNAUTH_LAN=1" >&2 + return 0 + fi + + cat >&2 < cog-ha-matter [--flags] # or via the short alias `ha-matter`. Strips the keyword and execs the @@ -52,4 +113,5 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then "$@" fi +guard_unauthenticated_network_bind "$@" exec "$@" diff --git a/tests/test_docker_entrypoint.sh b/tests/test_docker_entrypoint.sh index 1fa980eb..55dd05ce 100755 --- a/tests/test_docker_entrypoint.sh +++ b/tests/test_docker_entrypoint.sh @@ -2,11 +2,12 @@ # Regression tests for docker-entrypoint.sh # # Validates that the entrypoint script correctly handles: -# 1. No arguments → uses env var defaults -# 2. Flag arguments → prepends sensing-server binary -# 3. Explicit binary path → passes through unchanged -# 4. CSI_SOURCE env var substitution -# 5. MODELS_DIR env var propagation +# 1. No arguments without auth/opt-in → refuses unsafe network bind +# 2. Explicit trusted-LAN opt-in → uses env var defaults +# 3. Flag arguments → prepends sensing-server binary +# 4. Explicit binary path → passes through unchanged +# 5. CSI_SOURCE env var substitution +# 6. MODELS_DIR env var propagation # # These tests use a stub sensing-server that just prints its args. @@ -66,10 +67,30 @@ chmod +x "$TEST_ENTRYPOINT" echo "=== Docker entrypoint tests ===" -# Test 1: No arguments — should use CSI_SOURCE default (auto) +# Test 1: No arguments without auth/opt-in — should fail closed because the +# Docker default binds the sensing surface to 0.0.0.0. echo "" -echo "Test 1: No arguments (default CSI_SOURCE=auto)" +echo "Test 1: No arguments without auth or LAN opt-in" +set +e OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1) +STATUS=$? +set -e +if [ "$STATUS" -ne 0 ]; then + PASS=$((PASS + 1)) + echo " ✓ refuses unsafe default" +else + FAIL=$((FAIL + 1)) + echo " ✗ refuses unsafe default" + echo " expected non-zero exit" + echo " got: $OUT" +fi +assert_contains "explains RUVIEW_API_TOKEN requirement" "$OUT" "RUVIEW_API_TOKEN" +assert_contains "mentions explicit LAN opt-in" "$OUT" "RUVIEW_ALLOW_UNAUTH_LAN=1" + +# Test 2: No arguments with explicit LAN opt-in — should use CSI_SOURCE default (auto) +echo "" +echo "Test 2: No arguments with RUVIEW_ALLOW_UNAUTH_LAN=1" +OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1) assert_contains "includes --source auto" "$OUT" "--source auto" assert_contains "includes --tick-ms 100" "$OUT" "--tick-ms 100" assert_contains "includes --ui-path" "$OUT" "--ui-path /app/ui" @@ -77,49 +98,49 @@ assert_contains "includes --http-port 3000" "$OUT" "--http-port 3000" assert_contains "includes --ws-port 3001" "$OUT" "--ws-port 3001" assert_contains "includes --bind-addr 0.0.0.0" "$OUT" "--bind-addr 0.0.0.0" -# Test 2: CSI_SOURCE=esp32 — should substitute +# Test 3: CSI_SOURCE=esp32 — should substitute when LAN mode is explicit echo "" -echo "Test 2: CSI_SOURCE=esp32" -OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" 2>&1) +echo "Test 3: CSI_SOURCE=esp32" +OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" 2>&1) assert_contains "includes --source esp32" "$OUT" "--source esp32" -# Test 3: Flag arguments — should prepend binary +# Test 4: Flag arguments — should prepend binary echo "" -echo "Test 3: User passes --source wifi --tick-ms 500" -OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" --source wifi --tick-ms 500 2>&1) +echo "Test 4: User passes --source wifi --tick-ms 500" +OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=auto "$TEST_ENTRYPOINT" --source wifi --tick-ms 500 2>&1) assert_contains "includes --source wifi" "$OUT" "--source wifi" assert_contains "includes --tick-ms 500" "$OUT" "--tick-ms 500" -# Test 4: No CSI_SOURCE set — should default to auto +# Test 5: No CSI_SOURCE set — should default to auto echo "" -echo "Test 4: CSI_SOURCE unset" -OUT=$(unset CSI_SOURCE; "$TEST_ENTRYPOINT" 2>&1) +echo "Test 5: CSI_SOURCE unset" +OUT=$(unset CSI_SOURCE; RUVIEW_ALLOW_UNAUTH_LAN=1 "$TEST_ENTRYPOINT" 2>&1) assert_contains "includes --source auto (default)" "$OUT" "--source auto" -# Test 5: User passes --model flag — should be appended +# Test 6: User passes --model flag — should be appended echo "" -echo "Test 5: User passes --model /app/models/my.rvf" -OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" --model /app/models/my.rvf 2>&1) +echo "Test 6: User passes --model /app/models/my.rvf" +OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" --model /app/models/my.rvf 2>&1) assert_contains "includes --model" "$OUT" "--model /app/models/my.rvf" assert_contains "also includes default flags" "$OUT" "--source esp32" -# Test 6: CSI_SOURCE=simulated +# Test 7: CSI_SOURCE=simulated echo "" -echo "Test 6: CSI_SOURCE=simulated" -OUT=$(CSI_SOURCE=simulated "$TEST_ENTRYPOINT" 2>&1) +echo "Test 7: CSI_SOURCE=simulated" +OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=simulated "$TEST_ENTRYPOINT" 2>&1) assert_contains "includes --source simulated" "$OUT" "--source simulated" -# Test 7: Explicit binary path passed (e.g., docker run /bin/sh) +# Test 8: Explicit binary path passed (e.g., docker run /bin/sh) # First arg does NOT start with -, so entrypoint should exec it directly echo "" -echo "Test 7: Explicit command (echo hello)" +echo "Test 8: Explicit command (echo hello)" OUT=$("$TEST_ENTRYPOINT" echo hello 2>&1) assert_contains "passes through explicit command" "$OUT" "hello" assert_not_contains "does not inject sensing-server flags" "$OUT" "--source" -# Test 8: MODELS_DIR env var is passed through to the process +# Test 9: MODELS_DIR env var is passed through to the process echo "" -echo "Test 8: MODELS_DIR env var propagation" +echo "Test 9: MODELS_DIR env var propagation" # Create a stub that prints MODELS_DIR ENV_STUB="$TMPDIR/env-sensing-server" cat > "$ENV_STUB" << 'ENVEOF' @@ -131,12 +152,42 @@ ENV_ENTRYPOINT="$TMPDIR/env-entrypoint.sh" sed "s|/app/sensing-server|$ENV_STUB|g" "$ENTRYPOINT" > "$ENV_ENTRYPOINT" chmod +x "$ENV_ENTRYPOINT" -OUT=$(MODELS_DIR=/app/models CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1) +OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 MODELS_DIR=/app/models CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1) assert_contains "MODELS_DIR is visible" "$OUT" "MODELS_DIR=/app/models" -OUT=$(unset MODELS_DIR; CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1) +OUT=$(unset MODELS_DIR; RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1) assert_contains "MODELS_DIR defaults to unset" "$OUT" "MODELS_DIR=unset" +# Test 10: RUVIEW_API_TOKEN also permits the Docker network bind. +echo "" +echo "Test 10: RUVIEW_API_TOKEN permits 0.0.0.0 bind" +OUT=$(RUVIEW_API_TOKEN=test-token CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1) +assert_contains "token path includes --bind-addr" "$OUT" "--bind-addr 0.0.0.0" + +# Test 11: Loopback bind remains allowed without auth. +echo "" +echo "Test 11: --bind-addr 127.0.0.1 allowed without auth" +OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" --bind-addr 127.0.0.1 2>&1) +assert_contains "loopback override is preserved" "$OUT" "--bind-addr 127.0.0.1" + +# Test 12: Explicit sensing-server command with unsafe bind is also blocked. +echo "" +echo "Test 12: explicit sensing-server with --bind-addr 0.0.0.0 is blocked" +set +e +OUT=$("$TEST_ENTRYPOINT" "$STUB" --bind-addr 0.0.0.0 2>&1) +STATUS=$? +set -e +if [ "$STATUS" -ne 0 ]; then + PASS=$((PASS + 1)) + echo " ✓ explicit unsafe bind refused" +else + FAIL=$((FAIL + 1)) + echo " ✗ explicit unsafe bind refused" + echo " expected non-zero exit" + echo " got: $OUT" +fi +assert_contains "explicit unsafe bind explains token requirement" "$OUT" "RUVIEW_API_TOKEN" + echo "" echo "=== Results: $PASS passed, $FAIL failed ===" [ "$FAIL" -eq 0 ] || exit 1