From 0a3ee6c2962d9baa405fd739a6f2d68acded415f Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:38:43 +0000 Subject: [PATCH 1/2] fix: restrict Linux OS updates to full OS installations --- client/src/platform/linux/os_installation.rs | 77 ++++++++++++++++++++ client/src/platform/linux/os_update.rs | 24 +++++- client/src/platform/linux/server.rs | 2 +- client/src/platform/linux/ui.rs | 6 +- docs/linux-install.md | 6 ++ scripts/test-linux-setup.py | 3 + setup.sh | 1 + 7 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 client/src/platform/linux/os_installation.rs diff --git a/client/src/platform/linux/os_installation.rs b/client/src/platform/linux/os_installation.rs new file mode 100644 index 0000000..a135b75 --- /dev/null +++ b/client/src/platform/linux/os_installation.rs @@ -0,0 +1,77 @@ +use std::path::Path; + +// These files are provisioned by both full OS image builders. Neither an +// environment override nor an unrelated RAUC installation makes an app install +// eligible to replace the host OS. +pub fn updates_enabled(root: &Path, setting: Option<&str>) -> bool { + setting != Some("0") + && root.join("etc/rauc/system.conf").is_file() + && [ + "etc/betterframe/os-version", + "etc/betterframe/os-compatibility", + ] + .iter() + .all(|file| { + std::fs::read_to_string(root.join(file)) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_image_marker_is_required() { + let root = std::env::temp_dir().join(format!("bf-os-marker-matrix-{}", std::process::id())); + let files = [ + "etc/rauc/system.conf", + "etc/betterframe/os-version", + "etc/betterframe/os-compatibility", + ]; + for mask in 0..8 { + for (index, file) in files.iter().enumerate() { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + if mask & (1 << index) != 0 { + std::fs::write(path, "present").unwrap(); + } else if path.exists() { + std::fs::remove_file(path).unwrap(); + } + } + for setting in [None, Some("1"), Some("0")] { + assert_eq!( + updates_enabled(&root, setting), + mask == 7 && setting != Some("0") + ); + } + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn only_full_os_installations_can_update_the_os() { + let root = std::env::temp_dir().join(format!("bf-os-installation-{}", std::process::id())); + std::fs::create_dir_all(root.join("etc/rauc")).unwrap(); + std::fs::create_dir_all(root.join("etc/betterframe")).unwrap(); + assert!(!updates_enabled(&root, None)); + assert!(!updates_enabled(&root, Some("1"))); + std::fs::write(root.join("etc/rauc/system.conf"), "[system]\n").unwrap(); + assert!(!updates_enabled(&root, Some("1"))); + std::fs::write(root.join("etc/betterframe/os-version"), "1.0.22\n").unwrap(); + assert!(!updates_enabled(&root, None)); + let compatibility = root.join("etc/betterframe/os-compatibility"); + std::fs::write(&compatibility, " \n").unwrap(); + assert!(!updates_enabled(&root, None)); + for platform in ["betterframe-rpi5-aarch64", "betterframe-x86_64-generic"] { + std::fs::write(&compatibility, platform).unwrap(); + assert!(updates_enabled(&root, None)); + assert!(updates_enabled(&root, Some("1"))); + assert!(!updates_enabled(&root, Some("0"))); + } + std::fs::remove_file(root.join("etc/rauc/system.conf")).unwrap(); + assert!(!updates_enabled(&root, Some("1"))); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/client/src/platform/linux/os_update.rs b/client/src/platform/linux/os_update.rs index 58b9d3f..ea4308a 100644 --- a/client/src/platform/linux/os_update.rs +++ b/client/src/platform/linux/os_update.rs @@ -21,13 +21,23 @@ //! (deploy/rauc/betterframe-rauc-boot.sh) flips Pi 5 tryboot on the //! next boot. //! -//! Enabled unless env `BF_ENABLE_OS_OTA=0`. Non-A/B development installs -//! must opt out explicitly so they do not try to RAUC-install bundles. +//! Enabled only on full BetterFrame OS images with RAUC configuration and +//! image identity files. `BF_ENABLE_OS_OTA=0` additionally disables OS updates. //! //! 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. +#[path = "os_installation.rs"] +mod os_installation; + +pub fn enabled() -> bool { + os_installation::updates_enabled( + std::path::Path::new("/"), + std::env::var("BF_ENABLE_OS_OTA").ok().as_deref(), + ) +} + use crate::os_journal::{self, Journal, Stage}; use std::fs; use std::sync::Mutex; @@ -169,6 +179,9 @@ pub fn check(server: &str, key: &str) -> Option { } fn check_at(server: &str, key: Option<&str>, path: &str) -> Option { + if !enabled() { + return None; + } let compat = compatibility(); let cur = current_os_version(); let url = format!( @@ -231,6 +244,9 @@ fn apply_tracked( on_progress: impl Fn(&str, u8), force: bool, ) -> Result<(), String> { + if !enabled() { + return Err("OS updates require a full BetterFrame OS installation".into()); + } ensure_upgrade(info, ¤t_os_version())?; let id = boot_id(); if id.is_empty() { @@ -455,6 +471,10 @@ fn apply_inner( let _ = fs::remove_file(&bundle_path); return Err("os update canceled after channel change".to_string()); } + // Recheck after the potentially long download before touching the host OS. + if !enabled() { + return Err("OS updates are disabled or this is not a full BetterFrame OS installation".into()); + } // 4. Ensure rauc daemon is running. `rauc install` talks to the D-Bus // daemon; if it's not active the CLI exits with code 2. if let Ok(status) = Command::new("systemctl") diff --git a/client/src/platform/linux/server.rs b/client/src/platform/linux/server.rs index d5b5a2d..04c9c50 100644 --- a/client/src/platform/linux/server.rs +++ b/client/src/platform/linux/server.rs @@ -1001,7 +1001,7 @@ pub fn heartbeat( "axiom": crate::axiom::status(), "updates": { "app_enabled": ota_enabled("BF_ENABLE_APP_OTA"), - "os_enabled": ota_enabled("BF_ENABLE_OS_OTA"), + "os_enabled": crate::os_update::enabled(), }, }, "onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(), diff --git a/client/src/platform/linux/ui.rs b/client/src/platform/linux/ui.rs index f4599f9..d12b608 100644 --- a/client/src/platform/linux/ui.rs +++ b/client/src/platform/linux/ui.rs @@ -300,7 +300,7 @@ fn activate(app: &Application) { } std::thread::sleep(Duration::from_secs(2)); } - if server::ota_enabled("BF_ENABLE_OS_OTA") && os_update::boot_is_confirmed() { + if os_update::enabled() && os_update::boot_is_confirmed() { let _ = tx.send(WorkerMsg::StartupStatus("Checking for OS updates".into())); if let Some(update) = os_update::check_public(&server) { let version = update.version.clone(); @@ -985,8 +985,8 @@ fn maybe_apply_os_update( tx: &mpsc::Sender, force: bool, ) { - if !server::ota_enabled("BF_ENABLE_OS_OTA") { - info!("os-update: disabled (BF_ENABLE_OS_OTA = 0)"); + if !os_update::enabled() { + info!("os-update: disabled or not a full BetterFrame OS installation"); return; } if !os_update::boot_is_confirmed() { diff --git a/docs/linux-install.md b/docs/linux-install.md index 6d1164f..fc4ac28 100644 --- a/docs/linux-install.md +++ b/docs/linux-install.md @@ -167,3 +167,9 @@ offline discovery screen can confirm the running candidate without enrollment or server connectivity. The initial logo and initialization progress alone do not confirm startup. A late frame/heartbeat from the old app cannot confirm the new candidate. Saved alpha releases use the dev channel. + +OS updates require the full BetterFrame OS image, including its OS version, +compatibility identity, and RAUC configuration. Setting `BF_ENABLE_OS_OTA=1` +on an app-only installation cannot enable host OS updates. App updates restart +only BetterFrame. Re-run `setup.sh` to repair inherited systemd reboot actions +from older standalone installations. diff --git a/scripts/test-linux-setup.py b/scripts/test-linux-setup.py index 88a2cbb..41b1728 100644 --- a/scripts/test-linux-setup.py +++ b/scripts/test-linux-setup.py @@ -280,6 +280,9 @@ def test_service_restart_contract(self): self.assertIn('BF_ENABLE_OS_OTA=0', unit) self.assertIn('/opt/betterframe/kiosk/betterframe-kiosk', unit) self.assertNotIn('reboot', unit) + repair = self.shell('write_repair_override').stdout + for action in ['FailureAction', 'SuccessAction', 'StartLimitAction']: + self.assertIn(f'{action}=none', repair) updater = (ROOT / 'client/src/platform/linux/firmware.rs').read_text() self.assertNotIn('.arg("reboot")', updater) self.assertNotIn('Command::new', updater) diff --git a/setup.sh b/setup.sh index 9f68a0f..645d6c3 100755 --- a/setup.sh +++ b/setup.sh @@ -661,6 +661,7 @@ write_repair_override() { # Managed by setup.sh; custom server settings belong in override.conf. [Unit] FailureAction=none +SuccessAction=none StartLimitAction=none StartLimitIntervalSec=0 From b1b2e7a0165d171b3725f35ddcdd3f55a1805996 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:45:48 +0000 Subject: [PATCH 2/2] test: validate full OS eligibility gate during pairing --- server/tests/offline-kiosk.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/tests/offline-kiosk.test.ts b/server/tests/offline-kiosk.test.ts index 6ae91e8..f545e7b 100644 --- a/server/tests/offline-kiosk.test.ts +++ b/server/tests/offline-kiosk.test.ts @@ -30,9 +30,13 @@ test("unpaired kiosks render pairing and confirm boot before checking signed OS const api = readFileSync(new URL("../src/plugins/service-api-http/index.ts", import.meta.url), "utf8"); const proxy = readFileSync(new URL("../../deploy/angie/betterframe.docker.conf", import.meta.url), "utf8"); const update = kiosk.indexOf("os_update::check_public(&server)"); - assert.ok(kiosk.indexOf("WorkerMsg::ShowPairingCode(session.code.clone())") < update); - assert.ok(kiosk.indexOf('server::ota_enabled("BF_ENABLE_OS_OTA") && os_update::boot_is_confirmed()') < update); - assert.ok(update < kiosk.indexOf("server::poll_claim_until_expiry")); + const pairing = kiosk.indexOf("WorkerMsg::ShowPairingCode(session.code.clone())"); + const gate = kiosk.indexOf("os_update::enabled() && os_update::boot_is_confirmed()"); + const polling = kiosk.indexOf("server::poll_claim_until_expiry"); + assert.ok(pairing >= 0, "pairing screen must be shown"); + assert.ok(gate > pairing, "OS eligibility and boot confirmation must be checked after pairing is shown"); + assert.ok(update > gate, "public OS update check must follow the eligibility gate"); + assert.ok(polling > update, "claim polling must follow the public OS update check"); const pairingScreen = kiosk.slice(kiosk.indexOf("fn show_pairing_code("), kiosk.indexOf("fn show_pairing_progress(")); assert.match(pairingScreen, /mark_kiosk_healthy\(\)/); assert.match(api, /\/api\/os\/public\/check/);