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]]
name = "archipelago"
version = "1.7.21-alpha"
version = "1.7.22-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -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"]

View File

@ -12,8 +12,7 @@ use anyhow::Result;
impl RpcHandler {
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(&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<serde_json::Value> {
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<serde_json::Value> {
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.",
_ => "",
};

View File

@ -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

View File

@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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)
}

View File

@ -50,7 +50,7 @@
<div class="flex items-center justify-between gap-3 flex-wrap">
<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="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'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
@ -68,7 +68,7 @@
</button>
</div>
<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>
</div>

View File

@ -180,6 +180,18 @@ init()
</button>
</div>
<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 -->
<div>
<div class="flex items-center gap-2 mb-3">