BetterFrame/kiosk/src/os_update.rs

321 lines
12 KiB
Rust

//! Kiosk-side full-OS OTA via RAUC.
//!
//! Mirrors `firmware.rs` (which handles the kiosk binary) but for the
//! complete OS image. Server endpoints:
//!
//! GET /api/kiosk/os/check?compatibility=<X>&current=<ver>
//! → { up_to_date: true } | { up_to_date: false, update: {...} }
//! GET /api/kiosk/os/download/:release_id
//! → octet-stream .raucb bytes
//! POST /api/kiosk/os/applied { version, error? }
//!
//! Signature verification is RAUC's job — bundles are signed with the
//! X.509 cert pair generated by scripts/gen-rauc-signing-keys.sh, and
//! the corresponding CA cert is baked into the image at
//! /etc/rauc/keyring.pem. We only sha256-check the download here to
//! catch transport corruption before handing off to `rauc install`.
//!
//! Slot switching, atomic copy, and rollback are RAUC's job too —
//! we just shell out to `rauc install`, post the outcome, and tell
//! systemd to reboot. The custom bootloader backend
//! (deploy/rauc/betterframe-rauc-boot.sh) flips Pi 5 tryboot on the
//! next boot.
//!
//! Gated by env `BF_ENABLE_OS_OTA=1`. Default OFF so dev kiosks running
//! a non-A/B layout don't try (and fail) to RAUC-install bundles.
//!
//! Compatibility: read from `/etc/betterframe/os-compatibility` (written
//! at image build time). Falls back to env `BF_RAUC_COMPATIBILITY`, then
//! a hardcoded default matching deploy/rauc/system.conf.
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tracing::{info, warn};
pub const DEFAULT_COMPATIBILITY: &str = "betterframe-rpi5-aarch64";
fn compatibility() -> String {
if let Ok(s) = fs::read_to_string("/etc/betterframe/os-compatibility") {
let trimmed = s.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
std::env::var("BF_RAUC_COMPATIBILITY").unwrap_or_else(|_| DEFAULT_COMPATIBILITY.to_string())
}
pub fn current_os_version_public() -> String { current_os_version() }
fn current_os_version() -> String {
if let Ok(s) = fs::read_to_string("/etc/betterframe/os-version") {
let trimmed = s.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
String::new()
}
#[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,
#[allow(dead_code)]
pub channel: String,
#[allow(dead_code)]
pub compatibility: String,
pub sha256: String,
pub size_bytes: u64,
#[allow(dead_code)]
pub bundle_format: Option<String>,
pub download_url: String,
}
/// Hit `/api/kiosk/os/check`. Returns `Some(UpdateInfo)` when an upgrade is
/// available. `None` on up-to-date, network failure, or parse error.
pub fn check(server: &str, key: &str) -> Option<UpdateInfo> {
let compat = compatibility();
let cur = current_os_version();
let url = format!(
"{server}/api/kiosk/os/check?compatibility={compat}&current={cur}",
compat = urlencoding::encode(&compat),
cur = urlencoding::encode(&cur),
);
let client = reqwest::blocking::Client::new();
let resp = match client
.get(&url)
.header("Authorization", format!("Bearer {key}"))
.timeout(Duration::from_secs(10))
.send()
{
Ok(r) => r,
Err(err) => {
warn!("os-update check: request failed: {err}");
return None;
}
};
if !resp.status().is_success() {
warn!("os-update check: HTTP {}", resp.status());
return None;
}
match resp.json::<CheckResponse>() {
Ok(c) if !c.up_to_date => c.update,
Ok(_) => None,
Err(err) => {
warn!("os-update check: parse failed: {err}");
None
}
}
}
/// Download → sha256 verify → `rauc install` → post outcome → reboot.
///
/// On success: reboots the system (does not return). On failure: posts the
/// error to /api/kiosk/os/applied and returns Err so the caller logs it.
pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> {
info!(
"os-update: applying {} ({} bytes, release {})",
info.version, info.size_bytes, info.release_id
);
// 1. Download with chunked streaming + resume support.
// Streams directly to disk (no 1.2GB in RAM). On network failure,
// resumes from where it left off using Range header. Retries up to
// 5 times with 10s backoff between attempts.
let url = format!("{}{}", server, info.download_url);
let staging_dir = PathBuf::from("/var/tmp/betterframe");
fs::create_dir_all(&staging_dir).map_err(|e| format!("mkdir staging: {e}"))?;
let bundle_path = staging_dir.join(format!("os-{}.raucb", info.release_id));
let max_retries = 5;
for attempt in 1..=max_retries {
let existing_bytes = fs::metadata(&bundle_path)
.map(|m| m.len())
.unwrap_or(0);
// If we already have the full file from a previous attempt, skip download.
if existing_bytes >= info.size_bytes {
break;
}
info!(
"os-update: download attempt {attempt}/{max_retries} (resuming from {existing_bytes} / {} bytes)",
info.size_bytes
);
let client = reqwest::blocking::Client::new();
let mut req = client
.get(&url)
.header("Authorization", format!("Bearer {key}"));
if existing_bytes > 0 {
req = req.header("Range", format!("bytes={existing_bytes}-"));
}
let resp = match req.timeout(Duration::from_secs(300)).send() {
Ok(r) => r,
Err(e) => {
warn!("os-update: download request failed (attempt {attempt}): {e}");
if attempt < max_retries {
std::thread::sleep(Duration::from_secs(10));
continue;
}
return Err(format!("download failed after {max_retries} attempts: {e}"));
}
};
let status = resp.status().as_u16();
if status != 200 && status != 206 {
return Err(format!("download HTTP {status}"));
}
// Stream chunks to disk.
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&bundle_path)
.map_err(|e| format!("open bundle file: {e}"))?;
let mut reader = resp;
let mut buf = vec![0u8; 256 * 1024]; // 256KB chunks
let mut downloaded = existing_bytes;
let mut stream_ok = true;
loop {
match std::io::Read::read(&mut reader, &mut buf) {
Ok(0) => break, // EOF
Ok(n) => {
file.write_all(&buf[..n]).map_err(|e| format!("write chunk: {e}"))?;
downloaded += n as u64;
// Log progress every ~50MB
if downloaded % (50 * 1024 * 1024) < (256 * 1024) as u64 {
info!("os-update: {downloaded} / {} bytes ({:.0}%)",
info.size_bytes,
(downloaded as f64 / info.size_bytes as f64) * 100.0);
}
}
Err(e) => {
warn!("os-update: stream error at {downloaded} bytes (attempt {attempt}): {e}");
stream_ok = false;
break;
}
}
}
file.sync_all().ok();
if stream_ok && downloaded >= info.size_bytes {
break; // Download complete
}
if attempt < max_retries {
info!("os-update: retrying in 10s...");
std::thread::sleep(Duration::from_secs(10));
} else {
return Err(format!("download incomplete after {max_retries} attempts ({downloaded}/{} bytes)", info.size_bytes));
}
}
// 2. sha256 verify the complete file on disk.
let file_size = fs::metadata(&bundle_path)
.map(|m| m.len())
.unwrap_or(0);
if file_size != info.size_bytes {
let _ = fs::remove_file(&bundle_path);
return Err(format!("size mismatch: expected {}, got {file_size}", info.size_bytes));
}
let mut hasher = Sha256::new();
let mut f = fs::File::open(&bundle_path).map_err(|e| format!("open for hash: {e}"))?;
let mut buf = vec![0u8; 256 * 1024];
loop {
match std::io::Read::read(&mut f, &mut buf) {
Ok(0) => break,
Ok(n) => hasher.update(&buf[..n]),
Err(e) => {
let _ = fs::remove_file(&bundle_path);
return Err(format!("read for hash: {e}"));
}
}
}
drop(f);
let digest = hasher.finalize();
let got_sha = hex_lower(&digest);
if got_sha != info.sha256 {
let _ = fs::remove_file(&bundle_path);
return Err(format!("sha256 mismatch: expected {}, got {got_sha}", info.sha256));
}
// 4. Hand off to rauc. `rauc install` blocks until the bundle is fully
// copied into the inactive slot and bootloader is flipped. Exit code 0
// = success; anything else = leave current slot booted, no reboot.
let status = Command::new("rauc")
.args(["install", bundle_path.to_str().unwrap_or("")])
.status()
.map_err(|e| {
let _ = report_applied(server, key, &info.version, Some(&format!("rauc spawn: {e}")));
format!("rauc spawn: {e}")
})?;
let _ = fs::remove_file(&bundle_path);
if !status.success() {
let msg = format!("rauc install exit {status:?}");
let _ = report_applied(server, key, &info.version, Some(&msg));
return Err(msg);
}
// 5. Report success BEFORE reboot. After this we lose the server
// connection mid-call; that's fine, server sets last_attempt_at from
// the next heartbeat anyway, but recording success now means the
// admin UI shows progress immediately.
let _ = report_applied(server, key, &info.version, None);
info!("os-update: rauc install OK → rebooting into the new slot");
// RAUC's custom bootloader backend has already armed tryboot for the
// freshly-written slot. Reboot picks it up. On failure to reach the
// new slot, tryboot rolls back automatically on the next power cycle.
match Command::new("systemctl").arg("reboot").status() {
Ok(_) => {
// systemctl reboot returns before the reboot completes; sleep
// briefly so we don't race main() into a re-entry.
std::thread::sleep(Duration::from_secs(30));
std::process::exit(0);
}
Err(e) => Err(format!("systemctl reboot: {e}")),
}
}
fn report_applied(server: &str, key: &str, version: &str, error: Option<&str>) -> Result<(), String> {
let payload = if let Some(err) = error {
serde_json::json!({ "version": version, "error": err })
} else {
serde_json::json!({ "version": version })
};
reqwest::blocking::Client::new()
.post(format!("{server}/api/kiosk/os/applied"))
.header("Authorization", format!("Bearer {key}"))
.json(&payload)
.timeout(Duration::from_secs(5))
.send()
.map(|_| ())
.map_err(|e| format!("report applied: {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
}