Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit f39d6c301d
2058 changed files with 470067 additions and 0 deletions
+603
View File
@@ -0,0 +1,603 @@
//! Federation invite creation, parsing, and acceptance.
use anyhow::{Context, Result};
use std::path::Path;
use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes};
use super::types::{FederatedNode, FederationInvite, TrustLevel};
/// Parsed contents of a federation invite code.
#[derive(Debug, Clone)]
pub struct ParsedInvite {
pub did: String,
pub onion: String,
pub pubkey: String,
/// Per-invite randomness; retained by parsers but not consumed
/// end-to-end — the outer signature binds the relationship.
#[allow(dead_code)]
pub token: String,
/// Inviter's FIPS npub if advertised in the code.
pub fips_npub: Option<String>,
/// Trust level the invite grants both sides. Absent in legacy codes,
/// which default to Trusted.
pub trust_level: TrustLevel,
}
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token, fips_npub?, trust})>`.
/// `fips_npub` is only included when the local node has a materialised FIPS key.
/// `trust_level` is the level BOTH sides assign to each other for this invite
/// ("Invite a Peer" = Observer, "Link Your Nodes" = Trusted).
pub async fn create_invite(
data_dir: &Path,
did: &str,
onion: &str,
pubkey: &str,
fips_npub: Option<&str>,
trust_level: TrustLevel,
) -> Result<String> {
use base64::Engine;
// KEY-05: a federation invite token is unguessable-by-design — it is the
// whole authorisation for a peer join — so the source is named and the
// 16-byte draw is guarded. The `rand::Rng` import that brought `fill` into
// scope is gone with the call that needed it.
let mut token_bytes = [0u8; 16];
crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut token_bytes).map_err(|e| {
anyhow::anyhow!("Refusing to mint an invite token from degenerate entropy: {e}")
})?;
let token = hex::encode(token_bytes);
let mut payload = serde_json::json!({
"did": did,
"onion": onion,
"pubkey": pubkey,
"token": token,
"trust": trust_level.to_string(),
});
if let Some(npub) = fips_npub {
payload["fips_npub"] = serde_json::Value::String(npub.to_string());
}
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
);
let invite = FederationInvite {
code: code.clone(),
did: did.to_string(),
onion: onion.to_string(),
pubkey: pubkey.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
accepted: false,
fips_npub: fips_npub.map(|s| s.to_string()),
trust_level,
};
let mut invites = load_invites(data_dir).await?;
invites.outgoing.push(invite);
save_invites(data_dir, &invites).await?;
Ok(code)
}
/// Parse an invite code into its components.
pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
use base64::Engine;
let encoded = code
.strip_prefix("fed1:")
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.context("Invalid base64 in invite code")?;
let payload: serde_json::Value =
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
let did = payload["did"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
.to_string();
let onion = payload["onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
.to_string();
let pubkey = payload["pubkey"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
.to_string();
let token = payload["token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
.to_string();
let fips_npub = payload
.get("fips_npub")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Legacy codes (pre trust-threading) carry no "trust" field → Trusted,
// which matches what both sides did before the field existed.
let trust_level = payload
.get("trust")
.and_then(|v| v.as_str())
.and_then(TrustLevel::parse)
.unwrap_or(TrustLevel::Trusted);
Ok(ParsedInvite {
did,
onion,
pubkey,
token,
fips_npub,
trust_level,
})
}
/// Accept an invite: parse code, verify the remote node, add to federation.
pub async fn accept_invite(
data_dir: &Path,
code: &str,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<FederatedNode> {
let ParsedInvite {
did,
onion,
pubkey,
token,
fips_npub,
trust_level,
} = parse_invite(code)?;
// Refuse self-peering. If the invite's did / onion / pubkey matches
// our own, adding it pollutes the federation list with a node that
// sees itself as its own peer and causes sync loops. The user
// almost certainly pasted the wrong invite.
if did == local_did || pubkey == local_pubkey || {
let a = onion.trim_end_matches(".onion");
let b = local_onion.trim_end_matches(".onion");
!a.is_empty() && a == b
} {
anyhow::bail!(
"Refusing to federate with self — invite points at this node's own did / onion / pubkey"
);
}
// Make accept idempotent: drop any existing entry that conflicts with
// this invite — same DID (same node, refreshing the link), same onion
// (node rotated identity but kept its hidden service), or same pubkey
// (DID and onion reformatted but the underlying key is the same).
// Whatever is there gets replaced so re-accepting an invite is always
// safe and the user never has to manually remove an entry first.
let mut nodes = load_nodes(data_dir).await?;
let onion_norm = onion.trim_end_matches(".onion");
let before = nodes.len();
nodes.retain(|n| {
n.did != did && n.onion.trim_end_matches(".onion") != onion_norm && n.pubkey != pubkey
});
if nodes.len() != before {
save_nodes(data_dir, &nodes).await?;
tracing::info!(
removed = before - nodes.len(),
new_did = %did,
onion = %onion,
"Replaced stale federation entry on re-accept"
);
}
let node = FederatedNode {
trust_source: Some(super::types::TrustSource::Invite),
did: did.clone(),
pubkey,
onion,
name: None,
// The invite code itself says what this relationship is —
// Observer for "Invite a Peer", Trusted for "Link Your Nodes".
trust_level,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
fips_npub: fips_npub.clone(),
last_transport: None,
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
};
add_node(data_dir, node.clone()).await?;
// Record as incoming accepted invite
let mut invites = load_invites(data_dir).await?;
invites.incoming.push(FederationInvite {
code: code.to_string(),
did: did.clone(),
onion: node.onion.clone(),
pubkey: node.pubkey.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
accepted: true,
fips_npub,
trust_level,
});
save_invites(data_dir, &invites).await?;
// Notify remote node (best-effort, FIPS-first → Tor fallback)
let _ = notify_join(
&node.onion,
node.fips_npub.as_deref(),
local_did,
local_onion,
local_pubkey,
local_fips_npub,
local_name,
Some(&token),
trust_level,
sign_fn,
)
.await;
Ok(node)
}
/// Best-effort notification to the remote node that we joined their federation.
/// Prefers FIPS (if the remote advertised an npub in their invite) and
/// falls back to Tor. Signs the message with our ed25519 key so the
/// remote peer can verify authenticity regardless of transport.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn notify_join(
remote_onion: &str,
remote_fips_npub: Option<&str>,
local_did: &str,
local_onion: &str,
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
invite_token: Option<&str>,
trust_level: TrustLevel,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
// Signature domain intentionally unchanged — fips_npub + name are
// carried as unsigned informational fields. Name is display-only
// (any identity claim is anchored on the signed did/pubkey); the
// FIPS daemon's own Noise handshake authenticates the transport
// session regardless of the advertised npub.
//
// invite_token + trust are also unsigned: the inviter treats the token
// as a lookup key into its own stored invites (authoritative for the
// granted level) and only ever accepts the trust claim as a DOWNGRADE,
// so neither field can escalate privileges.
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
let mut params = serde_json::json!({
"did": local_did,
"onion": local_onion,
"pubkey": local_pubkey,
"signature": signature,
"trust": trust_level.to_string(),
});
if let Some(npub) = local_fips_npub {
params["fips_npub"] = serde_json::Value::String(npub.to_string());
}
if let Some(name) = local_name {
params["name"] = serde_json::Value::String(name.to_string());
}
if let Some(token) = invite_token {
params["invite_token"] = serde_json::Value::String(token.to_string());
}
let body = serde_json::json!({
"method": "federation.peer-joined",
"params": params,
});
// Deliver the notification in the BACKGROUND with retries, and return
// immediately. Two reasons:
// 1. The join RPC must not block on this. Awaiting a cold FIPS overlay
// (no shared FIPS path between LAN and remote/Tailscale peers) stalled
// the whole join until FIPS timed out, surfacing as "Request timeout"
// in the UI even though the local membership was already saved.
// 2. If this single best-effort POST failed, the inviter never learned
// about us → asymmetric federation (they couldn't see us). Retrying in
// the background until it lands makes federation converge to symmetric.
// `fips_timeout` fast-fails a dead FIPS path so the Tor fallback (which
// answers an onion in ~3-5s) is reached quickly on each attempt.
let remote_onion = remote_onion.to_string();
let remote_fips_npub = remote_fips_npub.map(|s| s.to_string());
tokio::spawn(async move {
// ~5 attempts with linear backoff: 0s, 10s, 20s, 30s, 40s — covers a
// peer that is briefly unreachable (restarting, publishing its onion)
// without hammering it.
for attempt in 1..=5u32 {
let res = crate::fips::dial::PeerRequest::new(
remote_fips_npub.as_deref(),
&remote_onion,
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.send_json(&body)
.await;
match res {
Ok((resp, transport)) if resp.status().is_success() => {
tracing::info!(
attempt,
transport = %transport,
"peer-joined notification delivered to inviter"
);
return;
}
Ok((resp, _)) => tracing::warn!(
attempt,
status = %resp.status(),
"peer-joined notification rejected; will retry"
),
Err(e) => {
tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry")
}
}
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
}
tracing::warn!(
onion = %remote_onion,
"peer-joined notification gave up after retries — peer may not see us until next sync"
);
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::storage::load_nodes;
#[tokio::test]
async fn test_create_and_parse_invite() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:z1",
"test.onion",
"aabbcc",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
assert!(code.starts_with("fed1:"));
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.did, "did:key:z1");
assert_eq!(parsed.onion, "test.onion");
assert_eq!(parsed.pubkey, "aabbcc");
assert_eq!(parsed.token.len(), 32); // 16 bytes = 32 hex chars
assert!(parsed.fips_npub.is_none());
}
#[tokio::test]
async fn test_invite_roundtrips_fips_npub() {
let dir = tempfile::tempdir().unwrap();
let fips = "npub1fipstest0000000000000000000000000000000000";
let code = create_invite(
dir.path(),
"did:key:z1",
"test.onion",
"aabbcc",
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.fips_npub.as_deref(), Some(fips));
}
#[tokio::test]
async fn test_parse_invite_tolerates_missing_fips() {
// Older invites minted before fips_npub existed must still parse.
use base64::Engine;
let legacy = serde_json::json!({
"did": "did:key:zOld",
"onion": "old.onion",
"pubkey": "00",
"token": "aa",
});
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_string(&legacy).unwrap())
);
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.did, "did:key:zOld");
assert!(parsed.fips_npub.is_none());
}
#[tokio::test]
async fn test_observer_invite_threads_trust_level() {
// "Invite a Peer" mints an observer invite; the acceptor must land
// on Observer, not Trusted (the original bug).
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
None,
TrustLevel::Observer,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Observer);
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.trust_level, TrustLevel::Observer);
// The inviter's stored outgoing invite carries the level too — this
// is what peer-joined uses as the authoritative grant on the far side.
let invites = load_invites(dir.path()).await.unwrap();
assert_eq!(invites.outgoing.len(), 1);
assert_eq!(invites.outgoing[0].trust_level, TrustLevel::Observer);
}
#[test]
fn test_parse_legacy_invite_defaults_to_trusted() {
// Codes minted before the trust field existed must stay Trusted.
use base64::Engine;
let legacy = serde_json::json!({
"did": "did:key:zOld",
"onion": "old.onion",
"pubkey": "00",
"token": "aa",
});
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_string(&legacy).unwrap())
);
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
}
#[test]
fn test_parse_invalid_invite() {
assert!(parse_invite("invalid").is_err());
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
}
#[tokio::test]
async fn test_accept_invite_creates_node() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
// Accept from a different "local" perspective
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.did, "did:key:zRemote");
assert_eq!(node.trust_level, TrustLevel::Trusted);
let nodes = load_nodes(dir2.path()).await.unwrap();
assert_eq!(nodes.len(), 1);
}
#[tokio::test]
async fn test_accept_invite_persists_fips_npub() {
let dir = tempfile::tempdir().unwrap();
let fips = "npub1remotefipsaddrxxxxxxxxxxxxxxxxxxxxxxxxxx";
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.fips_npub.as_deref(), Some(fips));
}
#[tokio::test]
async fn test_accept_invite_is_idempotent() {
// Re-accepting the same invite is a no-op refresh — it must not
// duplicate the entry and must not error. This is the contract the
// UI relies on: clicking "Join" twice or refreshing after an
// identity rotation always converges to one entry.
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
let dir2 = tempfile::tempdir().unwrap();
accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
let nodes = load_nodes(dir2.path()).await.unwrap();
assert_eq!(nodes.len(), 1, "re-accept should not duplicate");
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Node federation: trusted multi-node clusters with state sync.
//!
//! Nodes federate by exchanging invite codes containing DID + onion address.
//! Trust is bilateral — both sides must agree. Federated nodes periodically
//! sync container status, health metrics, and availability.
mod invites;
pub mod pending;
mod storage;
mod sync;
mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite, parse_invite};
// Crate-internal: used by the periodic federation auto-sync to re-assert
// membership to peers that don't list us back (asymmetry self-heal).
pub(crate) use invites::notify_join;
// Crate-internal: peer-joined resolves the granted trust level by matching
// the acceptor's invite_token against our stored outgoing invites.
pub(crate) use storage::load_invites;
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
record_sync_result, remove_node, save_nodes, set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel, TrustSource};
+374
View File
@@ -0,0 +1,374 @@
//! Pending peer-discovery requests received over Nostr.
//!
//! When another node discovers us via Nostr presence and sends an encrypted
//! `PeerRequest` (NIP-44 DM), we store the request here instead of acting
//! on it. The user explicitly approves or rejects each request via the
//! Federation UI; only on approval do we generate a federation invite code
//! and ship it back over the same encrypted channel.
//!
//! Nothing in this module ever exposes the local onion address. The onion
//! is only added to the wire later, by the approval handler, and only
//! inside a NIP-44 ciphertext addressed to the requester's nostr pubkey.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
const PENDING_FILE: &str = "federation/pending_requests.json";
const MAX_PENDING_PER_PUBKEY: usize = 5;
const PENDING_EXPIRY_DAYS: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PendingState {
/// Inbound: a remote node sent us a peer request, awaiting local approval.
Pending,
/// Outbound: we sent a peer request, awaiting their approval (and the
/// invite code they will send back via NIP-44 if they accept).
Sent,
/// Approved locally — the inbound request has been turned into a federation
/// invite that has been shipped back to the requester. Kept as history.
Approved,
/// Rejected locally. Kept as history so the same npub can't immediately
/// re-request without the user noticing.
Rejected,
/// Auto-expired after `PENDING_EXPIRY_DAYS` with no action.
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingPeerRequest {
/// UUID — stable identifier the FE refers to when approving/rejecting.
pub id: String,
/// Sender's Nostr secp256k1 pubkey (hex). Authoritative for routing
/// the encrypted NIP-44 reply on approval.
pub from_nostr_pubkey: String,
/// Sender's Nostr pubkey in bech32 npub format (display only).
pub from_nostr_npub: String,
/// Sender's claimed archipelago DID. Verified at *approval* time
/// (when their onion arrives via federation.peer-joined), not now —
/// the requester could lie here, but the worst case is a wasted
/// approval slot.
pub from_did: String,
/// Optional friendly name the requester typed.
pub from_name: Option<String>,
/// Optional one-line message the requester attached.
pub message: Option<String>,
pub received_at: String,
pub state: PendingState,
/// True if this row represents an outbound request we sent (`Sent`)
/// rather than an inbound one we received (`Pending`).
#[serde(default)]
pub outbound: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PendingRequestsFile {
pub requests: Vec<PendingPeerRequest>,
}
pub async fn load_pending(data_dir: &Path) -> Result<Vec<PendingPeerRequest>> {
let path = data_dir.join(PENDING_FILE);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read pending requests file")?;
let file: PendingRequestsFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.requests)
}
pub async fn save_pending(data_dir: &Path, requests: &[PendingPeerRequest]) -> Result<()> {
let path = data_dir.join(PENDING_FILE);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create federation dir")?;
}
let file = PendingRequestsFile {
requests: requests.to_vec(),
};
let content =
serde_json::to_string_pretty(&file).context("Failed to serialize pending requests")?;
fs::write(&path, content)
.await
.context("Failed to write pending requests file")?;
Ok(())
}
/// Sweep auto-expired entries. Returns the cleaned list, mutated in place.
fn expire_stale(requests: &mut Vec<PendingPeerRequest>) {
let cutoff = chrono::Utc::now() - chrono::Duration::days(PENDING_EXPIRY_DAYS);
for r in requests.iter_mut() {
if !matches!(r.state, PendingState::Pending | PendingState::Sent) {
continue;
}
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.received_at) {
if ts.with_timezone(&chrono::Utc) < cutoff {
r.state = PendingState::Expired;
}
}
}
}
/// Insert a new inbound peer request. Returns the stored row (with id),
/// or `None` if the request was deduplicated or rate-limited.
///
/// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a
/// `Pending` OR `Approved` entry, do not insert a second one. Including
/// `Approved` is what stops an already-approved peer from re-spawning a fresh
/// pending row every time their request re-syncs (the reported "approve, Poll
/// Now, see approved + a new pending" loop). `Rejected` is intentionally NOT
/// matched so a previously-rejected peer can still ask again later. Otherwise
/// count `Pending` entries per pubkey and reject beyond `MAX_PENDING_PER_PUBKEY`.
pub async fn insert_inbound(
data_dir: &Path,
from_nostr_pubkey: String,
from_nostr_npub: String,
from_did: String,
from_name: Option<String>,
message: Option<String>,
) -> Result<Option<PendingPeerRequest>> {
let mut requests = load_pending(data_dir).await?;
expire_stale(&mut requests);
let already_handled = requests.iter().any(|r| {
r.from_nostr_pubkey == from_nostr_pubkey
&& r.from_did == from_did
&& matches!(r.state, PendingState::Pending | PendingState::Approved)
&& !r.outbound
});
if already_handled {
save_pending(data_dir, &requests).await?;
return Ok(None);
}
let live_count = requests
.iter()
.filter(|r| {
r.from_nostr_pubkey == from_nostr_pubkey
&& matches!(r.state, PendingState::Pending)
&& !r.outbound
})
.count();
if live_count >= MAX_PENDING_PER_PUBKEY {
save_pending(data_dir, &requests).await?;
anyhow::bail!(
"rate-limited: {} already has {} pending requests",
from_nostr_pubkey,
live_count
);
}
let row = PendingPeerRequest {
id: uuid::Uuid::new_v4().to_string(),
from_nostr_pubkey,
from_nostr_npub,
from_did,
from_name,
message,
received_at: chrono::Utc::now().to_rfc3339(),
state: PendingState::Pending,
outbound: false,
};
requests.push(row.clone());
save_pending(data_dir, &requests).await?;
Ok(Some(row))
}
/// Record an outbound peer request we just sent, so the user can see it
/// in the "sent" tab and so the eventual NIP-44 invite reply can be
/// matched against it.
pub async fn insert_outbound(
data_dir: &Path,
to_nostr_pubkey: String,
to_nostr_npub: String,
to_did: String,
to_name: Option<String>,
message: Option<String>,
) -> Result<PendingPeerRequest> {
let mut requests = load_pending(data_dir).await?;
expire_stale(&mut requests);
requests.retain(|r| {
!(r.outbound
&& r.from_nostr_pubkey == to_nostr_pubkey
&& matches!(r.state, PendingState::Sent))
});
let row = PendingPeerRequest {
id: uuid::Uuid::new_v4().to_string(),
from_nostr_pubkey: to_nostr_pubkey,
from_nostr_npub: to_nostr_npub,
from_did: to_did,
from_name: to_name,
message,
received_at: chrono::Utc::now().to_rfc3339(),
state: PendingState::Sent,
outbound: true,
};
requests.push(row.clone());
save_pending(data_dir, &requests).await?;
Ok(row)
}
pub async fn find_by_id(data_dir: &Path, id: &str) -> Result<Option<PendingPeerRequest>> {
let requests = load_pending(data_dir).await?;
Ok(requests.into_iter().find(|r| r.id == id))
}
pub async fn set_state(data_dir: &Path, id: &str, state: PendingState) -> Result<()> {
let mut requests = load_pending(data_dir).await?;
if let Some(r) = requests.iter_mut().find(|r| r.id == id) {
r.state = state;
} else {
anyhow::bail!("Pending request not found: {}", id);
}
save_pending(data_dir, &requests).await?;
Ok(())
}
/// Remove a pending request entirely. Used when the sender cancels an
/// outbound request they initiated and we want it gone (not just marked
/// Rejected/Cancelled — those states fill up the UI audit trail).
pub async fn delete(data_dir: &Path, id: &str) -> Result<()> {
let mut requests = load_pending(data_dir).await?;
let before = requests.len();
requests.retain(|r| r.id != id);
if requests.len() == before {
anyhow::bail!("Pending request not found: {}", id);
}
save_pending(data_dir, &requests).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_insert_inbound_then_dedupes() {
let dir = tempfile::tempdir().unwrap();
let r1 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap();
assert!(r1.is_some());
let r2 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap();
assert!(r2.is_none(), "duplicate Pending request should be ignored");
}
#[tokio::test]
async fn test_approved_request_does_not_respawn_pending() {
// Regression for the "approve → Poll Now → approved + a fresh pending"
// loop: once a request is Approved, a re-synced inbound for the same
// peer must NOT create a new Pending row.
let dir = tempfile::tempdir().unwrap();
let r1 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap()
.expect("first insert stored");
set_state(dir.path(), &r1.id, PendingState::Approved)
.await
.unwrap();
let r2 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap();
assert!(
r2.is_none(),
"an already-approved peer must not re-spawn a pending request"
);
let pending = load_pending(dir.path()).await.unwrap();
assert_eq!(
pending
.iter()
.filter(|r| matches!(r.state, PendingState::Pending))
.count(),
0,
"no Pending rows should remain after approval + re-sync"
);
}
#[tokio::test]
async fn test_rate_limit() {
let dir = tempfile::tempdir().unwrap();
for i in 0..MAX_PENDING_PER_PUBKEY {
let res = insert_inbound(
dir.path(),
"npk-spammer".into(),
"npub-spammer".into(),
format!("did:key:zVar{}", i),
None,
None,
)
.await
.unwrap();
assert!(res.is_some());
}
let result = insert_inbound(
dir.path(),
"npk-spammer".into(),
"npub-spammer".into(),
"did:key:zOverflow".into(),
None,
None,
)
.await;
assert!(result.is_err(), "should rate-limit beyond MAX");
}
#[tokio::test]
async fn test_set_state_round_trip() {
let dir = tempfile::tempdir().unwrap();
let row = insert_inbound(
dir.path(),
"npk2".into(),
"npub2".into(),
"did:key:zXYZ".into(),
Some("Bob".into()),
Some("hi".into()),
)
.await
.unwrap()
.unwrap();
set_state(dir.path(), &row.id, PendingState::Approved)
.await
.unwrap();
let reloaded = find_by_id(dir.path(), &row.id).await.unwrap().unwrap();
assert_eq!(reloaded.state, PendingState::Approved);
}
}
File diff suppressed because it is too large Load Diff
+535
View File
@@ -0,0 +1,535 @@
//! Federation state sync and remote deployment.
//!
//! Requests prefer FIPS (direct ULA dial, ~LAN latency) and fall back to
//! Tor on any network failure. See `crate::fips::dial::PeerRequest` for
//! the fallback mechanics.
use anyhow::{Context, Result};
use std::path::Path;
use super::storage::update_node_state;
use super::types::{AppStatus, FederatedNode, FederationPeerHint, NodeStateSnapshot, TrustLevel};
use crate::fips::dial::PeerRequest;
/// Sync state with a single federated peer. Tries FIPS first; falls back
/// to Tor on any transport-level failure.
pub async fn sync_with_peer(
data_dir: &Path,
peer: &FederatedNode,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<NodeStateSnapshot> {
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "federation.get-state",
"params": {}
});
let (resp, transport) = PeerRequest::new(peer.fips_npub.as_deref(), &peer.onion, "/rpc/v1")
.service(crate::settings::transport::PeerService::Federation)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", signature)
.header("X-Federation-Timestamp", timestamp)
.timeout(std::time::Duration::from_secs(30))
// Fast-fail a cold/unreachable FIPS overlay (common between LAN and
// remote/Tailscale peers that share no FIPS path) so the Tor fallback —
// which answers an onion in ~3-5s — isn't stuck behind the full 30s FIPS
// budget. Without this, a state sync to a FIPS-unreachable peer "took
// ages" and join/sync appeared to time out even though Tor was healthy.
.fips_timeout(std::time::Duration::from_secs(6))
.send_json(&body)
.await
.context("Failed to reach federated peer")?;
if !resp.status().is_success() {
anyhow::bail!("Peer returned {} (via {})", resp.status(), transport);
}
// Record transport used so the UI badge on this peer's card reflects
// the transport that actually carried the call, not a prediction.
if let Err(e) = super::storage::record_peer_transport(
data_dir,
Some(&peer.did),
Some(&peer.onion),
&transport.to_string(),
)
.await
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
let state_val = result
.get("result")
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
let state: NodeStateSnapshot =
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
update_node_state(data_dir, &peer.did, state.clone()).await?;
// Transitive federation: merge in peers our (Trusted) source advertised
// so we can route directly to them over FIPS without a second invite
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
{
tracing::warn!(
peer_did = %peer.did,
error = %e,
"Transitive federation merge failed (non-fatal)"
);
}
}
Ok(state)
}
/// Convenience wrapper: look up a federated peer by DID, derive our
/// own local_did / signing context from the node identity on disk, and
/// call sync_with_peer. Used by transitive-discovery code paths where
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
/// follow-up task).
pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<NodeStateSnapshot> {
let nodes = super::storage::load_nodes(data_dir).await?;
let peer = nodes
.into_iter()
.find(|n| n.did == peer_did)
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
let identity_dir = data_dir.join("identity");
let node_identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let local_pubkey_hex = node_identity.pubkey_hex();
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
sync_with_peer(data_dir, &peer, &local_did, |data| node_identity.sign(data)).await
}
/// Merge peers advertised by a Trusted federated node into our own
/// federation list. New peers are added at `Trusted` — hints only
/// arrive from peers we already trust, and `build_local_state` only
/// re-exports our Trusted list, so transitive membership carries the
/// same trust the direct-invite path gives. Existing peers get their
/// `fips_npub` refreshed if we hadn't learned it yet.
///
/// Peers we are (us) or that we already track by DID are skipped.
async fn merge_transitive_peers(
data_dir: &std::path::Path,
source_did: &str,
local_did: &str,
hints: &[FederationPeerHint],
) -> Result<()> {
if hints.is_empty() {
return Ok(());
}
let mut nodes = super::storage::load_nodes(data_dir).await?;
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
// them via transitive discovery, or deleted (e.g. stale test) nodes
// reappear on the next sync with any peer that still lists them.
let removed = super::storage::load_removed_dids(data_dir)
.await
.unwrap_or_default();
let mut added = 0u32;
let mut refreshed = 0u32;
for hint in hints {
// Don't import the source peer advertising itself, or our own DID
// when the source advertises us back as one of its trusted peers.
if hint.did == source_did || hint.did == local_did {
continue;
}
// Skip anything the operator deliberately removed.
if removed.contains(&hint.did) {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
// Already known — just refresh fips_npub if we didn't have one.
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
existing.fips_npub = hint.fips_npub.clone();
refreshed += 1;
}
continue;
}
// Same physical node advertised under a DIFFERENT did? Match on the
// onion (its stable network identity). Without this, a node that
// appears under two dids (e.g. after a key/did change) gets added
// twice — showing up duplicated in the trusted-node list (B1) and as
// two separate mesh chat contacts (B2). Merge into the existing entry.
let hint_onion = hint.onion.trim_end_matches(".onion");
if !hint_onion.is_empty() {
if let Some(existing) = nodes
.iter_mut()
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
{
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
existing.fips_npub = hint.fips_npub.clone();
}
if existing.name.is_none() && hint.name.is_some() {
existing.name = hint.name.clone();
}
refreshed += 1;
continue;
}
}
// TRUST IS NOT TRANSITIVE. This peer was advertised to us by a Trusted
// source; we have no relationship with it and the operator has never
// seen it. Granting Trusted here made trust viral: once merged at
// Trusted, this node is itself synced with, its advertised peers are
// merged in turn, and one federation invite anywhere in the graph
// eventually marked the whole graph Trusted on every node.
//
// Observer is what the merge actually needs — the stated purpose is
// routing ("so we can route directly to them over FIPS without a second
// invite hop"), and Observer is reachable/syncable while being barred
// from expanding the federation further on its own authority (the
// guard at the call site checks for Trusted). Promotion stays an
// operator action.
nodes.push(FederatedNode {
did: hint.did.clone(),
pubkey: hint.pubkey.clone(),
onion: hint.onion.clone(),
name: hint.name.clone(),
trust_level: TrustLevel::Observer,
trust_source: Some(super::types::TrustSource::TransitiveMerge),
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
fips_npub: hint.fips_npub.clone(),
last_transport: None,
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
});
added += 1;
}
if added > 0 || refreshed > 0 {
super::storage::save_nodes(data_dir, &nodes).await?;
tracing::info!(
source_did = %source_did,
added,
refreshed,
"Transitive federation merge complete"
);
}
Ok(())
}
/// Build the local node's state snapshot for sharing with peers.
///
/// `federated_peers` should be the caller's full list of federated
/// nodes; `build_local_state` filters them down to a `FederationPeerHint`
/// so receivers can perform transitive pairing (learn peers-of-peers
/// and route directly over FIPS from now on). Only peers we trust are
/// shared — an Untrusted/Observer node should not be re-exported
/// through us to the network.
#[allow(clippy::too_many_arguments)]
pub fn build_local_state(
apps: Vec<AppStatus>,
cpu: f64,
mem_used: u64,
mem_total: u64,
disk_used: u64,
disk_total: u64,
uptime: u64,
tor_active: bool,
server_name: Option<String>,
nostr_npub: Option<String>,
own_fips_npub: Option<String>,
federated_peers: &[FederatedNode],
// Only Some when the node has opted in via server.set-location's
// `share` flag — see NodeStateSnapshot::lat/lon's doc comment.
shared_location: Option<(f64, f64)>,
) -> NodeStateSnapshot {
let hints = federated_peers
.iter()
.filter(|n| n.trust_level == TrustLevel::Trusted)
.map(|n| FederationPeerHint {
did: n.did.clone(),
pubkey: n.pubkey.clone(),
onion: n.onion.clone(),
name: n.name.clone(),
fips_npub: n.fips_npub.clone(),
})
.collect();
NodeStateSnapshot {
timestamp: chrono::Utc::now().to_rfc3339(),
node_name: server_name,
apps,
cpu_usage_percent: Some(cpu),
mem_used_bytes: Some(mem_used),
mem_total_bytes: Some(mem_total),
disk_used_bytes: Some(disk_used),
disk_total_bytes: Some(disk_total),
uptime_secs: Some(uptime),
tor_active: Some(tor_active),
nostr_npub,
own_fips_npub,
federated_peers: hints,
lat: shared_location.map(|(lat, _)| lat),
lon: shared_location.map(|(_, lon)| lon),
}
}
/// Deploy an app to a remote federated peer over Tor.
/// Only works if the peer is trusted and the app exists in our marketplace.
pub async fn deploy_to_peer(
peer: &FederatedNode,
app_id: &str,
version: &str,
marketplace_url: &str,
local_did: &str,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<serde_json::Value> {
if peer.trust_level != TrustLevel::Trusted {
anyhow::bail!(
"Can only deploy to trusted peers (current: {})",
peer.trust_level
);
}
let timestamp = chrono::Utc::now().to_rfc3339();
let signature = sign_fn(timestamp.as_bytes());
let body = serde_json::json!({
"method": "package.install",
"params": {
"id": app_id,
"version": version,
"marketplace-url": marketplace_url,
}
});
let (resp, transport) = PeerRequest::new(peer.fips_npub.as_deref(), &peer.onion, "/rpc/v1")
.service(crate::settings::transport::PeerService::Federation)
.header("X-Federation-DID", local_did)
.header("X-Federation-Sig", signature)
.header("X-Federation-Timestamp", timestamp)
.timeout(std::time::Duration::from_secs(120))
.send_json(&body)
.await
.context("Failed to reach federated peer for deploy")?;
if !resp.status().is_success() {
anyhow::bail!(
"Remote node returned HTTP {} (via {})",
resp.status(),
transport
);
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
if let Some(err) = result.get("error") {
if !err.is_null() {
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown remote error");
anyhow::bail!("Remote node refused deploy: {}", msg);
}
}
Ok(serde_json::json!({
"deployed": true,
"app_id": app_id,
"peer_did": peer.did,
"peer_onion": peer.onion,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_local_state() {
let state = build_local_state(
vec![AppStatus {
id: "lnd".to_string(),
status: "running".to_string(),
version: Some("0.18".to_string()),
}],
25.5,
2_000_000_000,
8_000_000_000,
100_000_000_000,
500_000_000_000,
3600,
true,
Some("Test Node".to_string()),
None,
None,
&[],
None,
);
assert_eq!(state.apps.len(), 1);
assert_eq!(state.cpu_usage_percent, Some(25.5));
assert_eq!(state.tor_active, Some(true));
assert_eq!(state.node_name, Some("Test Node".to_string()));
assert!(state.federated_peers.is_empty());
assert_eq!(state.lat, None);
}
#[test]
fn build_local_state_filters_non_trusted_peers() {
let peers = vec![
FederatedNode {
trust_source: None,
did: "did:key:zTrusted".into(),
pubkey: "aa".into(),
onion: "t.onion".into(),
name: None,
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: Some("npub1a".into()),
last_transport: None,
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
},
FederatedNode {
trust_source: None,
did: "did:key:zObserver".into(),
pubkey: "bb".into(),
onion: "o.onion".into(),
name: None,
trust_level: TrustLevel::Observer,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: Some("npub1b".into()),
last_transport: None,
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
},
FederatedNode {
trust_source: None,
did: "did:key:zUntrusted".into(),
pubkey: "cc".into(),
onion: "u.onion".into(),
name: None,
trust_level: TrustLevel::Untrusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
},
];
let state = build_local_state(
vec![],
0.0,
0,
0,
0,
0,
0,
true,
None,
None,
None,
&peers,
None,
);
assert_eq!(state.federated_peers.len(), 1);
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
assert_eq!(
state.federated_peers[0].fips_npub.as_deref(),
Some("npub1a")
);
}
#[tokio::test]
async fn merge_transitive_peers_skips_source_and_local_node() {
let dir = tempfile::tempdir().unwrap();
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
trust_source: None,
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
last_sync_error: None,
last_sync_error_at: None,
}],
)
.await
.unwrap();
merge_transitive_peers(
dir.path(),
"did:key:zSource",
"did:key:zLocal",
&[
FederationPeerHint {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zLocal".into(),
pubkey: "bb".into(),
onion: "local.onion".into(),
name: Some("Local".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zPeer".into(),
pubkey: "cc".into(),
onion: "peer.onion".into(),
name: Some("Kitchen".into()),
fips_npub: Some("npub1peer".into()),
},
],
)
.await
.unwrap();
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
let peer = nodes
.iter()
.find(|n| n.did == "did:key:zPeer")
.expect("transitive peer should be added (routing needs it)");
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
// TRUST IS NOT TRANSITIVE. This peer was advertised by a Trusted source;
// the operator has never seen it. It is added so we can route to it, at
// Observer — never Trusted. This assertion previously read `Trusted` and
// was pinning the escalation in place: one invite anywhere in the graph
// eventually marked the entire graph Trusted on every node.
assert_eq!(
peer.trust_level,
TrustLevel::Observer,
"a transitively-discovered peer must never be auto-Trusted"
);
assert_eq!(
peer.trust_source,
Some(super::super::types::TrustSource::TransitiveMerge),
"provenance must be recorded so the operator can audit it"
);
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Federation type definitions.
use serde::{Deserialize, Serialize};
/// Trust level for a federated node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustLevel {
Trusted,
Observer,
Untrusted,
}
impl std::fmt::Display for TrustLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustLevel::Trusted => write!(f, "trusted"),
TrustLevel::Observer => write!(f, "observer"),
TrustLevel::Untrusted => write!(f, "untrusted"),
}
}
}
impl TrustLevel {
/// Parse the lowercase wire form ("trusted" | "observer" | "untrusted").
pub fn parse(s: &str) -> Option<Self> {
match s {
"trusted" => Some(TrustLevel::Trusted),
"observer" => Some(TrustLevel::Observer),
"untrusted" => Some(TrustLevel::Untrusted),
_ => None,
}
}
/// The lower (less privileged) of two levels: Untrusted < Observer < Trusted.
pub fn min(self, other: Self) -> Self {
fn rank(l: TrustLevel) -> u8 {
match l {
TrustLevel::Untrusted => 0,
TrustLevel::Observer => 1,
TrustLevel::Trusted => 2,
}
}
if rank(self) <= rank(other) {
self
} else {
other
}
}
}
/// A federated node in our cluster.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedNode {
pub did: String,
pub pubkey: String,
pub onion: String,
#[serde(default)]
pub name: Option<String>,
pub trust_level: TrustLevel,
pub added_at: String,
#[serde(default)]
pub last_seen: Option<String>,
#[serde(default)]
pub last_state: Option<NodeStateSnapshot>,
/// FIPS mesh npub (bech32) for this peer, when they advertise one.
/// Lets the transport router prefer FIPS over Tor for peer traffic.
#[serde(default)]
pub fips_npub: Option<String>,
/// Transport kind used on the most recent successful reach
/// ("fips" | "tor" | "mesh" | "lan"). Written after each successful
/// PeerRequest so the UI can show a ground-truth badge ("this peer
/// is currently being reached over FIPS") instead of a prediction
/// based on available addresses.
#[serde(default)]
pub last_transport: Option<String>,
/// RFC 3339 timestamp of the last_transport value.
#[serde(default)]
pub last_transport_at: Option<String>,
/// Error from the most recent federation sync attempt with this peer,
/// `None` when that attempt succeeded. Written back after every attempt
/// (same shape as `last_transport`, for the failure side) so the
/// operator can tell a peer that hasn't synced in days from one that
/// synced a minute ago — previously a failed sync existed only as a
/// `debug!` log line, making the two indistinguishable in the UI.
///
/// Truncated to `storage::MAX_SYNC_ERROR_CHARS` before it is persisted
/// so a pathological error can't bloat `nodes.json` on every pass, and
/// carries only the error's own display string — never credential
/// material (see FED-02's privacy prohibition).
#[serde(default)]
pub last_sync_error: Option<String>,
/// RFC 3339 timestamp of the last_sync_error value. Cleared together
/// with `last_sync_error` when the peer recovers.
#[serde(default)]
pub last_sync_error_at: Option<String>,
/// HOW this peer's trust level came to be what it is.
///
/// `None` means "recorded before this field existed" — which is exactly
/// the population an operator needs to audit, because it is the set that
/// may have been granted Trusted by the two fail-open paths this field was
/// added to close (an uninvited `federation.peer-joined`, and transitive
/// merge). It deliberately does NOT default to a made-up provenance: an
/// unknown origin must read as unknown, not as `Invite`.
#[serde(default)]
pub trust_source: Option<TrustSource>,
}
/// Why a federated node holds the trust level it does.
///
/// Trust must be traceable to an operator decision. Anything that is not is a
/// candidate for review, which is what makes this worth persisting rather than
/// logging.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrustSource {
/// Matched an invite THIS node minted — the only path that may grant
/// `Trusted`. The level is the one the operator chose when minting.
Invite,
/// Joined via `federation.peer-joined` without presenting an invite token
/// we recognise. Capped at `Observer`: the caller is unauthenticated and
/// its signature only proves it holds the key it just supplied, never that
/// the operator ever invited it.
UninvitedJoin,
/// Learned from a Trusted peer's advertised peer list (transitive merge).
/// Capped at `Observer`: trust is not transitive, and a peer must not be
/// able to expand our trusted set on its own authority.
TransitiveMerge,
/// Set explicitly by the operator through the federation UI/RPC.
Manual,
}
/// State snapshot received from a federated peer during sync.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeStateSnapshot {
pub timestamp: String,
#[serde(default)]
pub node_name: Option<String>,
#[serde(default)]
pub apps: Vec<AppStatus>,
#[serde(default)]
pub cpu_usage_percent: Option<f64>,
#[serde(default)]
pub mem_used_bytes: Option<u64>,
#[serde(default)]
pub mem_total_bytes: Option<u64>,
#[serde(default)]
pub disk_used_bytes: Option<u64>,
#[serde(default)]
pub disk_total_bytes: Option<u64>,
#[serde(default)]
pub uptime_secs: Option<u64>,
#[serde(default)]
pub tor_active: Option<bool>,
/// bech32-encoded Nostr identity pubkey (npub1…) for cross-transport
/// peer identification in the mesh UI. Optional: older nodes that
/// haven't synced after this field was added will report None.
#[serde(default)]
pub nostr_npub: Option<String>,
/// The sender's own FIPS npub (bech32). Lets pre-FIPS federation
/// pairs — who federated before v1.4 added fips_npub to the invite
/// code — discover each other's FIPS identity on the next state
/// sync and route over FIPS from then on. Optional for back-compat
/// with older peers.
#[serde(default)]
pub own_fips_npub: Option<String>,
/// Minimal summary of peers this node trusts, used for transitive
/// federation: when Alice syncs with Bob, she learns Bob's trusted
/// peers and adds them as Observers on her side so `fips_npub` is
/// known and future state-syncs can route directly. Bounded to one
/// hop (Alice doesn't auto-promote Observer-via-Bob to Trusted nor
/// re-export them in her own state snapshots).
#[serde(default)]
pub federated_peers: Vec<FederationPeerHint>,
/// This node's own location, for the Mesh Map — only present when the
/// sender has opted in via `server.set-location`'s `share` flag. Absent
/// (not just null) for nodes that haven't opted in, so older receivers
/// and the map's "no location shared" state both fall out naturally.
#[serde(default)]
pub lat: Option<f64>,
#[serde(default)]
pub lon: Option<f64>,
}
/// Minimal peer summary shared via `NodeStateSnapshot.federated_peers`.
/// Excludes sensitive/per-receiver fields like trust_level and added_at.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationPeerHint {
pub did: String,
pub pubkey: String,
pub onion: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub fips_npub: Option<String>,
}
/// Status of a single app/container on a remote node.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppStatus {
pub id: String,
pub status: String, // "running", "stopped", "installed"
#[serde(default)]
pub version: Option<String>,
}
/// A pending invite (outgoing or incoming).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederationInvite {
pub code: String,
pub did: String,
pub onion: String,
pub pubkey: String,
pub created_at: String,
#[serde(default)]
pub accepted: bool,
/// Inviter's FIPS mesh npub if advertised in the code.
#[serde(default)]
pub fips_npub: Option<String>,
/// Trust level this invite grants both sides ("Invite a Peer" mints
/// Observer invites, "Link Your Nodes" mints Trusted ones). Defaults
/// to Trusted for invites minted before this field existed.
#[serde(default = "default_invite_trust")]
pub trust_level: TrustLevel,
}
fn default_invite_trust() -> TrustLevel {
TrustLevel::Trusted
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trust_level_serialization() {
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
assert_eq!(json, "\"trusted\"");
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
assert_eq!(parsed, TrustLevel::Observer);
}
#[test]
fn test_federated_node_serialization_roundtrip() {
let node = FederatedNode {
trust_source: None,
did: "did:key:zABC".to_string(),
pubkey: "aabbccdd".to_string(),
onion: "test.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,
last_sync_error: None,
last_sync_error_at: None,
};
let json = serde_json::to_string(&node).unwrap();
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.did, "did:key:zABC");
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
assert!(parsed.last_state.is_none());
assert!(parsed.fips_npub.is_none());
}
#[test]
fn test_federated_node_deserializes_without_fips_field() {
// Backward compat: nodes on older versions omit fips_npub entirely.
let json = r#"{
"did": "did:key:zOld",
"pubkey": "0011",
"onion": "old.onion",
"trust_level": "trusted",
"added_at": "2026-01-01T00:00:00Z"
}"#;
let parsed: FederatedNode = serde_json::from_str(json).unwrap();
assert!(parsed.fips_npub.is_none());
}
#[test]
fn test_node_state_snapshot_defaults() {
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
assert!(state.apps.is_empty());
assert!(state.cpu_usage_percent.is_none());
}
}