mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
#864 — Docker no longer exposes the sensing API/stream unauthenticated: - Add `require_ws_token` middleware gating `/ws/*` (sensing + introspection) with the API token via `?token=` (browser) or `Authorization: Bearer` (programmatic). Previously /ws/sensing was ungated even with a token set. - docker-entrypoint.sh now fails closed: auto-generates a strong RUVIEW_API_TOKEN when none is supplied and prints it; explicit RUVIEW_ALLOW_UNAUTHENTICATED=1 restores the open LAN posture. - compose/Dockerfile wire the env vars; startup logs + CI smoke test updated to assert secure-by-default (401 with no token) and the opt-out path. - 7 new bearer_auth unit tests (15 total pass). #866 — CSI callbacks were starving (~3 in 70s, 0pps) under the MGMT-only promiscuous filter: - The documented "10 Hz probe injection" never existed — implement it for real (csi_inject_probe_request + 10 Hz timer). Validated on ESP32-C6 (COM9): probe TX succeeds at 10 Hz, but management-frame CSI stays sparse. - Re-admit DATA frames (MGMT+DATA) now that the original wDev_ProcessFiq SPI-cache crash is mitigated by WiFi RX/TX IRAM opts + the existing 50 Hz rate gate. Kconfig CSI_PROMISC_MGMT_ONLY falls back if needed. - Hardware-validated on COM9: yield 0 -> ~9pps avg (peak 19), presence/motion sensing restored, 0 panics over 35s. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -34,6 +34,10 @@ 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/";
|
||||
|
||||
/// Path prefix for the WebSocket sensing/introspection topics that
|
||||
/// [`require_ws_token`] protects when auth is enabled (#864).
|
||||
pub const WS_PREFIX: &str = "/ws/";
|
||||
|
||||
/// Cheap, cloneable handle to the configured token (or `None`).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthState {
|
||||
@@ -115,6 +119,71 @@ pub async fn require_bearer(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a bearer token from a WebSocket-upgrade request. Browsers cannot set
|
||||
/// arbitrary headers on a WS handshake, so the token is accepted via the
|
||||
/// `?token=<t>` query parameter in addition to the `Authorization: Bearer`
|
||||
/// header that programmatic clients (wscat, curl) can send.
|
||||
///
|
||||
/// No percent-decoding is applied: generated tokens are URL-safe (hex from
|
||||
/// `openssl rand` / UUID concatenation). Operators who pin a custom token
|
||||
/// should keep it URL-safe.
|
||||
fn ws_supplied_token(request: &Request) -> Option<String> {
|
||||
// 1. Authorization: Bearer <token> — for programmatic clients.
|
||||
if let Some(t) = request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
// 2. ?token=<token> query parameter — the only option browsers have on a
|
||||
// WebSocket handshake.
|
||||
request.uri().query().and_then(token_from_query)
|
||||
}
|
||||
|
||||
/// Find the `token` value in a `&`-separated `key=value` query string.
|
||||
fn token_from_query(query: &str) -> Option<String> {
|
||||
query.split('&').find_map(|pair| {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
match (it.next(), it.next()) {
|
||||
(Some("token"), Some(v)) => Some(v.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Axum middleware: enforces a valid token on `/ws/*` upgrade requests when
|
||||
/// [`AuthState::is_enabled`] returns `true` (#864). Mirrors [`require_bearer`]
|
||||
/// but reads the token from `?token=` (browser-friendly) or `Authorization`.
|
||||
/// When auth is disabled the middleware is a no-op, preserving the LAN-only
|
||||
/// default for non-Docker local runs.
|
||||
pub async fn require_ws_token(
|
||||
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(WS_PREFIX) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let ok = ws_supplied_token(&request)
|
||||
.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 token (append ?token=<RUVIEW_API_TOKEN> to the ws URL, \
|
||||
or send Authorization: Bearer <token>)\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -256,5 +325,82 @@ mod tests {
|
||||
// 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/");
|
||||
assert_eq!(WS_PREFIX, "/ws/");
|
||||
}
|
||||
|
||||
// ── #864: WebSocket token enforcement ────────────────────────────────────
|
||||
|
||||
fn ws_router(auth: AuthState) -> Router {
|
||||
Router::new()
|
||||
.route("/ws/sensing", get(|| async { "stream" }))
|
||||
.route("/ws/introspection", get(|| async { "stream" }))
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(auth, require_ws_token))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_from_query_parses_first_match() {
|
||||
assert_eq!(token_from_query("token=abc").as_deref(), Some("abc"));
|
||||
assert_eq!(token_from_query("a=1&token=abc&b=2").as_deref(), Some("abc"));
|
||||
assert_eq!(token_from_query("a=1&b=2").as_deref(), None);
|
||||
assert_eq!(token_from_query("").as_deref(), None);
|
||||
// bare key with no value is not a token
|
||||
assert_eq!(token_from_query("token").as_deref(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_unprotected_when_token_unset() {
|
||||
let r = ws_router(AuthState::default());
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing", None).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_blocks_without_token() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/ws/sensing", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/introspection", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_allows_with_query_token() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing?token=s3cr3t", None).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_allows_with_bearer_header() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing", Some("s3cr3t")).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_blocks_with_wrong_query_token() {
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r, "GET", "/ws/sensing?token=nope", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ws_middleware_never_gates_non_ws_paths() {
|
||||
// /health rides on the same router (dedicated WS port) and must stay open.
|
||||
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(status(r, "GET", "/health", None).await, StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6103,7 +6103,10 @@ async fn main() {
|
||||
// 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)");
|
||||
info!(
|
||||
"API auth: bearer-token enforcement ON for /api/v1/* and /ws/* (RUVIEW_API_TOKEN set). \
|
||||
WebSocket clients pass it as ?token=<token>."
|
||||
);
|
||||
if bind_ip.is_unspecified() {
|
||||
warn!(
|
||||
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
|
||||
@@ -6111,8 +6114,9 @@ async fn main() {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
|
||||
warn!(
|
||||
"API auth: OFF — /api/v1/* and /ws/* sensing streams are UNAUTHENTICATED. \
|
||||
Set RUVIEW_API_TOKEN=<token> to enforce bearer auth (the Docker image does this by default)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6145,6 +6149,13 @@ async fn main() {
|
||||
let ws_app = Router::new()
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
.route("/health", get(health))
|
||||
// #864: gate the live sensing stream with the API token when set. Reads
|
||||
// `?token=` (browser-friendly) or `Authorization: Bearer`. No-op when
|
||||
// RUVIEW_API_TOKEN is unset (LAN-mode default for local non-Docker runs).
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
host_allowlist.clone(),
|
||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||
@@ -6257,12 +6268,19 @@ async fn main() {
|
||||
))
|
||||
// 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).
|
||||
// LAN-mode-friendly. `/health*` and `/ui/*` are never gated
|
||||
// (orchestrator probes + local browsers loading the static UI).
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
// #864: gate the live `/ws/*` sensing + introspection streams with the
|
||||
// same token. Browsers pass it as `?token=`; programmatic clients use
|
||||
// `Authorization: Bearer`. No-op when RUVIEW_API_TOKEN is unset.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
|
||||
))
|
||||
// DNS-rebinding defense: applied last so it runs first on the request
|
||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||
// header is not in the allowlist before any handler — including
|
||||
|
||||
Reference in New Issue
Block a user