diff --git a/core/archipelago/src/fips/endpoints.rs b/core/archipelago/src/fips/endpoints.rs new file mode 100644 index 00000000..7fbcd9a0 --- /dev/null +++ b/core/archipelago/src/fips/endpoints.rs @@ -0,0 +1,205 @@ +//! Last-known-good FIPS peer endpoints (A3.10). +//! +//! The LAN direct-peering tick (`anchors::lan_fips_anchors`) only helps peers +//! we can currently see on the LAN. When a federation peer's LAN path is gone +//! (renumbered network, remote site, mDNS blackout) the only route left is the +//! anchor spanning tree — the exact hairpin RC2 calls out. But if we were EVER +//! connected to that peer directly, the daemon knew a working endpoint for it +//! (`fipsctl show peers` → `transport_addr`/`transport_type`, which covers +//! LAN, Tailscale, and WAN endpoints alike). This module persists those +//! npub-keyed endpoints and re-offers them as dial candidates when the live +//! paths disappear: LAN → last-known-good → anchor tree. +//! +//! Persisted at `/fips-endpoints.json`. Entries are refreshed every +//! time the peer is seen connected and dropped after `RETENTION` without a +//! sighting, so a peer that genuinely moved doesn't get dialed at a stale +//! address forever ( `fipsctl connect` to a dead address is harmless but not +//! free). + +use std::collections::HashMap; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use tokio::fs; + +use super::anchors::SeedAnchor; + +const FILE_NAME: &str = "fips-endpoints.json"; +/// Forget endpoints not seen connected for this long (seconds) — 30 days. +const RETENTION_SECS: u64 = 30 * 24 * 60 * 60; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct KnownEndpoint { + /// "ip:port" as reported by the daemon (`transport_addr`). + pub address: String, + /// "udp" | "tcp" (`transport_type`). + pub transport: String, + /// Unix seconds of the last time this peer was seen connected here. + pub last_ok_unix: u64, +} + +/// A currently-connected peer as parsed from `fipsctl show peers`. +#[derive(Debug, Clone)] +pub struct ConnectedPeer { + pub npub: String, + pub address: String, + pub transport: String, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +pub async fn load(data_dir: &Path) -> HashMap { + let path = data_dir.join(FILE_NAME); + match fs::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +async fn save(data_dir: &Path, map: &HashMap) -> Result<()> { + let path = data_dir.join(FILE_NAME); + let tmp = data_dir.join(format!("{FILE_NAME}.tmp")); + fs::write(&tmp, serde_json::to_vec_pretty(map)?).await?; + fs::rename(&tmp, &path).await?; + Ok(()) +} + +/// Merge the currently-connected peers into the store (refreshing their +/// timestamps), prune expired entries, persist, and return the updated map. +/// Persistence failures are non-fatal — the in-memory result is still +/// returned so this tick's fallback logic works. +pub async fn record_connected( + data_dir: &Path, + connected: &[ConnectedPeer], +) -> HashMap { + let mut map = load(data_dir).await; + let now = now_unix(); + let before = map.clone(); + for p in connected { + if p.npub.is_empty() || p.address.is_empty() { + continue; + } + map.insert( + p.npub.clone(), + KnownEndpoint { + address: p.address.clone(), + transport: p.transport.clone(), + last_ok_unix: now, + }, + ); + } + map.retain(|_, e| now.saturating_sub(e.last_ok_unix) <= RETENTION_SECS); + if map != before { + if let Err(e) = save(data_dir, &map).await { + tracing::debug!("fips endpoint store save failed (non-fatal): {e}"); + } + } + map +} + +/// Build fallback anchors for federation peers whose live paths are gone: +/// every `wanted_npub` that is neither currently connected nor covered by a +/// live LAN direct entry, but has a last-known-good endpoint, becomes a dial +/// candidate. `fipsctl connect` is idempotent and failure-tolerant, so a +/// stale candidate costs one failed dial, bounded by apply()'s per-connect +/// timeout. +pub fn fallback_anchors( + known: &HashMap, + wanted_npubs: &[String], + connected_npubs: &[String], + lan_direct: &[SeedAnchor], +) -> Vec { + let mut out = Vec::new(); + for npub in wanted_npubs { + if connected_npubs.iter().any(|c| c == npub) { + continue; + } + if lan_direct.iter().any(|a| &a.npub == npub) { + continue; + } + if let Some(e) = known.get(npub) { + out.push(SeedAnchor { + npub: npub.clone(), + address: e.address.clone(), + transport: e.transport.clone(), + label: "last-known-good endpoint (direct FIPS)".to_string(), + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ep(addr: &str) -> KnownEndpoint { + KnownEndpoint { + address: addr.to_string(), + transport: "udp".to_string(), + last_ok_unix: now_unix(), + } + } + + #[tokio::test] + async fn record_and_reload_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let connected = vec![ConnectedPeer { + npub: "npub1aaa".into(), + address: "100.114.134.21:2121".into(), + transport: "udp".into(), + }]; + let map = record_connected(dir.path(), &connected).await; + assert_eq!(map["npub1aaa"].address, "100.114.134.21:2121"); + let reloaded = load(dir.path()).await; + assert_eq!(reloaded, map); + } + + #[tokio::test] + async fn expired_entries_are_pruned_on_record() { + let dir = tempfile::tempdir().unwrap(); + let mut stale = HashMap::new(); + stale.insert( + "npub1old".to_string(), + KnownEndpoint { + address: "10.0.0.1:2121".into(), + transport: "udp".into(), + last_ok_unix: now_unix() - RETENTION_SECS - 60, + }, + ); + save(dir.path(), &stale).await.unwrap(); + let map = record_connected(dir.path(), &[]).await; + assert!(map.is_empty()); + } + + #[test] + fn fallback_skips_connected_and_lan_covered_peers() { + let mut known = HashMap::new(); + known.insert("npub1gone".to_string(), ep("100.1.2.3:2121")); + known.insert("npub1conn".to_string(), ep("100.1.2.4:2121")); + known.insert("npub1lan".to_string(), ep("100.1.2.5:2121")); + let wanted: Vec = ["npub1gone", "npub1conn", "npub1lan", "npub1never"] + .iter() + .map(|s| s.to_string()) + .collect(); + let connected = vec!["npub1conn".to_string()]; + let lan = vec![SeedAnchor { + npub: "npub1lan".into(), + address: "192.168.63.198:2121".into(), + transport: "udp".into(), + label: "LAN".into(), + }]; + let out = fallback_anchors(&known, &wanted, &connected, &lan); + assert_eq!(out.len(), 1); + assert_eq!(out[0].npub, "npub1gone"); + assert_eq!(out[0].address, "100.1.2.3:2121"); + // npub1never has no stored endpoint → nothing to dial. + } +} diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index 2664bbe9..7f367c3c 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -29,6 +29,7 @@ pub mod anchors; pub mod app_ports; pub mod config; pub mod dial; +pub mod endpoints; pub mod iface; pub mod service; pub mod telemetry; diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index 53ca36d2..c88a051c 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -227,6 +227,52 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo (authenticated_peer_count, anchor_connected) } +/// Currently-connected peers with their live endpoints, from +/// `fipsctl show peers` (`transport_addr`/`transport_type`). Feeds the +/// last-known-good endpoint store (A3.10); empty on any failure. +pub async fn connected_peer_endpoints() -> Vec { + let peers_json = match Command::new("sudo") + .args(["-n", "fipsctl", "show", "peers"]) + .output() + .await + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + parsed + .get("peers") + .and_then(|p| p.as_array()) + .map(|peers| { + peers + .iter() + .filter(|p| { + p.get("connectivity") + .and_then(|c| c.as_str()) + .map(|s| s == "connected") + .unwrap_or(false) + }) + .filter_map(|p| { + let npub = p.get("npub").and_then(|n| n.as_str())?; + let address = p.get("transport_addr").and_then(|a| a.as_str())?; + let transport = p + .get("transport_type") + .and_then(|t| t.as_str()) + .unwrap_or("udp"); + Some(crate::fips::endpoints::ConnectedPeer { + npub: npub.to_string(), + address: address.to_string(), + transport: transport.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + /// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return /// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used /// as a fallback on legacy/dev nodes where no seed-derived key exists. diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index dd2e0b33..93b1f038 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -847,6 +847,42 @@ impl Server { if !direct.is_empty() { let _ = crate::fips::anchors::apply(&direct).await; } + + // A3.10 — endpoint fallback for direct peering. Record + // where currently-connected peers actually are (their + // transport_addr covers LAN, Tailscale, and WAN alike), + // then re-dial the last-known-good endpoint of every + // federation peer whose live paths are gone: not + // connected now, no LAN direct entry this tick. Escala- + // tion order is LAN → last-known-good → anchor tree; + // a stale candidate costs one bounded failed dial. + let connected = + crate::fips::service::connected_peer_endpoints().await; + let known = crate::fips::endpoints::record_connected( + &data_dir, &connected, + ) + .await; + let wanted: Vec = reg + .all_peers() + .await + .iter() + .filter_map(|p| p.fips_npub.clone()) + .collect(); + let connected_npubs: Vec = + connected.iter().map(|c| c.npub.clone()).collect(); + let fallback = crate::fips::endpoints::fallback_anchors( + &known, + &wanted, + &connected_npubs, + &direct, + ); + if !fallback.is_empty() { + tracing::info!( + count = fallback.len(), + "dialing last-known-good endpoints for disconnected federation peers" + ); + let _ = crate::fips::anchors::apply(&fallback).await; + } } let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {