diff --git a/core/Cargo.lock b/core/Cargo.lock index ff8173fd..e677dca1 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.20-alpha" +version = "1.7.21-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 20582544..649f6339 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.7.20-alpha" +version = "1.7.21-alpha" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 6f663d3d..a430ab97 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -414,6 +414,16 @@ impl RpcHandler { "fips.install" => self.handle_fips_install().await, "fips.restart" => self.handle_fips_restart().await, "fips.reconnect" => self.handle_fips_reconnect().await, + "fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await, + "fips.add-seed-anchor" => { + let p = params.unwrap_or(serde_json::json!({})); + self.handle_fips_add_seed_anchor(&p).await + } + "fips.remove-seed-anchor" => { + let p = params.unwrap_or(serde_json::json!({})); + self.handle_fips_remove_seed_anchor(&p).await + } + "fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await, // System updates "update.check" => self.handle_update_check().await, diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs index 6abcf899..dae114cd 100644 --- a/core/archipelago/src/api/rpc/fips.rs +++ b/core/archipelago/src/api/rpc/fips.rs @@ -129,4 +129,66 @@ impl RpcHandler { "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::>(), + })) + } } diff --git a/core/archipelago/src/fips/anchors.rs b/core/archipelago/src/fips/anchors.rs new file mode 100644 index 00000000..9fd3dfef --- /dev/null +++ b/core/archipelago/src/fips/anchors.rs @@ -0,0 +1,241 @@ +//! 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, ""); + } +} diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index 3336d1f4..80e9c8a9 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -25,6 +25,7 @@ // the module is deliberately API-ready ahead of those call-sites. #![allow(dead_code)] +pub mod anchors; pub mod config; pub mod dial; pub mod iface; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index d911373b..8f9f73d4 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -353,6 +353,34 @@ impl Server { }); } + // FIPS seed-anchor apply loop — every 5 minutes we re-push the + // configured seed anchors into the running fips daemon via + // `fipsctl connect`. This keeps the mesh bootstrap resilient: + // operators add cluster-local anchors in the UI, and a daemon + // restart or a flaky public anchor can't strand the node. + // First run is delayed 30s so fips has time to come up after + // onboarding before we start dialing. + { + let data_dir = config.data_dir.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(30)).await; + let mut interval = tokio::time::interval(Duration::from_secs(300)); + loop { + interval.tick().await; + match crate::fips::anchors::load(&data_dir).await { + Ok(list) if !list.is_empty() => { + let _ = crate::fips::anchors::apply(&list).await; + } + Ok(_) => { /* no seed anchors configured yet */ } + Err(e) => tracing::debug!( + "Seed-anchor apply: load failed (non-fatal): {}", + e + ), + } + } + }); + } + // did:dht auto-refresh — re-publish DHT records every 2 hours if config.nostr_discovery_enabled { let data_dir = config.data_dir.clone(); diff --git a/neode-ui/src/views/server/FipsSeedAnchorsCard.vue b/neode-ui/src/views/server/FipsSeedAnchorsCard.vue new file mode 100644 index 00000000..0941f5fb --- /dev/null +++ b/neode-ui/src/views/server/FipsSeedAnchorsCard.vue @@ -0,0 +1,169 @@ + + + diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 8a082742..8961764f 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -180,6 +180,18 @@ init()
+ +
+
+ v1.7.21-alpha + Apr 21, 2026 +
+
+

FIPS bootstrap no longer depends on a single public anchor. You can now add your own anchors — other archipelago nodes or a VPS you control — and the node will dial every one of them to join the mesh on startup. If one anchor is down, the next one seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.

+

Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label so you can remember which is which.

+

No behavior change if you don't configure any — the upstream daemon's own defaults keep working as before. This purely adds an operator-controlled list on top.

+
+
diff --git a/releases/manifest.json b/releases/manifest.json index 6ef50f49..453fb64f 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,26 +1,27 @@ { - "version": "1.7.20-alpha", + "version": "1.7.21-alpha", "release_date": "2026-04-21", "changelog": [ - "Fixed a critical bug where nodes on 'Check & Apply Daily' could end up offline after their nightly update. The scheduler was killing the service a moment too early, before the built-in restart handler could bring the new version back up — leaving the node dead until someone SSH'd in. The scheduler now uses the same restart path as the 'Install Update' button, so auto-applied updates come back online on their own.", - "Applies automatically — no action needed on your end beyond taking this update." + "FIPS bootstrap no longer depends on a single public anchor. You can add your own anchors — other archipelago nodes or a VPS you control — and the node dials every one on startup to join the mesh. If one anchor is down, the next seeds the routing layer instead, so a flaky public anchor no longer strands a fresh install.", + "Anchors persist across restarts and are re-applied every five minutes, so a daemon that got temporarily isolated reconnects on its own without anyone having to SSH in. Each anchor carries an operator-editable label.", + "No behavior change if you don't configure any — the upstream daemon's defaults keep working as before. This purely adds an operator-controlled list on top." ], "components": [ { "name": "archipelago", - "current_version": "1.7.19-alpha", - "new_version": "1.7.20-alpha", - "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.20-alpha/archipelago", - "sha256": "bf4f8b91b021cad445a868f454707e0fa005446f755604f8c3e072bb7a059e6f", - "size_bytes": 40640016 + "current_version": "1.7.20-alpha", + "new_version": "1.7.21-alpha", + "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.21-alpha/archipelago", + "sha256": "47e5ddff3a2eeb3ff7117bfccb1799a72932e77afefb4f03b17679c21858f21c", + "size_bytes": 40799840 }, { - "name": "archipelago-frontend-1.7.20-alpha.tar.gz", - "current_version": "1.7.19-alpha", - "new_version": "1.7.20-alpha", - "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.20-alpha/archipelago-frontend-1.7.20-alpha.tar.gz", - "sha256": "a82f187b597c51e5f3d8753529914651ab2d8e8bb3ad9c36d287b335e4d386a9", - "size_bytes": 162082209 + "name": "archipelago-frontend-1.7.21-alpha.tar.gz", + "current_version": "1.7.20-alpha", + "new_version": "1.7.21-alpha", + "download_url": "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz", + "sha256": "f8fd8f7d07f99fd227c6d3f3a188154b51e20e19b0f2f303175ea46095ae30d9", + "size_bytes": 162082809 } ] } diff --git a/releases/v1.7.21-alpha/archipelago b/releases/v1.7.21-alpha/archipelago new file mode 100755 index 00000000..483250d6 Binary files /dev/null and b/releases/v1.7.21-alpha/archipelago differ diff --git a/releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz b/releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz new file mode 100644 index 00000000..83eac820 Binary files /dev/null and b/releases/v1.7.21-alpha/archipelago-frontend-1.7.21-alpha.tar.gz differ