From 81033ed6f534716110c0987bf9d5e0f1148e0efc Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 13:50:16 -0400 Subject: [PATCH] fix(ota): repair a stale Restart=on-failure unit that leaves nodes dead after update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit austin-sapien (100.70.96.88) sat dead for over two hours after taking v1.7.122 — 'server starting' in the UI, service inactive, exit status 0/SUCCESS. It did not crash: the in-process updater replaces the binary and exits cleanly for systemd to restart it, and that node's unit still carried Restart=on-failure from an older install. systemd read the clean exit as success and left it stopped. Every node with the old unit has this waiting for it on the next update. self-update.sh does refresh units, but the in-process update path never runs it, so nothing was repairing them. The daemon now checks its own unit at boot and rewrites only the Restart= line, so a node that starts even once ends up with a policy that survives the next update. Also carries the session-policy wiring: validate() now honours the configured idle and absolute limits and the per-device class, instead of the single hard-coded 24h constant. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/bootstrap.rs | 51 +++++++++++++++++++++++++++++++ core/archipelago/src/main.rs | 5 +++ core/archipelago/src/session.rs | 43 +++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 44d21116..5a4c7b59 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -1271,3 +1271,54 @@ mod tests { assert_ne!(outcome, PodmanHealOutcome::Healthy); } } + +/// Repair this node's own systemd restart policy. +/// +/// The in-process updater replaces the binary and then asks systemd to +/// restart the service, treating `Restart=always` on the unit as its second +/// net if that request is ever lost. On austin-sapien (2026-08-05) the unit +/// was an old one carrying `Restart=on-failure`: the daemon exited cleanly +/// (status 0), systemd read that as success, and the node sat dead for over +/// two hours after a routine update — "server starting" in the UI, with +/// nothing to start it. +/// +/// A node cannot be relied on to fix this via `self-update.sh` (which does +/// refresh units) because the in-process update path never runs it. So the +/// daemon checks its own unit at boot: any node that starts even once ends +/// up with a policy that survives the next update. Deliberately narrow — +/// only the `Restart=` line is touched, so local edits elsewhere in the unit +/// are preserved. +pub async fn ensure_restart_policy() { + const UNIT: &str = "/etc/systemd/system/archipelago.service"; + let Ok(body) = fs::read_to_string(UNIT).await else { + return; // not a systemd install (container, dev box) — nothing to do + }; + if !body.lines().any(|l| { + let l = l.trim(); + l.starts_with("Restart=") && l != "Restart=always" + }) { + return; // already correct, or no Restart= line to repair + } + let patched: String = body + .lines() + .map(|l| { + if l.trim().starts_with("Restart=") && l.trim() != "Restart=always" { + "Restart=always" + } else { + l + } + }) + .collect::>() + .join("\n"); + match write_root_if_needed(UNIT, &patched).await { + Ok(true) => { + tracing::warn!( + "repaired archipelago.service Restart= policy to always — this node would \ + have stayed dead after an in-process update" + ); + let _ = host_sudo(&["systemctl", "daemon-reload"]).await; + } + Ok(false) => {} + Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"), + } +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 553b53c2..04f862ef 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -409,6 +409,11 @@ async fn main() -> Result<()> { // flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed. tokio::spawn(bootstrap::ensure_kiosk_hardened()); + // Repair our own restart policy before anything else can need it: a node + // whose unit still says Restart=on-failure stays dead after the next + // in-process update, because the daemon exits cleanly to be restarted. + tokio::spawn(bootstrap::ensure_restart_policy()); + // HDMI audio: install the PipeWire stack + audio-router daemon on kiosk // nodes (older ISOs shipped no audio stack; the router also heals the // boot-time ELD race that leaves HDMI silently unavailable). diff --git a/core/archipelago/src/session.rs b/core/archipelago/src/session.rs index c150eac8..13b44e4f 100644 --- a/core/archipelago/src/session.rs +++ b/core/archipelago/src/session.rs @@ -40,12 +40,19 @@ struct Session { created_at: SystemTime, last_activity: SystemTime, session_type: SessionType, + /// What kind of screen this login came from. A TV on the wall must not + /// be signed out for sitting still — nobody is there to type a password + /// back in — while a browser must be. + device_class: crate::settings::session_policy::DeviceClass, } #[derive(Clone)] pub struct SessionStore { sessions: Arc>>, persist_path: PathBuf, + /// Where the session policy lives. Held rather than looked up globally + /// so tests can point at a temp dir. + data_dir: PathBuf, } /// On-disk representation of a persisted session (only Full sessions, no TOTP secrets). @@ -67,6 +74,7 @@ impl SessionStore { Self { sessions: Arc::new(RwLock::new(sessions)), persist_path, + data_dir: PathBuf::from("/var/lib/archipelago"), } } @@ -75,9 +83,17 @@ impl SessionStore { /// machine's real /var/lib/archipelago/sessions.json. #[cfg(test)] pub fn new_for_tests(persist_path: PathBuf) -> Self { + // data_dir shares the temp path's parent so a test that writes a + // policy file is honoured, and one that doesn't gets the defaults + // rather than the dev machine's real configuration. + let data_dir = persist_path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); Self { sessions: Arc::new(RwLock::new(HashMap::new())), persist_path, + data_dir, } } @@ -120,6 +136,7 @@ impl SessionStore { created_at, last_activity, session_type: SessionType::Full, + device_class: crate::settings::session_policy::DeviceClass::Browser, }, ); } @@ -160,6 +177,7 @@ impl SessionStore { created_at: now, last_activity: now, session_type: SessionType::Full, + device_class: crate::settings::session_policy::DeviceClass::Browser, }; let mut sessions = self.sessions.write().await; @@ -184,6 +202,10 @@ impl SessionStore { totp_secret, attempts: 0, }, + // A half-finished login is always treated as a browser: it lives + // for PENDING_SESSION_TTL either way, and a kiosk exemption on a + // session that has not passed 2FA yet would be the wrong default. + device_class: crate::settings::session_policy::DeviceClass::Browser, }; self.sessions.write().await.insert(hash, session); token @@ -192,19 +214,23 @@ impl SessionStore { /// Validate a full session token. Returns true if the session exists and hasn't expired. /// Updates last_activity on successful validation (inactivity-based expiry). pub async fn validate(&self, token: &str) -> bool { + let policy = self.policy().await; let hash = hash_token(token); let mut sessions = self.sessions.write().await; if let Some(session) = sessions.get_mut(&hash) { if !matches!(session.session_type, SessionType::Full) { return false; } - if session + let idle = session .last_activity .elapsed() .unwrap_or_default() - .as_secs() - >= FULL_SESSION_TTL - { + .as_secs(); + let age = session.created_at.elapsed().unwrap_or_default().as_secs(); + // Both limits, not just idleness: the dashboard polls, so an + // idle timeout alone would never fire on an open tab. The + // absolute cap is what actually guarantees a login ends. + if policy.is_expired(session.device_class, age, idle) { sessions.remove(&hash); return false; } @@ -215,6 +241,13 @@ impl SessionStore { } } + /// The operator's session policy, re-read from disk rather than cached + /// for the process lifetime so a change in Settings takes effect on the + /// next request instead of the next restart. + pub async fn policy(&self) -> crate::settings::session_policy::SessionPolicy { + crate::settings::session_policy::load(&self.data_dir).await + } + /// Get the TOTP secret from a pending session. Returns None if not a valid pending session. /// Increments the attempt counter. pub async fn get_pending_secret(&self, token: &str) -> Option> { @@ -259,6 +292,7 @@ impl SessionStore { created_at: now, last_activity: now, session_type: SessionType::Full, + device_class: crate::settings::session_policy::DeviceClass::Browser, }, ); Self::save_to_disk(&sessions, &self.persist_path).await; @@ -300,6 +334,7 @@ impl SessionStore { created_at: now, last_activity: now, session_type: SessionType::Full, + device_class: crate::settings::session_policy::DeviceClass::Browser, }, ); Self::save_to_disk(&sessions, &self.persist_path).await;