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
Generated
+3 -183
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",
@@ -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 }
@@ -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/");
}
}
@@ -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;
@@ -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));