Pre-v1.4 federation pairs (who exchanged invites before fips_npub was part of the invite code) had no path to learn each other's FIPS npub — they'd stay Tor-only forever even after upgrading. Fix: every state snapshot now carries the sender's own_fips_npub, and update_node_state refreshes the stored fips_npub on the receiver side whenever it differs. - NodeStateSnapshot.own_fips_npub (serde default for back-compat). - build_local_state takes own_fips_npub alongside the other single-value fields. - handle_federation_get_state populates own_fips_npub from identity::fips_npub, with a fallback to the upstream daemon's /etc/fips/fips.pub for legacy nodes that never materialised a seed-derived key. - storage::update_node_state now writes fips_npub into the FederatedNode when a new value arrives and trims whitespace before comparing, so key rotations also flow through. - Test fixtures (storage + transport/delta + sync) updated for the new field; existing tests pass. Net effect: on the next sync, .116 and .228 learn each other's fips_npub (currently null from the old invite) and subsequent federation calls route FIPS-first automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
//! Federation persistent storage: node list and invite management on disk.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::Path;
|
|
use tokio::fs;
|
|
|
|
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
|
|
|
pub(crate) const FEDERATION_DIR: &str = "federation";
|
|
pub(crate) const NODES_FILE: &str = "nodes.json";
|
|
pub(crate) const INVITES_FILE: &str = "invites.json";
|
|
|
|
/// Top-level file structures.
|
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
pub(crate) struct NodesFile {
|
|
pub(crate) nodes: Vec<FederatedNode>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
pub(crate) struct InvitesFile {
|
|
pub(crate) outgoing: Vec<FederationInvite>,
|
|
pub(crate) incoming: Vec<FederationInvite>,
|
|
}
|
|
|
|
/// Ensure federation directory exists.
|
|
pub(crate) async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
|
|
let dir = data_dir.join(FEDERATION_DIR);
|
|
fs::create_dir_all(&dir)
|
|
.await
|
|
.context("Failed to create federation directory")?;
|
|
Ok(dir)
|
|
}
|
|
|
|
// ──────────────────────────── Node Management ────────────────────────────
|
|
|
|
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
|
let dir = data_dir.join(FEDERATION_DIR);
|
|
let path = dir.join(NODES_FILE);
|
|
if !path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.context("Failed to read federation nodes")?;
|
|
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
|
Ok(file.nodes)
|
|
}
|
|
|
|
/// Look up a federated peer's FIPS npub given their onion address.
|
|
/// Returns `None` when the onion isn't in our federation list or the
|
|
/// peer hasn't advertised a FIPS key. Matching is suffix-tolerant so
|
|
/// callers can pass `abc` or `abc.onion` interchangeably.
|
|
pub async fn fips_npub_for_onion(data_dir: &Path, onion: &str) -> Option<String> {
|
|
let target = onion.trim_end_matches(".onion");
|
|
let nodes = load_nodes(data_dir).await.ok()?;
|
|
nodes
|
|
.iter()
|
|
.find(|n| n.onion.trim_end_matches(".onion") == target)
|
|
.and_then(|n| n.fips_npub.clone())
|
|
}
|
|
|
|
/// Record the transport used on the most recent successful peer reach.
|
|
/// Used for the "FIPS"/"Tor" badge on each node card in the UI — we write
|
|
/// what we actually used, not what was predicted.
|
|
///
|
|
/// Matches by DID first (precise) and falls back to onion (when the
|
|
/// caller didn't carry the DID through). No-op if the peer isn't in
|
|
/// our federation list.
|
|
pub async fn record_peer_transport(
|
|
data_dir: &Path,
|
|
did: Option<&str>,
|
|
onion: Option<&str>,
|
|
transport: &str,
|
|
) -> Result<()> {
|
|
let mut nodes = load_nodes(data_dir).await?;
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
let onion_target = onion.map(|o| o.trim_end_matches(".onion"));
|
|
|
|
let mut modified = false;
|
|
for node in nodes.iter_mut() {
|
|
let did_match = did.is_some_and(|d| d == node.did);
|
|
let onion_match = onion_target
|
|
.is_some_and(|t| node.onion.trim_end_matches(".onion") == t);
|
|
if did_match || onion_match {
|
|
node.last_transport = Some(transport.to_string());
|
|
node.last_transport_at = Some(now.clone());
|
|
node.last_seen = Some(now.clone());
|
|
modified = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if modified {
|
|
save_nodes(data_dir, &nodes).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
|
|
let dir = ensure_dir(data_dir).await?;
|
|
let file = NodesFile {
|
|
nodes: nodes.to_vec(),
|
|
};
|
|
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
|
|
fs::write(dir.join(NODES_FILE), content)
|
|
.await
|
|
.context("Failed to write federation nodes")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
|
|
let mut nodes = load_nodes(data_dir).await?;
|
|
let exists = nodes.iter().any(|n| n.did == node.did);
|
|
if exists {
|
|
anyhow::bail!("Node with DID {} is already federated", node.did);
|
|
}
|
|
nodes.push(node);
|
|
save_nodes(data_dir, &nodes).await?;
|
|
Ok(nodes)
|
|
}
|
|
|
|
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
|
let mut nodes = load_nodes(data_dir).await?;
|
|
let before = nodes.len();
|
|
nodes.retain(|n| n.did != did);
|
|
if nodes.len() == before {
|
|
anyhow::bail!("No federated node with DID {}", did);
|
|
}
|
|
save_nodes(data_dir, &nodes).await?;
|
|
Ok(nodes)
|
|
}
|
|
|
|
pub async fn set_trust_level(
|
|
data_dir: &Path,
|
|
did: &str,
|
|
trust: TrustLevel,
|
|
) -> Result<Vec<FederatedNode>> {
|
|
let mut nodes = load_nodes(data_dir).await?;
|
|
let node = nodes
|
|
.iter_mut()
|
|
.find(|n| n.did == did)
|
|
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
|
node.trust_level = trust;
|
|
save_nodes(data_dir, &nodes).await?;
|
|
Ok(nodes)
|
|
}
|
|
|
|
/// Update a federated node's metadata (onion, pubkey, name, last_seen).
|
|
pub async fn update_node(data_dir: &Path, updated: &FederatedNode) -> Result<()> {
|
|
let mut nodes = load_nodes(data_dir).await?;
|
|
if let Some(node) = nodes.iter_mut().find(|n| n.did == updated.did) {
|
|
if !updated.onion.is_empty() {
|
|
node.onion = updated.onion.clone();
|
|
}
|
|
if !updated.pubkey.is_empty() {
|
|
node.pubkey = updated.pubkey.clone();
|
|
}
|
|
if updated.name.is_some() {
|
|
node.name = updated.name.clone();
|
|
}
|
|
if updated.last_seen.is_some() {
|
|
node.last_seen = updated.last_seen.clone();
|
|
}
|
|
save_nodes(data_dir, &nodes).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_node_state(data_dir: &Path, did: &str, state: NodeStateSnapshot) -> Result<()> {
|
|
let mut nodes = load_nodes(data_dir).await?;
|
|
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
|
|
node.last_seen = Some(state.timestamp.clone());
|
|
// Update node name from sync if provided (peer announced their name)
|
|
if let Some(ref name) = state.node_name {
|
|
if !name.is_empty() {
|
|
node.name = Some(name.clone());
|
|
}
|
|
}
|
|
// Learn the peer's FIPS npub from their state snapshot so
|
|
// federations established before v1.4 (pre-fips_npub) start
|
|
// routing over FIPS on the very next sync. Refresh if the peer
|
|
// rotated their FIPS key, too.
|
|
if let Some(ref npub) = state.own_fips_npub {
|
|
if !npub.is_empty()
|
|
&& node.fips_npub.as_deref().map(str::trim) != Some(npub.trim())
|
|
{
|
|
node.fips_npub = Some(npub.clone());
|
|
}
|
|
}
|
|
node.last_state = Some(state);
|
|
save_nodes(data_dir, &nodes).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ──────────────────────────── Invite Storage ────────────────────────────
|
|
|
|
pub(crate) async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
|
|
let dir = data_dir.join(FEDERATION_DIR);
|
|
let path = dir.join(INVITES_FILE);
|
|
if !path.exists() {
|
|
return Ok(InvitesFile::default());
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.context("Failed to read invites")?;
|
|
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
|
|
Ok(file)
|
|
}
|
|
|
|
pub(crate) async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
|
|
let dir = ensure_dir(data_dir).await?;
|
|
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
|
|
fs::write(dir.join(INVITES_FILE), content)
|
|
.await
|
|
.context("Failed to write invites")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::federation::types::AppStatus;
|
|
|
|
fn make_node(did: &str, onion: &str) -> FederatedNode {
|
|
FederatedNode {
|
|
did: did.to_string(),
|
|
pubkey: "aabbccdd".to_string(),
|
|
onion: onion.to_string(),
|
|
name: None,
|
|
trust_level: TrustLevel::Trusted,
|
|
added_at: "2026-01-01T00:00:00Z".to_string(),
|
|
last_seen: None,
|
|
last_state: None,
|
|
fips_npub: None,
|
|
last_transport: None,
|
|
last_transport_at: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_nodes_empty_when_no_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let nodes = load_nodes(dir.path()).await.unwrap();
|
|
assert!(nodes.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_save_and_load_nodes_roundtrip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let nodes = vec![
|
|
make_node("did:key:z1", "a.onion"),
|
|
make_node("did:key:z2", "b.onion"),
|
|
];
|
|
save_nodes(dir.path(), &nodes).await.unwrap();
|
|
let loaded = load_nodes(dir.path()).await.unwrap();
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(loaded[0].did, "did:key:z1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_add_node_deduplicates_by_did() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
|
.await
|
|
.unwrap();
|
|
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_remove_node_by_did() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
|
.await
|
|
.unwrap();
|
|
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
|
.await
|
|
.unwrap();
|
|
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].did, "did:key:z2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_remove_nonexistent_node_errors() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let result = remove_node(dir.path(), "did:key:nonexistent").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_set_trust_level() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
|
.await
|
|
.unwrap();
|
|
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_update_node_state() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let state = NodeStateSnapshot {
|
|
timestamp: "2026-03-10T12:00:00Z".to_string(),
|
|
node_name: None,
|
|
apps: vec![AppStatus {
|
|
id: "bitcoin".to_string(),
|
|
status: "running".to_string(),
|
|
version: Some("27.0".to_string()),
|
|
}],
|
|
cpu_usage_percent: Some(45.2),
|
|
mem_used_bytes: Some(4_000_000_000),
|
|
mem_total_bytes: Some(8_000_000_000),
|
|
disk_used_bytes: None,
|
|
disk_total_bytes: None,
|
|
uptime_secs: Some(86400),
|
|
tor_active: Some(true),
|
|
nostr_npub: None,
|
|
own_fips_npub: None,
|
|
federated_peers: Vec::new(),
|
|
};
|
|
|
|
update_node_state(dir.path(), "did:key:z1", state)
|
|
.await
|
|
.unwrap();
|
|
|
|
let nodes = load_nodes(dir.path()).await.unwrap();
|
|
assert!(nodes[0].last_seen.is_some());
|
|
let ls = nodes[0].last_state.as_ref().unwrap();
|
|
assert_eq!(ls.apps.len(), 1);
|
|
assert_eq!(ls.cpu_usage_percent, Some(45.2));
|
|
}
|
|
}
|