diff --git a/core/Cargo.lock b/core/Cargo.lock index e677dca1..1e79f2fd 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.21-alpha" +version = "1.7.22-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 649f6339..05c8cf1b 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.21-alpha" +version = "1.7.22-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs index dae114cd..0abb8161 100644 --- a/core/archipelago/src/api/rpc/fips.rs +++ b/core/archipelago/src/api/rpc/fips.rs @@ -12,8 +12,7 @@ use anyhow::Result; impl RpcHandler { pub(super) async fn handle_fips_status(&self) -> Result { - let identity_dir = fips::identity_dir_from(&self.config.data_dir); - let status = fips::FipsStatus::query(&identity_dir).await; + let status = fips::FipsStatus::query(&self.config.data_dir).await; Ok(serde_json::to_value(status)?) } @@ -36,13 +35,19 @@ impl RpcHandler { let identity_dir = fips::identity_dir_from(&self.config.data_dir); fips::config::install(&identity_dir).await?; fips::service::activate(fips::SERVICE_UNIT).await?; - let status = fips::FipsStatus::query(&identity_dir).await; + let status = fips::FipsStatus::query(&self.config.data_dir).await; Ok(serde_json::to_value(status)?) } + /// Restart whichever fips unit is supervising the daemon on this host. + /// Nodes installed from the archipelago ISO use `archipelago-fips.service`; + /// nodes that had the upstream debian package set up first may only have + /// `fips.service`. We resolve the active one via `service::active_unit()` + /// so the UI button is never a no-op. pub(super) async fn handle_fips_restart(&self) -> Result { - fips::service::restart(fips::SERVICE_UNIT).await?; - Ok(serde_json::json!({ "restarted": true })) + let unit = fips::service::active_unit().await; + fips::service::restart(unit).await?; + Ok(serde_json::json!({ "restarted": true, "unit": unit })) } /// Full reconnect: stop the daemon, bring it back, wait for the DHT @@ -53,7 +58,7 @@ impl RpcHandler { /// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client. pub(super) async fn handle_fips_reconnect(&self) -> Result { let identity_dir = fips::identity_dir_from(&self.config.data_dir); - let before = fips::FipsStatus::query(&identity_dir).await; + let before = fips::FipsStatus::query(&self.config.data_dir).await; // Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes // mismatch. The daemon silently authenticates with a garbage @@ -70,12 +75,26 @@ impl RpcHandler { let _ = fips::config::install(&identity_dir).await; } - // Clean stop+start rather than `restart`, so a daemon that - // fails to come back up surfaces as service_active=false - // instead of quietly sticking with the old process. - let _ = fips::service::stop(fips::SERVICE_UNIT).await; + // Operate on whichever fips unit is actually up — nodes that + // have the upstream `fips.service` rather than the + // archipelago-managed `archipelago-fips.service` used to see + // Reconnect silently fail because we stopped a unit that + // didn't exist. Clean stop+start rather than `restart` so a + // daemon that fails to come back up surfaces as + // service_active=false instead of quietly sticking with the + // old process. + let unit = fips::service::active_unit().await; + let _ = fips::service::stop(unit).await; tokio::time::sleep(std::time::Duration::from_millis(800)).await; - fips::service::activate(fips::SERVICE_UNIT).await?; + fips::service::activate(unit).await?; + + // Re-push seed anchors after restart so freshly-bound daemons + // don't have to wait 5 min for the periodic apply loop. + if let Ok(list) = fips::anchors::load(&self.config.data_dir).await { + if !list.is_empty() { + let _ = fips::anchors::apply(&list).await; + } + } // Anchor bootstrap window: poll the status every ~3s for up to // 20s. Bail as soon as the anchor is connected. @@ -83,7 +102,7 @@ impl RpcHandler { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); loop { tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let s = fips::FipsStatus::query(&identity_dir).await; + let s = fips::FipsStatus::query(&self.config.data_dir).await; if s.anchor_connected { last_status = Some(s); break; @@ -111,13 +130,13 @@ impl RpcHandler { "peers_but_no_anchor" }; let hint = match likely_cause { - "connected" => "Anchor is reachable.", - "daemon_down" => "The FIPS daemon didn't come back up — check archipelago-fips.service.", + "connected" => "An anchor is reachable.", + "daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.", "no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.", "no_outbound_udp_or_anchor_down" => - "Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or the anchor (fips.v0l.io) could be down.", + "Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.", "peers_but_no_anchor" => - "Mesh has peers but the anchor hasn't been seen yet. Give it a minute and re-check.", + "Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.", _ => "", }; diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index 80e9c8a9..a32c0d18 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -99,7 +99,13 @@ pub struct FipsStatus { impl FipsStatus { /// Snapshot the current state across package, key, and service. - pub async fn query(identity_dir: &Path) -> Self { + /// + /// `data_dir` is the archipelago data-dir (used to load the + /// operator-configured seed-anchor list so "anchor_connected" means + /// "at least one authenticated peer matches a public or configured + /// seed anchor", not just "fips.v0l.io specifically"). + pub async fn query(data_dir: &Path) -> Self { + let identity_dir = identity_dir_from(data_dir); let installed = service::package_installed().await; let version = if installed { service::daemon_version().await.ok() @@ -110,17 +116,24 @@ impl FipsStatus { let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await; let service_active = service_state == "active" || upstream_service_state == "active"; - let key_present = crate::identity::fips_key_exists(identity_dir); + let key_present = crate::identity::fips_key_exists(&identity_dir); // Prefer the seed-derived npub; otherwise read the daemon's own // key file at /etc/fips/fips.pub (world-readable per debian pkg). - let npub = match crate::identity::fips_npub(identity_dir).await { + let npub = match crate::identity::fips_npub(&identity_dir).await { Ok(Some(n)) => Some(n), _ => service::read_upstream_npub().await.ok().flatten(), }; let (authenticated_peer_count, anchor_connected) = if service_active { - service::peer_connectivity_summary().await + // Build the anchor-candidate list: hardcoded public anchor + // plus every entry in the operator's seed-anchors.json. + // The card lights up if any of them is authenticated. + let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()]; + if let Ok(seed) = anchors::load(data_dir).await { + anchor_npubs.extend(seed.into_iter().map(|a| a.npub)); + } + service::peer_connectivity_summary(&anchor_npubs).await } else { (0, false) }; @@ -153,10 +166,11 @@ mod tests { #[tokio::test] async fn test_status_reports_no_key_pre_onboarding() { let dir = tempfile::tempdir().unwrap(); - let id_dir = dir.path().join("identity"); - tokio::fs::create_dir_all(&id_dir).await.unwrap(); - - let status = FipsStatus::query(&id_dir).await; + // query() now takes a data_dir (parent) rather than identity_dir, + // since it also reads seed-anchors.json for the anchor check. + // No identity/ subdir → no key; no seed-anchors.json → public + // anchor is the only candidate. + let status = FipsStatus::query(dir.path()).await; assert!(!status.key_present, "no key before onboarding"); assert!(status.npub.is_none()); // `installed`, `service_state`, `version` depend on the host and are diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index 8a34dd1f..314d7ee2 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -97,6 +97,27 @@ pub async fn restart(unit: &str) -> Result<()> { sudo_systemctl("restart", unit).await } +/// Resolve which systemd unit is actually supervising the fips daemon +/// on this host. Nodes installed from the archipelago ISO run +/// `archipelago-fips.service`; nodes that were apt-installed (or had +/// fips running before archipelago took over) may only have the +/// upstream `fips.service`. Restart/Reconnect must operate on whichever +/// one is running, otherwise the UI button is a silent no-op. +/// +/// Returns the archipelago-managed unit name if it's active, +/// else the upstream unit name if that's active, +/// else the archipelago-managed name as a default (so activate() can +/// bring it up). +pub async fn active_unit() -> &'static str { + if unit_state(super::SERVICE_UNIT).await == "active" { + return super::SERVICE_UNIT; + } + if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" { + return super::UPSTREAM_SERVICE_UNIT; + } + super::SERVICE_UNIT +} + pub async fn mask(unit: &str) -> Result<()> { let _ = sudo_systemctl("stop", unit).await; let _ = sudo_systemctl("disable", unit).await; @@ -108,12 +129,19 @@ pub async fn mask(unit: &str) -> Result<()> { 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) { +/// Summarise peer connectivity from `fipsctl show peers`. Returns +/// `(authenticated_peer_count, anchor_connected)`. +/// +/// `anchor_candidates` is the operator-controlled list of npubs this +/// node considers a valid mesh anchor — always includes the hard-coded +/// public anchor, plus any entries from `seed-anchors.json`. A node is +/// "anchor connected" when at least one currently-authenticated peer +/// matches one of these npubs. We used to check the identity cache +/// (which includes transient hearsay from other peers), but a cache +/// hit on `fips.v0l.io` didn't mean we could actually route through +/// it, and the card lied to users whose mesh was federated through +/// their own seed anchors instead. +pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bool) { let peers_json = match Command::new("sudo") .args(["-n", "fipsctl", "show", "peers"]) .output() @@ -122,39 +150,26 @@ pub async fn peer_connectivity_summary() -> (u32, bool) { 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, + let parsed: serde_json::Value = + match serde_json::from_slice(&peers_json) { + Ok(v) => v, + Err(_) => return (0, false), }; - - // 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, - }; + let peers = parsed + .get("peers") + .and_then(|p| p.as_array()) + .cloned() + .unwrap_or_default(); + let authenticated_peer_count = peers.len() as u32; + let anchor_connected = peers.iter().any(|p| { + let npub = p.get("npub").and_then(|n| n.as_str()).unwrap_or_default(); + let connected = p + .get("connectivity") + .and_then(|c| c.as_str()) + .map(|s| s == "connected") + .unwrap_or(true); + connected && anchor_candidates.iter().any(|a| a == npub) + }); (authenticated_peer_count, anchor_connected) } diff --git a/neode-ui/src/views/server/FipsNetworkCard.vue b/neode-ui/src/views/server/FipsNetworkCard.vue index 07d7a3ec..0badf5d8 100644 --- a/neode-ui/src/views/server/FipsNetworkCard.vue +++ b/neode-ui/src/views/server/FipsNetworkCard.vue @@ -50,7 +50,7 @@
- Anchor (fips.v0l.io): + Anchor: {{ status.anchor_connected ? 'connected' : 'not reached' }} @@ -68,7 +68,7 @@

- Without the anchor, DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until it reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. + No known anchor is currently an authenticated peer. DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until one reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. Add a cluster-local anchor in Seed Anchors if the public one is unreachable.

diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 8961764f..746fdb8a 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -180,6 +180,18 @@ init()
+ +
+
+ v1.7.22-alpha + Apr 21, 2026 +
+
+

The FIPS Reconnect and Restart buttons now work on every node, regardless of which systemd unit is actually supervising the daemon. Previously they targeted only the archipelago-managed unit — nodes that were running the upstream unit instead saw the buttons silently do nothing. Both paths now auto-detect which unit is up and act on that one.

+

The FIPS anchor status no longer shows red just because one specific public anchor is unreachable. It now lights green whenever any authenticated peer is a recognised anchor — that's either the public anchor or something you added under Seed Anchors. A federated cluster that routes through its own seed anchor finally reports the truth.

+

Reconnect also re-pushes your seed anchors after the restart, so you don't have to wait five minutes for the background apply loop to re-dial them.

+
+