fix(calibration): address PR review — aarch64 decouple, API auth, path traversal, throttle

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 <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-09 13:12:25 -04:00
parent a811283438
commit 0f8c5c919b
6 changed files with 120 additions and 21 deletions
@@ -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.
- **Teacherstudent 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
@@ -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 <T>`** (or `CALIBRATE_TOKEN` env) requires `Authorization: Bearer <T>` 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).
---
+5 -2
View File
@@ -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" }
@@ -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 <token>` on every API request. Strongly
/// recommended before binding to anything other than 127.0.0.1.
#[arg(long, env = "CALIBRATE_TOKEN")]
pub token: Option<String>,
}
/// 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<RwLock<SharedStatus>>,
}
/// 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 <token>` match.
async fn require_bearer(
axum::extract::State(token): axum::extract::State<String>,
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<ActiveSession> = 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(&params.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('/'));
}
}
+2
View File
@@ -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),
+2
View File
@@ -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);
}
}