From aa068a32f149f5d0ec88060d0376bc7075e02140 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Tue, 26 May 2026 02:12:58 +0200 Subject: [PATCH] feat(kiosk): double-verified auto-wipe on server-side deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server returns {bf_kiosk_deleted: true} (200) instead of 401 when kiosk key not found on bundle/heartbeat. Kiosk then confirms via GET /api/kiosk/_check — only wipes config if _check also returns 401. Prevents proxy glitches from nuking valid kiosks. Flow: bf_kiosk_deleted signal → confirm via _check → 401 = wipe, 200 = ignore (false alarm). Co-Authored-By: Claude Opus 4.6 (1M context) --- kiosk/src/server.rs | 55 +++++++++++++++++++- server/src/plugins/service-api-http/index.ts | 4 +- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 24de890..aa73cc3 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -211,6 +211,35 @@ pub fn is_paired() -> bool { key_file().exists() } +/// Confirm with the server that our key is truly rejected before wiping. +/// Calls /api/kiosk/_check — if 200 the key is still valid (false alarm). +fn confirm_deletion(server: &str, key: &str) -> bool { + let client = reqwest::blocking::Client::new(); + match client + .get(format!("{server}/api/kiosk/_check")) + .header("Authorization", format!("Bearer {key}")) + .timeout(Duration::from_secs(5)) + .send() + { + Ok(r) => r.status().as_u16() == 401, + Err(_) => false, // network error — don't wipe + } +} + +/// Wipe all kiosk state and exit. Systemd restarts the service, +/// kiosk boots fresh with a new pairing code. Only called after +/// double-verification (bf_kiosk_deleted + _check 401). +fn wipe_and_restart() -> ! { + let dir = state_dir(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let _ = std::fs::remove_file(entry.path()); + } + } + tracing::info!("config wiped, exiting for systemd restart"); + std::process::exit(1); +} + /// Load cluster key (if stored from pairing). Used for ONVIF password decrypt. pub fn load_cluster_key() -> Option { crate::at_rest::read_text_maybe_encrypted(&cluster_key_file()) @@ -358,7 +387,22 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option { *BUNDLE_ETAG.lock().unwrap() = Some(etag.to_string()); } - match resp.json::() { + let text = match resp.text() { + Ok(t) => t, + Err(e) => { tracing::warn!("bundle read failed: {e}"); return None; } + }; + + // Server signals kiosk was deleted — double-verify via _check before wiping + if text.contains("\"bf_kiosk_deleted\"") { + tracing::warn!("server reports kiosk deleted, confirming via _check"); + if confirm_deletion(server, key) { + tracing::error!("deletion confirmed, wiping config and restarting"); + wipe_and_restart(); + } + tracing::info!("_check says key still valid, ignoring bf_kiosk_deleted"); + } + + match serde_json::from_str::(&text) { Ok(b) => { save_bundle(&b); Some(b) @@ -470,8 +514,15 @@ pub fn heartbeat( if !r.status().is_success() { return Ok(false); } - // Parse channels from heartbeat response and cache for terminal access check. if let Ok(body) = r.json::() { + if body.get("bf_kiosk_deleted").and_then(|v| v.as_bool()).unwrap_or(false) { + tracing::warn!("server reports kiosk deleted via heartbeat, confirming via _check"); + if confirm_deletion(server, key) { + tracing::error!("deletion confirmed via heartbeat, wiping config and restarting"); + wipe_and_restart(); + } + tracing::info!("_check says key still valid, ignoring bf_kiosk_deleted from heartbeat"); + } if let Some(fc) = body.get("firmware_channel").and_then(|v| v.as_str()) { CACHED_FIRMWARE_CHANNEL.lock().unwrap().replace(fc.to_string()); } diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index cb81848..8568e6c 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -369,7 +369,7 @@ function registerKioskRoutes( if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); const kiosk = await auth.verifyKioskKey(token); - if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + if (!kiosk) return { bf_kiosk_deleted: true }; event.context.obs?.log.info("bundle fetch for kiosk {id}", { id: String(kiosk.id) }); const clusterKey = await getClusterKey(repo, secrets); @@ -402,7 +402,7 @@ function registerKioskRoutes( if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); const kiosk = await auth.verifyKioskKey(token); - if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + if (!kiosk) return { bf_kiosk_deleted: true }; event.context.obs?.log.info("heartbeat from kiosk {id}", { id: String(kiosk.id) }); const body = await readBody<{