feat(federation): route state-sync / invites / notifications via FIPS first

Every federation peer-to-peer call now prefers FIPS (direct ULA dial
over `fips0`, ~LAN latency) and falls back to Tor only on network
failure. Per-method ed25519 signatures are preserved on both
transports so authenticity doesn't change.

- fips::dial::PeerRequest — fluent builder that owns transport
  selection. Returns the Response plus the TransportKind that carried
  it, so handlers can log or expose which path was used.
- fips::dial::is_service_active — free-standing async probe used by
  migration sites (the transport::fips::is_available cache is keyed
  to a `&self`, not usable from static contexts).
- federation/sync.rs: sync_with_peer + deploy_to_peer drop the
  hand-rolled reqwest::Proxy dance, call PeerRequest instead.
- federation/invites.rs: notify_join takes the remote's fips_npub
  (already parsed out of the invite code since v1.4) and dials over
  FIPS when available. The "peer-joined" signature domain is
  unchanged.
- api/rpc/federation/handlers.rs: DID rotation broadcast loops over
  federated peers through PeerRequest; the per-peer result payload
  gains a `transport` field so the UI can surface mesh vs. onion.
- api/rpc/tor/mod.rs: onion-address-change propagation is now the
  most useful FIPS-first call — fips_npub is stable across onion
  rotation, so peers get the new address even when the old onion
  is already dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-19 01:20:44 -04:00
co-authored by Claude Opus 4.7
parent 274ed008fe
commit 1fdb5e5cf2
5 changed files with 248 additions and 105 deletions
@@ -6,7 +6,7 @@ use crate::identity;
use crate::mesh;
use crate::network::dwn_store::DwnStore;
use crate::nostr_handshake;
use anyhow::{Context, Result};
use anyhow::Result;
use tracing::{debug, info, warn};
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
@@ -630,14 +630,6 @@ impl RpcHandler {
let nodes = federation::load_nodes(&self.config.data_dir).await?;
let proxy =
reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build HTTP client")?;
let mut notified = 0u32;
let mut failed = 0u32;
let mut results = Vec::new();
@@ -648,13 +640,6 @@ impl RpcHandler {
continue;
}
let host = if node.onion.ends_with(".onion") {
node.onion.clone()
} else {
format!("{}.onion", node.onion)
};
let url = format!("http://{}/rpc/v1", host);
let body = serde_json::json!({
"method": "federation.peer-did-changed",
"params": {
@@ -666,23 +651,31 @@ impl RpcHandler {
}
});
match client.post(&url).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {
let req = crate::fips::dial::PeerRequest::new(
node.fips_npub.as_deref(),
&node.onion,
"/rpc/v1",
)
.timeout(std::time::Duration::from_secs(30));
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
notified += 1;
results.push(serde_json::json!({
"did": node.did,
"status": "ok",
"transport": transport.to_string(),
}));
info!(peer_did = %node.did, "Notified peer of DID rotation");
info!(peer_did = %node.did, transport = %transport, "Notified peer of DID rotation");
}
Ok(resp) => {
Ok((resp, transport)) => {
failed += 1;
results.push(serde_json::json!({
"did": node.did,
"status": "error",
"error": format!("Peer returned {}", resp.status()),
"error": format!("Peer returned {} (via {})", resp.status(), transport),
}));
warn!(peer_did = %node.did, status = %resp.status(), "Peer rejected DID rotation notification");
warn!(peer_did = %node.did, status = %resp.status(), transport = %transport, "Peer rejected DID rotation notification");
}
Err(e) => {
failed += 1;
+17 -20
View File
@@ -417,11 +417,13 @@ pub(super) async fn notify_federation_peers_address_change(
return;
}
};
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
// `tor_proxy` is retained for API compat but unused — the FIPS
// fallback dial uses constants::TOR_SOCKS_PROXY internally.
let _ = tor_proxy;
match federation::load_nodes(data_dir).await {
Ok(peers) => {
for peer in peers {
if peer.onion.is_empty() {
if peer.onion.is_empty() && peer.fips_npub.is_none() {
continue;
}
let payload = serde_json::json!({
@@ -432,24 +434,19 @@ pub(super) async fn notify_federation_peers_address_change(
"old_onion": old_onion,
}
});
let url = format!("http://{}/rpc/v1", &peer.onion);
let client = match reqwest::Client::builder()
.proxy(
match reqwest::Proxy::all(format!("socks5h://{}", proxy)).or_else(
|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY),
) {
Ok(p) => p,
Err(_) => continue,
},
)
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => continue,
};
match client.post(&url).json(&payload).send().await {
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
// FIPS-preferred: peer's fips_npub is stable across
// onion rotation, so this notification reaches them
// even when their (or our) old onion is now stale.
let req = crate::fips::dial::PeerRequest::new(
peer.fips_npub.as_deref(),
&peer.onion,
"/rpc/v1",
)
.timeout(std::time::Duration::from_secs(30));
match req.send_json(&payload).await {
Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
}
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
}
}