//! Kiosk-side OTA update flow. //! //! 1. `check(server, key, arch, current_version)` → asks BF server if there's //! a newer release for this kiosk's channel/pin. //! 2. `apply(server, key, info)` → downloads, verifies sha256 + //! Ed25519 signature (server's firmware-signing pubkey, PEM, embedded in //! the check response), atomically swaps the running binary, reports //! outcome, and exits so systemd's `Restart=always` brings up the new //! binary. //! //! Binary location: `/opt/betterframe/kiosk/betterframe-kiosk` (production //! deploy via `deploy/scripts/setup-pi-kiosk.sh`). Override with env //! `BF_KIOSK_BINARY`. //! //! Rollback: the previous binary is kept at `.prev` before the swap. //! systemd's StartLimitBurst=10 catches a broken binary; an out-of-band //! script (`/usr/local/bin/bf-rollback-firmware`, future) handles the //! restore. For now this module only does forward updates. use std::fs; use std::io::Write; use std::path::PathBuf; use std::time::Duration; use base64::Engine as _; use ed25519_dalek::{Signature, Verifier, VerifyingKey, pkcs8::DecodePublicKey}; 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"; const FIRMWARE_MARKER: &str = "/var/lib/betterframe/kiosk/firmware-applying.json"; fn binary_path() -> PathBuf { std::env::var("BF_KIOSK_BINARY") .unwrap_or_else(|_| DEFAULT_BIN_PATH.to_string()) .into() } #[derive(Debug, Deserialize)] pub struct CheckResponse { pub up_to_date: bool, pub update: Option, } #[derive(Debug, Clone, Deserialize)] pub struct UpdateInfo { #[allow(dead_code)] // surfaced for logging / future rollout coordination pub release_id: String, pub version: String, #[allow(dead_code)] // surfaced for logging pub channel: String, pub sha256: String, pub signature: String, pub size_bytes: u64, pub download_url: String, pub public_key_pem: String, } /// Hit `/api/kiosk/firmware/check` and return the update info if one is /// available. Returns `None` on up-to-date / network error / unparsable /// response — never panics. pub fn check(server: &str, key: &str, current_version: &str) -> Option { let client = reqwest::blocking::Client::new(); // current_version is semver-shaped (already URL-safe). Empty string is // fine — server treats it as "unknown" and offers any release. let url = format!( "{server}/api/kiosk/firmware/check?arch={arch}¤t={cur}", arch = ARCH, cur = current_version, ); let resp = match client .get(&url) .header("Authorization", format!("Bearer {key}")) .timeout(Duration::from_secs(10)) .send() { Ok(r) => r, Err(err) => { warn!("firmware check: request failed: {err}"); return None; } }; if !resp.status().is_success() { warn!("firmware check: HTTP {}", resp.status()); return None; } match resp.json::() { Ok(c) if !c.up_to_date => c.update, Ok(_) => None, Err(err) => { warn!("firmware check: parse failed: {err}"); None } } } /// Download + verify + swap. Reports outcome to the server. On success the /// process exits with code 0 so systemd's Restart=always picks up the new /// binary. On failure the function returns Err and the kiosk keeps running. pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { info!("firmware: applying {} ({} bytes)", info.version, info.size_bytes); // 1. Download let url = format!("{}{}", server, info.download_url); let client = reqwest::blocking::Client::new(); let resp = client .get(&url) .header("Authorization", format!("Bearer {key}")) .timeout(Duration::from_secs(300)) .send() .map_err(|e| format!("download request: {e}"))?; if !resp.status().is_success() { return Err(format!("download HTTP {}", resp.status())); } let bytes = resp.bytes().map_err(|e| format!("download body: {e}"))?; if bytes.len() as u64 != info.size_bytes { return Err(format!( "size mismatch: expected {}, got {}", info.size_bytes, bytes.len() )); } // 2. sha256 let mut hasher = Sha256::new(); hasher.update(&bytes); let digest = hasher.finalize(); let got_sha = hex_lower(&digest); if got_sha != info.sha256 { return Err(format!("sha256 mismatch: expected {}, got {}", info.sha256, got_sha)); } // 3. Ed25519 signature verify (sig is over the hex-encoded sha256 string) verify_signature(&info.public_key_pem, &info.sha256, &info.signature) .map_err(|e| format!("signature verify: {e}"))?; // 4. Atomic swap let bin = binary_path(); let new_path = bin.with_extension("new"); let prev_path = bin.with_extension("prev"); { let mut f = fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .mode_for_unix(0o755) .open(&new_path) .map_err(|e| format!("open {}: {e}", new_path.display()))?; f.write_all(&bytes).map_err(|e| format!("write {}: {e}", new_path.display()))?; f.sync_all().ok(); } // Drop a marker file the systemd ExecStartPre script reads to detect a // failed first boot of the new binary. We delete it after a clean boot // (see `mark_firmware_applied()`). If we crash before that, next start // sees a stale marker → restores .prev. { let marker = PathBuf::from(FIRMWARE_MARKER); let payload = serde_json::json!({ "version": info.version, "attempt_at": chrono_now_iso(), "bin": bin.to_string_lossy(), "prev": prev_path.to_string_lossy(), }); let _ = fs::write(&marker, payload.to_string()); } // 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}")) } /// Clear the in-progress marker. Call after the kiosk has booted cleanly and /// reported back to the server — proves the new binary survives startup. pub fn mark_firmware_applied() { let marker = PathBuf::from(FIRMWARE_MARKER); if marker.exists() { let _ = fs::remove_file(marker); } } fn chrono_now_iso() -> String { // Sidesteps adding a chrono dep — Unix epoch ms is enough for the // ExecStartPre rollback check. let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); format!("{secs}") } 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 } }