mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
90346f4efd
commit
436d2d730c
12 changed files with 486 additions and 33 deletions
|
|
@ -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"
|
||||
|
|
|
|||
174
kiosk/src/at_rest.rs
Normal file
174
kiosk/src/at_rest.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod at_rest;
|
||||
mod bundle;
|
||||
mod cec;
|
||||
mod firmware;
|
||||
|
|
|
|||
|
|
@ -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<KioskBundle> {
|
||||
let path = bundle_cache_path();
|
||||
let text = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<KioskBundle>(&text) {
|
||||
let bytes = crate::at_rest::read_maybe_encrypted(&bundle_cache_path())?;
|
||||
match serde_json::from_slice::<KioskBundle>(&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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<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 -------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof Event
|
|||
kiosk_id: kiosk.id,
|
||||
kiosk_name: kioskData.name,
|
||||
event: "disconnected",
|
||||
source: "server",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -95,10 +95,9 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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<typeof setInterval>;
|
||||
|
||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||
super(cfg);
|
||||
|
|
@ -175,14 +174,27 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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<void> {
|
||||
// Long-lived; no work in run().
|
||||
async run(obs: Observable): Promise<void> {
|
||||
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> {
|
||||
if (this.purgeTimer) clearInterval(this.purgeTimer);
|
||||
this.db?.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>(r["context"], {}),
|
||||
logged_at: s(r["logged_at"]),
|
||||
received_at: s(r["received_at"]),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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 ? <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">
|
||||
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
||||
</form>
|
||||
|
|
|
|||
Loading…
Reference in a new issue