Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions client/src/platform/linux/os_installation.rs
Original file line number Diff line number Diff line change
@@ -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();
}
}
24 changes: 22 additions & 2 deletions client/src/platform/linux/os_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -169,6 +179,9 @@ pub fn check(server: &str, key: &str) -> Option<UpdateInfo> {
}

fn check_at(server: &str, key: Option<&str>, path: &str) -> Option<UpdateInfo> {
if !enabled() {
return None;
}
let compat = compatibility();
let cur = current_os_version();
let url = format!(
Expand Down Expand Up @@ -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, &current_os_version())?;
let id = boot_id();
if id.is_empty() {
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion client/src/platform/linux/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions client/src/platform/linux/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Comment thread
bcbetterninja marked this conversation as resolved.
let _ = tx.send(WorkerMsg::StartupStatus("Checking for OS updates".into()));
if let Some(update) = os_update::check_public(&server) {
let version = update.version.clone();
Expand Down Expand Up @@ -985,8 +985,8 @@ fn maybe_apply_os_update(
tx: &mpsc::Sender<WorkerMsg>,
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() {
Expand Down
6 changes: 6 additions & 0 deletions docs/linux-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions scripts/test-linux-setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions server/tests/offline-kiosk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
1 change: 1 addition & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading