fix(fips): ship a second, reachable default anchor (once-and-for-all)

Nodes depended on a single public anchor (185.18.221.160) that is
unreachable from most real networks — confirmed on the thinkpad WiFi and a
fresh Framework node, both of which islanded (is_root=true, depth=0). Its
DNS even resolves IPv6-first while the daemon is IPv4-only.

Stand up an Archipelago-operated FIPS anchor on vps2 (146.59.87.168, the
OTA host every node already reaches) and ship it as an additional default
anchor. default_public_anchor() -> default_public_anchors() -> Vec; load()
returns the set; the apply loop already dials each, so whichever the node's
network can reach wins. Verified end-to-end: a fresh node joined the tree
via vps2 in 3s (depth 1). anchors tests: 7/7 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-20 17:42:18 -04:00
parent 7eb4d6a7bf
commit 0f13545375

View File

@ -49,9 +49,7 @@ pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443"; pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp"; pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried /// The upstream public anchor as a ready-to-apply `SeedAnchor`.
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
pub fn default_public_anchor() -> SeedAnchor { pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor { SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(), npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
@ -61,6 +59,38 @@ pub fn default_public_anchor() -> SeedAnchor {
} }
} }
// Archipelago-operated anchor on vps2 (the OTA/registry host, 146.59.87.168).
// Every node already reaches this host for updates, so it is reachable from
// networks that the upstream anchor is not — which is most of them (the
// upstream anchor answers on one IPv4 that many home/office networks can't
// reach, and its DNS resolves IPv6-first while the daemon is IPv4-only).
// TCP because that traverses NAT/firewalls best; 8444 because 8443 on that
// host is already taken by a container.
pub const ARCHY_ANCHOR_NPUB: &str =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak";
pub const ARCHY_ANCHOR_ADDR: &str = "146.59.87.168:8444";
pub const ARCHY_ANCHOR_TRANSPORT: &str = "tcp";
/// The Archipelago-operated anchor as a ready-to-apply `SeedAnchor`.
pub fn archy_anchor() -> SeedAnchor {
SeedAnchor {
npub: ARCHY_ANCHOR_NPUB.to_string(),
address: ARCHY_ANCHOR_ADDR.to_string(),
transport: ARCHY_ANCHOR_TRANSPORT.to_string(),
label: "Archipelago anchor (vps2)".to_string(),
}
}
/// The default anchor set carried implicitly by `load()` on nodes that have
/// never edited their anchor list, so every node dials them without operator
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
/// node: `fipsctl connect` is attempted for each, and whichever the node's
/// network can reach wins. The Archipelago-operated anchor is listed first
/// because it is reachable from the widest set of networks.
pub fn default_public_anchors() -> Vec<SeedAnchor> {
vec![archy_anchor(), default_public_anchor()]
}
/// One seed-anchor entry. `address` must be directly dialable (IP or /// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp", /// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts). /// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
@ -94,7 +124,7 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> { pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir); let path = anchors_path(data_dir);
if !path.exists() { if !path.exists() {
return Ok(vec![default_public_anchor()]); return Ok(default_public_anchors());
} }
let bytes = tokio::fs::read(&path) let bytes = tokio::fs::read(&path)
.await .await
@ -268,28 +298,46 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn load_missing_seeds_default_public_anchor() { async fn load_missing_seeds_default_public_anchors() {
// A node that has never edited its anchor list should still get // A node that has never edited its anchor list should still get the
// the public anchor so it can bootstrap the mesh out of the box. // full default anchor set so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap(); let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![default_public_anchor()]); assert_eq!(got, default_public_anchors());
// ...and the default must be the TCP/8443 form, not the dead udp:8668. // The Archipelago-operated anchor must come first (widest reachability)
assert_eq!(got[0].transport, "tcp"); // and the upstream anchor must remain present as a fallback.
assert!(got[0].address.ends_with(":8443")); assert_eq!(got[0], archy_anchor());
assert!(got.contains(&default_public_anchor()));
// Every default must be a TCP form (traverses NAT/firewalls), never the
// dead udp:8668 the upstream anchor never answers on.
assert!(got.iter().all(|a| a.transport == "tcp"));
} }
#[tokio::test] #[tokio::test]
async fn removing_default_persists_as_empty() { async fn removing_one_default_persists_and_keeps_the_other() {
// Once the operator removes the default, a file exists and is // Editing the anchor list (here removing one default) makes the file
// authoritative — we must not silently re-seed it on next load. // authoritative: the removed anchor must not be silently re-seeded on
// next load, and the remaining default must stay.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
assert!(!list.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
assert!(list.contains(&default_public_anchor()));
let got = load(dir.path()).await.unwrap();
assert_eq!(got, list, "edited list is authoritative; no re-seed");
}
#[tokio::test]
async fn removing_all_defaults_persists_as_empty() {
// Removing every default leaves an empty authoritative list that must
// not be re-seeded on next load.
let dir = tempfile::tempdir().unwrap();
remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB) let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await .await
.unwrap(); .unwrap();
assert!(list.is_empty()); assert!(list.is_empty());
let got = load(dir.path()).await.unwrap(); let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited"); assert!(got.is_empty(), "defaults must stay removed once edited");
} }
#[tokio::test] #[tokio::test]