release(v1.7.22-alpha): honest anchor status + Reconnect works on all nodes

- fips::service::active_unit() picks whichever fips unit is running
  (archipelago-fips.service vs upstream fips.service) so
  handle_fips_restart and handle_fips_reconnect don't silently no-op
  on hosts where the archipelago-managed unit was never created.
- peer_connectivity_summary(anchor_candidates) replaces the old
  identity-cache check. anchor_connected is now true when at least
  one authenticated peer's npub matches the public anchor OR any
  entry in seed-anchors.json, which matches what the user actually
  cares about ("am I in the mesh?") rather than what the card used
  to claim ("is this one specific public anchor reachable?").
- FipsStatus::query takes data_dir now (so it can read seed-anchors)
  rather than identity_dir. All call-sites updated.
- handle_fips_reconnect re-pushes seed anchors after restart so the
  new daemon gets dialed without waiting for the 5-min apply loop.
- FipsNetworkCard label drops "(fips.v0l.io)" — misleading now that
  multiple anchors may be configured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-21 07:08:26 -04:00
parent f8304aed90
commit 4b6a088e38
7 changed files with 126 additions and 66 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.21-alpha" version = "1.7.22-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.21-alpha" version = "1.7.22-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -12,8 +12,7 @@ use anyhow::Result;
impl RpcHandler { impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> { pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir); let status = fips::FipsStatus::query(&self.config.data_dir).await;
let status = fips::FipsStatus::query(&identity_dir).await;
Ok(serde_json::to_value(status)?) Ok(serde_json::to_value(status)?)
} }
@ -36,13 +35,19 @@ impl RpcHandler {
let identity_dir = fips::identity_dir_from(&self.config.data_dir); let identity_dir = fips::identity_dir_from(&self.config.data_dir);
fips::config::install(&identity_dir).await?; fips::config::install(&identity_dir).await?;
fips::service::activate(fips::SERVICE_UNIT).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)?) 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<serde_json::Value> { pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
fips::service::restart(fips::SERVICE_UNIT).await?; let unit = fips::service::active_unit().await;
Ok(serde_json::json!({ "restarted": true })) 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 /// 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. /// Runtime: ~20s. Needs an RPC timeout ≥ 45s on the client.
pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> { pub(super) async fn handle_fips_reconnect(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir); 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 // Heal the pre-fix bech32-text fips_key.pub → 32-raw-bytes
// mismatch. The daemon silently authenticates with a garbage // mismatch. The daemon silently authenticates with a garbage
@ -70,12 +75,26 @@ impl RpcHandler {
let _ = fips::config::install(&identity_dir).await; let _ = fips::config::install(&identity_dir).await;
} }
// Clean stop+start rather than `restart`, so a daemon that // Operate on whichever fips unit is actually up — nodes that
// fails to come back up surfaces as service_active=false // have the upstream `fips.service` rather than the
// instead of quietly sticking with the old process. // archipelago-managed `archipelago-fips.service` used to see
let _ = fips::service::stop(fips::SERVICE_UNIT).await; // 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; 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 // Anchor bootstrap window: poll the status every ~3s for up to
// 20s. Bail as soon as the anchor is connected. // 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); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
loop { loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await; 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 { if s.anchor_connected {
last_status = Some(s); last_status = Some(s);
break; break;
@ -111,13 +130,13 @@ impl RpcHandler {
"peers_but_no_anchor" "peers_but_no_anchor"
}; };
let hint = match likely_cause { let hint = match likely_cause {
"connected" => "Anchor is reachable.", "connected" => "An anchor is reachable.",
"daemon_down" => "The FIPS daemon didn't come back up — check archipelago-fips.service.", "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_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
"no_outbound_udp_or_anchor_down" => "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" => "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.",
_ => "", _ => "",
}; };

View File

@ -99,7 +99,13 @@ pub struct FipsStatus {
impl FipsStatus { impl FipsStatus {
/// Snapshot the current state across package, key, and service. /// 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 installed = service::package_installed().await;
let version = if installed { let version = if installed {
service::daemon_version().await.ok() service::daemon_version().await.ok()
@ -110,17 +116,24 @@ impl FipsStatus {
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await; let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
let service_active = let service_active =
service_state == "active" || upstream_service_state == "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 // Prefer the seed-derived npub; otherwise read the daemon's own
// key file at /etc/fips/fips.pub (world-readable per debian pkg). // 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), Ok(Some(n)) => Some(n),
_ => service::read_upstream_npub().await.ok().flatten(), _ => service::read_upstream_npub().await.ok().flatten(),
}; };
let (authenticated_peer_count, anchor_connected) = if service_active { 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 { } else {
(0, false) (0, false)
}; };
@ -153,10 +166,11 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_status_reports_no_key_pre_onboarding() { async fn test_status_reports_no_key_pre_onboarding() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let id_dir = dir.path().join("identity"); // query() now takes a data_dir (parent) rather than identity_dir,
tokio::fs::create_dir_all(&id_dir).await.unwrap(); // since it also reads seed-anchors.json for the anchor check.
// No identity/ subdir → no key; no seed-anchors.json → public
let status = FipsStatus::query(&id_dir).await; // anchor is the only candidate.
let status = FipsStatus::query(dir.path()).await;
assert!(!status.key_present, "no key before onboarding"); assert!(!status.key_present, "no key before onboarding");
assert!(status.npub.is_none()); assert!(status.npub.is_none());
// `installed`, `service_state`, `version` depend on the host and are // `installed`, `service_state`, `version` depend on the host and are

View File

@ -97,6 +97,27 @@ pub async fn restart(unit: &str) -> Result<()> {
sudo_systemctl("restart", unit).await 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<()> { pub async fn mask(unit: &str) -> Result<()> {
let _ = sudo_systemctl("stop", unit).await; let _ = sudo_systemctl("stop", unit).await;
let _ = sudo_systemctl("disable", 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 = pub const PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"; "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
/// Summarise peer connectivity from `fipsctl show peers` + `identity-cache`. /// Summarise peer connectivity from `fipsctl show peers`. Returns
/// Returns `(authenticated_peer_count, anchor_connected)`. Shells out rather /// `(authenticated_peer_count, anchor_connected)`.
/// 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 /// `anchor_candidates` is the operator-controlled list of npubs this
/// rather be consistent with `fipsctl` than snapshot it ourselves. /// node considers a valid mesh anchor — always includes the hard-coded
pub async fn peer_connectivity_summary() -> (u32, bool) { /// 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") let peers_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "peers"]) .args(["-n", "fipsctl", "show", "peers"])
.output() .output()
@ -122,39 +150,26 @@ pub async fn peer_connectivity_summary() -> (u32, bool) {
Ok(o) if o.status.success() => o.stdout, Ok(o) if o.status.success() => o.stdout,
_ => return (0, false), _ => return (0, false),
}; };
let authenticated_peer_count = let parsed: serde_json::Value =
match serde_json::from_slice::<serde_json::Value>(&peers_json) { match serde_json::from_slice(&peers_json) {
Ok(v) => v Ok(v) => v,
.get("peers") Err(_) => return (0, false),
.and_then(|p| p.as_array())
.map(|a| a.len() as u32)
.unwrap_or(0),
Err(_) => 0,
}; };
let peers = parsed
// Anchor check: look in identity-cache (known node pubkeys the daemon .get("peers")
// has heard about) rather than authenticated peers — the anchor may be .and_then(|p| p.as_array())
// in the cache but not currently at session depth. .cloned()
let cache_json = match Command::new("sudo") .unwrap_or_default();
.args(["-n", "fipsctl", "show", "identity-cache"]) let authenticated_peer_count = peers.len() as u32;
.output() let anchor_connected = peers.iter().any(|p| {
.await let npub = p.get("npub").and_then(|n| n.as_str()).unwrap_or_default();
{ let connected = p
Ok(o) if o.status.success() => o.stdout, .get("connectivity")
_ => return (authenticated_peer_count, false), .and_then(|c| c.as_str())
}; .map(|s| s == "connected")
let anchor_connected = match serde_json::from_slice::<serde_json::Value>(&cache_json) { .unwrap_or(true);
Ok(v) => v connected && anchor_candidates.iter().any(|a| a == npub)
.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) (authenticated_peer_count, anchor_connected)
} }

View File

@ -50,7 +50,7 @@
<div class="flex items-center justify-between gap-3 flex-wrap"> <div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span> <span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor (fips.v0l.io):</span> <span class="text-white/70">Anchor:</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'"> <span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }} {{ status.anchor_connected ? 'connected' : 'not reached' }}
</span> </span>
@ -68,7 +68,7 @@
</button> </button>
</div> </div>
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug"> <p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
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.
</p> </p>
</div> </div>

View File

@ -180,6 +180,18 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.22-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.22-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>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.</p>
<p>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.</p>
<p>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.</p>
</div>
</div>
<!-- v1.7.21-alpha --> <!-- v1.7.21-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">