fix(ota): repair a stale Restart=on-failure unit that leaves nodes dead after update

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 13:50:16 -04:00
co-authored by Claude Fable 5
parent d8647f6576
commit 81033ed6f5
3 changed files with 95 additions and 4 deletions
+51
View File
@@ -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::<Vec<_>>()
.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"),
}
}
+5
View File
@@ -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).
+39 -4
View File
@@ -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<RwLock<HashMap<[u8; 32], Session>>>,
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<Vec<u8>> {
@@ -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;