//! Seed-anchor management for FIPS bootstrap. //! //! A freshly-installed node can't reach the global mesh via npub //! routing until it's connected to at least one peer that's already in //! the DHT. Upstream `fips` solves this by dialing a public anchor //! (e.g. `fips.v0l.io`) on first start. That's a single point of //! failure and doesn't help nodes behind restrictive firewalls or //! intermittent networks — archipelago operators reported fresh //! installs failing to reach any public anchor. //! //! This module adds a local, operator-editable seed-anchor list. Each //! entry is a `{npub, address, transport}` triple that archipelago //! pushes into the running daemon via `fipsctl connect` on startup and //! periodically thereafter. If one anchor falls over, the next one //! seeds the DHT instead. A well-configured cluster (e.g. a VPS //! running fips in anchor mode + a couple of home nodes) stops //! depending on the global anchor entirely. //! //! The list is persisted at `/seed-anchors.json`. The //! archipelago service user owns that directory, so no sudo is needed //! to read or write it. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use tokio::process::Command; /// On-disk filename under `data_dir/`. const SEED_ANCHORS_FILE: &str = "seed-anchors.json"; /// Public anchor (`fips.v0l.io`) carried as a default seed for fresh /// installs — the one the upstream daemon dials anyway. Operators can /// remove it from the UI once their own cluster has independent anchors. pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str = "npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n"; pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668"; /// One seed-anchor entry. `address` must be directly dialable (IP or /// resolvable hostname + UDP port); `transport` is one of "udp", "tcp", /// "tor", "ethernet" (the values upstream `fipsctl connect` accepts). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SeedAnchor { /// Bech32 `npub1...` of the anchor's FIPS identity. pub npub: String, /// Directly-dialable transport address, e.g. `192.168.1.116:8668`. pub address: String, /// Transport to use — almost always `"udp"`. #[serde(default = "default_transport")] pub transport: String, /// Human-readable note shown in the UI (e.g. "Home anchor", "VPS"). #[serde(default)] pub label: String, } fn default_transport() -> String { "udp".to_string() } fn anchors_path(data_dir: &Path) -> PathBuf { data_dir.join(SEED_ANCHORS_FILE) } /// Load the seed-anchor list. Returns an empty list if the file /// doesn't exist yet — a first-boot node with no operator config. pub async fn load(data_dir: &Path) -> Result> { let path = anchors_path(data_dir); if !path.exists() { return Ok(Vec::new()); } let bytes = tokio::fs::read(&path) .await .with_context(|| format!("read {}", path.display()))?; let anchors: Vec = serde_json::from_slice(&bytes) .with_context(|| format!("parse {}", path.display()))?; Ok(anchors) } /// Persist the list. Overwrites atomically via write-then-rename so a /// crashed archipelago never leaves a half-written config. pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> { tokio::fs::create_dir_all(data_dir) .await .with_context(|| format!("mkdir -p {}", data_dir.display()))?; let path = anchors_path(data_dir); let tmp = path.with_extension("json.tmp"); let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?; tokio::fs::write(&tmp, json) .await .with_context(|| format!("write {}", tmp.display()))?; tokio::fs::rename(&tmp, &path) .await .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; Ok(()) } /// Add (or update) one anchor, keyed by npub. Returns the resulting list. pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result> { let mut list = load(data_dir).await?; if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) { *existing = anchor; } else { list.push(anchor); } save(data_dir, &list).await?; Ok(list) } /// Remove an anchor by npub. Returns the resulting list. pub async fn remove(data_dir: &Path, npub: &str) -> Result> { let mut list = load(data_dir).await?; list.retain(|a| a.npub != npub); save(data_dir, &list).await?; Ok(list) } /// Apply the seed anchors to the running FIPS daemon. For each entry, /// asks `fipsctl connect` to dial the peer. Errors are logged but don't /// fail the whole operation — a single unreachable anchor shouldn't /// block the others. /// /// `fipsctl connect` is idempotent-ish: calling it for an already- /// connected peer is a no-op at the protocol layer, so re-applying on /// a timer is safe. Returns a list of per-anchor results for logging. pub async fn apply(anchors: &[SeedAnchor]) -> Vec { let mut results = Vec::with_capacity(anchors.len()); for anchor in anchors { let out = Command::new("fipsctl") .args([ "connect", &anchor.npub, &anchor.address, &anchor.transport, ]) .output() .await; let result = match out { Ok(o) if o.status.success() => ApplyResult { npub: anchor.npub.clone(), ok: true, message: String::from_utf8_lossy(&o.stdout).trim().to_string(), }, Ok(o) => ApplyResult { npub: anchor.npub.clone(), ok: false, message: format!( "fipsctl exited {}: {}", o.status, String::from_utf8_lossy(&o.stderr).trim() ), }, Err(e) => ApplyResult { npub: anchor.npub.clone(), ok: false, message: format!("fipsctl launch failed: {}", e), }, }; if result.ok { tracing::debug!(npub = %result.npub, "Seed anchor applied"); } else { tracing::warn!( npub = %result.npub, message = %result.message, "Seed anchor apply failed (non-fatal)" ); } results.push(result); } results } /// Outcome of a single `fipsctl connect` call. #[derive(Debug, Clone)] pub struct ApplyResult { pub npub: String, pub ok: bool, pub message: String, } #[cfg(test)] mod tests { use super::*; fn mk(npub: &str) -> SeedAnchor { SeedAnchor { npub: npub.to_string(), address: "example.test:8668".to_string(), transport: "udp".to_string(), label: "test".to_string(), } } #[tokio::test] async fn load_missing_returns_empty() { let dir = tempfile::tempdir().unwrap(); let got = load(dir.path()).await.unwrap(); assert!(got.is_empty()); } #[tokio::test] async fn save_and_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); let a = mk("npub1aaa"); let b = mk("npub1bbb"); save(dir.path(), &[a.clone(), b.clone()]).await.unwrap(); let got = load(dir.path()).await.unwrap(); assert_eq!(got, vec![a, b]); } #[tokio::test] async fn add_replaces_existing_by_npub() { let dir = tempfile::tempdir().unwrap(); let mut a = mk("npub1aaa"); save(dir.path(), &[a.clone()]).await.unwrap(); a.address = "newhost:8668".to_string(); let list = add(dir.path(), a.clone()).await.unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].address, "newhost:8668"); } #[tokio::test] async fn remove_by_npub() { let dir = tempfile::tempdir().unwrap(); save( dir.path(), &[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")], ) .await .unwrap(); let list = remove(dir.path(), "npub1bbb").await.unwrap(); assert_eq!(list.len(), 2); assert!(list.iter().all(|a| a.npub != "npub1bbb")); } #[test] fn seed_anchor_uses_udp_by_default() { let json = r#"{"npub":"npub1x","address":"h:8668"}"#; let a: SeedAnchor = serde_json::from_str(json).unwrap(); assert_eq!(a.transport, "udp"); assert_eq!(a.label, ""); } }