//! RPC handlers for the FIPS mesh transport subsystem. //! //! Surface is deliberately thin: a read-only `fips.status`, a user-gated //! `fips.check-update`, a stubbed `fips.apply-update`, and a //! `fips.install` that (re-)materialises the daemon config + key and //! activates the service. All writes go through `sudo` helpers in //! `crate::fips`. use super::RpcHandler; use crate::fips; use anyhow::Result; impl RpcHandler { pub(super) async fn handle_fips_status(&self) -> Result { let status = fips::FipsStatus::query(&self.config.data_dir).await; Ok(serde_json::to_value(status)?) } pub(super) async fn handle_fips_check_update(&self) -> Result { let check = fips::update::check().await?; Ok(serde_json::to_value(check)?) } pub(super) async fn handle_fips_apply_update(&self) -> Result { fips::update::apply().await?; Ok(serde_json::json!({ "applied": true })) } /// Install config + key into /etc/fips and activate the service. /// Intended to be called: /// - once by the seed-onboarding flow, right after the FIPS key /// is written to /data/identity/fips_key, and /// - on user demand from the dashboard if something drifted. pub(super) async fn handle_fips_install(&self) -> Result { 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(&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 { 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 /// bootstrap window, poll the identity-cache + peer list, and /// classify what recovered (or didn't) so the UI can explain it to /// the user instead of showing a generic failure. /// /// 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(&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 // pubkey when the .pub file is 63-char text, which looks like // "anchor unreachable" to the user even though the real fault // was an identity malformed on the node itself. Re-install the // config + keys so /etc/fips gets the healed .pub. let key_src = identity_dir.join("fips_key"); let pub_src = identity_dir.join("fips_key.pub"); if key_src.exists() { let _ = fips::config::normalize_pub_file(&key_src, &pub_src).await; // Re-install refreshes /etc/fips/fips.pub from the healed // source. No-op if nothing changed. let _ = fips::config::install(&identity_dir).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(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. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); let after = loop { tokio::time::sleep(std::time::Duration::from_secs(3)).await; let s = fips::FipsStatus::query(&self.config.data_dir).await; if s.anchor_connected || std::time::Instant::now() >= deadline { break s; } }; let recovered = after.anchor_connected && !before.anchor_connected; let likely_cause = if after.anchor_connected { "connected" } else if !after.service_active { "daemon_down" } else if !after.key_present { "no_seed_key" } else if after.authenticated_peer_count == 0 { // Daemon is up with a key but hasn't authenticated any peers — // almost always the outbound connection to the anchor being // dropped by the local firewall/router, or the anchor itself // being down. The public anchor is reached over TCP/8443 (not // UDP/8668 — that endpoint is dead). "no_outbound_or_anchor_down" } else { "peers_but_no_anchor" }; let hint = match likely_cause { "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_or_anchor_down" => "Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.", "peers_but_no_anchor" => "Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.", _ => "", }; Ok(serde_json::json!({ "recovered": recovered, "likely_cause": likely_cause, "hint": hint, "before": before, "after": after, })) } /// List the seed-anchor entries configured on this node. pub(super) async fn handle_fips_list_seed_anchors(&self) -> Result { let list = fips::anchors::load(&self.config.data_dir).await?; Ok(serde_json::json!({ "seed_anchors": list })) } /// Add (or update) a seed anchor and immediately push it into the /// running daemon. Params: `{ npub, address, transport?, label? }`. pub(super) async fn handle_fips_add_seed_anchor( &self, params: &serde_json::Value, ) -> Result { let anchor: fips::anchors::SeedAnchor = serde_json::from_value(params.clone()) .map_err(|e| anyhow::anyhow!("bad seed anchor payload: {}", e))?; if !anchor.npub.starts_with("npub1") { anyhow::bail!("npub must be bech32 (npub1...)"); } if !anchor.address.contains(':') { anyhow::bail!("address must be host:port (e.g. 192.168.1.116:8668)"); } let list = fips::anchors::add(&self.config.data_dir, anchor.clone()).await?; // Push just the newly-added anchor into the running daemon so // the user sees effect without waiting for the periodic apply. let results = fips::anchors::apply(&[anchor]).await; Ok(serde_json::json!({ "seed_anchors": list, "apply": results.iter().map(|r| { serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message }) }).collect::>(), })) } /// Remove a seed anchor by npub. Params: `{ npub }`. Does NOT tear /// down an already-authenticated peer connection — it only stops /// us from re-dialing the anchor on the next apply cycle. pub(super) async fn handle_fips_remove_seed_anchor( &self, params: &serde_json::Value, ) -> Result { let npub = params .get("npub") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("missing npub"))?; let list = fips::anchors::remove(&self.config.data_dir, npub).await?; Ok(serde_json::json!({ "seed_anchors": list })) } /// Re-apply all seed anchors to the running daemon. Useful after a /// FIPS restart or when the user wants to force a reconnection /// attempt without waiting for the periodic apply loop. pub(super) async fn handle_fips_apply_seed_anchors(&self) -> Result { let list = fips::anchors::load(&self.config.data_dir).await?; let results = fips::anchors::apply(&list).await; Ok(serde_json::json!({ "applied": results.len(), "results": results.iter().map(|r| { serde_json::json!({ "npub": r.npub, "ok": r.ok, "message": r.message }) }).collect::>(), })) } }