feat(docker+sensing-server): refresh Docker publish + opt-in bearer-token API auth

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-<short>` 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 <token>`
    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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-13 08:52:25 -04:00
parent 00304f9dc7
commit c641fc44ae
8 changed files with 481 additions and 183 deletions

View File

@ -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"

View File

@ -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 <token>` 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-<short>` 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

View File

@ -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 <token>` (see bearer_auth module, #443).
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
ENV RUVIEW_API_TOKEN=
# HTTP API
EXPOSE 3000
# WebSocket

186
v2/Cargo.lock generated
View File

@ -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",

View File

@ -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 }

View File

@ -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 <token>` 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<Arc<String>>,
}
impl AuthState {
/// Build an [`AuthState`] from an explicit string. Empty ⇒ disabled.
pub fn from_token(t: impl Into<String>) -> 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 <token>` 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<AuthState>,
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 <RUVIEW_API_TOKEN>)\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 { "<html/>" }))
}
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 <token>" 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/");
}
}

View File

@ -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;

View File

@ -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 <token>`.
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=<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));