diff --git a/.github/workflows/release-kiosk.yml b/.github/workflows/release-kiosk.yml new file mode 100644 index 0000000..0c3ba40 --- /dev/null +++ b/.github/workflows/release-kiosk.yml @@ -0,0 +1,125 @@ +# Build the kiosk binary for multiple targets on tag push (vX.Y.Z), upload +# each as a GitHub Release asset, and optionally auto-import into a running +# BetterFrame server via /api/admin/firmware/import. +# +# Blacksmith runners are used for the heavy GTK/GStreamer/WebKit deps. The +# host arch matches the target so cargo can build natively (no cross). +# +# Required secrets: +# BF_AUTOIMPORT_URL e.g. https://bf.example.com (optional) +# BF_AUTOIMPORT_API_KEY admin-scope API key for the BF server (optional) +# +# Tagging: +# git tag v0.4.2 → channel = stable +# git tag v0.4.2-beta.1 → channel = beta +# workflow_dispatch → channel = dev (one-off builds) + +name: release-kiosk + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + channel: + description: "Release channel" + type: choice + options: ["dev", "beta", "stable"] + default: "dev" + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-unknown-linux-gnu + runs-on: blacksmith-2vcpu-ubuntu-2204-arm + arch_label: "aarch64 (Pi5)" + - target: x86_64-unknown-linux-gnu + runs-on: blacksmith-4vcpu-ubuntu-2204 + arch_label: "x86_64" + + runs-on: ${{ matrix.runs-on }} + + steps: + - uses: actions/checkout@v4 + + - name: Determine channel + version + id: meta + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + channel="${{ inputs.channel }}" + version="0.0.0-dev.$(git rev-parse --short HEAD)" + else + tag="${GITHUB_REF#refs/tags/v}" + version="$tag" + if [[ "$tag" == *"-beta."* ]]; then channel="beta"; + else channel="stable"; fi + fi + echo "channel=$channel" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install build deps + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgtk-4-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + libwebkitgtk-6.0-dev pkg-config build-essential + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: cargo build --release + working-directory: kiosk + env: + BF_BUILD_ARCH: ${{ matrix.target }} + run: cargo build --release --target ${{ matrix.target }} + + - name: Strip + rename binary + working-directory: kiosk + run: | + strip target/${{ matrix.target }}/release/betterframe-kiosk + cp target/${{ matrix.target }}/release/betterframe-kiosk \ + betterframe-kiosk-${{ steps.meta.outputs.version }}-${{ matrix.target }} + + - name: Upload to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: kiosk/betterframe-kiosk-${{ steps.meta.outputs.version }}-${{ matrix.target }} + + - name: Auto-import into BF server + if: env.BF_AUTOIMPORT_URL != '' && env.BF_AUTOIMPORT_API_KEY != '' + env: + BF_AUTOIMPORT_URL: ${{ secrets.BF_AUTOIMPORT_URL }} + BF_AUTOIMPORT_API_KEY: ${{ secrets.BF_AUTOIMPORT_API_KEY }} + working-directory: kiosk + run: | + bin="betterframe-kiosk-${{ steps.meta.outputs.version }}-${{ matrix.target }}" + content_b64=$(base64 -w 0 "$bin") + curl -sSf -X POST \ + -H "Authorization: Bearer ${BF_AUTOIMPORT_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$(jq -nc \ + --arg v "${{ steps.meta.outputs.version }}" \ + --arg c "${{ steps.meta.outputs.channel }}" \ + --arg a "${{ matrix.target }}" \ + --arg n "Built by GH Actions (${{ github.sha }})" \ + --arg b "$content_b64" \ + '{version:$v, channel:$c, arch:$a, release_notes:$n, content_b64:$b}')" \ + "${BF_AUTOIMPORT_URL}/api/admin/firmware/import" + + - name: Upload artifact (always) + uses: actions/upload-artifact@v4 + with: + name: betterframe-kiosk-${{ matrix.target }} + path: kiosk/betterframe-kiosk-${{ steps.meta.outputs.version }}-${{ matrix.target }} + retention-days: 14 diff --git a/kiosk/Cargo.toml b/kiosk/Cargo.toml index e5cc9b0..6798a13 100644 --- a/kiosk/Cargo.toml +++ b/kiosk/Cargo.toml @@ -34,3 +34,8 @@ futures-util = "0.3" url = "2" webkit6 = "0.4" gpiod = "0.3" + +# OTA firmware update: sha256 + Ed25519 signature verify +sha2 = "0.10" +ed25519-dalek = { version = "2", features = ["pem"] } +base64 = "0.22" diff --git a/kiosk/src/firmware.rs b/kiosk/src/firmware.rs new file mode 100644 index 0000000..d8d4219 --- /dev/null +++ b/kiosk/src/firmware.rs @@ -0,0 +1,221 @@ +//! Kiosk-side OTA update flow. +//! +//! 1. `check(server, key, arch, current_version)` → asks BF server if there's +//! a newer release for this kiosk's channel/pin. +//! 2. `apply(server, key, info)` → downloads, verifies sha256 + +//! Ed25519 signature (server's firmware-signing pubkey, PEM, embedded in +//! the check response), atomically swaps the running binary, reports +//! outcome, and exits so systemd's `Restart=always` brings up the new +//! binary. +//! +//! Binary location: `/opt/betterframe/kiosk/betterframe-kiosk` (production +//! deploy via `deploy/scripts/setup-pi-kiosk.sh`). Override with env +//! `BF_KIOSK_BINARY`. +//! +//! Rollback: the previous binary is kept at `.prev` before the swap. +//! systemd's StartLimitBurst=10 catches a broken binary; an out-of-band +//! script (`/usr/local/bin/bf-rollback-firmware`, future) handles the +//! restore. For now this module only does forward updates. + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; + +use base64::Engine as _; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tracing::{info, warn}; + +/// Build-time arch string baked into the binary so `check` can ask for the +/// right target. Falls back to "aarch64-unknown-linux-gnu" when not provided +/// (matches Pi5 default). +pub const ARCH: &str = match option_env!("BF_BUILD_ARCH") { + Some(s) => s, + None => "aarch64-unknown-linux-gnu", +}; + +const DEFAULT_BIN_PATH: &str = "/opt/betterframe/kiosk/betterframe-kiosk"; + +fn binary_path() -> PathBuf { + std::env::var("BF_KIOSK_BINARY") + .unwrap_or_else(|_| DEFAULT_BIN_PATH.to_string()) + .into() +} + +#[derive(Debug, Deserialize)] +pub struct CheckResponse { + pub up_to_date: bool, + pub update: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateInfo { + pub release_id: String, + pub version: String, + pub channel: String, + pub sha256: String, + pub signature: String, + pub size_bytes: u64, + pub download_url: String, + pub public_key_pem: String, +} + +/// Hit `/api/kiosk/firmware/check` and return the update info if one is +/// available. Returns `None` on up-to-date / network error / unparsable +/// response — never panics. +pub fn check(server: &str, key: &str, current_version: &str) -> Option { + let client = reqwest::blocking::Client::new(); + // current_version is semver-shaped (already URL-safe). Empty string is + // fine — server treats it as "unknown" and offers any release. + let url = format!( + "{server}/api/kiosk/firmware/check?arch={arch}¤t={cur}", + arch = ARCH, + cur = current_version, + ); + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {key}")) + .timeout(Duration::from_secs(10)) + .send() + { + Ok(r) => r, + Err(err) => { + warn!("firmware check: request failed: {err}"); + return None; + } + }; + if !resp.status().is_success() { + warn!("firmware check: HTTP {}", resp.status()); + return None; + } + match resp.json::() { + Ok(c) if !c.up_to_date => c.update, + Ok(_) => None, + Err(err) => { + warn!("firmware check: parse failed: {err}"); + None + } + } +} + +/// Download + verify + swap. Reports outcome to the server. On success the +/// process exits with code 0 so systemd's Restart=always picks up the new +/// binary. On failure the function returns Err and the kiosk keeps running. +pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { + info!("firmware: applying {} ({} bytes)", info.version, info.size_bytes); + + // 1. Download + let url = format!("{}{}", server, info.download_url); + let client = reqwest::blocking::Client::new(); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {key}")) + .timeout(Duration::from_secs(300)) + .send() + .map_err(|e| format!("download request: {e}"))?; + if !resp.status().is_success() { + return Err(format!("download HTTP {}", resp.status())); + } + let bytes = resp.bytes().map_err(|e| format!("download body: {e}"))?; + if bytes.len() as u64 != info.size_bytes { + return Err(format!( + "size mismatch: expected {}, got {}", + info.size_bytes, + bytes.len() + )); + } + + // 2. sha256 + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let digest = hasher.finalize(); + let got_sha = hex_lower(&digest); + if got_sha != info.sha256 { + return Err(format!("sha256 mismatch: expected {}, got {}", info.sha256, got_sha)); + } + + // 3. Ed25519 signature verify (sig is over the hex-encoded sha256 string) + verify_signature(&info.public_key_pem, &info.sha256, &info.signature) + .map_err(|e| format!("signature verify: {e}"))?; + + // 4. Atomic swap + let bin = binary_path(); + let new_path = bin.with_extension("new"); + let prev_path = bin.with_extension("prev"); + + { + let mut f = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode_for_unix(0o755) + .open(&new_path) + .map_err(|e| format!("open {}: {e}", new_path.display()))?; + f.write_all(&bytes).map_err(|e| format!("write {}: {e}", new_path.display()))?; + f.sync_all().ok(); + } + + // Save current binary as .prev so an out-of-band rollback can restore it. + if bin.exists() { + let _ = fs::remove_file(&prev_path); + if let Err(e) = fs::rename(&bin, &prev_path) { + warn!("firmware: could not stash previous binary: {e}"); + } + } + fs::rename(&new_path, &bin).map_err(|e| format!("rename → {}: {e}", bin.display()))?; + + // 5. Tell the server we're about to apply. + let _ = client + .post(format!("{server}/api/kiosk/firmware/applied")) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ "version": info.version })) + .timeout(Duration::from_secs(5)) + .send(); + + info!("firmware: swap complete → exiting for systemd to relaunch"); + // systemd Restart=always picks up the new binary on next start. + std::process::exit(0); +} + +fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> { + let vk = VerifyingKey::from_public_key_pem(public_key_pem) + .map_err(|e| format!("parse pubkey: {e}"))?; + let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(sig_b64url.trim_end_matches('=')) + .map_err(|e| format!("decode signature: {e}"))?; + let sig = Signature::from_slice(&sig_bytes).map_err(|e| format!("signature shape: {e}"))?; + vk.verify(sha256_hex.as_bytes(), &sig) + .map_err(|e| format!("verify: {e}")) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + +// Helper trait so OpenOptions.mode_for_unix(0o755) compiles cross-platform. +// On non-unix we no-op the mode bits — kiosk doesn't run on Windows in prod +// but the unit tests / IDE check on dev machines need to compile. +trait OpenOptionsModeExt { + fn mode_for_unix(&mut self, mode: u32) -> &mut Self; +} + +#[cfg(unix)] +impl OpenOptionsModeExt for fs::OpenOptions { + fn mode_for_unix(&mut self, mode: u32) -> &mut Self { + use std::os::unix::fs::OpenOptionsExt; + self.mode(mode) + } +} + +#[cfg(not(unix))] +impl OpenOptionsModeExt for fs::OpenOptions { + fn mode_for_unix(&mut self, _mode: u32) -> &mut Self { self } +} diff --git a/kiosk/src/main.rs b/kiosk/src/main.rs index 8ea1fcb..0106dd9 100644 --- a/kiosk/src/main.rs +++ b/kiosk/src/main.rs @@ -1,6 +1,7 @@ mod server; mod bundle; mod cec; +mod firmware; mod gpio; mod hwmon; mod pipeline; @@ -15,6 +16,8 @@ pub enum ServerMsg { Fan(Option), /// Switch to a specific layout by ID (must be present in current bundle). SwitchLayout(u32), + /// Server-pushed "go check for a firmware update now". + FirmwareCheck, } use gtk4::prelude::{ApplicationExt, ApplicationExtManual}; diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index f7a2689..57db6fe 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -11,6 +11,7 @@ use tracing::{info, warn}; use crate::bundle::{BundleDisplayWithLayouts, KioskBundle}; use crate::cec; use crate::gpio; +use crate::firmware; use crate::hwmon; use crate::pipeline; use crate::server; @@ -229,15 +230,18 @@ fn activate(app: &Application) { ServerMsg::SwitchLayout(id) => { let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id)); } + ServerMsg::FirmwareCheck => { + maybe_apply_firmware_update(&server_for_reload, &key_for_reload); + } } } }); - // Heartbeat loop — reports display geometry + hwmon. Fire once - // immediately so admin "Hardware" panel populates without waiting a - // full minute after boot/pair. + // Heartbeat loop — reports display geometry + hwmon, also checks for + // firmware updates so kiosks pick up new builds without admin push. loop { send_heartbeat_now(&server, &key); + maybe_apply_firmware_update(&server, &key); std::thread::sleep(std::time::Duration::from_secs(60)); } }); @@ -298,6 +302,24 @@ fn send_heartbeat_now(server_url: &str, kiosk_key: &str) { server::heartbeat(server_url, kiosk_key, &displays, &hw); } +/// Ask the server whether an update is available. On hit, download + verify +/// + swap + report + exit (systemd brings up the new binary). On miss or +/// error: log + keep running. Designed to be safe to call from any thread. +fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { + let current = env!("CARGO_PKG_VERSION"); + let Some(info) = firmware::check(server_url, kiosk_key, current) else { return }; + info!("firmware: update {} → {} available", current, info.version); + if let Err(err) = firmware::apply(server_url, kiosk_key, &info) { + warn!("firmware: apply failed: {err}"); + let _ = reqwest::blocking::Client::new() + .post(format!("{server_url}/api/kiosk/firmware/applied")) + .header("Authorization", format!("Bearer {kiosk_key}")) + .json(&serde_json::json!({ "version": info.version, "error": err })) + .timeout(std::time::Duration::from_secs(5)) + .send(); + } +} + /// Install the once-per-second watchdog that enforces idle/sleep timeouts /// per display. Safe to call multiple times — installs at most once. fn install_idle_watchdog() { diff --git a/kiosk/src/ws_client.rs b/kiosk/src/ws_client.rs index 54c0a39..4fea7ea 100644 --- a/kiosk/src/ws_client.rs +++ b/kiosk/src/ws_client.rs @@ -56,6 +56,9 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender) { } else { warn!("ws: layout-switch missing layout_id"); } + } else if text.contains("\"type\":\"firmware_check\"") { + info!("ws: firmware_check received"); + let _ = tx.send(ServerMsg::FirmwareCheck); } else if text.contains("\"type\":\"fan\"") { info!("ws: fan received: {text}"); let Ok(msg) = serde_json::from_str::(&text) else { diff --git a/scripts/gen-firmware-signing-key.sh b/scripts/gen-firmware-signing-key.sh new file mode 100755 index 0000000..38cafe8 --- /dev/null +++ b/scripts/gen-firmware-signing-key.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Generate an Ed25519 keypair for firmware signing. +# +# Output: +# firmware-signing.key (private, PKCS8 PEM, 0600) +# firmware-signing.pub (public, SPKI PEM, 0644) +# +# Use cases: +# 1. Local dev: drop the .key into the server's dataDir +# (/var/lib/betterframe/firmware-signing.key) — server picks it up on +# next boot. The server auto-generates one if missing, this script is +# only needed when you want a reproducible / shared key. +# 2. Cloud deploy: paste the .key content into the +# BF_FIRMWARE_SIGNING_KEY env var on your hosting platform (Coolify, +# k8s secret, GitHub Actions secret, etc.). The server detects the env +# var and prefers it over the on-disk file. +# +# Pinning on kiosks: the public key gets shipped to kiosks via the +# /api/kiosk/firmware/check response — no manual distribution needed. + +set -euo pipefail + +OUT_DIR="${1:-.}" +mkdir -p "$OUT_DIR" +priv="$OUT_DIR/firmware-signing.key" +pub="$OUT_DIR/firmware-signing.pub" + +if [ -e "$priv" ] || [ -e "$pub" ]; then + echo "error: $priv or $pub already exists. Refusing to overwrite." >&2 + exit 1 +fi + +openssl genpkey -algorithm Ed25519 -out "$priv" +chmod 600 "$priv" +openssl pkey -in "$priv" -pubout -out "$pub" +chmod 644 "$pub" + +echo "wrote: $priv" +echo "wrote: $pub" +echo +echo "To use in cloud:" +echo " set BF_FIRMWARE_SIGNING_KEY environment variable to the contents of $priv" +echo +echo "To use locally:" +echo " install $priv to /var/lib/betterframe/firmware-signing.key (mode 0600)" diff --git a/server/src/plugins/service-admin-http/html-response.ts b/server/src/plugins/service-admin-http/html-response.ts index 5f92b7b..2be3b39 100644 --- a/server/src/plugins/service-admin-http/html-response.ts +++ b/server/src/plugins/service-admin-http/html-response.ts @@ -11,6 +11,13 @@ export function htmlPage(markup: unknown): Response { }); } +/** Same as htmlPage — separate name for htmx fragment swaps to read clearly. */ +export function htmlFragment(markup: unknown): Response { + return new Response(String(markup), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} + /** * Build a redirect Response with optional Set-Cookie header. * Avoids h3's setCookie which doesn't play well with returning diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index ac462ff..964b2b5 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -19,6 +19,7 @@ import { getRepo } from "../../shared/plugin-registry.js"; import { initSecrets, type SecretsApi } from "../../shared/secrets.js"; import { createAuth, type AuthApi } from "../../shared/auth.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; +import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import type { Repository } from "../service-store/repository.js"; import { registerMiddleware } from "./middleware.js"; @@ -26,6 +27,7 @@ import { registerSetupRoutes } from "./routes-setup.js"; import { registerAuthRoutes } from "./routes-auth.js"; import { registerAdminRoutes } from "./routes-admin.js"; import { registerAccountRoutes } from "./routes-account.js"; +import { registerFirmwareRoutes } from "./routes-firmware.js"; import { registerStaticRoutes } from "./routes-static.js"; // ---- Config ----------------------------------------------------------------- @@ -80,6 +82,7 @@ export interface AdminDeps { secrets: SecretsApi; cookieName: string; nodered: NoderedBridge; + firmware: FirmwareApi; } // ---- Plugin ----------------------------------------------------------------- @@ -123,12 +126,18 @@ export class Plugin extends BSBService, typeof Event { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); + const firmware = initFirmware( + { dataDir: this.config.dataDir }, + { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, + ); + const deps: AdminDeps = { repo, auth, secrets, cookieName: this.config.cookieName, nodered, + firmware, }; const app = new H3(); @@ -139,6 +148,7 @@ export class Plugin extends BSBService, typeof Event registerAuthRoutes(app, deps); registerAdminRoutes(app, deps); registerAccountRoutes(app, deps); + registerFirmwareRoutes(app, deps); // Auth-check endpoint for Angie auth_request subrequest. // Returns 200 if session cookie is valid + admin role, 401 otherwise. diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 525813a..ee24954 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -617,12 +617,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const nameOverride = (body?.["name_override"] ?? "").trim() || undefined; const labelsStr = (body?.["initial_labels"] ?? "").trim(); const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; + const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim(); + const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; try { await confirmPairing(deps.repo, deps.auth, deps.secrets, { code, nameOverride, initialLabels, + replaceKioskId, }); } catch (err) { const user = event.context.user!; @@ -1171,6 +1174,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const firstDisplay = displays[0]; const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : []; const gpioBindings = deps.repo.listGpioBindings(id); + const firmwareReleases = deps.repo.listFirmwareReleases(); return htmlPage(KioskEditPage({ user: user.username, kiosk, @@ -1179,6 +1183,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { displays, switchableLayouts, gpioBindings, + firmwareReleases, })); }); diff --git a/server/src/plugins/service-admin-http/routes-firmware.ts b/server/src/plugins/service-admin-http/routes-firmware.ts new file mode 100644 index 0000000..1af8048 --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-firmware.ts @@ -0,0 +1,171 @@ +/** + * Admin firmware routes — release upload, list, yank, per-kiosk push. + * + * Upload path supports: + * - browser multipart form ("upload from your machine") + * - CI auto-import via API key (header X-BetterFrame-API-Key: bf-…) + * POST /api/admin/firmware/import with JSON {version, channel, arch, + * signature, sha256, release_notes, content_b64} so GitHub Actions can + * publish releases without a session. + */ +import { type H3, getRouterParam, readBody, createError } from "h3"; +import { randomUUID } from "node:crypto"; + +import { htmlPage, htmlFragment } from "./html-response.js"; +import type { AdminDeps } from "./index.js"; +import { + FirmwarePage, + KioskFirmwarePanel, +} from "../../web-templates/admin-pages.js"; +import { getCoordinator } from "../../shared/coordinator-registry.js"; +import type { FirmwareChannel } from "../../shared/types.js"; + +const ALLOWED_CHANNELS: ReadonlySet = new Set(["stable", "beta", "dev"]); +const ALLOWED_ARCHES = new Set([ + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "armv7-unknown-linux-gnueabihf", +]); + +export function registerFirmwareRoutes(app: H3, deps: AdminDeps): void { + // ---- List page ----------------------------------------------------------- + app.get("/admin/firmware", (event) => { + const user = event.context.user!; + const releases = deps.repo.listFirmwareReleases(); + return htmlPage(FirmwarePage({ + user: user.username, + releases, + publicKeyPem: deps.firmware.publicKeyPem(), + })); + }); + + // ---- Human upload (multipart) ------------------------------------------- + app.post("/admin/firmware/upload", async (event) => { + const user = event.context.user!; + const req = event.req; + const form = await req.formData(); + const file = form.get("artifact"); + if (!(file instanceof File)) { + throw createError({ statusCode: 400, statusMessage: "artifact file required" }); + } + const version = String(form.get("version") ?? "").trim(); + const channelRaw = String(form.get("channel") ?? "stable").trim(); + const arch = String(form.get("arch") ?? "").trim(); + const releaseNotes = String(form.get("release_notes") ?? "").trim() || null; + + if (!ALLOWED_CHANNELS.has(channelRaw as FirmwareChannel)) { + throw createError({ statusCode: 400, statusMessage: `invalid channel '${channelRaw}'` }); + } + if (!ALLOWED_ARCHES.has(arch)) { + throw createError({ statusCode: 400, statusMessage: `invalid arch '${arch}'` }); + } + if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { + throw createError({ statusCode: 400, statusMessage: `invalid version '${version}' (expected semver)` }); + } + + const buf = Buffer.from(await file.arrayBuffer()); + const { sha256, signature } = deps.firmware.signBlob(buf); + const artifactPath = await deps.firmware.storeBlob(buf, sha256); + + deps.repo.createFirmwareRelease({ + id: randomUUID(), + version, + channel: channelRaw as FirmwareChannel, + arch, + artifact_path: artifactPath, + size_bytes: buf.length, + sha256, + signature, + release_notes: releaseNotes, + uploaded_by: user.id, + }); + + return new Response(null, { status: 302, headers: { location: "/admin/firmware" } }); + }); + + // ---- CI auto-import (JSON, API-key-auth) -------------------------------- + // Body: {version, channel, arch, release_notes?, content_b64} + // Server signs server-side (no client-side trust required for signing key) + app.post("/api/admin/firmware/import", async (event) => { + // Middleware already verified API key on /api/admin/* — admin scope + // checked there. No further auth needed here. + const body = await readBody<{ + version: string; + channel: FirmwareChannel; + arch: string; + release_notes?: string; + content_b64: string; + }>(event); + + if (!body?.version || !body.channel || !body.arch || !body.content_b64) { + throw createError({ statusCode: 400, statusMessage: "version, channel, arch, content_b64 required" }); + } + if (!ALLOWED_CHANNELS.has(body.channel)) { + throw createError({ statusCode: 400, statusMessage: `invalid channel '${body.channel}'` }); + } + if (!ALLOWED_ARCHES.has(body.arch)) { + throw createError({ statusCode: 400, statusMessage: `invalid arch '${body.arch}'` }); + } + + const buf = Buffer.from(body.content_b64, "base64"); + if (buf.length === 0) { + throw createError({ statusCode: 400, statusMessage: "empty artifact" }); + } + + const { sha256, signature } = deps.firmware.signBlob(buf); + const artifactPath = await deps.firmware.storeBlob(buf, sha256); + const id = randomUUID(); + const release = deps.repo.createFirmwareRelease({ + id, + version: body.version, + channel: body.channel, + arch: body.arch, + artifact_path: artifactPath, + size_bytes: buf.length, + sha256, + signature, + release_notes: body.release_notes ?? null, + uploaded_by: null, + }); + + return { ok: true, release_id: release.id, sha256, signature }; + }); + + // ---- Yank --------------------------------------------------------------- + app.post("/admin/firmware/:id/yank", (event) => { + const id = String(getRouterParam(event, "id")); + deps.repo.yankFirmwareRelease(id); + return new Response(null, { status: 302, headers: { location: "/admin/firmware" } }); + }); + + // ---- Per-kiosk firmware settings ---------------------------------------- + // POST channel + target_version (used by KioskFirmwarePanel form) + app.post("/admin/kiosks/:id/firmware", async (event) => { + const id = Number(getRouterParam(event, "id")); + const body = await readBody>(event); + const channelRaw = (body?.["channel"] ?? "stable").trim() as FirmwareChannel; + const targetRaw = (body?.["target_version"] ?? "").trim(); + if (!ALLOWED_CHANNELS.has(channelRaw)) { + throw createError({ statusCode: 400, statusMessage: "invalid channel" }); + } + deps.repo.setKioskFirmwarePref(id, { + channel: channelRaw, + target_version: targetRaw ? targetRaw : null, + }); + const k = deps.repo.getKioskById(id); + if (!k) { + return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } }); + } + const releases = deps.repo.listFirmwareReleases(); + return htmlFragment(KioskFirmwarePanel({ kiosk: k, releases })); + }); + + // Push update now: server pings the kiosk via WS coordinator so it goes + // and pulls /api/kiosk/firmware/check immediately. The actual download + // happens kiosk-side over the existing kiosk_key channel. + app.post("/admin/kiosks/:id/firmware/push", (event) => { + const id = Number(getRouterParam(event, "id")); + const dispatched = getCoordinator().sendToKiosk(id, { type: "firmware_check" }); + return { ok: true, dispatched }; + }); +} diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index dfc7f95..b7b91a4 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -21,9 +21,11 @@ import { createAuth } from "../../shared/auth.js"; import { initiatePairing, claimPairing } from "../../shared/pairing.js"; import { generateBundle } from "../../shared/bundle.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; +import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import type { Repository } from "../service-store/repository.js"; import type { AuthApi } from "../../shared/auth.js"; import type { SecretsApi } from "../../shared/secrets.js"; +import type { FirmwareChannel } from "../../shared/types.js"; // ---- Config ----------------------------------------------------------------- @@ -105,6 +107,10 @@ export class Plugin extends BSBService, typeof Event { baseUrl: this.config.noderedUrl }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); + const firmware = initFirmware( + { dataDir: this.config.dataDir }, + { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, + ); const app = new H3(); @@ -134,7 +140,7 @@ export class Plugin extends BSBService, typeof Event }); registerPairingRoutes(app, repo, auth, secrets, codeTtl); - registerKioskRoutes(app, repo, auth, secrets, nodered); + registerKioskRoutes(app, repo, auth, secrets, nodered, firmware); this.server = serve(app, { port: this.config.port, @@ -230,6 +236,7 @@ function registerKioskRoutes( auth: AuthApi, secrets: SecretsApi, nodered: NoderedBridge, + firmware: FirmwareApi, ): void { // Bundle delivery app.get("/api/kiosk/bundle", async (event) => { @@ -379,4 +386,114 @@ function registerKioskRoutes( return { ok: true, event_id: eventId }; }); + + // ---- Firmware: kiosk checks for + downloads its assigned release ------- + + /** + * Kiosk polls this on heartbeat (or after a `firmware_check` WS push). + * Decision tree: + * 1. If kiosk.firmware_target_version is set → look up that version on the + * kiosk's arch; offer if it exists and isn't yanked. + * 2. Otherwise pick latest non-yanked release on the kiosk's channel + arch. + * 3. If chosen.version === current_version (reported via heartbeat) → + * "up_to_date". + * + * `arch` is supplied by the kiosk because the server has no other way to + * know which build target the kiosk was built against. + */ + app.get("/api/kiosk/firmware/check", async (event) => { + const token = extractBearerToken(event); + if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" }); + const verified = await auth.verifyKioskKey(token); + if (!verified) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" }); + const kiosk = repo.getKioskById(verified.id); + if (!kiosk) throw createError({ statusCode: 404, statusMessage: "kiosk not found" }); + + const url = new URL(event.req.url); + const arch = url.searchParams.get("arch")?.trim(); + if (!arch) { + throw createError({ statusCode: 400, statusMessage: "arch query param required" }); + } + const currentVersion = url.searchParams.get("current")?.trim() ?? kiosk.kiosk_app_version ?? ""; + + let release = null; + if (kiosk.firmware_target_version) { + release = repo.getFirmwareReleaseByVersionArch(kiosk.firmware_target_version, arch); + if (release?.yanked_at) release = null; + } + if (!release) { + const channel = (kiosk.firmware_channel ?? "stable") as FirmwareChannel; + release = repo.getLatestFirmwareRelease(channel, arch); + } + + if (!release || release.version === currentVersion) { + return { up_to_date: true }; + } + + return { + up_to_date: false, + update: { + release_id: release.id, + version: release.version, + channel: release.channel, + sha256: release.sha256, + signature: release.signature, + size_bytes: release.size_bytes, + download_url: `/api/kiosk/firmware/download/${release.id}`, + public_key_pem: firmware.publicKeyPem(), + }, + }; + }); + + /** + * Stream the signed binary. Bearer kiosk-key auth — internal access only, + * Angie will not pass this externally because /api/kiosk/* is in the + * kiosk-key location block. + */ + app.get("/api/kiosk/firmware/download/:id", 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 id = (event.context as any).params?.id as string | undefined + ?? new URL(event.req.url).pathname.split("/").pop(); + if (!id) throw createError({ statusCode: 400, statusMessage: "release id required" }); + + const release = repo.getFirmwareRelease(id); + if (!release || release.yanked_at) { + throw createError({ statusCode: 404, statusMessage: "release not found" }); + } + + const buf = await firmware.readBlob(release.artifact_path, release.sha256); + return new Response(buf, { + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-length": String(buf.length), + "x-bf-sha256": release.sha256, + "x-bf-signature": release.signature, + "x-bf-version": release.version, + }, + }); + }); + + /** + * Kiosk reports the outcome of an update attempt. On success it should + * also be sending its new kiosk_app_version on heartbeat. On failure + * the error string is surfaced on the admin kiosk page. + */ + app.post("/api/kiosk/firmware/applied", 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<{ version: string; error?: string }>(event); + if (!body?.version) { + throw createError({ statusCode: 400, statusMessage: "version required" }); + } + repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); + return { ok: true }; + }); } diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index c39e7ec..a0a166e 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -18,6 +18,10 @@ import type { EntityType, EventLog, EventSourceType, + FirmwareChannel, + FirmwareRelease, + FirmwareRollout, + FirmwareRolloutState, GpioDirection, GpioEdge, GpioPull, @@ -250,10 +254,46 @@ export function rowToKiosk(r: Row): Kiosk { cpu_temp_c: nn(r["cpu_temp_c"]), fan_rpm: nn(r["fan_rpm"]), fan_pwm: nn(r["fan_pwm"]), + firmware_channel: (s(r["firmware_channel"] ?? "stable")) as FirmwareChannel, + firmware_target_version: sn(r["firmware_target_version"]), + firmware_last_attempt_at: sn(r["firmware_last_attempt_at"]), + firmware_last_attempt_version: sn(r["firmware_last_attempt_version"]), + firmware_last_error: sn(r["firmware_last_error"]), created_at: s(r["created_at"]), }; } +export function rowToFirmwareRelease(r: Row): FirmwareRelease { + return { + id: s(r["id"]), + version: s(r["version"]), + channel: s(r["channel"]) as FirmwareChannel, + arch: s(r["arch"]), + artifact_path: s(r["artifact_path"]), + size_bytes: n(r["size_bytes"]), + sha256: s(r["sha256"]), + signature: s(r["signature"]), + release_notes: sn(r["release_notes"]), + uploaded_at: s(r["uploaded_at"]), + uploaded_by: nn(r["uploaded_by"]), + yanked_at: sn(r["yanked_at"]), + }; +} + +export function rowToFirmwareRollout(r: Row): FirmwareRollout { + return { + id: s(r["id"]), + release_id: s(r["release_id"]), + target_kiosk_ids: j(r["target_kiosk_ids"], []), + state: s(r["state"]) as FirmwareRolloutState, + percentage: n(r["percentage"]), + started_at: sn(r["started_at"]), + finished_at: sn(r["finished_at"]), + created_at: s(r["created_at"]), + created_by: nn(r["created_by"]), + }; +} + export function rowToLabel(r: Row): Label { return { id: n(r["id"]), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index cf4c464..7c080d3 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -709,4 +709,48 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ `); db.exec("PRAGMA foreign_keys = ON"); }, + + // ---- firmware OTA -------------------------------------------------------- + // One row per signed kiosk binary. arch lets us hold images for + // aarch64-pi5 + x86_64 + future targets side by side. signature is + // Ed25519(sha256(binary)) by the server's firmware-signing key — kiosk + // verifies before swap. + `CREATE TABLE IF NOT EXISTS firmware_releases ( + id TEXT PRIMARY KEY, + version TEXT NOT NULL, + channel TEXT NOT NULL CHECK(channel IN ('stable', 'beta', 'dev')), + arch TEXT NOT NULL, + artifact_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + signature TEXT NOT NULL, + release_notes TEXT, + uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + yanked_at TEXT + ) STRICT`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_firmware_releases_version_arch ON firmware_releases(version, arch)`, + `CREATE INDEX IF NOT EXISTS idx_firmware_releases_channel ON firmware_releases(channel, arch, uploaded_at DESC)`, + + `CREATE TABLE IF NOT EXISTS firmware_rollouts ( + id TEXT PRIMARY KEY, + release_id TEXT NOT NULL REFERENCES firmware_releases(id) ON DELETE CASCADE, + target_kiosk_ids TEXT NOT NULL DEFAULT '[]', + state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'active', 'paused', 'complete')), + percentage INTEGER NOT NULL DEFAULT 100 CHECK(percentage BETWEEN 1 AND 100), + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_firmware_rollouts_state ON firmware_rollouts(state)`, + + // Per-kiosk firmware preferences + update tracking. + (db: DatabaseSync) => { + addColumnIfNotExists(db, "kiosks", "firmware_channel", "TEXT NOT NULL DEFAULT 'stable'"); + addColumnIfNotExists(db, "kiosks", "firmware_target_version", "TEXT"); + addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_at", "TEXT"); + addColumnIfNotExists(db, "kiosks", "firmware_last_attempt_version", "TEXT"); + addColumnIfNotExists(db, "kiosks", "firmware_last_error", "TEXT"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 8f7a3a1..9eef9a8 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -22,6 +22,10 @@ import type { EntityType, EventLog, EventSourceType, + FirmwareChannel, + FirmwareRelease, + FirmwareRollout, + FirmwareRolloutState, GpioDirection, GpioEdge, GpioPull, @@ -48,6 +52,8 @@ import { rowToDisplay, rowToEntity, rowToEventLog, + rowToFirmwareRelease, + rowToFirmwareRollout, rowToKiosk, rowToKioskGpioBinding, rowToLabel, @@ -981,6 +987,47 @@ export class Repository { return k; } + /** + * Rekey an existing kiosk for a replacement device. Preserves identity + * (id, name) and downstream references (display_id, labels, gpio bindings, + * layouts that mention it), but issues fresh credentials + capabilities and + * resets transient runtime state so the old hardware can't reconnect. + */ + replaceKioskKey( + id: number, + input: { + key_hash: string; + key_prefix: string; + capabilities?: string[]; + hardware_model?: string | null; + }, + ): void { + this.prep( + `UPDATE kiosks SET + key_hash = ?, + key_prefix = ?, + capabilities = ?, + hardware_model = ?, + paired_at = ?, + last_seen_at = NULL, + last_bundle_version = NULL, + kiosk_app_version = NULL, + os_version = NULL, + cpu_temp_c = NULL, + fan_rpm = NULL, + fan_pwm = NULL + WHERE id = ?`, + ).run( + input.key_hash, + input.key_prefix, + J(input.capabilities ?? []), + input.hardware_model ?? null, + isoNow(), + id, + ); + void this.notify("kiosks", "update", id); + } + touchKiosk( id: number, patch: { @@ -1014,6 +1061,172 @@ export class Repository { ); } + // =========================================================================== + // firmware_releases + firmware_rollouts + // =========================================================================== + + createFirmwareRelease(input: { + id: string; + version: string; + channel: FirmwareChannel; + arch: string; + artifact_path: string; + size_bytes: number; + sha256: string; + signature: string; + release_notes: string | null; + uploaded_by: number | null; + }): FirmwareRelease { + this.prep( + `INSERT INTO firmware_releases + (id, version, channel, arch, artifact_path, size_bytes, sha256, + signature, release_notes, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.id, + input.version, + input.channel, + input.arch, + input.artifact_path, + input.size_bytes, + input.sha256, + input.signature, + input.release_notes, + input.uploaded_by, + ); + void this.notify("firmware_releases", "create", input.id); + const r = this.getFirmwareRelease(input.id); + if (!r) throw new Error("firmware release vanished after insert"); + return r; + } + + getFirmwareRelease(id: string): FirmwareRelease | null { + const r = this.prep("SELECT * FROM firmware_releases WHERE id = ?").get(id); + return r ? rowToFirmwareRelease(r as Record) : null; + } + + getFirmwareReleaseByVersionArch(version: string, arch: string): FirmwareRelease | null { + const r = this.prep( + "SELECT * FROM firmware_releases WHERE version = ? AND arch = ?", + ).get(version, arch); + return r ? rowToFirmwareRelease(r as Record) : null; + } + + /** Latest non-yanked release for a (channel, arch) pair. */ + getLatestFirmwareRelease(channel: FirmwareChannel, arch: string): FirmwareRelease | null { + const r = this.prep( + `SELECT * FROM firmware_releases + WHERE channel = ? AND arch = ? AND yanked_at IS NULL + ORDER BY uploaded_at DESC + LIMIT 1`, + ).get(channel, arch); + return r ? rowToFirmwareRelease(r as Record) : null; + } + + listFirmwareReleases(): FirmwareRelease[] { + const rs = this.prep( + "SELECT * FROM firmware_releases ORDER BY uploaded_at DESC", + ).all(); + return rs.map((r) => rowToFirmwareRelease(r as Record)); + } + + yankFirmwareRelease(id: string): void { + this.prep("UPDATE firmware_releases SET yanked_at = ? WHERE id = ?").run(isoNow(), id); + void this.notify("firmware_releases", "update", id); + } + + /** Mark the per-kiosk firmware attempt state (called from /api/kiosk/firmware/applied). */ + recordKioskFirmwareAttempt( + kioskId: number, + version: string, + error: string | null, + ): void { + this.prep( + `UPDATE kiosks SET + firmware_last_attempt_at = ?, + firmware_last_attempt_version = ?, + firmware_last_error = ? + WHERE id = ?`, + ).run(isoNow(), version, error, kioskId); + void this.notify("kiosks", "update", kioskId); + } + + /** Set the per-kiosk update channel + optional explicit version pin. */ + setKioskFirmwarePref( + kioskId: number, + patch: { channel?: FirmwareChannel; target_version?: string | null }, + ): void { + const sets: string[] = []; + const vals: unknown[] = []; + if (patch.channel !== undefined) { + sets.push("firmware_channel = ?"); + vals.push(patch.channel); + } + if (patch.target_version !== undefined) { + sets.push("firmware_target_version = ?"); + vals.push(patch.target_version); + } + if (sets.length === 0) return; + vals.push(kioskId); + this.db.prepare(`UPDATE kiosks SET ${sets.join(", ")} WHERE id = ?`).run(...(vals as any[])); + void this.notify("kiosks", "update", kioskId); + } + + createFirmwareRollout(input: { + id: string; + release_id: string; + target_kiosk_ids: number[]; + percentage: number; + created_by: number | null; + }): FirmwareRollout { + this.prep( + `INSERT INTO firmware_rollouts + (id, release_id, target_kiosk_ids, percentage, created_by, state) + VALUES (?, ?, ?, ?, ?, 'queued')`, + ).run( + input.id, + input.release_id, + J(input.target_kiosk_ids), + input.percentage, + input.created_by, + ); + void this.notify("firmware_rollouts", "create", input.id); + const r = this.getFirmwareRollout(input.id); + if (!r) throw new Error("rollout vanished after insert"); + return r; + } + + getFirmwareRollout(id: string): FirmwareRollout | null { + const r = this.prep("SELECT * FROM firmware_rollouts WHERE id = ?").get(id); + return r ? rowToFirmwareRollout(r as Record) : null; + } + + listFirmwareRollouts(): FirmwareRollout[] { + const rs = this.prep( + "SELECT * FROM firmware_rollouts ORDER BY created_at DESC", + ).all(); + return rs.map((r) => rowToFirmwareRollout(r as Record)); + } + + updateFirmwareRolloutState( + id: string, + state: FirmwareRolloutState, + ): void { + const now = isoNow(); + if (state === "active") { + this.prep( + `UPDATE firmware_rollouts SET state = ?, started_at = COALESCE(started_at, ?) WHERE id = ?`, + ).run(state, now, id); + } else if (state === "complete") { + this.prep( + `UPDATE firmware_rollouts SET state = ?, finished_at = ? WHERE id = ?`, + ).run(state, now, id); + } else { + this.prep(`UPDATE firmware_rollouts SET state = ? WHERE id = ?`).run(state, id); + } + void this.notify("firmware_rollouts", "update", id); + } + // =========================================================================== // pairing_codes // =========================================================================== diff --git a/server/src/shared/firmware.ts b/server/src/shared/firmware.ts new file mode 100644 index 0000000..8d8e44a --- /dev/null +++ b/server/src/shared/firmware.ts @@ -0,0 +1,190 @@ +/** + * Firmware signing + storage helpers. + * + * Server holds an Ed25519 keypair used to sign kiosk binaries during upload. + * Kiosks verify the signature before swapping. The private key never leaves + * the server. The public key is shipped to kiosks via the bundle response + * (so it can rotate without re-pairing) AND embedded in the binary at build + * time as a fallback for first-boot. + * + * Key file lives at `${dataDir}/firmware-signing.key` (private, 0600) and + * `${dataDir}/firmware-signing.pub` (public, 0644). Both PEM-encoded. + * If env var BF_FIRMWARE_SIGNING_KEY is set (PEM string), it overrides the + * file — convenient for cloud deploys where the key comes from a secret + * manager. + * + * Storage for the firmware blobs themselves is `${dataDir}/firmware/`, one + * file per release, named `.bin`. Hashing on insert dedupes binaries + * across version/arch metadata changes. + */ +import { + createHash, + generateKeyPairSync, + sign as cryptoSign, + verify as cryptoVerify, + createPrivateKey, + createPublicKey, + type KeyObject, +} from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { writeFile, readFile, unlink, rename } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export interface FirmwareKeyPair { + privateKey: KeyObject; + publicKeyPem: string; +} + +export interface FirmwareApi { + /** Base64url of Ed25519(sha256(bytes)). Returns `{sha256, signature}`. */ + signBlob(bytes: Buffer): { sha256: string; signature: string }; + /** Verify a signature against bytes. Used by tests + admin re-verify. */ + verifyBlob(bytes: Buffer, signature: string): boolean; + /** PEM-encoded SPKI public key for export to kiosks/bundle. */ + publicKeyPem(): string; + /** Persist an uploaded firmware blob to disk and return absolute path. */ + storeBlob(bytes: Buffer, sha256: string): Promise; + /** Read a stored firmware blob by absolute path (re-checks sha256). */ + readBlob(path: string, expectedSha256: string): Promise; + /** Delete a stored firmware blob (yank cleanup). */ + removeBlob(path: string): Promise; + /** Directory holding all firmware blobs. */ + firmwareDir(): string; +} + +export interface FirmwareConfig { + /** Server data dir (same as secrets dataDir, typically /var/lib/betterframe). */ + dataDir: string; +} + +export interface FirmwareLog { + info(msg: string): void; + warn(msg: string): void; +} + +export function initFirmware(config: FirmwareConfig, log: FirmwareLog): FirmwareApi { + const keyDir = config.dataDir; + const privPath = join(keyDir, "firmware-signing.key"); + const pubPath = join(keyDir, "firmware-signing.pub"); + const firmwareDir = join(config.dataDir, "firmware"); + + if (!existsSync(firmwareDir)) { + mkdirSync(firmwareDir, { recursive: true, mode: 0o755 }); + } + + let keyPair = loadOrCreateKeyPair(keyDir, privPath, pubPath, log); + + function signBlob(bytes: Buffer): { sha256: string; signature: string } { + const sha256 = createHash("sha256").update(bytes).digest("hex"); + // Sign the sha256 hex digest rather than the raw bytes — smaller, faster + // verify, and matches what we ship as the integrity metadata anyway. + const sig = cryptoSign(null, Buffer.from(sha256, "utf8"), keyPair.privateKey); + return { sha256, signature: sig.toString("base64url") }; + } + + function verifyBlob(bytes: Buffer, signature: string): boolean { + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const pub = createPublicKey(keyPair.publicKeyPem); + return cryptoVerify( + null, + Buffer.from(sha256, "utf8"), + pub, + Buffer.from(signature, "base64url"), + ); + } + + async function storeBlob(bytes: Buffer, sha256: string): Promise { + const path = join(firmwareDir, `${sha256}.bin`); + // Atomic write: write to .tmp then rename. Avoids partial files if the + // server is killed mid-upload. + const tmp = `${path}.tmp`; + await writeFile(tmp, bytes, { mode: 0o644 }); + await rename(tmp, path); + return path; + } + + async function readBlob(path: string, expectedSha256: string): Promise { + const buf = await readFile(path); + const got = createHash("sha256").update(buf).digest("hex"); + if (got !== expectedSha256) { + throw new Error(`firmware sha256 mismatch on ${path}: expected ${expectedSha256}, got ${got}`); + } + return buf; + } + + async function removeBlob(path: string): Promise { + try { + await unlink(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw err; + } + } + + return { + signBlob, + verifyBlob, + publicKeyPem: () => keyPair.publicKeyPem, + storeBlob, + readBlob, + removeBlob, + firmwareDir: () => firmwareDir, + }; +} + +function loadOrCreateKeyPair( + keyDir: string, + privPath: string, + pubPath: string, + log: FirmwareLog, +): FirmwareKeyPair { + // Env override for cloud / k8s — full private key PEM in a single var. + const envKey = process.env["BF_FIRMWARE_SIGNING_KEY"]; + if (envKey && envKey.trim().length > 0) { + const priv = createPrivateKey({ key: envKey, format: "pem" }); + const pub = createPublicKey(priv).export({ format: "pem", type: "spki" }); + log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env"); + return { privateKey: priv, publicKeyPem: String(pub) }; + } + + if (existsSync(privPath) && existsSync(pubPath)) { + const priv = createPrivateKey({ key: readFileSync(privPath), format: "pem" }); + const pub = readFileSync(pubPath, "utf8"); + return { privateKey: priv, publicKeyPem: pub }; + } + + log.warn("firmware: generating new Ed25519 signing keypair (no existing key found)"); + if (!existsSync(keyDir)) mkdirSync(keyDir, { recursive: true, mode: 0o755 }); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const privPem = String(privateKey.export({ format: "pem", type: "pkcs8" })); + const pubPem = String(publicKey.export({ format: "pem", type: "spki" })); + writeFileSync(privPath, privPem, { mode: 0o600 }); + writeFileSync(pubPath, pubPem, { mode: 0o644 }); + return { privateKey, publicKeyPem: pubPem }; +} + +/** + * Standalone verifier used by anyone with just the public key (kiosk-side + * equivalent of `verifyBlob` lives in Rust — this is for server-side checks + * during upload re-verification). + */ +export function verifyDetached( + publicKeyPem: string, + sha256: string, + signature: string, +): boolean { + const pub = createPublicKey(publicKeyPem); + return cryptoVerify( + null, + Buffer.from(sha256, "utf8"), + pub, + Buffer.from(signature, "base64url"), + ); +} + +// path helper export so admin routes can serve relative paths cleanly +export function blobFilenameFromHash(sha256: string): string { + return `${sha256}.bin`; +} + +export { dirname }; diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 79e3cd0..596042e 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -105,6 +105,13 @@ export interface PairingConfirmInput { code: string; nameOverride?: string; initialLabels?: string[]; + /** + * If set, rekey this existing kiosk instead of creating a new one. Display + * assignments, labels, GPIO bindings, and layout references all stay + * pointed at the same kiosk id — only credentials + hardware metadata roll. + * When set, nameOverride and initialLabels are ignored. + */ + replaceKioskId?: number; } export async function confirmPairing( @@ -118,52 +125,67 @@ export async function confirmPairing( if (pc.consumed_at) throw new Error("pairing code already used"); if (new Date(pc.expires_at) < new Date()) throw new Error("pairing code expired"); - const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`; - // Auto-suffix if name collides (kiosks.name is UNIQUE) - let kioskName = baseName; - let suffix = 2; - while (repo.getKioskByName(kioskName)) { - kioskName = `${baseName}-${suffix}`; - suffix++; - if (suffix > 100) throw new Error("could not generate unique kiosk name"); - } - const kioskKeyPlaintext = `bf-${randomBytes(24).toString("base64url")}`; const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext); const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8); - const kiosk = repo.createKiosk({ - name: kioskName, - key_hash: kioskKeyHash, - key_prefix: kioskKeyPrefix, - capabilities: pc.kiosk_capabilities, - hardware_model: pc.kiosk_hardware_model, - }); + let kioskId: number; + let kioskName: string; - // Create a default display for this kiosk (HDMI-0) - repo.createDisplayForKiosk(kiosk.id, { - name: `${kioskName} HDMI-0`, - }); - - // Attach initial labels - if (input.initialLabels?.length) { - for (const labelName of input.initialLabels) { - const trimmed = labelName.trim().toLowerCase(); - if (!trimmed) continue; - const label = repo.ensureLabel(trimmed); - repo.attachKioskLabel(kiosk.id, label.id, "consume"); + if (input.replaceKioskId != null) { + const existing = repo.getKioskById(input.replaceKioskId); + if (!existing) throw new Error("replacement target kiosk not found"); + repo.replaceKioskKey(existing.id, { + key_hash: kioskKeyHash, + key_prefix: kioskKeyPrefix, + capabilities: pc.kiosk_capabilities, + hardware_model: pc.kiosk_hardware_model, + }); + kioskId = existing.id; + kioskName = existing.name; + } else { + const baseName = input.nameOverride || pc.kiosk_proposed_name || `kiosk-${input.code.toLowerCase()}`; + let candidate = baseName; + let suffix = 2; + while (repo.getKioskByName(candidate)) { + candidate = `${baseName}-${suffix}`; + suffix++; + if (suffix > 100) throw new Error("could not generate unique kiosk name"); } + + const kiosk = repo.createKiosk({ + name: candidate, + key_hash: kioskKeyHash, + key_prefix: kioskKeyPrefix, + capabilities: pc.kiosk_capabilities, + hardware_model: pc.kiosk_hardware_model, + }); + + repo.createDisplayForKiosk(kiosk.id, { + name: `${candidate} HDMI-0`, + }); + + if (input.initialLabels?.length) { + for (const labelName of input.initialLabels) { + const trimmed = labelName.trim().toLowerCase(); + if (!trimmed) continue; + const label = repo.ensureLabel(trimmed); + repo.attachKioskLabel(kiosk.id, label.id, "consume"); + } + } + + kioskId = kiosk.id; + kioskName = candidate; } - // Get cluster key for kiosk + // Cluster key delivery (shared across pair + replace) const clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined; const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : undefined; - // Store plaintext kiosk_key + cluster_key in extras for kiosk to claim once - repo.markPairingCodeClaimed(input.code, kiosk.id, { + repo.markPairingCodeClaimed(input.code, kioskId, { kiosk_key_plaintext: kioskKeyPlaintext, cluster_key: clusterKey, }); - return { kioskId: kiosk.id, kioskName }; + return { kioskId, kioskName }; } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index c220887..3235bf3 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -211,9 +211,44 @@ export interface Kiosk { cpu_temp_c: number | null; fan_rpm: number | null; fan_pwm: number | null; + firmware_channel: FirmwareChannel; + firmware_target_version: string | null; + firmware_last_attempt_at: string | null; + firmware_last_attempt_version: string | null; + firmware_last_error: string | null; created_at: string; } +export type FirmwareChannel = "stable" | "beta" | "dev"; +export type FirmwareRolloutState = "queued" | "active" | "paused" | "complete"; + +export interface FirmwareRelease { + id: string; + version: string; + channel: FirmwareChannel; + arch: string; + artifact_path: string; + size_bytes: number; + sha256: string; + signature: string; + release_notes: string | null; + uploaded_at: string; + uploaded_by: number | null; + yanked_at: string | null; +} + +export interface FirmwareRollout { + id: string; + release_id: string; + target_kiosk_ids: number[]; + state: FirmwareRolloutState; + percentage: number; + started_at: string | null; + finished_at: string | null; + created_at: string; + created_by: number | null; +} + export interface Label { id: number; name: string; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 2603ba0..c4862c4 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -7,6 +7,7 @@ import type { Camera, Display, Entity, + FirmwareRelease, Kiosk, KioskGpioBinding, Label, @@ -873,11 +874,24 @@ export function KiosksPage(props: KiosksProps) {
8-character code shown on kiosk screen.
- + + +
+ Pick the kiosk this device replaces. Display, layouts, labels, and GPIO + bindings stay; only the device credentials roll. Old kiosk's key is revoked. +
+
+
+
- +
Comma-separated label names.
@@ -1287,6 +1301,7 @@ interface KioskEditProps { displays?: Display[]; switchableLayouts?: LayoutType[]; gpioBindings?: KioskGpioBinding[]; + firmwareReleases?: FirmwareRelease[]; error?: string; success?: string; } @@ -1507,6 +1522,10 @@ export function KioskEditPage(props: KioskEditProps) { )} + {props.firmwareReleases && ( + KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases }) + )} + {/* GPIO bindings */}

GPIO Bindings

@@ -2641,3 +2660,183 @@ export function SystemHealthPage(props: SystemHealthPageProps) { ); } + +// ---- Firmware --------------------------------------------------------------- + +interface FirmwarePageProps { + user: string; + releases: FirmwareRelease[]; + publicKeyPem: string; +} + +export function FirmwarePage(props: FirmwarePageProps) { + return ( + +

+ Signed kiosk firmware artifacts. Uploaded binaries are hashed + + Ed25519-signed by the server before kiosks can install them. +

+ +
+

Upload release

+
+
+ + +
Stripped release binary, no archive wrapper.
+
+
+ + +
+
+ + +
+
+ + +
+
+ +