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
co-authored by Claude Opus 4.7
parent f8304aed90
commit 4b6a088e38
7 changed files with 126 additions and 66 deletions
+22 -8
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
+53 -38
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)
}