From cb301b1aefed0985a92d9c98d61954270f2b7ba2 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 20 Jul 2026 16:39:02 -0400 Subject: [PATCH] fix(wifi,fips): self-heal polkit rule + fast-retry anchors after daemon restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fleet fixes that OTA can deliver (unlike the ISO-only polkit rule): - WiFi (issue #99): nodes drive NetworkManager from a system-level service with no logind seat, so the stock polkit rule denies them and Wi-Fi setup fails with 'Insufficient privileges'. Fresh ISOs since 2026-05 ship the scoped rule, but OTA'd nodes never got it (OTA replaces binary + web UI, not host system config). Add a startup self-heal that installs the rule if missing and best-effort ensures polkitd — both wrapped so an offline/locked apt can never fail startup; the rule is written regardless so it activates once polkitd lands. Idempotent on the rule's unique subject.user marker. - FIPS: regenerating fips.yaml (this build does, once, on first boot after the OTA) restarts the fips daemon; for a few seconds /run/fips/control.sock is gone and every seed-anchor dial fails, islanding the node until the next 5-min tick. Detect that exact failure (all dials fail on control.sock) and retry in 15s instead — bounded to 8 fast retries/episode so a node with no fips daemon falls back to the steady cadence rather than busy-looping. FIPS unit suite: 33/33 pass. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/bootstrap.rs | 69 +++++++++++++++++++++++++++++++ core/archipelago/src/server.rs | 30 ++++++++++++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 87183f50..9c05e7c4 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -137,6 +137,13 @@ pub async fn ensure_doctor_installed() { Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"), Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e), } + match run_polkit_networkmanager_repair().await { + Ok(true) => info!( + "Installed NetworkManager polkit rule for the archipelago user — Wi-Fi setup enabled" + ), + Ok(false) => debug!("NetworkManager polkit rule already present"), + Err(e) => warn!("polkit NetworkManager repair failed (non-fatal): {:#}", e), + } match run_journald_dropin().await { Ok(true) => info!("Installed journald log-volume policy drop-in"), Ok(false) => debug!("journald log-volume policy already in place"), @@ -442,6 +449,68 @@ exit 2 } } +/// Self-heal Wi-Fi setup on nodes that predate the polkit fix (issue #99). +/// +/// Archipelago drives NetworkManager from a system-level systemd service +/// (`User=archipelago`, no logind seat), so the stock NM polkit rule — which +/// only authorizes `subject.local && subject.active` sessions — denies it, and +/// "connect to Wi-Fi" fails with "Insufficient privileges". Fresh ISO installs +/// since 2026-05 ship the rule below, but nodes that reached this build over +/// OTA never got it (OTA replaces the binary + web UI, not host system config). +/// +/// Install the scoped rule if it is missing, and best-effort ensure `polkitd` +/// itself is present (without the daemon the rule is inert). Both are wrapped +/// so an offline/locked apt or a missing package can never fail startup — the +/// rule is still written so it takes effect once polkitd arrives (e.g. after an +/// ISO reflash). Idempotent: keyed on the rule's unique `subject.user` marker. +async fn run_polkit_networkmanager_repair() -> Result { + let script = r#" +set -u +RULE=/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules +MARKER='subject.user == "archipelago"' +# Rule already installed — nothing to do. +if grep -qF "$MARKER" "$RULE" 2>/dev/null; then + exit 0 +fi +# The rule is inert without the polkit daemon. Older nodes (the ones that hit +# issue #99) shipped without it. Try to install it, but never let apt failure +# (offline node, locked dpkg, package unavailable) abort the heal — the rule is +# written regardless so it activates whenever polkitd lands. +if [ ! -d /usr/share/polkit-1 ] && ! command -v pkaction >/dev/null 2>&1; then + timeout 240 apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1 \ + || timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1' \ + || true +fi +mkdir -p /etc/polkit-1/rules.d +cat > "$RULE" <<'RULEEOF' +polkit.addRule(function(action, subject) { + if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) { + return polkit.Result.YES; + } +}); +RULEEOF +chmod 644 "$RULE" +# Pick up the new rule. polkitd re-reads rules.d on reload; restart as a +# fallback. Non-fatal if the unit name differs or the daemon is absent. +systemctl reload polkit 2>/dev/null \ + || systemctl restart polkit 2>/dev/null \ + || systemctl restart polkit.service 2>/dev/null \ + || true +exit 2 +"#; + let status = host_sudo(&["sh", "-lc", script]) + .await + .context("install NetworkManager polkit rule")?; + match status.code() { + Some(0) => Ok(false), + Some(2) => Ok(true), + _ => { + warn!("polkit NetworkManager repair helper exited with {}", status); + Ok(false) + } + } +} + async fn run_bitcoin_rpc_repair() -> Result { // Older installs can have a container-owned bitcoin.conf with only rpcauth // and printtoconsole. Repair it at startup so OTA fixes existing nodes diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 9c5dd1d0..0a956eb0 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -688,12 +688,27 @@ impl Server { let fips_peer_registry = fips_peer_registry.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(30)).await; - let mut interval = tokio::time::interval(Duration::from_secs(300)); + // Steady cadence, but retry fast right after a daemon restart: + // regenerating fips.yaml (this build does, once, on first boot + // after the OTA) restarts the fips daemon, and for a few seconds + // `/run/fips/control.sock` is gone so every `fipsctl connect` + // fails and the node islands until the next tick. Detect that + // exact failure and retry in 15s instead of 5 min — bounded, so a + // node with no fips daemon falls back to the steady cadence + // rather than busy-looping. + const STEADY: Duration = Duration::from_secs(300); + const FAST: Duration = Duration::from_secs(15); + const MAX_FAST_RETRIES: u32 = 8; // ≤2 min of fast retries/episode + let mut fast_retries: u32 = 0; loop { - interval.tick().await; + let mut daemon_restarting = false; match crate::fips::anchors::load(&data_dir).await { Ok(list) if !list.is_empty() => { - let _ = crate::fips::anchors::apply(&list).await; + let results = crate::fips::anchors::apply(&list).await; + daemon_restarting = !results.is_empty() + && results + .iter() + .all(|r| !r.ok && r.message.contains("control.sock")); } Ok(_) => { /* no seed anchors configured yet */ } Err(e) => { @@ -717,6 +732,15 @@ impl Server { let _ = crate::fips::anchors::apply(&direct).await; } } + + let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES { + fast_retries += 1; + FAST + } else { + fast_retries = 0; + STEADY + }; + tokio::time::sleep(next).await; } }); }