diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index d5c5a616..3336d1f4 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -82,6 +82,18 @@ pub struct FipsStatus { /// present; falls back to the upstream daemon's own key on legacy /// nodes where `/etc/fips/fips.pub` is readable. pub npub: Option, + /// Number of currently authenticated FIPS peers, per + /// `fipsctl show peers`. 0 → isolated / anchor unreachable; + /// >0 → DHT routing is viable. + #[serde(default)] + pub authenticated_peer_count: u32, + /// True when at least one peer in the identity cache is a known + /// public anchor (currently `fips.v0l.io`). Anchors bootstrap DHT + /// routing for general-case deployments, so a red anchor status is + /// the top UX indicator of "FIPS traffic will probably degrade to + /// Tor until the anchor is reachable." + #[serde(default)] + pub anchor_connected: bool, } impl FipsStatus { @@ -106,6 +118,12 @@ impl FipsStatus { _ => service::read_upstream_npub().await.ok().flatten(), }; + let (authenticated_peer_count, anchor_connected) = if service_active { + service::peer_connectivity_summary().await + } else { + (0, false) + }; + Self { installed, version, @@ -114,6 +132,8 @@ impl FipsStatus { service_active, key_present, npub, + authenticated_peer_count, + anchor_connected, } } } diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index 94845ed2..8a34dd1f 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -103,6 +103,61 @@ pub async fn mask(unit: &str) -> Result<()> { sudo_systemctl("mask", unit).await } +/// Known public anchor npub (fips.v0l.io as of 2026-04). Used to decide +/// whether the `anchor_connected` badge in the dashboard lights up. +pub const PUBLIC_ANCHOR_NPUB: &str = + "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"; + +/// Summarise peer connectivity from `fipsctl show peers` + `identity-cache`. +/// Returns `(authenticated_peer_count, anchor_connected)`. Shells out rather +/// than embedding a fips client because fipsctl is the daemon's own ground +/// truth — the daemon can always rewrite its internal routing and we'd +/// rather be consistent with `fipsctl` than snapshot it ourselves. +pub async fn peer_connectivity_summary() -> (u32, bool) { + let peers_json = match Command::new("sudo") + .args(["-n", "fipsctl", "show", "peers"]) + .output() + .await + { + Ok(o) if o.status.success() => o.stdout, + _ => return (0, false), + }; + let authenticated_peer_count = + match serde_json::from_slice::(&peers_json) { + Ok(v) => v + .get("peers") + .and_then(|p| p.as_array()) + .map(|a| a.len() as u32) + .unwrap_or(0), + Err(_) => 0, + }; + + // Anchor check: look in identity-cache (known node pubkeys the daemon + // has heard about) rather than authenticated peers — the anchor may be + // in the cache but not currently at session depth. + let cache_json = match Command::new("sudo") + .args(["-n", "fipsctl", "show", "identity-cache"]) + .output() + .await + { + Ok(o) if o.status.success() => o.stdout, + _ => return (authenticated_peer_count, false), + }; + let anchor_connected = match serde_json::from_slice::(&cache_json) { + Ok(v) => v + .get("entries") + .and_then(|e| e.as_array()) + .map(|entries| { + entries + .iter() + .any(|e| e.get("npub").and_then(|n| n.as_str()) == Some(PUBLIC_ANCHOR_NPUB)) + }) + .unwrap_or(false), + Err(_) => false, + }; + (authenticated_peer_count, anchor_connected) +} + /// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return /// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used /// as a fallback on legacy/dev nodes where no seed-derived key exists.