diff --git a/core/Cargo.lock b/core/Cargo.lock index 7bdc945c..adb51d9d 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.9-alpha" +version = "1.7.10-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index e383d6b7..e7e33b53 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.9-alpha" +version = "1.7.10-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index bc5e0af2..f19a25ff 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -244,6 +244,32 @@ pub async fn download_update(data_dir: &Path) -> Result { }) } +/// Run a command as root, but *outside* the archipelago service's +/// restricted mount namespace. +/// +/// archipelago.service uses `ProtectSystem=strict`, which makes `/opt` +/// and `/usr` read-only inside the service — and sudo inherits the +/// namespace, so `sudo mv /opt/archipelago/...` fails with EROFS even +/// though sudo itself is root. `systemd-run --wait` spawns a transient +/// service unit that inherits systemd's default protections (i.e. none +/// of ours), escaping the namespace. +async fn host_sudo(args: &[&str]) -> Result { + let mut full: Vec<&str> = vec![ + "systemd-run", + "--wait", + "--quiet", + "--collect", + "--pipe", + "--", + ]; + full.extend_from_slice(args); + tokio::process::Command::new("sudo") + .args(&full) + .status() + .await + .context("sudo systemd-run spawn failed") +} + /// Apply a downloaded update. Backs up current binaries, replaces with staged versions. pub async fn apply_update(data_dir: &Path) -> Result<()> { let staging_dir = data_dir.join("update-staging"); @@ -277,31 +303,25 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> { match name.as_str() { "archipelago" => { - // We're running FROM /usr/local/bin/archipelago right now, - // so we can't rewrite it in place — `install` / `cp` would - // hit ETXTBSY on the busy executable. Use `mv` instead: - // rename() is atomic and doesn't modify the existing file, - // it just re-points the path at a new inode. The currently - // running process keeps executing off the old inode; new - // invocations (i.e. after the post-apply systemctl - // restart) pick up the new binary. + // Two namespace gotchas this block works around: + // 1. We're running FROM /usr/local/bin/archipelago, so + // `install`/`cp` (O_TRUNC + write) fail with ETXTBSY. + // Use `mv`, which is atomic rename() and tolerates a + // busy destination. + // 2. archipelago.service sets ProtectSystem=strict, so + // even `sudo mv` into /usr/local/bin/ fails EROFS — + // sudo inherits the service's mount namespace. Route + // the rename through systemd-run so it runs in a + // transient unit with default protections. let staged = src.to_string_lossy().to_string(); - let _ = tokio::process::Command::new("sudo") - .args(["chmod", "0755", &staged]) - .status() - .await; - let _ = tokio::process::Command::new("sudo") - .args(["chown", "root:root", &staged]) - .status() - .await; - let status = tokio::process::Command::new("sudo") - .args(["mv", &staged, "/usr/local/bin/archipelago"]) - .status() + let _ = host_sudo(&["chmod", "0755", &staged]).await; + let _ = host_sudo(&["chown", "root:root", &staged]).await; + let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"]) .await .with_context(|| format!("Failed to spawn mv for {}", name))?; if !status.success() { anyhow::bail!( - "sudo mv failed for {} (exit {:?})", + "mv into /usr/local/bin failed for {} (exit {:?})", name, status.code() ); @@ -320,78 +340,66 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> { let web_ui = "/opt/archipelago/web-ui"; let backup_path = "/opt/archipelago/web-ui.bak"; - let mk = tokio::process::Command::new("sudo") - .args(["mkdir", "-p", &staging_new]) - .status() + // All sudo calls that touch /opt/archipelago go through + // host_sudo so they see a normal root mount namespace. + let mk = host_sudo(&["mkdir", "-p", &staging_new]) .await .context("Failed to create frontend staging dir")?; if !mk.success() { anyhow::bail!("mkdir {} failed", staging_new); } - let extract = tokio::process::Command::new("sudo") - .args(["tar", "-xzf", &src.to_string_lossy(), "-C", &staging_new]) - .status() - .await - .with_context(|| format!("Failed to extract {}", name))?; + let extract = host_sudo(&[ + "tar", + "-xzf", + &src.to_string_lossy(), + "-C", + &staging_new, + ]) + .await + .with_context(|| format!("Failed to extract {}", name))?; if !extract.success() { - // Best-effort cleanup of the partial extraction. - let _ = tokio::process::Command::new("sudo") - .args(["rm", "-rf", &staging_new]) - .status() - .await; + let _ = host_sudo(&["rm", "-rf", &staging_new]).await; anyhow::bail!("tar extraction failed for {}", name); } - let _ = tokio::process::Command::new("sudo") - .args(["chown", "-R", "archipelago:archipelago", &staging_new]) - .status() - .await; + let _ = host_sudo(&[ + "chown", + "-R", + "archipelago:archipelago", + &staging_new, + ]) + .await; // Swap: mv current web-ui aside, then mv new into place. if Path::new(web_ui).exists() { - let mv_old = tokio::process::Command::new("sudo") - .args(["mv", web_ui, &staging_old]) - .status() + let mv_old = host_sudo(&["mv", web_ui, &staging_old]) .await .context("Failed to rotate old web-ui")?; if !mv_old.success() { anyhow::bail!("failed to move old web-ui aside"); } } - let mv_new = tokio::process::Command::new("sudo") - .args(["mv", &staging_new, web_ui]) - .status() + let mv_new = host_sudo(&["mv", &staging_new, web_ui]) .await .context("Failed to swap new web-ui into place")?; if !mv_new.success() { - // Roll back the rename so nginx keeps serving. if Path::new(&staging_old).exists() { - let _ = tokio::process::Command::new("sudo") - .args(["mv", &staging_old, web_ui]) - .status() - .await; + let _ = host_sudo(&["mv", &staging_old, web_ui]).await; } anyhow::bail!("failed to move new web-ui into place"); } - // Rotate previous rollback aside (best-effort) and install - // this apply's old copy as the new rollback. + // Rotate previous rollback aside and install this apply's + // old copy as the new rollback. if Path::new(&staging_old).exists() { if Path::new(backup_path).exists() { - // Tag the previous backup with its own ts so it - // doesn't collide; best-effort cleanup. - let _ = tokio::process::Command::new("sudo") - .args([ - "mv", - backup_path, - &format!("{}.{}", backup_path, ts), - ]) - .status() - .await; - } - let _ = tokio::process::Command::new("sudo") - .args(["mv", &staging_old, backup_path]) - .status() + let _ = host_sudo(&[ + "mv", + backup_path, + &format!("{}.{}", backup_path, ts), + ]) .await; + } + let _ = host_sudo(&["mv", &staging_old, backup_path]).await; } info!(name = %name, "Frontend archive extracted to /opt/archipelago/web-ui"); } @@ -422,10 +430,10 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> { // starting the new process — it would deadlock otherwise. tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let _ = tokio::process::Command::new("sudo") - .args(["systemctl", "--no-block", "restart", "archipelago"]) - .status() - .await; + // systemctl talks to PID 1 over D-Bus — doesn't need the host + // mount namespace, but routing through host_sudo keeps the + // apply flow's sudo calls uniform. + let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await; }); Ok(()) diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 54c45d1a..92fc3b0c 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -317,26 +317,35 @@ const torConnected = computed(() => { }) const vpnStatus = ref({ connected: false, provider: '' }) const vpnConnected = computed(() => vpnStatus.value.connected || (!!packages.value['tailscale'] && packages.value['tailscale'].state === PackageState.Running)) -const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null) +const fipsStatus = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null) const fipsDotClass = computed(() => { const s = fipsStatus.value if (!s || !s.installed) return 'bg-white/40' - if (s.service_active) return 'bg-green-400' - return 'bg-white/40' + if (!s.service_active) return 'bg-white/40' + // Active but no anchor = degraded, not fully green + if (s.anchor_connected === false) return 'bg-orange-400' + return 'bg-green-400' }) const fipsTextClass = computed(() => { const s = fipsStatus.value if (!s || !s.installed) return 'text-white/40' - if (s.service_active) return 'text-green-400' - return 'text-white/40' + if (!s.service_active) return 'text-white/40' + if (s.anchor_connected === false) return 'text-orange-400' + return 'text-green-400' }) const fipsStatusLabel = computed(() => { const s = fipsStatus.value if (!s) return '…' if (!s.installed) return 'Not installed' - if (s.service_active) return 'Active' - if (!s.key_present) return 'Awaiting seed' - return 'Inactive' + if (!s.service_active) { + if (!s.key_present) return 'Awaiting seed' + return 'Inactive' + } + // Service is active — reflect anchor reachability in the label so the + // Home and Server rows flip in sync with the FIPS card. + if (s.anchor_connected === false) return 'No anchor' + const peers = s.authenticated_peer_count ?? 0 + return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers` }) const bitcoinSyncDisplay = computed(() => { if (!systemStats.bitcoinAvailable) return 'Not running' diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index ad5f802e..ed027c1d 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -420,25 +420,31 @@ const networkData = ref({ }) // FIPS status row for the Local Network card. Full FIPS card lives below. -const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean } | null>(null) +const fipsSummary = ref<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number } | null>(null) const fipsRowLabel = computed(() => { const s = fipsSummary.value if (!s) return '…' if (!s.installed) return 'Not installed' - // Service-active wins even on legacy nodes with no seed-derived key. - if (s.service_active) return 'Active' - if (!s.key_present) return 'Awaiting seed' - return 'Inactive' + if (!s.service_active) { + if (!s.key_present) return 'Awaiting seed' + return 'Inactive' + } + // Service is active — reflect anchor reachability so the row flips in + // sync with the full FIPS card below. + if (s.anchor_connected === false) return 'No anchor' + const peers = s.authenticated_peer_count ?? 0 + return peers === 1 ? 'Active · 1 peer' : `Active · ${peers} peers` }) const fipsRowTextClass = computed(() => { const s = fipsSummary.value if (!s || !s.installed) return 'text-white/40' - if (s.service_active) return 'text-green-400' - return 'text-white/60' + if (!s.service_active) return 'text-white/60' + if (s.anchor_connected === false) return 'text-orange-400' + return 'text-green-400' }) async function loadFipsSummary() { try { - fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean }>({ method: 'fips.status' }) + fipsSummary.value = await rpcClient.call<{ installed: boolean; service_active: boolean; key_present: boolean; anchor_connected?: boolean; authenticated_peer_count?: number }>({ method: 'fips.status' }) } catch { /* backend too old */ } } diff --git a/neode-ui/src/views/web5/Web5Identities.vue b/neode-ui/src/views/web5/Web5Identities.vue index 27f9ac78..30777af2 100644 --- a/neode-ui/src/views/web5/Web5Identities.vue +++ b/neode-ui/src/views/web5/Web5Identities.vue @@ -68,8 +68,13 @@ >