mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
feat(ota): replacement pairing + firmware OTA (admin UI, kiosk client, CI)
This commit is contained in:
parent
2bfecb2819
commit
e5009fdd14
20 changed files with 1517 additions and 39 deletions
125
.github/workflows/release-kiosk.yml
vendored
Normal file
125
.github/workflows/release-kiosk.yml
vendored
Normal 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
|
||||||
|
|
@ -34,3 +34,8 @@ futures-util = "0.3"
|
||||||
url = "2"
|
url = "2"
|
||||||
webkit6 = "0.4"
|
webkit6 = "0.4"
|
||||||
gpiod = "0.3"
|
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
221
kiosk/src/firmware.rs
Normal 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}¤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::<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 }
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod server;
|
mod server;
|
||||||
mod bundle;
|
mod bundle;
|
||||||
mod cec;
|
mod cec;
|
||||||
|
mod firmware;
|
||||||
mod gpio;
|
mod gpio;
|
||||||
mod hwmon;
|
mod hwmon;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
|
@ -15,6 +16,8 @@ pub enum ServerMsg {
|
||||||
Fan(Option<u32>),
|
Fan(Option<u32>),
|
||||||
/// Switch to a specific layout by ID (must be present in current bundle).
|
/// Switch to a specific layout by ID (must be present in current bundle).
|
||||||
SwitchLayout(u32),
|
SwitchLayout(u32),
|
||||||
|
/// Server-pushed "go check for a firmware update now".
|
||||||
|
FirmwareCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use tracing::{info, warn};
|
||||||
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
|
use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
|
||||||
use crate::cec;
|
use crate::cec;
|
||||||
use crate::gpio;
|
use crate::gpio;
|
||||||
|
use crate::firmware;
|
||||||
use crate::hwmon;
|
use crate::hwmon;
|
||||||
use crate::pipeline;
|
use crate::pipeline;
|
||||||
use crate::server;
|
use crate::server;
|
||||||
|
|
@ -229,15 +230,18 @@ fn activate(app: &Application) {
|
||||||
ServerMsg::SwitchLayout(id) => {
|
ServerMsg::SwitchLayout(id) => {
|
||||||
let _ = tx_for_reload.send(WorkerMsg::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
|
// Heartbeat loop — reports display geometry + hwmon, also checks for
|
||||||
// immediately so admin "Hardware" panel populates without waiting a
|
// firmware updates so kiosks pick up new builds without admin push.
|
||||||
// full minute after boot/pair.
|
|
||||||
loop {
|
loop {
|
||||||
send_heartbeat_now(&server, &key);
|
send_heartbeat_now(&server, &key);
|
||||||
|
maybe_apply_firmware_update(&server, &key);
|
||||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
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);
|
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
|
/// Install the once-per-second watchdog that enforces idle/sleep timeouts
|
||||||
/// per display. Safe to call multiple times — installs at most once.
|
/// per display. Safe to call multiple times — installs at most once.
|
||||||
fn install_idle_watchdog() {
|
fn install_idle_watchdog() {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
|
||||||
} else {
|
} else {
|
||||||
warn!("ws: layout-switch missing layout_id");
|
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\"") {
|
} else if text.contains("\"type\":\"fan\"") {
|
||||||
info!("ws: fan received: {text}");
|
info!("ws: fan received: {text}");
|
||||||
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) else {
|
let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||||
|
|
|
||||||
45
scripts/gen-firmware-signing-key.sh
Executable file
45
scripts/gen-firmware-signing-key.sh
Executable 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)"
|
||||||
|
|
@ -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.
|
* Build a redirect Response with optional Set-Cookie header.
|
||||||
* Avoids h3's setCookie which doesn't play well with returning
|
* Avoids h3's setCookie which doesn't play well with returning
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { getRepo } from "../../shared/plugin-registry.js";
|
||||||
import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
|
import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
|
||||||
import { createAuth, type AuthApi } from "../../shared/auth.js";
|
import { createAuth, type AuthApi } from "../../shared/auth.js";
|
||||||
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.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 { Repository } from "../service-store/repository.js";
|
||||||
|
|
||||||
import { registerMiddleware } from "./middleware.js";
|
import { registerMiddleware } from "./middleware.js";
|
||||||
|
|
@ -26,6 +27,7 @@ import { registerSetupRoutes } from "./routes-setup.js";
|
||||||
import { registerAuthRoutes } from "./routes-auth.js";
|
import { registerAuthRoutes } from "./routes-auth.js";
|
||||||
import { registerAdminRoutes } from "./routes-admin.js";
|
import { registerAdminRoutes } from "./routes-admin.js";
|
||||||
import { registerAccountRoutes } from "./routes-account.js";
|
import { registerAccountRoutes } from "./routes-account.js";
|
||||||
|
import { registerFirmwareRoutes } from "./routes-firmware.js";
|
||||||
import { registerStaticRoutes } from "./routes-static.js";
|
import { registerStaticRoutes } from "./routes-static.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
@ -80,6 +82,7 @@ export interface AdminDeps {
|
||||||
secrets: SecretsApi;
|
secrets: SecretsApi;
|
||||||
cookieName: string;
|
cookieName: string;
|
||||||
nodered: NoderedBridge;
|
nodered: NoderedBridge;
|
||||||
|
firmware: FirmwareApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Plugin -----------------------------------------------------------------
|
// ---- 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, {}) },
|
{ 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 = {
|
const deps: AdminDeps = {
|
||||||
repo,
|
repo,
|
||||||
auth,
|
auth,
|
||||||
secrets,
|
secrets,
|
||||||
cookieName: this.config.cookieName,
|
cookieName: this.config.cookieName,
|
||||||
nodered,
|
nodered,
|
||||||
|
firmware,
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = new H3();
|
const app = new H3();
|
||||||
|
|
@ -139,6 +148,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
registerAuthRoutes(app, deps);
|
registerAuthRoutes(app, deps);
|
||||||
registerAdminRoutes(app, deps);
|
registerAdminRoutes(app, deps);
|
||||||
registerAccountRoutes(app, deps);
|
registerAccountRoutes(app, deps);
|
||||||
|
registerFirmwareRoutes(app, deps);
|
||||||
|
|
||||||
// Auth-check endpoint for Angie auth_request subrequest.
|
// Auth-check endpoint for Angie auth_request subrequest.
|
||||||
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
||||||
|
|
|
||||||
|
|
@ -617,12 +617,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const nameOverride = (body?.["name_override"] ?? "").trim() || undefined;
|
const nameOverride = (body?.["name_override"] ?? "").trim() || undefined;
|
||||||
const labelsStr = (body?.["initial_labels"] ?? "").trim();
|
const labelsStr = (body?.["initial_labels"] ?? "").trim();
|
||||||
const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
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 {
|
try {
|
||||||
await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
||||||
code,
|
code,
|
||||||
nameOverride,
|
nameOverride,
|
||||||
initialLabels,
|
initialLabels,
|
||||||
|
replaceKioskId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
|
|
@ -1171,6 +1174,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const firstDisplay = displays[0];
|
const firstDisplay = displays[0];
|
||||||
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
||||||
const gpioBindings = deps.repo.listGpioBindings(id);
|
const gpioBindings = deps.repo.listGpioBindings(id);
|
||||||
|
const firmwareReleases = deps.repo.listFirmwareReleases();
|
||||||
return htmlPage(KioskEditPage({
|
return htmlPage(KioskEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
kiosk,
|
kiosk,
|
||||||
|
|
@ -1179,6 +1183,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
displays,
|
displays,
|
||||||
switchableLayouts,
|
switchableLayouts,
|
||||||
gpioBindings,
|
gpioBindings,
|
||||||
|
firmwareReleases,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
171
server/src/plugins/service-admin-http/routes-firmware.ts
Normal file
171
server/src/plugins/service-admin-http/routes-firmware.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -21,9 +21,11 @@ import { createAuth } from "../../shared/auth.js";
|
||||||
import { initiatePairing, claimPairing } from "../../shared/pairing.js";
|
import { initiatePairing, claimPairing } from "../../shared/pairing.js";
|
||||||
import { generateBundle } from "../../shared/bundle.js";
|
import { generateBundle } from "../../shared/bundle.js";
|
||||||
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.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 { Repository } from "../service-store/repository.js";
|
||||||
import type { AuthApi } from "../../shared/auth.js";
|
import type { AuthApi } from "../../shared/auth.js";
|
||||||
import type { SecretsApi } from "../../shared/secrets.js";
|
import type { SecretsApi } from "../../shared/secrets.js";
|
||||||
|
import type { FirmwareChannel } from "../../shared/types.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -105,6 +107,10 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
{ baseUrl: this.config.noderedUrl },
|
{ baseUrl: this.config.noderedUrl },
|
||||||
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
{ 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();
|
const app = new H3();
|
||||||
|
|
||||||
|
|
@ -134,7 +140,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
});
|
});
|
||||||
|
|
||||||
registerPairingRoutes(app, repo, auth, secrets, codeTtl);
|
registerPairingRoutes(app, repo, auth, secrets, codeTtl);
|
||||||
registerKioskRoutes(app, repo, auth, secrets, nodered);
|
registerKioskRoutes(app, repo, auth, secrets, nodered, firmware);
|
||||||
|
|
||||||
this.server = serve(app, {
|
this.server = serve(app, {
|
||||||
port: this.config.port,
|
port: this.config.port,
|
||||||
|
|
@ -230,6 +236,7 @@ function registerKioskRoutes(
|
||||||
auth: AuthApi,
|
auth: AuthApi,
|
||||||
secrets: SecretsApi,
|
secrets: SecretsApi,
|
||||||
nodered: NoderedBridge,
|
nodered: NoderedBridge,
|
||||||
|
firmware: FirmwareApi,
|
||||||
): void {
|
): void {
|
||||||
// Bundle delivery
|
// Bundle delivery
|
||||||
app.get("/api/kiosk/bundle", async (event) => {
|
app.get("/api/kiosk/bundle", async (event) => {
|
||||||
|
|
@ -379,4 +386,114 @@ function registerKioskRoutes(
|
||||||
|
|
||||||
return { ok: true, event_id: eventId };
|
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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ import type {
|
||||||
EntityType,
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
|
FirmwareChannel,
|
||||||
|
FirmwareRelease,
|
||||||
|
FirmwareRollout,
|
||||||
|
FirmwareRolloutState,
|
||||||
GpioDirection,
|
GpioDirection,
|
||||||
GpioEdge,
|
GpioEdge,
|
||||||
GpioPull,
|
GpioPull,
|
||||||
|
|
@ -250,10 +254,46 @@ export function rowToKiosk(r: Row): Kiosk {
|
||||||
cpu_temp_c: nn(r["cpu_temp_c"]),
|
cpu_temp_c: nn(r["cpu_temp_c"]),
|
||||||
fan_rpm: nn(r["fan_rpm"]),
|
fan_rpm: nn(r["fan_rpm"]),
|
||||||
fan_pwm: nn(r["fan_pwm"]),
|
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"]),
|
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 {
|
export function rowToLabel(r: Row): Label {
|
||||||
return {
|
return {
|
||||||
id: n(r["id"]),
|
id: n(r["id"]),
|
||||||
|
|
|
||||||
|
|
@ -709,4 +709,48 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
`);
|
`);
|
||||||
db.exec("PRAGMA foreign_keys = ON");
|
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");
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ import type {
|
||||||
EntityType,
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
|
FirmwareChannel,
|
||||||
|
FirmwareRelease,
|
||||||
|
FirmwareRollout,
|
||||||
|
FirmwareRolloutState,
|
||||||
GpioDirection,
|
GpioDirection,
|
||||||
GpioEdge,
|
GpioEdge,
|
||||||
GpioPull,
|
GpioPull,
|
||||||
|
|
@ -48,6 +52,8 @@ import {
|
||||||
rowToDisplay,
|
rowToDisplay,
|
||||||
rowToEntity,
|
rowToEntity,
|
||||||
rowToEventLog,
|
rowToEventLog,
|
||||||
|
rowToFirmwareRelease,
|
||||||
|
rowToFirmwareRollout,
|
||||||
rowToKiosk,
|
rowToKiosk,
|
||||||
rowToKioskGpioBinding,
|
rowToKioskGpioBinding,
|
||||||
rowToLabel,
|
rowToLabel,
|
||||||
|
|
@ -981,6 +987,47 @@ export class Repository {
|
||||||
return k;
|
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(
|
touchKiosk(
|
||||||
id: number,
|
id: number,
|
||||||
patch: {
|
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
|
// pairing_codes
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
190
server/src/shared/firmware.ts
Normal file
190
server/src/shared/firmware.ts
Normal 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 };
|
||||||
|
|
@ -105,6 +105,13 @@ export interface PairingConfirmInput {
|
||||||
code: string;
|
code: string;
|
||||||
nameOverride?: string;
|
nameOverride?: string;
|
||||||
initialLabels?: 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(
|
export async function confirmPairing(
|
||||||
|
|
@ -118,52 +125,67 @@ export async function confirmPairing(
|
||||||
if (pc.consumed_at) throw new Error("pairing code already used");
|
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");
|
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 kioskKeyPlaintext = `bf-${randomBytes(24).toString("base64url")}`;
|
||||||
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
const kioskKeyHash = await auth.hashPassword(kioskKeyPlaintext);
|
||||||
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
const kioskKeyPrefix = kioskKeyPlaintext.slice(0, 8);
|
||||||
|
|
||||||
const kiosk = repo.createKiosk({
|
let kioskId: number;
|
||||||
name: kioskName,
|
let kioskName: string;
|
||||||
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)
|
if (input.replaceKioskId != null) {
|
||||||
repo.createDisplayForKiosk(kiosk.id, {
|
const existing = repo.getKioskById(input.replaceKioskId);
|
||||||
name: `${kioskName} HDMI-0`,
|
if (!existing) throw new Error("replacement target kiosk not found");
|
||||||
});
|
repo.replaceKioskKey(existing.id, {
|
||||||
|
key_hash: kioskKeyHash,
|
||||||
// Attach initial labels
|
key_prefix: kioskKeyPrefix,
|
||||||
if (input.initialLabels?.length) {
|
capabilities: pc.kiosk_capabilities,
|
||||||
for (const labelName of input.initialLabels) {
|
hardware_model: pc.kiosk_hardware_model,
|
||||||
const trimmed = labelName.trim().toLowerCase();
|
});
|
||||||
if (!trimmed) continue;
|
kioskId = existing.id;
|
||||||
const label = repo.ensureLabel(trimmed);
|
kioskName = existing.name;
|
||||||
repo.attachKioskLabel(kiosk.id, label.id, "consume");
|
} 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 clusterKeyEncrypted = repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
|
||||||
const clusterKey = clusterKeyEncrypted ? secrets.decryptString(clusterKeyEncrypted, "cluster") : 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, kioskId, {
|
||||||
repo.markPairingCodeClaimed(input.code, kiosk.id, {
|
|
||||||
kiosk_key_plaintext: kioskKeyPlaintext,
|
kiosk_key_plaintext: kioskKeyPlaintext,
|
||||||
cluster_key: clusterKey,
|
cluster_key: clusterKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { kioskId: kiosk.id, kioskName };
|
return { kioskId, kioskName };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,9 +211,44 @@ export interface Kiosk {
|
||||||
cpu_temp_c: number | null;
|
cpu_temp_c: number | null;
|
||||||
fan_rpm: number | null;
|
fan_rpm: number | null;
|
||||||
fan_pwm: 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;
|
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 {
|
export interface Label {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
Camera,
|
Camera,
|
||||||
Display,
|
Display,
|
||||||
Entity,
|
Entity,
|
||||||
|
FirmwareRelease,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
KioskGpioBinding,
|
KioskGpioBinding,
|
||||||
Label,
|
Label,
|
||||||
|
|
@ -873,11 +874,24 @@ export function KiosksPage(props: KiosksProps) {
|
||||||
<div class="form-hint">8-character code shown on kiosk screen.</div>
|
<div class="form-hint">8-character code shown on kiosk screen.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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" />
|
<input id="name_override" name="name_override" type="text" class="form-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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" />
|
<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 class="form-hint">Comma-separated label names.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1287,6 +1301,7 @@ interface KioskEditProps {
|
||||||
displays?: Display[];
|
displays?: Display[];
|
||||||
switchableLayouts?: LayoutType[];
|
switchableLayouts?: LayoutType[];
|
||||||
gpioBindings?: KioskGpioBinding[];
|
gpioBindings?: KioskGpioBinding[];
|
||||||
|
firmwareReleases?: FirmwareRelease[];
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1507,6 +1522,10 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{props.firmwareReleases && (
|
||||||
|
KioskFirmwarePanel({ kiosk: props.kiosk, releases: props.firmwareReleases })
|
||||||
|
)}
|
||||||
|
|
||||||
{/* GPIO bindings */}
|
{/* GPIO bindings */}
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
|
||||||
|
|
@ -2641,3 +2660,183 @@ export function SystemHealthPage(props: SystemHealthPageProps) {
|
||||||
</Layout>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
<NavItem href="/admin/layouts" label="Layouts" icon="▦" active={a === "layouts"} />
|
||||||
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
<NavItem href="/admin/displays" label="Displays" icon="▪" active={a === "displays"} />
|
||||||
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
<NavItem href="/admin/kiosks" label="Kiosks" icon="◈" active={a === "kiosks"} />
|
||||||
|
<NavItem href="/admin/firmware" label="Firmware" icon="▲" active={a === "firmware"} />
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
<hr />
|
<hr />
|
||||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue