From c641fc44ae546728c749d5050819eb23b401c8da Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 13 May 2026 08:52:25 -0400 Subject: [PATCH] feat(docker+sensing-server): refresh Docker publish + opt-in bearer-token API auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #520, #514, #443. ## #520 / #514 — stale Docker image, missing UI assets `ruvnet/wifi-densepose:latest` was published before `ui/observatory*` and `ui/pose-fusion*` were added; users see /app/ui missing those files and the v0.6+ packet format doesn't reach the server. Two fixes: 1. `docker/Dockerfile.rust` now `RUN`s a build-time guard after `COPY ui/` that fails the build if `index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` (or the `observatory/` / `pose-fusion/` / `components/` / `services/` directories) are missing, plus an exec-bit check on `/app/sensing-server`. A stale image can never be silently produced again. 2. New `.github/workflows/sensing-server-docker.yml` rebuilds + pushes on every change to the Dockerfile, the server crate, the signal/vitals/ wifiscan crates, the workspace manifests, the `ui/` tree, or itself — plus `v*` tags and manual dispatch. Pushes to both `docker.io/ruvnet/ wifi-densepose` AND `ghcr.io/ruvnet/wifi-densepose` with `latest` + `vX.Y.Z` + `sha-` tags, then post-push smoke-tests the artifact: /health, /api/v1/info, the observatory + pose-fusion HTML, AND the bearer-auth path (no token → 401, wrong → 401, correct → 200). Uses the `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN` repo secrets; ghcr.io rides on the workflow's GITHUB_TOKEN. ## #443 — sensing-server REST API auth model QE security audit raised that 40+ /api/v1/* routes have no auth layer with a default `0.0.0.0` bind. New `wifi_densepose_sensing_server::bearer_auth` module + middleware: - Env-var-gated: `RUVIEW_API_TOKEN` unset/empty ⇒ middleware is a no-op (current LAN-mode behaviour preserved — **no default change**); set ⇒ every `/api/v1/*` request must carry `Authorization: Bearer ` or the server returns 401. - Constant-time byte compare via local `ct_eq` (no new dep). - `/health*`, `/ws/sensing`, and `/ui/*` are intentionally never gated (orchestrator probes + local browsers). - Startup logs which mode is active and warns when auth is ON with a `0.0.0.0` bind. - 8 unit tests on the middleware via `tower::ServiceExt::oneshot` (sensing-server lib tests 191 → 199, 0 failures). Verified locally: `cargo build --workspace --no-default-features` ✓, `cargo test -p wifi-densepose-sensing-server --no-default-features` ✓. Co-Authored-By: claude-flow --- .github/workflows/sensing-server-docker.yml | 164 ++++++++++++ CHANGELOG.md | 28 +++ docker/Dockerfile.rust | 19 ++ v2/Cargo.lock | 186 +------------- .../wifi-densepose-sensing-server/Cargo.toml | 2 + .../src/bearer_auth.rs | 235 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/lib.rs | 2 + .../wifi-densepose-sensing-server/src/main.rs | 28 +++ 8 files changed, 481 insertions(+), 183 deletions(-) create mode 100644 .github/workflows/sensing-server-docker.yml create mode 100644 v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs diff --git a/.github/workflows/sensing-server-docker.yml b/.github/workflows/sensing-server-docker.yml new file mode 100644 index 00000000..fe0977ce --- /dev/null +++ b/.github/workflows/sensing-server-docker.yml @@ -0,0 +1,164 @@ +name: wifi-densepose sensing-server → Docker Hub + ghcr.io + +# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub +# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on: +# - push to main affecting the Dockerfile, the server crate, the UI assets, +# or this workflow itself, +# - tag push matching v* (release builds), +# - manual workflow_dispatch. +# +# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically +# whenever the surface that produces it changes, and the Dockerfile fails the +# build if the observatory/pose-fusion UI assets ever go missing again. +# +# Secrets: +# DOCKERHUB_USERNAME — `ruvnet` (Docker Hub login name) +# DOCKERHUB_TOKEN — Docker Hub access token with read/write/delete scope +# (ghcr.io uses the workflow's GITHUB_TOKEN — no secret needed.) + +on: + push: + branches: [main] + paths: + - 'docker/Dockerfile.rust' + - 'docker/docker-entrypoint.sh' + - 'v2/crates/wifi-densepose-sensing-server/**' + - 'v2/crates/wifi-densepose-signal/**' + - 'v2/crates/wifi-densepose-vitals/**' + - 'v2/crates/wifi-densepose-wifiscan/**' + - 'v2/Cargo.toml' + - 'v2/Cargo.lock' + - 'ui/**' + - '.github/workflows/sensing-server-docker.yml' + tags: ['v*'] + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +concurrency: + group: sensing-server-docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-publish: + name: build · push · smoke-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute tags + id: meta + uses: docker/metadata-action@v5 + with: + images: | + docker.io/ruvnet/wifi-densepose + ghcr.io/ruvnet/wifi-densepose + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build + push + id: build + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.rust + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + # --------------------------------------------------------------------- + # Smoke-test the freshly-pushed image: + # 1. UI assets that closed #520 are inside `/app/ui` (the Dockerfile's + # 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 + # Bearer header, 200 with the correct one (the #443 auth middleware). + # --------------------------------------------------------------------- + - name: Smoke-test image assets + LAN-mode HTTP + run: | + set -euo pipefail + IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}" + docker pull "$IMAGE" + docker run --rm "$IMAGE" sh -c \ + '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" + # 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 + sleep 1 + done + curl -fsS http://127.0.0.1:3000/health + curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null + curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null + curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null + docker stop sm + + - name: Smoke-test the bearer-token auth path + run: | + set -euo pipefail + IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}" + docker run -d --name auth \ + -p 3000:3000 \ + -e CSI_SOURCE=simulated \ + -e RUVIEW_API_TOKEN=smoke-test-token-do-not-use \ + "$IMAGE" + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi + sleep 1 + done + # /health stays unauthenticated. + curl -fsS http://127.0.0.1:3000/health >/dev/null + # /api/v1/info without a bearer → 401. + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info) + test "$code" = "401" || { echo "expected 401, got $code"; exit 1; } + # Wrong bearer → 401. + code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer wrong' http://127.0.0.1:3000/api/v1/info) + test "$code" = "401" || { echo "expected 401 (wrong token), got $code"; exit 1; } + # Correct bearer → 200. + curl -fsS -H 'Authorization: Bearer smoke-test-token-do-not-use' http://127.0.0.1:3000/api/v1/info >/dev/null + docker stop auth + + - name: Summary + if: always() + run: | + { + echo "## sensing-server image published" + echo + echo "Tags:" + echo '```' + echo "${{ steps.meta.outputs.tags }}" + echo '```' + echo + echo "Closes #520 (missing observatory/pose-fusion UI assets) and #514 (stale `:latest` for the v0.6+ packet format)." + echo "The Dockerfile fails the build if those UI assets ever disappear again, and this workflow rebuilds + pushes automatically on every change to the surface." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index da1e68d8..329f8269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Opt-in bearer-token auth on `wifi-densepose-sensing-server`'s `/api/v1/*` HTTP surface (closes #443).** + New `wifi_densepose_sensing_server::bearer_auth` module: when the + `RUVIEW_API_TOKEN` env var is set, every request whose path begins with + `/api/v1/` must carry an `Authorization: Bearer ` header (constant-time + compared) or the server responds `401 Unauthorized`. When the variable is + unset or empty the middleware is a no-op — the long-standing LAN-only + deployment posture is preserved, so this is a binary deployment-time switch + with **no default behaviour change**. `/health*`, `/ws/sensing`, and the + `/ui/*` static mount are intentionally never gated (orchestrator probes + + local browsers). Startup logs which mode is active and warns when auth is on + with a `0.0.0.0` bind. 8 unit tests on the middleware (lib test count 191 → 199). + Resolves the security audit raised in #443. + ### Changed +- **Docker image: build-time guard for the UI assets, plus a CI workflow that + rebuilds and pushes on every change (closes #520, #514).** `docker/Dockerfile.rust` + now `RUN`s a guard after `COPY ui/` that fails the build if any of + `index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` / the + `observatory/` / `pose-fusion/` / `components/` / `services/` directories are + missing, so a stale image can never be silently produced again. New + `.github/workflows/sensing-server-docker.yml` builds the image on push to + `main` (paths-filtered) and on `v*` tags and pushes to both + `docker.io/ruvnet/wifi-densepose` and `ghcr.io/ruvnet/wifi-densepose` with + `latest` + `vX.Y.Z` + `sha-` tags, then smoke-tests the published + artifact: `/health`, `/api/v1/info`, the observatory + pose-fusion UI assets, + and the `RUVIEW_API_TOKEN` auth path (no token → 401, wrong → 401, correct + → 200). Uses `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets for the + Docker Hub push; ghcr.io uses the workflow's `GITHUB_TOKEN`. - **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*` crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/ `-runtime`/`-node`/`-cli` — added inline in #542) now live in diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust index 60fab8f2..018e8dad 100644 --- a/docker/Dockerfile.rust +++ b/docker/Dockerfile.rust @@ -33,6 +33,25 @@ COPY --from=builder /build/target/release/sensing-server /app/sensing-server # Copy UI assets COPY ui/ /app/ui/ +# Sanity-check the assets the runtime actually serves (regression guard for +# #520/#514 — the published image must include the observatory and pose-fusion +# dashboards, not just the legacy `index.html` set). Build fails if any of +# these are missing, so a stale image can't be silently pushed. +RUN set -e; \ + for f in /app/ui/index.html /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/viz.html; do \ + test -f "$f" || { echo "FATAL: missing UI asset $f"; exit 1; }; \ + done; \ + for d in /app/ui/observatory /app/ui/pose-fusion /app/ui/components /app/ui/services; do \ + test -d "$d" || { echo "FATAL: missing UI directory $d"; exit 1; }; \ + done; \ + test -x /app/sensing-server || { echo "FATAL: /app/sensing-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). +# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ... +ENV RUVIEW_API_TOKEN= + # HTTP API EXPOSE 3000 # WebSocket diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 0238f6f7..f3061a92 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -944,15 +944,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie" version = "0.18.1" @@ -1294,7 +1285,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case 0.4.0", + "convert_case", "proc-macro2", "quote", "rustc_version", @@ -3200,7 +3191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading 0.7.4", + "libloading", "once_cell", ] @@ -3220,16 +3211,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libm" version = "0.2.16" @@ -3643,63 +3624,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "napi" -version = "2.16.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" -dependencies = [ - "bitflags 2.11.0", - "ctor", - "napi-derive", - "napi-sys", - "once_cell", -] - -[[package]] -name = "napi-build" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" - -[[package]] -name = "napi-derive" -version = "2.16.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" -dependencies = [ - "cfg-if", - "convert_case 0.6.0", - "napi-derive-backend", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "napi-derive-backend" -version = "1.0.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" -dependencies = [ - "convert_case 0.6.0", - "once_cell", - "proc-macro2", - "quote", - "regex", - "semver", - "syn 2.0.117", -] - -[[package]] -name = "napi-sys" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" -dependencies = [ - "libloading 0.8.9", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -5955,111 +5879,6 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "178f93f84a4a72c582026a45d9b8710acf188df4a22a25434c5dbba1df6c4cac" -[[package]] -name = "rvcsi-adapter-file" -version = "0.3.0" -dependencies = [ - "rvcsi-core", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", -] - -[[package]] -name = "rvcsi-adapter-nexmon" -version = "0.3.0" -dependencies = [ - "cc", - "rvcsi-core", - "thiserror 1.0.69", -] - -[[package]] -name = "rvcsi-cli" -version = "0.3.0" -dependencies = [ - "anyhow", - "clap", - "rvcsi-adapter-file", - "rvcsi-adapter-nexmon", - "rvcsi-core", - "rvcsi-runtime", - "serde", - "serde_json", - "tempfile", -] - -[[package]] -name = "rvcsi-core" -version = "0.3.0" -dependencies = [ - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "rvcsi-dsp" -version = "0.3.0" -dependencies = [ - "rvcsi-core", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "rvcsi-events" -version = "0.3.0" -dependencies = [ - "rvcsi-core", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "rvcsi-node" -version = "0.3.0" -dependencies = [ - "napi", - "napi-build", - "napi-derive", - "rvcsi-adapter-nexmon", - "rvcsi-core", - "rvcsi-runtime", - "serde", - "serde_json", - "tempfile", -] - -[[package]] -name = "rvcsi-runtime" -version = "0.3.0" -dependencies = [ - "rvcsi-adapter-file", - "rvcsi-adapter-nexmon", - "rvcsi-core", - "rvcsi-dsp", - "rvcsi-events", - "rvcsi-ruvector", - "serde", - "serde_json", - "tempfile", -] - -[[package]] -name = "rvcsi-ruvector" -version = "0.3.0" -dependencies = [ - "rvcsi-core", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", -] - [[package]] name = "ryu" version = "1.0.23" @@ -8706,6 +8525,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tower 0.4.13", "tower-http 0.5.2", "tracing", "tracing-subscriber", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 0647e8e9..2b8dadc0 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -52,3 +52,5 @@ wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", [dev-dependencies] tempfile = "3.10" +# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth). +tower = { workspace = true } diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs new file mode 100644 index 00000000..6ed987d8 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -0,0 +1,235 @@ +//! Opt-in bearer-token auth for the sensing-server HTTP API (#443). +//! +//! When the `RUVIEW_API_TOKEN` environment variable is set, every request +//! whose path begins with `/api/v1/` must carry a matching +//! `Authorization: Bearer ` header, otherwise the server responds with +//! `401 Unauthorized`. When the env var is unset (or empty), the middleware is +//! a no-op and the API stays unauthenticated — preserving the long-standing +//! LAN-only deployment posture documented in the issue. This is a binary, +//! deployment-time switch with **no default authentication change**. +//! +//! Endpoints outside `/api/v1/*` (`/health*`, `/ws/sensing`, the static `/ui/*` +//! mount, `/`) are intentionally **not** gated: +//! * `/health*` is the liveness/readiness probe that orchestrators hit +//! anonymously; +//! * `/ws/sensing` and `/ui/*` are served to local browsers that can't easily +//! inject headers — the sensitive control plane is the `/api/v1/*` tree, and +//! that is what this layer protects. +//! +//! The header check uses a length-then-byte constant-time compare to avoid +//! leaking the token through timing. + +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::{header::AUTHORIZATION, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +/// Environment variable that gates the middleware. Unset / empty ⇒ auth off. +pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN"; + +/// Path prefix the middleware protects when auth is enabled. +pub const PROTECTED_PREFIX: &str = "/api/v1/"; + +/// Cheap, cloneable handle to the configured token (or `None`). +#[derive(Debug, Clone, Default)] +pub struct AuthState { + /// The expected bearer token, if any. `None` ⇒ middleware is a no-op. + token: Option>, +} + +impl AuthState { + /// Build an [`AuthState`] from an explicit string. Empty ⇒ disabled. + pub fn from_token(t: impl Into) -> Self { + let s = t.into(); + if s.is_empty() { + AuthState { token: None } + } else { + AuthState { token: Some(Arc::new(s)) } + } + } + + /// Read [`API_TOKEN_ENV`] from the process environment. Returns + /// `AuthState { token: None }` when the variable is unset or empty. + pub fn from_env() -> Self { + match std::env::var(API_TOKEN_ENV) { + Ok(s) if !s.is_empty() => AuthState::from_token(s), + _ => AuthState::default(), + } + } + + /// Whether the middleware will enforce auth on `/api/v1/*` requests. + pub fn is_enabled(&self) -> bool { + self.token.is_some() + } +} + +/// Constant-time byte slice equality. Returns `false` immediately on length +/// mismatch (lengths are not secret here — both sides are fixed tokens). +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// Axum middleware: enforces `Authorization: Bearer ` on `/api/v1/*` +/// requests when [`AuthState::is_enabled`] returns `true`. Wires up via +/// [`axum::middleware::from_fn_with_state`]. +pub async fn require_bearer( + State(auth): State, + request: Request, + next: Next, +) -> Response { + let Some(expected) = auth.token.clone() else { + return next.run(request).await; + }; + if !request.uri().path().starts_with(PROTECTED_PREFIX) { + return next.run(request).await; + } + let supplied = request + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")); + let ok = supplied + .map(|s| ct_eq(s.as_bytes(), expected.as_bytes())) + .unwrap_or(false); + if ok { + next.run(request).await + } else { + ( + StatusCode::UNAUTHORIZED, + "missing or invalid bearer token (set Authorization: Bearer )\n", + ) + .into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + fn ok_handler() -> Router { + Router::new() + .route("/health", get(|| async { "ok" })) + .route("/api/v1/info", get(|| async { "ok" })) + .route("/api/v1/sensitive", axum::routing::post(|| async { "ok" })) + .route("/ui/index.html", get(|| async { "" })) + } + + fn wrap(auth: AuthState) -> Router { + ok_handler() + .layer(axum::middleware::from_fn_with_state(auth, require_bearer)) + } + + async fn status(router: Router, method: &str, path: &str, auth: Option<&str>) -> StatusCode { + let mut req = Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .unwrap(); + if let Some(t) = auth { + req.headers_mut() + .insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap()); + } + router.oneshot(req).await.unwrap().status() + } + + #[tokio::test] + async fn middleware_is_no_op_when_token_unset() { + let r = wrap(AuthState::default()); + assert_eq!(status(r.clone(), "GET", "/api/v1/info", None).await, StatusCode::OK); + assert_eq!(status(r.clone(), "POST", "/api/v1/sensitive", None).await, StatusCode::OK); + assert_eq!(status(r.clone(), "GET", "/health", None).await, StatusCode::OK); + assert_eq!(status(r, "GET", "/ui/index.html", None).await, StatusCode::OK); + } + + #[tokio::test] + async fn enabled_blocks_api_without_bearer() { + let r = wrap(AuthState::from_token("s3cr3t")); + assert_eq!(status(r.clone(), "GET", "/api/v1/info", None).await, StatusCode::UNAUTHORIZED); + assert_eq!( + status(r, "POST", "/api/v1/sensitive", None).await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn enabled_blocks_api_with_wrong_bearer() { + let r = wrap(AuthState::from_token("s3cr3t")); + assert_eq!( + status(r.clone(), "GET", "/api/v1/info", Some("nope")).await, + StatusCode::UNAUTHORIZED + ); + // Wrong scheme (Basic / token) — only "Bearer " is accepted. + let mut req = Request::builder() + .method("GET") + .uri("/api/v1/info") + .body(Body::empty()) + .unwrap(); + req.headers_mut() + .insert(AUTHORIZATION, "Basic s3cr3t".parse().unwrap()); + assert_eq!(r.oneshot(req).await.unwrap().status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn enabled_allows_api_with_correct_bearer() { + let r = wrap(AuthState::from_token("s3cr3t")); + assert_eq!( + status(r.clone(), "GET", "/api/v1/info", Some("s3cr3t")).await, + StatusCode::OK + ); + assert_eq!( + status(r, "POST", "/api/v1/sensitive", Some("s3cr3t")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn enabled_never_gates_paths_outside_api_v1() { + let r = wrap(AuthState::from_token("s3cr3t")); + // Even with auth ON, `/health` and `/ui/*` are reachable without a token: + // orchestrator probes and the local UI need to load unchallenged. + assert_eq!(status(r.clone(), "GET", "/health", None).await, StatusCode::OK); + assert_eq!(status(r, "GET", "/ui/index.html", None).await, StatusCode::OK); + } + + #[test] + fn ct_eq_basics() { + assert!(ct_eq(b"abc", b"abc")); + assert!(!ct_eq(b"abc", b"abd")); + assert!(!ct_eq(b"abc", b"ab")); // length mismatch + assert!(!ct_eq(b"", b"x")); + assert!(ct_eq(b"", b"")); + } + + #[test] + fn from_env_treats_empty_as_disabled() { + // Avoid touching the real env in a thread-shared test — exercise the + // string ctor directly with the same trim logic. + assert!(!AuthState::from_token("").is_enabled()); + assert!(AuthState::from_token("x").is_enabled()); + } + + #[test] + fn protected_prefix_and_env_constants_are_stable() { + // These are documented in the issue body and the README; keep them locked. + assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN"); + assert_eq!(PROTECTED_PREFIX, "/api/v1/"); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index aba864b5..68fa17a9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -3,7 +3,9 @@ //! This crate provides: //! - Vital sign detection from WiFi CSI amplitude data //! - RVF (RuVector Format) binary container for model weights +//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`) +pub mod bearer_auth; pub mod vital_signs; pub mod rvf_container; pub mod rvf_pipeline; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index a8b207e4..5887a752 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -4861,6 +4861,26 @@ async fn main() { let bind_ip: std::net::IpAddr = args.bind_addr.parse() .expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)"); + // #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN` + // unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒ + // every `/api/v1/*` request must carry `Authorization: Bearer `. + let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env(); + if bearer_auth_state.is_enabled() { + info!( + "API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)" + ); + if bind_ip.is_unspecified() { + warn!( + "API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments", + bind_ip + ); + } + } else { + info!( + "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." + ); + } + // WebSocket server on dedicated port (8765) let ws_state = state.clone(); let ws_app = Router::new() @@ -4947,6 +4967,14 @@ async fn main() { axum::http::header::CACHE_CONTROL, HeaderValue::from_static("no-cache, no-store, must-revalidate"), )) + // Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN` + // is unset/empty the middleware is a no-op — the default stays + // LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never + // gated (orchestrator probes + local browsers). + .layer(axum::middleware::from_fn_with_state( + bearer_auth_state.clone(), + wifi_densepose_sensing_server::bearer_auth::require_bearer, + )) .with_state(state.clone()); let http_addr = SocketAddr::from((bind_ip, args.http_port));