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;