mirror of
https://github.com/ruvnet/RuView
synced 2026-07-23 17:33:20 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(¶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('/'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user