feat(ota): replacement pairing + firmware OTA (admin UI, kiosk client, CI)

This commit is contained in:
Mitchell R 2026-05-13 20:56:42 +02:00
parent 2bfecb2819
commit e5009fdd14
20 changed files with 1517 additions and 39 deletions

125
.github/workflows/release-kiosk.yml vendored Normal file
View file

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

View file

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

221
kiosk/src/firmware.rs Normal file
View file

@ -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 `<bin>.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<UpdateInfo>,
}
#[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<UpdateInfo> {
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}&current={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::<CheckResponse>() {
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 }
}

View file

@ -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<u32>),
/// 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};

View file

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

View file

@ -56,6 +56,9 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
} 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::<serde_json::Value>(&text) else {

View file

@ -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)"

View file

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

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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.

View file

@ -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,
}));
});

View file

@ -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<FirmwareChannel> = 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<Record<string, string>>(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 };
});
}

View file

@ -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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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 };
});
}

View file

@ -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<number[]>(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"]),

View file

@ -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");
},
];

View file

@ -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<string, unknown>) : 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<string, unknown>) : 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<string, unknown>) : 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<string, unknown>));
}
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<string, unknown>) : 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<string, unknown>));
}
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
// ===========================================================================

View file

@ -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 `<sha256>.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<string>;
/** Read a stored firmware blob by absolute path (re-checks sha256). */
readBlob(path: string, expectedSha256: string): Promise<Buffer>;
/** Delete a stored firmware blob (yank cleanup). */
removeBlob(path: string): Promise<void>;
/** 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<string> {
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<Buffer> {
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<void> {
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 };

View file

@ -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,34 +125,46 @@ 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);
let kioskId: number;
let kioskName: string;
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: kioskName,
name: candidate,
key_hash: kioskKeyHash,
key_prefix: kioskKeyPrefix,
capabilities: pc.kiosk_capabilities,
hardware_model: pc.kiosk_hardware_model,
});
// Create a default display for this kiosk (HDMI-0)
repo.createDisplayForKiosk(kiosk.id, {
name: `${kioskName} HDMI-0`,
name: `${candidate} HDMI-0`,
});
// Attach initial labels
if (input.initialLabels?.length) {
for (const labelName of input.initialLabels) {
const trimmed = labelName.trim().toLowerCase();
@ -155,15 +174,18 @@ export async function confirmPairing(
}
}
// Get cluster key for kiosk
kioskId = kiosk.id;
kioskName = candidate;
}
// 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 };
}

View file

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

View file

@ -7,6 +7,7 @@ import type {
Camera,
Display,
Entity,
FirmwareRelease,
Kiosk,
KioskGpioBinding,
Label,
@ -873,11 +874,24 @@ export function KiosksPage(props: KiosksProps) {
<div class="form-hint">8-character code shown on kiosk screen.</div>
</div>
<div class="form-group">
<label for="name_override">Name Override (optional)</label>
<label for="replace_kiosk_id">Replacing existing kiosk?</label>
<select id="replace_kiosk_id" name="replace_kiosk_id" class="form-input">
<option value="">-- No, this is a new kiosk --</option>
{props.kiosks.map((k) => (
<option value={String(k.id)}>{k.name}{k.last_seen_at ? ` (last seen ${formatTime(k.last_seen_at)})` : " (never seen)"}</option>
))}
</select>
<div class="form-hint">
Pick the kiosk this device replaces. Display, layouts, labels, and GPIO
bindings stay; only the device credentials roll. Old kiosk's key is revoked.
</div>
</div>
<div class="form-group">
<label for="name_override">Name Override (new kiosks only)</label>
<input id="name_override" name="name_override" type="text" class="form-input" />
</div>
<div class="form-group">
<label for="initial_labels">Initial Labels (optional)</label>
<label for="initial_labels">Initial Labels (new kiosks only)</label>
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
<div class="form-hint">Comma-separated label names.</div>
</div>
@ -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) {
)}
</div>
{props.firmwareReleases && (
KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases })
)}
{/* GPIO bindings */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
@ -2641,3 +2660,183 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
</Layout>
);
}
// ---- Firmware ---------------------------------------------------------------
interface FirmwarePageProps {
user: string;
releases: FirmwareRelease[];
publicKeyPem: string;
}
export function FirmwarePage(props: FirmwarePageProps) {
return (
<Layout title="Firmware" user={props.user} activeNav="kiosks">
<p style="color:#666; margin-bottom:1rem">
Signed kiosk firmware artifacts. Uploaded binaries are hashed +
Ed25519-signed by the server before kiosks can install them.
</p>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Upload release</h2>
<form
method="post"
action="/admin/firmware/upload"
enctype="multipart/form-data"
style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem"
>
<div class="form-group" style="grid-column:1/-1">
<label for="artifact">Binary</label>
<input id="artifact" name="artifact" type="file" required class="form-input" />
<div class="form-hint">Stripped release binary, no archive wrapper.</div>
</div>
<div class="form-group">
<label for="version">Version</label>
<input id="version" name="version" type="text" required class="form-input" placeholder="0.4.2" />
</div>
<div class="form-group">
<label for="channel">Channel</label>
<select id="channel" name="channel" class="form-input">
<option value="stable">stable</option>
<option value="beta">beta</option>
<option value="dev">dev</option>
</select>
</div>
<div class="form-group" style="grid-column:1/-1">
<label for="arch">Arch</label>
<select id="arch" name="arch" class="form-input">
<option value="aarch64-unknown-linux-gnu">aarch64 (Pi5)</option>
<option value="x86_64-unknown-linux-gnu">x86_64</option>
<option value="armv7-unknown-linux-gnueabihf">armv7</option>
</select>
</div>
<div class="form-group" style="grid-column:1/-1">
<label for="release_notes">Release notes</label>
<textarea id="release_notes" name="release_notes" class="form-input" rows="3" />
</div>
<button type="submit" class="btn btn-primary" style="grid-column:1/-1">Upload + sign</button>
</form>
</div>
<div class="table-wrap" style="margin-bottom:1.5rem">
<table>
<thead>
<tr>
<th>Version</th>
<th>Channel</th>
<th>Arch</th>
<th>Size</th>
<th>SHA256</th>
<th>Uploaded</th>
<th></th>
</tr>
</thead>
<tbody>
{props.releases.length === 0 ? (
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No firmware releases yet.</td></tr>
) : (
props.releases.map((r) => (
<tr style={r.yanked_at ? "opacity:0.4" : ""}>
<td><strong>{r.version}</strong></td>
<td><span class={`badge ${r.channel === "stable" ? "badge-green" : r.channel === "beta" ? "badge-yellow" : "badge-gray"}`}>{r.channel}</span></td>
<td style="font-family:monospace; font-size:0.8rem">{r.arch}</td>
<td style="font-size:0.85rem">{Math.round(r.size_bytes / 1024)} KiB</td>
<td style="font-family:monospace; font-size:0.75rem">{r.sha256.slice(0, 12)}</td>
<td style="font-size:0.85rem; white-space:nowrap">{formatTime(r.uploaded_at)}</td>
<td>
{r.yanked_at ? (
<span style="color:#999; font-size:0.8rem">yanked</span>
) : (
<button
type="button"
class="btn btn-sm btn-danger"
{...{
"hx-post": `/admin/firmware/${r.id}/yank`,
"hx-confirm": "Yank this release? Devices already running it stay, but no new devices will pick it up.",
"hx-swap": "none",
"hx-on::after-request": "location.reload()",
}}
>Yank</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<details class="card" style="font-size:0.85rem">
<summary style="cursor:pointer; font-weight:600">Signing public key</summary>
<p style="color:#666; margin:0.5rem 0">
Ed25519 public key kiosks pin during pairing. Safe to share. Kept here for backup.
</p>
<pre style="background:#fafafa; padding:0.75rem; overflow:auto; font-size:0.75rem">{props.publicKeyPem}</pre>
</details>
</Layout>
);
}
interface KioskFirmwarePanelProps {
kiosk: Kiosk;
releases: FirmwareRelease[];
}
export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) {
const k = props.kiosk;
const current = k.kiosk_app_version ?? "unknown";
return (
<div id={`kiosk-firmware-${String(k.id)}`} class="card" style="margin-bottom:1.5rem">
<h3 style="margin:0 0 0.75rem; font-size:1rem">Firmware</h3>
<div style="font-size:0.85rem; color:#666; margin-bottom:0.75rem">
<div>Running: <code>{current}</code></div>
{k.firmware_last_attempt_version && (
<div>
Last attempt: <code>{k.firmware_last_attempt_version}</code>
{k.firmware_last_attempt_at && <span> at {formatTime(k.firmware_last_attempt_at)}</span>}
{k.firmware_last_error && <span style="color:#a00"> {k.firmware_last_error}</span>}
</div>
)}
</div>
<form
{...{
"hx-post": `/admin/kiosks/${String(k.id)}/firmware`,
"hx-target": `#kiosk-firmware-${String(k.id)}`,
"hx-swap": "outerHTML",
}}
style="display:grid; grid-template-columns:1fr 1fr; gap:0.5rem; align-items:end"
>
<div class="form-group">
<label for={`channel-${String(k.id)}`}>Channel</label>
<select id={`channel-${String(k.id)}`} name="channel" class="form-input">
{(["stable", "beta", "dev"] as const).map((c) => (
<option value={c} selected={k.firmware_channel === c}>{c}</option>
))}
</select>
</div>
<div class="form-group">
<label for={`target-${String(k.id)}`}>Pin to version</label>
<select id={`target-${String(k.id)}`} name="target_version" class="form-input">
<option value="">-- follow channel --</option>
{props.releases.filter((r) => !r.yanked_at).map((r) => (
<option value={r.version} selected={k.firmware_target_version === r.version}>
{r.version} ({r.channel}, {r.arch})
</option>
))}
</select>
</div>
<div style="grid-column:1/-1; display:flex; gap:0.5rem">
<button type="submit" class="btn btn-primary">Save</button>
<button
type="button"
class="btn"
{...{
"hx-post": `/admin/kiosks/${String(k.id)}/firmware/push`,
"hx-swap": "none",
}}
>Push update now</button>
</div>
</form>
</div>
);
}

View file

@ -49,6 +49,7 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/layouts" label="Layouts" icon="&#9638;" active={a === "layouts"} />
<NavItem href="/admin/displays" label="Displays" icon="&#9642;" active={a === "displays"} />
<NavItem href="/admin/kiosks" label="Kiosks" icon="&#9672;" active={a === "kiosks"} />
<NavItem href="/admin/firmware" label="Firmware" icon="&#9650;" active={a === "firmware"} />
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />