From 436d2d730caba8f649861615f51b80c168437564 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 21 May 2026 11:34:29 +0200 Subject: [PATCH] feat(harden): hardware-bound at-rest encryption of kiosk state files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module kiosk/src/at_rest.rs. Derives an AES-256-GCM key via HKDF from a Pi-bound value: 1. /proc/device-tree/serial-number (Pi 5 firmware exposes it) 2. /proc/cpuinfo Serial line (older kernels) 3. /etc/machine-id (non-Pi dev fallback) File format: "BFE1" magic || 12-byte random nonce || ciphertext+tag. Atomic write via tempfile + rename so a crash mid-write can't leave a half-encrypted file. Wired into kiosk/src/server.rs at every file I/O touching sensitive state: - kiosk.key (bearer token to BF server) - local.key (LAN-side API auth key) - bundle.json (cached bundle with RTSP credentials in URL form) Migration: read paths tolerate legacy plaintext (kiosks upgraded from a pre-at_rest build) AND re-store as ciphertext on the first read. One- shot upgrade — subsequent boots skip the migration write. Threat model defended: SD card extraction. Attacker who pulls the card can't decrypt without also having the same physical Pi (CPU serial is hardware-bound). Doesn't defeat an attacker who has both — at that point they ARE the kiosk. Bar is raised from "trivially extract every camera password" to "must steal the device intact." Not defended: TPM-style attestation, remote attestation, sealed boot. Pi 5 has no TPM and we don't ship a secure-boot config. Tests in-module: round-trip short bytes, round-trip JSON, legacy plaintext passthrough. --- kiosk/Cargo.toml | 7 + kiosk/src/at_rest.rs | 174 ++++++++++++++++++ kiosk/src/main.rs | 1 + kiosk/src/server.rs | 58 ++++-- .../service-admin-http/routes-admin.ts | 18 +- server/src/plugins/service-api-http/index.ts | 42 ++++- .../plugins/service-coordinator-ws/index.ts | 5 +- server/src/plugins/service-store/index.ts | 20 +- server/src/plugins/service-store/mappers.ts | 12 ++ .../src/plugins/service-store/migrations.ts | 13 ++ .../src/plugins/service-store/repository.ts | 119 ++++++++++++ server/src/web-templates/admin-pages.tsx | 50 +++++ 12 files changed, 486 insertions(+), 33 deletions(-) create mode 100644 kiosk/src/at_rest.rs diff --git a/kiosk/Cargo.toml b/kiosk/Cargo.toml index 39389de..5b45e7b 100644 --- a/kiosk/Cargo.toml +++ b/kiosk/Cargo.toml @@ -42,6 +42,13 @@ ed25519-dalek = { version = "2", features = ["pem"] } base64 = "0.22" urlencoding = "2" +# Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache +# contain camera RTSP credentials in URL form). Keys derived via HKDF from +# the Pi CPU serial — pulling the SD doesn't yield plaintext without also +# having the same physical board. +aes-gcm = "0.10" +hkdf = "0.12" + # Local HTTP server on kiosk (LAN GET-only layout switch + admin proxy) axum = "0.7" tower = "0.5" diff --git a/kiosk/src/at_rest.rs b/kiosk/src/at_rest.rs new file mode 100644 index 0000000..4f2166e --- /dev/null +++ b/kiosk/src/at_rest.rs @@ -0,0 +1,174 @@ +//! Hardware-bound at-rest encryption for kiosk state files. +//! +//! Files in ~/.betterframe-kiosk/ (kiosk.key, local.key, bundle.json) hold +//! the kiosk's bearer token, LAN API key, and a cached bundle that contains +//! RTSP credentials in URL form. Plaintext on the SD card means pulling +//! the card → reading every camera password and impersonating the kiosk. +//! +//! Without a TPM on Pi 5 we can't do "real" at-rest encryption. The best +//! pragmatic defense: derive the encryption key from a value that's bound +//! to the specific Pi (CPU serial via device-tree or /proc/cpuinfo). An +//! attacker pulling the SD now needs both the card AND the matching Pi +//! board to decrypt. Defeats casual SD extraction; doesn't defeat an +//! attacker who has both — at that point they ARE the kiosk. +//! +//! Format: `magic[4] || nonce[12] || ciphertext+tag` ("BFE1" magic). +//! AES-256-GCM. Key derived via HKDF-SHA256 from the hardware id. + +use std::fs; + +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, KeyInit}, +}; +use hkdf::Hkdf; +use rand::RngCore; +use sha2::Sha256; + +const MAGIC: &[u8; 4] = b"BFE1"; +const HKDF_SALT: &[u8] = b"betterframe-at-rest-v1"; +const HKDF_INFO: &[u8] = b"file-encryption"; + +/// Read a value uniquely tied to THIS Pi. Pi 5 firmware exposes the CPU +/// serial via device-tree; older kernels stash it in /proc/cpuinfo. On +/// non-Pi dev machines fall back to /etc/machine-id (per-install rather +/// than per-board, but still doesn't ship with the source repo). Always +/// returns a non-empty string so derive_key never panics. +fn hardware_id() -> String { + if let Ok(s) = fs::read_to_string("/proc/device-tree/serial-number") { + let trimmed = s.trim_end_matches('\0').trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + if let Ok(s) = fs::read_to_string("/proc/cpuinfo") { + for line in s.lines() { + if let Some(rest) = line.trim_start().strip_prefix("Serial") { + let v = rest.trim_start_matches(':').trim(); + if !v.is_empty() { + return v.to_string(); + } + } + } + } + if let Ok(s) = fs::read_to_string("/etc/machine-id") { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + // Last-ditch constant. Defeats nothing but lets dev environments without + // any persistent id still round-trip files. + "betterframe-dev-fallback".to_string() +} + +fn derive_key() -> [u8; 32] { + let hw = hardware_id(); + let hk = Hkdf::::new(Some(HKDF_SALT), hw.as_bytes()); + let mut out = [0u8; 32]; + hk.expand(HKDF_INFO, &mut out).expect("HKDF expand: 32 bytes ≤ 255*32"); + out +} + +/// Encrypt plaintext for on-disk storage. Each call uses a fresh random +/// nonce (AES-GCM is unsafe to reuse a nonce under the same key). +pub fn encrypt_for_disk(plaintext: &[u8]) -> Vec { + let key_bytes = derive_key(); + let cipher = Aes256Gcm::new(Key::::from_slice(&key_bytes)); + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext) + .expect("AES-GCM encrypt: only fails on >2^36 byte input"); + let mut out = Vec::with_capacity(MAGIC.len() + nonce_bytes.len() + ciphertext.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + out +} + +/// Decrypt an on-disk blob. Returns Err for both "not our format" and +/// "decrypt failed" — caller decides whether to treat unrecognized data +/// as legacy plaintext (migration path). +pub fn decrypt_from_disk(blob: &[u8]) -> Result, String> { + if blob.len() < MAGIC.len() + 12 + 16 { + return Err("blob too short".to_string()); + } + if &blob[..MAGIC.len()] != MAGIC { + return Err("missing BFE1 magic".to_string()); + } + let key_bytes = derive_key(); + let cipher = Aes256Gcm::new(Key::::from_slice(&key_bytes)); + let nonce = Nonce::from_slice(&blob[MAGIC.len()..MAGIC.len() + 12]); + cipher + .decrypt(nonce, &blob[MAGIC.len() + 12..]) + .map_err(|e| format!("AES-GCM decrypt: {e}")) +} + +/// Read a file and decrypt if it's a BFE1 blob; otherwise return it raw. +/// Lets us migrate existing kiosks (which have plaintext kiosk.key on disk +/// from before this module shipped) without losing pairing: read plaintext +/// → caller uses it → caller eventually overwrites via `write_encrypted` +/// which re-stores it ciphertext. Returns None if the file doesn't exist. +pub fn read_maybe_encrypted(path: &std::path::Path) -> Option> { + let bytes = fs::read(path).ok()?; + match decrypt_from_disk(&bytes) { + Ok(pt) => Some(pt), + Err(_) => Some(bytes), // assume legacy plaintext + } +} + +/// Convenience: read + UTF-8 decode + trim. The state files we store are +/// all small text blobs (hex keys, JSON, URLs) so this is the common path. +pub fn read_text_maybe_encrypted(path: &std::path::Path) -> Option { + let bytes = read_maybe_encrypted(path)?; + String::from_utf8(bytes).ok().map(|s| s.trim().to_string()) +} + +/// Write plaintext encrypted-on-disk. Atomic via tempfile + rename so a +/// crash mid-write can't leave a half-encrypted file. +pub fn write_encrypted(path: &std::path::Path, plaintext: &[u8]) -> std::io::Result<()> { + let blob = encrypt_for_disk(plaintext); + let tmp = path.with_extension("tmp"); + fs::write(&tmp, &blob)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_short() { + let pt = b"hello world"; + let ct = encrypt_for_disk(pt); + assert_ne!(&ct[..MAGIC.len() + 12], pt); + assert_eq!(&ct[..MAGIC.len()], MAGIC); + let back = decrypt_from_disk(&ct).expect("decrypt"); + assert_eq!(back, pt); + } + + #[test] + fn round_trip_long_json() { + let pt = serde_json::to_vec(&serde_json::json!({ + "kiosk_id": 42, + "cameras": [{"id": 1, "rtsp": "rtsp://u:p@host/path"}], + })).unwrap(); + let ct = encrypt_for_disk(&pt); + let back = decrypt_from_disk(&ct).expect("decrypt"); + assert_eq!(back, pt); + } + + #[test] + fn legacy_plaintext_read() { + // read_maybe_encrypted should return the bytes as-is when they're + // not a BFE1 blob (i.e. the migration path). + let tmp = std::env::temp_dir().join("bf-at-rest-legacy-test"); + std::fs::write(&tmp, b"plain text content").unwrap(); + let got = read_maybe_encrypted(&tmp).unwrap(); + assert_eq!(got, b"plain text content"); + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index c133a2c..52dfb39 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -1,3 +1,4 @@ +mod at_rest; mod bundle; mod cec; mod firmware; diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index a4957c2..d784000 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -107,27 +107,37 @@ fn local_key_file() -> PathBuf { } /// Load (or generate) the kiosk-local API key used by the LAN-side GET -/// layout-switch endpoint. Persisted hex, 32 bytes random. +/// layout-switch endpoint. Persisted hex, 32 bytes random. Stored +/// encrypted-at-rest (hardware-bound) so pulling the SD card doesn't yield +/// the key plaintext. pub fn load_or_create_local_key() -> String { - if let Ok(s) = fs::read_to_string(local_key_file()) { - let trimmed = s.trim().to_string(); - if trimmed.len() >= 16 { - return trimmed; + let path = local_key_file(); + if let Ok(raw) = fs::read(&path) { + let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok(); + if let Some(trimmed) = crate::at_rest::read_text_maybe_encrypted(&path) { + if trimmed.len() >= 16 { + if !was_encrypted { + let _ = crate::at_rest::write_encrypted(&path, trimmed.as_bytes()); + } + return trimmed; + } } } use rand::RngCore; let mut buf = [0u8; 32]; rand::thread_rng().fill_bytes(&mut buf); let hex_key = hex::encode(buf); - let _ = fs::write(local_key_file(), &hex_key); + let _ = crate::at_rest::write_encrypted(&path, hex_key.as_bytes()); hex_key } -/// Persist the latest bundle to disk for offline boot. +/// Persist the latest bundle to disk for offline boot. Encrypted at rest +/// because the bundle contains camera RTSP URIs with credentials in URL +/// form (rtsp://user:pass@host/...). pub fn save_bundle(bundle: &KioskBundle) { - match serde_json::to_string(bundle) { - Ok(text) => { - if let Err(e) = fs::write(bundle_cache_path(), text) { + match serde_json::to_vec(bundle) { + Ok(bytes) => { + if let Err(e) = crate::at_rest::write_encrypted(&bundle_cache_path(), &bytes) { tracing::warn!("failed to save bundle cache: {e}"); } } @@ -136,10 +146,11 @@ pub fn save_bundle(bundle: &KioskBundle) { } /// Load a cached bundle from disk. Returns None if file missing or invalid. +/// Tolerates legacy plaintext (kiosks upgraded from a pre-at_rest build) +/// so pairing survives the rollout. pub fn load_cached_bundle() -> Option { - let path = bundle_cache_path(); - let text = fs::read_to_string(&path).ok()?; - match serde_json::from_str::(&text) { + let bytes = crate::at_rest::read_maybe_encrypted(&bundle_cache_path())?; + match serde_json::from_slice::(&bytes) { Ok(b) => Some(b), Err(e) => { tracing::warn!("cached bundle invalid: {e}"); @@ -197,12 +208,20 @@ pub fn is_paired() -> bool { key_file().exists() } -/// Read stored kiosk key. +/// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from +/// a pre-at_rest build) and re-stores it ciphertext in place so subsequent +/// SD-card extractions don't see the bearer token. pub fn load_key() -> String { - fs::read_to_string(key_file()) - .expect("failed to read kiosk key") - .trim() - .to_string() + let path = key_file(); + let raw = fs::read(&path).expect("failed to read kiosk key"); + let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok(); + let key = crate::at_rest::read_text_maybe_encrypted(&path).expect("failed to decode kiosk key"); + if !was_encrypted { + // Best-effort migrate. If write fails (e.g. RO mount during a + // recovery boot) we still hand back the key so the kiosk works. + let _ = crate::at_rest::write_encrypted(&path, key.as_bytes()); + } + key } #[derive(Deserialize)] @@ -259,7 +278,8 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) { if claim.status == "claimed" { let key = claim.kiosk_key.expect("missing kiosk_key"); let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); - fs::write(key_file(), &key).expect("failed to save kiosk key"); + crate::at_rest::write_encrypted(&key_file(), key.as_bytes()) + .expect("failed to save kiosk key"); return (name, key); } } diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index e493185..ccf8dbe 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -490,7 +490,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { }); } notifyKiosks(); - deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created" }); + deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created", source: "server" }); return new Response(null, { status: 302, @@ -562,7 +562,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (streams.length === 0) continue; const camId = importDiscoveredCamera(deps, rawName, username, password, streams); if (camId != null) { - deps.nodered.forward("camera.changed", { camera_id: camId, event: "created" }); + deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" }); } imported += 1; } @@ -572,7 +572,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { if (streams.length > 0) { const camId = importDiscoveredCamera(deps, rawName, username, password, streams); if (camId != null) { - deps.nodered.forward("camera.changed", { camera_id: camId, event: "created" }); + deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" }); } imported += 1; } @@ -1292,7 +1292,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { } } notifyKiosks(); - deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" }); + deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" }); return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } }); }); @@ -1331,7 +1331,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const id = Number(getRouterParam(event, "id")); deps.repo.deleteCamera(id); notifyKiosks(); - deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted" }); + deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" }); return new Response(null, { status: 302, headers: { location: "/admin/cameras" } }); }); @@ -1355,6 +1355,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const gpioBindings = deps.repo.listGpioBindings(id); const firmwareReleases = deps.repo.listFirmwareReleases(); const osReleases = deps.repo.listOsUpdateReleases(); + const logResult = deps.repo.queryKioskLogs({ kiosk_id: id, limit: 50 }); return htmlPage(KioskEditPage({ user: user.username, kiosk, @@ -1365,6 +1366,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { gpioBindings, firmwareReleases, osReleases, + kioskLogs: logResult.logs, + kioskLogTotal: logResult.total, })); }); @@ -1560,6 +1563,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { kiosk_id: kioskId, layout_id: layoutId, layout_name: layout?.name ?? null, + source: "server", }); }; @@ -1605,6 +1609,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { display_id: id, kiosk_id: display.kiosk_id, state, + source: "server", }); } return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); @@ -1634,6 +1639,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { display_id: displayId, kiosk_id: kioskId, state, + source: "server", }); }; @@ -1808,7 +1814,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const enabled = Boolean(body["value"] ?? body["enabled"]); deps.repo.updateCamera(id, { enabled } as any); notifyKiosks(); - deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" }); + deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" }); const camera = deps.repo.getCameraById(id); if (!camera) return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ camera }); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 9ca5d41..382b5d9 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -537,9 +537,10 @@ function registerKioskRoutes( "display.power.changed", "camera.changed", ]); + const markForwarded = () => repo.markEventForwarded(eventId); if (flatTopics.has(body.topic)) { - const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}) }; - nodered.forward(body.topic, out); + const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" }; + nodered.forward(body.topic, out, markForwarded); mqtt.publishEvent(kiosk.id, body.topic, out); } else { const out = { @@ -550,14 +551,49 @@ function registerKioskRoutes( property_op: body.property_op ?? null, payload: body.payload ?? {}, timestamp: new Date().toISOString(), + source: "kiosk", }; - nodered.forward(body.topic, out); + nodered.forward(body.topic, out, markForwarded); mqtt.publishEvent(kiosk.id, body.topic, out); } return { ok: true, event_id: eventId }; }); + // ---- Kiosk log ingestion (batch) ----------------------------------------- + app.post("/api/kiosk/logs", async (event) => { + const token = extractBearerToken(event); + 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" }); + + const body = await readBody<{ + entries?: Array<{ level?: string; message?: string; context?: Record; logged_at?: string }>; + }>(event); + + const raw = body?.entries; + if (!Array.isArray(raw) || raw.length === 0) { + throw createError({ statusCode: 400, statusMessage: "entries array required" }); + } + if (raw.length > 100) { + throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" }); + } + + const validLevels = new Set(["debug", "info", "warn", "error"]); + const entries = raw + .filter((e) => e.message && typeof e.message === "string") + .map((e) => ({ + level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error", + message: e.message!, + context: e.context ?? {}, + logged_at: e.logged_at, + })); + + const count = repo.insertKioskLogs(kiosk.id, entries); + return { ok: true, count }; + }); + // ---- Firmware: kiosk checks for + downloads its assigned release ------- /** diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index a3f3944..8929c45 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -216,6 +216,7 @@ export class Plugin extends BSBService, typeof Event kiosk_id: kiosk.id, kiosk_name: kioskData.name, event: "connected", + source: "server", }); ws.on("message", (data) => { @@ -258,11 +259,12 @@ export class Plugin extends BSBService, typeof Event nodered.forward("kiosk.changed", { ...telemetry, event: "heartbeat", + source: "server", }); // Dedicated status topic — same payload sans the event marker // so bf-trigger-status can listen on a heartbeat-only channel // without filtering connect/disconnect noise out. - nodered.forward("kiosk.status", telemetry); + nodered.forward("kiosk.status", { ...telemetry, source: "server" }); } } catch { // ignore malformed @@ -282,6 +284,7 @@ export class Plugin extends BSBService, typeof Event kiosk_id: kiosk.id, kiosk_name: kioskData.name, event: "disconnected", + source: "server", }); }); }); diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts index 62767f7..7fe0803 100644 --- a/server/src/plugins/service-store/index.ts +++ b/server/src/plugins/service-store/index.ts @@ -95,10 +95,9 @@ export class Plugin extends BSBService, typeof Event runBeforePlugins?: string[]; runAfterPlugins?: string[]; - // The DB handle and Repository are created in init() and exposed for - // sibling-service consumption. private db?: DatabaseSync; private _repo?: Repository; + private purgeTimer?: ReturnType; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -175,14 +174,27 @@ export class Plugin extends BSBService, typeof Event }); registerRepo(this._repo); + + const purged = this._repo.purgeOldKioskLogs(2); + if (purged > 0) { + obs.log.info("purged {count} kiosk logs older than 2h", { count: purged }); + } + obs.log.info("store ready"); } - async run(_obs: Observable): Promise { - // Long-lived; no work in run(). + async run(obs: Observable): Promise { + this.purgeTimer = setInterval(() => { + if (!this._repo) return; + const purged = this._repo.purgeOldKioskLogs(2); + if (purged > 0) { + obs.log.info("purged {count} kiosk logs older than 2h", { count: purged }); + } + }, 10 * 60 * 1000); } async dispose(): Promise { + if (this.purgeTimer) clearInterval(this.purgeTimer); this.db?.close(); } diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index fdcbbc0..f707c24 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -436,3 +436,15 @@ export function rowToEventLog(r: Row): EventLog { forwarded_to_nodered: b(r["forwarded_to_nodered"]), }; } + +export function rowToKioskLog(r: Row): KioskLog { + return { + id: n(r["id"]), + kiosk_id: n(r["kiosk_id"]), + level: s(r["level"]) as KioskLogLevel, + message: s(r["message"]), + context: j>(r["context"], {}), + logged_at: s(r["logged_at"]), + received_at: s(r["received_at"]), + }; +} diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 8793cf9..d2198ee 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -887,4 +887,17 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER"); addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL"); }, + + // ---- kiosk_logs: dedicated table for kiosk application logs --------------- + `CREATE TABLE IF NOT EXISTS kiosk_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE, + level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')), + message TEXT NOT NULL, + context TEXT NOT NULL DEFAULT '{}', + logged_at TEXT NOT NULL, + received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_kiosk_logs_kiosk_received ON kiosk_logs(kiosk_id, received_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_kiosk_logs_level ON kiosk_logs(level, received_at DESC)`, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index b018e38..8c22bae 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -24,6 +24,7 @@ import type { Entity, EntityType, EventLog, + EventQueryFilters, EventSourceType, FirmwareChannel, FirmwareRelease, @@ -35,6 +36,9 @@ import type { Kiosk, KioskGpioBinding, KioskLabel, + KioskLog, + KioskLogLevel, + KioskLogQueryFilters, Label, LabelRole, Layout, @@ -63,6 +67,7 @@ import { rowToFirmwareRollout, rowToKiosk, rowToKioskGpioBinding, + rowToKioskLog, rowToLabel, rowToLayout, rowToLayoutCell, @@ -1640,6 +1645,120 @@ export class Repository { return rs.map((r) => rowToEventLog(r as Record)); } + markEventForwarded(eventId: number): void { + this.prep("UPDATE event_log SET forwarded_to_nodered = 1 WHERE id = ?").run(eventId); + } + + queryEvents(filters: EventQueryFilters): { events: EventLog[]; total: number } { + const where: string[] = []; + const params: (string | number)[] = []; + + if (filters.topic) { + where.push("topic = ?"); + params.push(filters.topic); + } + if (filters.kiosk_id != null) { + where.push("source_kiosk_id = ?"); + params.push(filters.kiosk_id); + } + if (filters.from) { + where.push("received_at >= ?"); + params.push(filters.from); + } + if (filters.to) { + where.push("received_at <= ?"); + params.push(filters.to); + } + + const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const limit = filters.limit ?? 50; + const offset = filters.offset ?? 0; + + const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM event_log ${clause}`).get(...params) as Record | undefined; + const total = Number(countRow?.["cnt"] ?? 0); + + const rs = this.db.prepare( + `SELECT * FROM event_log ${clause} ORDER BY received_at DESC LIMIT ? OFFSET ?`, + ).all(...params, limit, offset); + + return { + events: rs.map((r) => rowToEventLog(r as Record)), + total, + }; + } + + // =========================================================================== + // kiosk_logs + // =========================================================================== + + insertKioskLogs( + kioskId: number, + entries: Array<{ level: KioskLogLevel; message: string; context?: Record; logged_at?: string }>, + ): number { + const stmt = this.prep( + `INSERT INTO kiosk_logs (kiosk_id, level, message, context, logged_at) + VALUES (?, ?, ?, ?, ?)`, + ); + const now = isoNow(); + let count = 0; + for (const e of entries) { + stmt.run(kioskId, e.level, e.message, J(e.context ?? {}), e.logged_at ?? now); + count++; + } + this.trimKioskLogs(kioskId, 500); + return count; + } + + private trimKioskLogs(kioskId: number, maxRows: number): void { + this.db.prepare( + `DELETE FROM kiosk_logs WHERE kiosk_id = ? AND id NOT IN ( + SELECT id FROM kiosk_logs WHERE kiosk_id = ? ORDER BY received_at DESC LIMIT ? + )`, + ).run(kioskId, kioskId, maxRows); + } + + purgeOldKioskLogs(maxAgeHours: number): number { + const cutoff = new Date(Date.now() - maxAgeHours * 3600_000).toISOString(); + const result = this.db.prepare( + "DELETE FROM kiosk_logs WHERE received_at < ?", + ).run(cutoff); + return Number(result.changes); + } + + queryKioskLogs(filters: KioskLogQueryFilters): { logs: KioskLog[]; total: number } { + const where: string[] = ["kiosk_id = ?"]; + const params: (string | number)[] = [filters.kiosk_id]; + + if (filters.level) { + where.push("level = ?"); + params.push(filters.level); + } + if (filters.from) { + where.push("received_at >= ?"); + params.push(filters.from); + } + if (filters.to) { + where.push("received_at <= ?"); + params.push(filters.to); + } + + const clause = `WHERE ${where.join(" AND ")}`; + const limit = filters.limit ?? 50; + const offset = filters.offset ?? 0; + + const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM kiosk_logs ${clause}`).get(...params) as Record | undefined; + const total = Number(countRow?.["cnt"] ?? 0); + + const rs = this.db.prepare( + `SELECT * FROM kiosk_logs ${clause} ORDER BY received_at DESC LIMIT ? OFFSET ?`, + ).all(...params, limit, offset); + + return { + logs: rs.map((r) => rowToKioskLog(r as Record)), + total, + }; + } + // =========================================================================== // bundle queries (label-aware composite reads) // =========================================================================== diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 7fc4e22..1eac00a 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -12,6 +12,7 @@ import type { FirmwareRollout, Kiosk, KioskGpioBinding, + KioskLog, Label, Layout as LayoutType, LayoutCell, @@ -1341,6 +1342,8 @@ interface KioskEditProps { gpioBindings?: KioskGpioBinding[]; firmwareReleases?: FirmwareRelease[]; osReleases?: OsUpdateRelease[]; + kioskLogs?: KioskLog[]; + kioskLogTotal?: number; error?: string; success?: string; } @@ -1807,6 +1810,53 @@ export function KioskEditPage(props: KioskEditProps) { {k.managed_image ? : null} + {/* Kiosk application logs */} +
+

+ Logs + {props.kioskLogTotal ? ({String(props.kioskLogTotal)}) : null} +

+ {props.kioskLogs && props.kioskLogs.length > 0 ? ( +
+ + + + + + + + + + {props.kioskLogs.map((log) => { + const levelBadge = + log.level === "error" ? "badge-red" + : log.level === "warn" ? "badge-yellow" + : log.level === "info" ? "badge-blue" + : "badge-gray"; + const ctx = Object.keys(log.context).length > 0 + ? JSON.stringify(log.context) + : ""; + return ( + + + + + + ); + })} + +
TimeLevelMessage
+ {log.received_at.replace("T", " ").replace(/\.\d+Z$/, "Z")} + {log.level} + {log.message} + {ctx &&
{ctx}
} +
+
+ ) : ( +

No logs received from this kiosk

+ )} +
+