feat(harden): hardware-bound at-rest encryption of kiosk state files

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.
This commit is contained in:
Mitchell R 2026-05-21 11:34:29 +02:00
parent 90346f4efd
commit 436d2d730c
No known key found for this signature in database
12 changed files with 486 additions and 33 deletions

View file

@ -42,6 +42,13 @@ ed25519-dalek = { version = "2", features = ["pem"] }
base64 = "0.22" base64 = "0.22"
urlencoding = "2" 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) # Local HTTP server on kiosk (LAN GET-only layout switch + admin proxy)
axum = "0.7" axum = "0.7"
tower = "0.5" tower = "0.5"

174
kiosk/src/at_rest.rs Normal file
View file

@ -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::<Sha256>::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<u8> {
let key_bytes = derive_key();
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::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<Vec<u8>, 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::<Aes256Gcm>::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<Vec<u8>> {
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<String> {
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);
}
}

View file

@ -1,3 +1,4 @@
mod at_rest;
mod bundle; mod bundle;
mod cec; mod cec;
mod firmware; mod firmware;

View file

@ -107,27 +107,37 @@ fn local_key_file() -> PathBuf {
} }
/// Load (or generate) the kiosk-local API key used by the LAN-side GET /// 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 { pub fn load_or_create_local_key() -> String {
if let Ok(s) = fs::read_to_string(local_key_file()) { let path = local_key_file();
let trimmed = s.trim().to_string(); if let Ok(raw) = fs::read(&path) {
if trimmed.len() >= 16 { let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok();
return trimmed; 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; use rand::RngCore;
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf); rand::thread_rng().fill_bytes(&mut buf);
let hex_key = hex::encode(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 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) { pub fn save_bundle(bundle: &KioskBundle) {
match serde_json::to_string(bundle) { match serde_json::to_vec(bundle) {
Ok(text) => { Ok(bytes) => {
if let Err(e) = fs::write(bundle_cache_path(), text) { if let Err(e) = crate::at_rest::write_encrypted(&bundle_cache_path(), &bytes) {
tracing::warn!("failed to save bundle cache: {e}"); 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. /// 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<KioskBundle> { pub fn load_cached_bundle() -> Option<KioskBundle> {
let path = bundle_cache_path(); let bytes = crate::at_rest::read_maybe_encrypted(&bundle_cache_path())?;
let text = fs::read_to_string(&path).ok()?; match serde_json::from_slice::<KioskBundle>(&bytes) {
match serde_json::from_str::<KioskBundle>(&text) {
Ok(b) => Some(b), Ok(b) => Some(b),
Err(e) => { Err(e) => {
tracing::warn!("cached bundle invalid: {e}"); tracing::warn!("cached bundle invalid: {e}");
@ -197,12 +208,20 @@ pub fn is_paired() -> bool {
key_file().exists() 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 { pub fn load_key() -> String {
fs::read_to_string(key_file()) let path = key_file();
.expect("failed to read kiosk key") let raw = fs::read(&path).expect("failed to read kiosk key");
.trim() let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok();
.to_string() 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)] #[derive(Deserialize)]
@ -259,7 +278,8 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) {
if claim.status == "claimed" { if claim.status == "claimed" {
let key = claim.kiosk_key.expect("missing kiosk_key"); let key = claim.kiosk_key.expect("missing kiosk_key");
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into()); 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); return (name, key);
} }
} }

View file

@ -490,7 +490,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
} }
notifyKiosks(); 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, { return new Response(null, {
status: 302, status: 302,
@ -562,7 +562,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
if (streams.length === 0) continue; if (streams.length === 0) continue;
const camId = importDiscoveredCamera(deps, rawName, username, password, streams); const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
if (camId != null) { 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; imported += 1;
} }
@ -572,7 +572,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
if (streams.length > 0) { if (streams.length > 0) {
const camId = importDiscoveredCamera(deps, rawName, username, password, streams); const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
if (camId != null) { 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; imported += 1;
} }
@ -1292,7 +1292,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
} }
} }
notifyKiosks(); 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}` } }); 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")); const id = Number(getRouterParam(event, "id"));
deps.repo.deleteCamera(id); deps.repo.deleteCamera(id);
notifyKiosks(); 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" } }); 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 gpioBindings = deps.repo.listGpioBindings(id);
const firmwareReleases = deps.repo.listFirmwareReleases(); const firmwareReleases = deps.repo.listFirmwareReleases();
const osReleases = deps.repo.listOsUpdateReleases(); const osReleases = deps.repo.listOsUpdateReleases();
const logResult = deps.repo.queryKioskLogs({ kiosk_id: id, limit: 50 });
return htmlPage(KioskEditPage({ return htmlPage(KioskEditPage({
user: user.username, user: user.username,
kiosk, kiosk,
@ -1365,6 +1366,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
gpioBindings, gpioBindings,
firmwareReleases, firmwareReleases,
osReleases, osReleases,
kioskLogs: logResult.logs,
kioskLogTotal: logResult.total,
})); }));
}); });
@ -1560,6 +1563,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
kiosk_id: kioskId, kiosk_id: kioskId,
layout_id: layoutId, layout_id: layoutId,
layout_name: layout?.name ?? null, layout_name: layout?.name ?? null,
source: "server",
}); });
}; };
@ -1605,6 +1609,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
display_id: id, display_id: id,
kiosk_id: display.kiosk_id, kiosk_id: display.kiosk_id,
state, state,
source: "server",
}); });
} }
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); 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, display_id: displayId,
kiosk_id: kioskId, kiosk_id: kioskId,
state, state,
source: "server",
}); });
}; };
@ -1808,7 +1814,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const enabled = Boolean(body["value"] ?? body["enabled"]); const enabled = Boolean(body["value"] ?? body["enabled"]);
deps.repo.updateCamera(id, { enabled } as any); deps.repo.updateCamera(id, { enabled } as any);
notifyKiosks(); 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); const camera = deps.repo.getCameraById(id);
if (!camera) return jsonResponse({ error: "not_found" }, 404); if (!camera) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ camera }); return jsonResponse({ camera });

View file

@ -537,9 +537,10 @@ function registerKioskRoutes(
"display.power.changed", "display.power.changed",
"camera.changed", "camera.changed",
]); ]);
const markForwarded = () => repo.markEventForwarded(eventId);
if (flatTopics.has(body.topic)) { if (flatTopics.has(body.topic)) {
const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}) }; const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" };
nodered.forward(body.topic, out); nodered.forward(body.topic, out, markForwarded);
mqtt.publishEvent(kiosk.id, body.topic, out); mqtt.publishEvent(kiosk.id, body.topic, out);
} else { } else {
const out = { const out = {
@ -550,14 +551,49 @@ function registerKioskRoutes(
property_op: body.property_op ?? null, property_op: body.property_op ?? null,
payload: body.payload ?? {}, payload: body.payload ?? {},
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
source: "kiosk",
}; };
nodered.forward(body.topic, out); nodered.forward(body.topic, out, markForwarded);
mqtt.publishEvent(kiosk.id, body.topic, out); mqtt.publishEvent(kiosk.id, body.topic, out);
} }
return { ok: true, event_id: eventId }; 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<string, unknown>; 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 ------- // ---- Firmware: kiosk checks for + downloads its assigned release -------
/** /**

View file

@ -216,6 +216,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
kiosk_id: kiosk.id, kiosk_id: kiosk.id,
kiosk_name: kioskData.name, kiosk_name: kioskData.name,
event: "connected", event: "connected",
source: "server",
}); });
ws.on("message", (data) => { ws.on("message", (data) => {
@ -258,11 +259,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
nodered.forward("kiosk.changed", { nodered.forward("kiosk.changed", {
...telemetry, ...telemetry,
event: "heartbeat", event: "heartbeat",
source: "server",
}); });
// Dedicated status topic — same payload sans the event marker // Dedicated status topic — same payload sans the event marker
// so bf-trigger-status can listen on a heartbeat-only channel // so bf-trigger-status can listen on a heartbeat-only channel
// without filtering connect/disconnect noise out. // without filtering connect/disconnect noise out.
nodered.forward("kiosk.status", telemetry); nodered.forward("kiosk.status", { ...telemetry, source: "server" });
} }
} catch { } catch {
// ignore malformed // ignore malformed
@ -282,6 +284,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
kiosk_id: kiosk.id, kiosk_id: kiosk.id,
kiosk_name: kioskData.name, kiosk_name: kioskData.name,
event: "disconnected", event: "disconnected",
source: "server",
}); });
}); });
}); });

View file

@ -95,10 +95,9 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
runBeforePlugins?: string[]; runBeforePlugins?: string[];
runAfterPlugins?: string[]; runAfterPlugins?: string[];
// The DB handle and Repository are created in init() and exposed for
// sibling-service consumption.
private db?: DatabaseSync; private db?: DatabaseSync;
private _repo?: Repository; private _repo?: Repository;
private purgeTimer?: ReturnType<typeof setInterval>;
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) { constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg); super(cfg);
@ -175,14 +174,27 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}); });
registerRepo(this._repo); 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"); obs.log.info("store ready");
} }
async run(_obs: Observable): Promise<void> { async run(obs: Observable): Promise<void> {
// Long-lived; no work in run(). 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<void> { async dispose(): Promise<void> {
if (this.purgeTimer) clearInterval(this.purgeTimer);
this.db?.close(); this.db?.close();
} }

View file

@ -436,3 +436,15 @@ export function rowToEventLog(r: Row): EventLog {
forwarded_to_nodered: b(r["forwarded_to_nodered"]), 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<Record<string, unknown>>(r["context"], {}),
logged_at: s(r["logged_at"]),
received_at: s(r["received_at"]),
};
}

View file

@ -887,4 +887,17 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER"); addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER");
addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL"); 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)`,
]; ];

View file

@ -24,6 +24,7 @@ import type {
Entity, Entity,
EntityType, EntityType,
EventLog, EventLog,
EventQueryFilters,
EventSourceType, EventSourceType,
FirmwareChannel, FirmwareChannel,
FirmwareRelease, FirmwareRelease,
@ -35,6 +36,9 @@ import type {
Kiosk, Kiosk,
KioskGpioBinding, KioskGpioBinding,
KioskLabel, KioskLabel,
KioskLog,
KioskLogLevel,
KioskLogQueryFilters,
Label, Label,
LabelRole, LabelRole,
Layout, Layout,
@ -63,6 +67,7 @@ import {
rowToFirmwareRollout, rowToFirmwareRollout,
rowToKiosk, rowToKiosk,
rowToKioskGpioBinding, rowToKioskGpioBinding,
rowToKioskLog,
rowToLabel, rowToLabel,
rowToLayout, rowToLayout,
rowToLayoutCell, rowToLayoutCell,
@ -1640,6 +1645,120 @@ export class Repository {
return rs.map((r) => rowToEventLog(r as Record<string, unknown>)); return rs.map((r) => rowToEventLog(r as Record<string, unknown>));
} }
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<string, unknown> | 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<string, unknown>)),
total,
};
}
// ===========================================================================
// kiosk_logs
// ===========================================================================
insertKioskLogs(
kioskId: number,
entries: Array<{ level: KioskLogLevel; message: string; context?: Record<string, unknown>; 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<string, unknown> | 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<string, unknown>)),
total,
};
}
// =========================================================================== // ===========================================================================
// bundle queries (label-aware composite reads) // bundle queries (label-aware composite reads)
// =========================================================================== // ===========================================================================

View file

@ -12,6 +12,7 @@ import type {
FirmwareRollout, FirmwareRollout,
Kiosk, Kiosk,
KioskGpioBinding, KioskGpioBinding,
KioskLog,
Label, Label,
Layout as LayoutType, Layout as LayoutType,
LayoutCell, LayoutCell,
@ -1341,6 +1342,8 @@ interface KioskEditProps {
gpioBindings?: KioskGpioBinding[]; gpioBindings?: KioskGpioBinding[];
firmwareReleases?: FirmwareRelease[]; firmwareReleases?: FirmwareRelease[];
osReleases?: OsUpdateRelease[]; osReleases?: OsUpdateRelease[];
kioskLogs?: KioskLog[];
kioskLogTotal?: number;
error?: string; error?: string;
success?: string; success?: string;
} }
@ -1807,6 +1810,53 @@ export function KioskEditPage(props: KioskEditProps) {
{k.managed_image ? <ManagedConfigCard kiosk={k} /> : null} {k.managed_image ? <ManagedConfigCard kiosk={k} /> : null}
{/* Kiosk application logs */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">
Logs
{props.kioskLogTotal ? <span style="color:#999; font-weight:normal; font-size:0.85rem"> ({String(props.kioskLogTotal)})</span> : null}
</h2>
{props.kioskLogs && props.kioskLogs.length > 0 ? (
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:10rem">Time</th>
<th style="width:4rem">Level</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{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 (
<tr>
<td style="font-size:0.8rem; white-space:nowrap; color:#666; font-family:monospace">
{log.received_at.replace("T", " ").replace(/\.\d+Z$/, "Z")}
</td>
<td><span class={`badge ${levelBadge}`}>{log.level}</span></td>
<td>
<span style="font-size:0.85rem">{log.message}</span>
{ctx && <pre style="margin:0.2rem 0 0; font-size:0.75rem; color:#888; white-space:pre-wrap; word-break:break-all">{ctx}</pre>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p style="color:#999">No logs received from this kiosk</p>
)}
</div>
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem"> <form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button> <button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
</form> </form>