feat(kiosk): double-verified auto-wipe on server-side deletion

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) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 02:12:58 +02:00
parent 3ee79b9e83
commit aa068a32f1
No known key found for this signature in database
2 changed files with 55 additions and 4 deletions

View file

@ -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<String> {
crate::at_rest::read_text_maybe_encrypted(&cluster_key_file())
@ -358,7 +387,22 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
*BUNDLE_ETAG.lock().unwrap() = Some(etag.to_string());
}
match resp.json::<KioskBundle>() {
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::<KioskBundle>(&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::<serde_json::Value>() {
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());
}

View file

@ -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<{