242 lines
8.4 KiB
Rust
242 lines
8.4 KiB
Rust
|
|
//! 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 `<data_dir>/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<Vec<SeedAnchor>> {
|
||
|
|
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<SeedAnchor> = 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<Vec<SeedAnchor>> {
|
||
|
|
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<Vec<SeedAnchor>> {
|
||
|
|
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<ApplyResult> {
|
||
|
|
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, "");
|
||
|
|
}
|
||
|
|
}
|