From 0f8c5c919bb6423e9a1929bbac9e879244e7ee20 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 9 Jun 2026 13:12:25 -0400 Subject: [PATCH] =?UTF-8?q?fix(calibration):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20aarch64=20decouple,=20API=20auth,=20path=20traversa?= =?UTF-8?q?l,=20throttle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the review on #989: - **Cross-compile (the appliance blocker):** make wifi-densepose-mat optional and feature-gate it (`mat`), so `cargo build -p wifi-densepose-cli --no-default-features` excludes the mat→nn→ort(ONNX)→openssl-sys chain. Verified: `cargo tree --no-default-features` shows 0 ort/openssl deps → calibration cross-compiles clean for the Pi. - **Security (must-fix before LAN):** - `--token` / CALIBRATE_TOKEN bearer-auth middleware on every route; warns if bound non-loopback without a token. - sanitize client-supplied `room_id` to [A-Za-z0-9_-] (≤64) before it reaches the baseline write path — kills the `../` file-write primitive. + test. - **Perf:** stop locking shared status + cloning SessionStatus on every UDP frame — counters/snapshot flush on the 200 ms tick instead (no CPU starvation under flood). finalize write moved to async `tokio::fs::write`. - **Docs:** ADR-151 STALE wording matches the impl (baseline-id change; drift-threshold = P6 refinement); integration doc gets the `--no-default-features` build + auth/sanitize notes. 35 calibration + 15 CLI tests (no-default) / 20 CLI (default) pass. Co-Authored-By: claude-flow --- ...51-room-calibration-specialist-training.md | 2 +- .../calibration-appliance-integration.md | 20 +++- v2/crates/wifi-densepose-cli/Cargo.toml | 7 +- .../wifi-densepose-cli/src/calibrate_api.rs | 108 +++++++++++++++--- v2/crates/wifi-densepose-cli/src/lib.rs | 2 + v2/crates/wifi-densepose-cli/src/main.rs | 2 + 6 files changed, 120 insertions(+), 21 deletions(-) diff --git a/docs/adr/ADR-151-room-calibration-specialist-training.md b/docs/adr/ADR-151-room-calibration-specialist-training.md index 75990be0..80017829 100644 --- a/docs/adr/ADR-151-room-calibration-specialist-training.md +++ b/docs/adr/ADR-151-room-calibration-specialist-training.md @@ -151,7 +151,7 @@ Design properties that follow from invariant (A): - **SONA online adaptation.** Each specialist may carry a SONA/MicroLoRA online-adaptation slot (`ruvllm_sona_*` / `microlora` primitives) so it tracks slow drift (furniture moved, seasonal RF change) between full re-enrollments, gated by ADR-135 baseline drift. - **Teacher–student distillation (optional, offline).** Where a labelled public corpus exists (MM-Fi, Wi-Pose), the ADR-150 backbone acts as teacher to pre-shape a head before per-room fine-tuning, improving cold-start. The *teacher* is global/HF; the *student head* is local. -**Invalidation contract.** The bank stores the `baseline_hash` it was trained against. When ADR-135 reports baseline drift beyond τ (room rearranged, AP moved, band changed), the runtime marks affected specialists `STALE` and prompts re-enrollment rather than emitting silently wrong vitals. This is the calibration analogue of the #954 `DEGRADED` honesty rule: never report confident numbers from an invalid model. +**Invalidation contract.** The bank stores the `baseline_id` (the baseline UUID) it was trained against. **As implemented**, the runtime marks the bank `STALE` whenever the *current* baseline id differs from the trained one — a conservative trigger that catches re-calibration (room rearranged, AP moved, band changed) because any of those produces a new baseline. A finer **drift-threshold** trigger (mark STALE when ADR-135's per-subcarrier deviation exceeds τ *without* a full re-baseline) is a planned refinement (P6). Either way the runtime prompts re-enrollment rather than emitting silently wrong vitals — the calibration analogue of the #954 `DEGRADED` honesty rule: never report confident numbers from an invalid model. ### 2.5 Runtime — mixture of specialists with confidence gating diff --git a/docs/integration/calibration-appliance-integration.md b/docs/integration/calibration-appliance-integration.md index ff8078b6..a7202b44 100644 --- a/docs/integration/calibration-appliance-integration.md +++ b/docs/integration/calibration-appliance-integration.md @@ -170,7 +170,16 @@ Pure Rust; deps are `wifi-densepose-core` + `wifi-densepose-signal` (default-fea Verified on `cognitum-v0`: aarch64, `cargo 1.96.0`, Hailo `HAILO10H`, `ruview-vitals-worker:50054`. **Step 1 — vendor / depend on the crate.** Add `wifi-densepose-calibration` (path or published crate) -to the appliance workspace. It builds natively (aarch64, no BLAS/GPU). +to the appliance workspace. It builds natively on aarch64 — no BLAS/GPU, **and no ONNX/OpenSSL**: +the CLI's `mat`→`nn`→`ort`(ONNX)→`openssl-sys` chain is now feature-gated out of the calibration build. + +```bash +# Pi/appliance calibration binary — cross-compiles clean (no ort/openssl): +cargo build -p wifi-densepose-cli --no-default-features --release +# (omit `--no-default-features` only if you also need the MAT subcommands) +``` +Verified: `cargo tree -p wifi-densepose-cli --no-default-features` shows **0** `ort`/`openssl-sys` deps; +`cross test --target aarch64-unknown-linux-gnu` passes the calibration suite under qemu. **Step 2 — wire the CSI source.** Two options: - (a) Tee the ESP32 UDP stream the vitals worker already receives into the calibration ingest, or @@ -191,8 +200,13 @@ head to Hailo HEF, serve via `ruvector-hailo-worker:50051`; the small specialist embedding. This is the ADR-150 follow-on — *not required* for the calibration service to run. **Privacy / security:** keep baselines + banks local; if federating across appliances (ADR-105), -exchange bank/model deltas, never raw CSI. `calibrate-serve` CORS is permissive for dev — bind to -loopback and gate via the appliance proxy in production. +exchange bank/model deltas, never raw CSI. Hardening already in place: +- **`--token `** (or `CALIBRATE_TOKEN` env) requires `Authorization: Bearer ` on every route; the + server warns loudly if bound to a non-loopback address without a token. +- **`room_id` is sanitized** to `[A-Za-z0-9_-]` (≤64 chars) before it touches the baseline write path — + no `../` / absolute-path traversal. +- CORS is permissive for dev — in production bind to loopback and reverse-proxy through the appliance + gateway (which already enforces bearer auth). --- diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index c794a50d..5f84d54d 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -16,12 +16,15 @@ name = "wifi-densepose" path = "src/main.rs" [features] +# `mat` pulls wifi-densepose-mat → -nn → ort (ONNX) → openssl-sys, which does NOT +# cross-compile to aarch64 and is irrelevant to the calibration path. Build the +# Pi/appliance calibration binary with `--no-default-features` to exclude it. default = ["mat"] -mat = [] +mat = ["dep:wifi-densepose-mat"] [dependencies] # Internal crates -wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat" } +wifi-densepose-mat = { version = "0.3.0", path = "../wifi-densepose-mat", optional = true } wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal", default-features = false } wifi-densepose-core = { version = "0.3.0", path = "../wifi-densepose-core" } wifi-densepose-calibration = { version = "0.3.0", path = "../wifi-densepose-calibration" } diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index 49f8aac7..ca0b0e22 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -73,6 +73,27 @@ pub struct CalibrateServeArgs { /// Directory where finalized baseline `.bin` files are written. #[arg(long, default_value = "./baselines")] pub output_dir: String, + + /// Require `Authorization: Bearer ` on every API request. Strongly + /// recommended before binding to anything other than 127.0.0.1. + #[arg(long, env = "CALIBRATE_TOKEN")] + pub token: Option, +} + +/// Sanitize a client-supplied `room_id` for use in a filename (defends the +/// baseline write path against `../` / absolute-path traversal). Keeps only +/// `[A-Za-z0-9_-]`; empty result falls back to `default`. +fn sanitize_room_id(raw: &str) -> String { + let cleaned: String = raw + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .take(64) + .collect(); + if cleaned.is_empty() { + "default".into() + } else { + cleaned + } } // --------------------------------------------------------------------------- @@ -159,6 +180,32 @@ struct ApiState { status: Arc>, } +/// Bearer-token gate (applied only when `--token` is set). Constant-time-ish +/// compare is unnecessary here (local appliance), but reject anything that +/// isn't an exact `Bearer ` match. +async fn require_bearer( + axum::extract::State(token): axum::extract::State, + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let authorized = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(|t| t == token) + .unwrap_or(false); + if authorized { + next.run(req).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "missing or invalid bearer token"})), + ) + .into_response() + } +} + // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- @@ -194,7 +241,7 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> { } let state = ApiState { cmd_tx, status }; - let app = Router::new() + let mut app = Router::new() .route("/", get(descriptor)) .route("/api/v1/calibration/health", get(health)) .route("/api/v1/calibration/start", post(start)) @@ -205,6 +252,17 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> { .layer(CorsLayer::permissive()) .with_state(state); + // Optional bearer auth — required before any non-loopback exposure. + if let Some(token) = args.token.clone() { + app = app.layer(axum::middleware::from_fn_with_state(token, require_bearer)); + eprintln!("[calibrate-serve] bearer auth ENABLED"); + } else if args.http_bind != "127.0.0.1" && args.http_bind != "localhost" { + eprintln!( + "[calibrate-serve] WARNING: bound to {} with NO --token — anyone on the network can drive calibration", + args.http_bind + ); + } + let http_addr = format!("{}:{}", args.http_bind, args.http_port); let listener = tokio::net::TcpListener::bind(&http_addr) .await @@ -243,6 +301,10 @@ async fn ingest_loop( let mut buf = vec![0u8; RECV_BUF]; let mut active: Option = None; let mut tick = tokio::time::interval(Duration::from_millis(200)); + // Counters mirrored to shared status only on the 200 ms tick — avoids a lock + // + SessionStatus clone on every UDP frame (CPU starvation under flood). + let mut frames_seen: u64 = 0; + let mut last_frame_ms: u64 = 0; loop { tokio::select! { @@ -264,7 +326,8 @@ async fn ingest_loop( } let target_frames = config.min_frames as usize; let dur = params.duration_s.max(1) as u64; - let room_id = params.room_id.unwrap_or_else(|| "default".into()); + // Sanitize: room_id is interpolated into the baseline write path. + let room_id = sanitize_room_id(¶ms.room_id.unwrap_or_else(|| "default".into())); let sess = ActiveSession { recorder: CalibrationRecorder::new(config), room_id: room_id.clone(), @@ -297,14 +360,10 @@ async fn ingest_loop( } }, - // --- incoming CSI frame --- + // --- incoming CSI frame (no shared-status lock here; flushed on tick) --- Ok(n) = socket.recv(&mut buf) => { - let now_ms = unix_ms(); - { - let mut s = status.write().await; - s.frames_seen += 1; - s.last_frame_unix_ms = now_ms; - } + frames_seen += 1; + last_frame_ms = unix_ms(); if let Some(sess) = active.as_mut() { let tier = sess.tier.clone(); if let Some(frame) = parse_csi_packet(&buf[..n], &tier) { @@ -313,10 +372,7 @@ async fn ingest_loop( sess.z_max = score.amplitude_z_max; sess.motion_flagged = score.motion_flagged; } - let frames = sess.recorder.frames_recorded() as usize; - let snap = session_snapshot(sess, "recording", None); - status.write().await.session = Some(snap); - if frames >= sess.target_frames { + if sess.recorder.frames_recorded() as usize >= sess.target_frames { if let Some(done) = active.take() { let _ = finalize(done, &output_dir, &status).await; } @@ -325,8 +381,16 @@ async fn ingest_loop( } }, - // --- periodic deadline check (fires even if frames stop arriving) --- + // --- 200 ms tick: flush counters + session snapshot, deadline check --- _ = tick.tick() => { + { + let mut s = status.write().await; + s.frames_seen = frames_seen; + s.last_frame_unix_ms = last_frame_ms; + if let Some(sess) = active.as_ref() { + s.session = Some(session_snapshot(sess, "recording", None)); + } + } if let Some(sess) = active.as_ref() { if Instant::now() >= sess.deadline { let frames = sess.recorder.frames_recorded() as usize; @@ -376,7 +440,10 @@ async fn finalize( let uuid = baseline.calibration_uuid().to_string(); let path = format!("{output_dir}/{room_id}-{uuid}.bin"); let bytes = baseline.to_bytes(); - std::fs::write(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?; + // Async write — never block the ingest task's UDP/command path. + tokio::fs::write(&path, &bytes) + .await + .map_err(|e| format!("cannot write {path}: {e}"))?; let summary = ResultSummary { calibration_id: uuid, @@ -588,8 +655,19 @@ mod tests { udp_bind: "0.0.0.0".into(), tier: "ht20".into(), output_dir: "./baselines".into(), + token: None, }; assert_eq!(a.http_port, 8090); assert_eq!(a.udp_port, 5005); } + + #[test] + fn sanitize_blocks_path_traversal() { + assert_eq!(sanitize_room_id("../../etc/passwd"), "etcpasswd"); + assert_eq!(sanitize_room_id("/abs/path"), "abspath"); + assert_eq!(sanitize_room_id("living-room_1"), "living-room_1"); + assert_eq!(sanitize_room_id(""), "default"); + assert_eq!(sanitize_room_id("..\\..\\win"), "win"); + assert!(!sanitize_room_id("a/b/c").contains('/')); + } } diff --git a/v2/crates/wifi-densepose-cli/src/lib.rs b/v2/crates/wifi-densepose-cli/src/lib.rs index 9ce1c8e7..d2b73bd9 100644 --- a/v2/crates/wifi-densepose-cli/src/lib.rs +++ b/v2/crates/wifi-densepose-cli/src/lib.rs @@ -29,6 +29,7 @@ use clap::{Parser, Subcommand}; pub mod calibrate; pub mod calibrate_api; pub mod room; +#[cfg(feature = "mat")] pub mod mat; /// WiFi-DensePose Command Line Interface @@ -73,6 +74,7 @@ pub enum Commands { RoomWatch(room::RoomWatchArgs), /// Mass Casualty Assessment Tool commands + #[cfg(feature = "mat")] #[command(subcommand)] Mat(mat::MatCommand), diff --git a/v2/crates/wifi-densepose-cli/src/main.rs b/v2/crates/wifi-densepose-cli/src/main.rs index 14a782ae..e4d48f8b 100644 --- a/v2/crates/wifi-densepose-cli/src/main.rs +++ b/v2/crates/wifi-densepose-cli/src/main.rs @@ -36,11 +36,13 @@ async fn main() -> anyhow::Result<()> { Commands::RoomWatch(args) => { wifi_densepose_cli::room::room_watch(args).await?; } + #[cfg(feature = "mat")] Commands::Mat(mat_cmd) => { wifi_densepose_cli::mat::execute(mat_cmd).await?; } Commands::Version => { println!("wifi-densepose {}", env!("CARGO_PKG_VERSION")); + #[cfg(feature = "mat")] println!("MAT module version: {}", wifi_densepose_mat::VERSION); } }