feat(content): route peer content fetch via FIPS first

All four content-over-peer handlers prefer FIPS when the peer is in
our federation and has advertised a FIPS npub; fall back to Tor
otherwise (unknown peers, FIPS daemon down, transient failure).

- content.handle_content_download_peer / _paid: DID-authenticated
  fetch, payment token header threaded through both transports.
- content.handle_content_browse_peer / _preview: no DID header by
  design (anonymous browse) — still benefits from FIPS when the
  peer happens to be federated.
- federation::fips_npub_for_onion: storage helper that looks up a
  peer's FIPS npub from the federation nodes file given their onion
  address. Suffix-tolerant (`abc` matches `abc.onion`).

Preserves the Tor-only path for truly unknown peers: PeerRequest
returns Err from the Tor branch instead of silently succeeding,
matching the previous behavior when the peer was unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-19 01:29:13 -04:00
parent 17ad45cab7
commit c8defc9bf1
3 changed files with 62 additions and 67 deletions

View File

@ -207,7 +207,9 @@ impl RpcHandler {
Ok(serde_json::json!({ "updated": true }))
}
/// Download content from a peer over Tor, returning base64-encoded data.
/// Download content from a peer. Prefers FIPS when the peer is known
/// in our federation and has advertised a FIPS npub; falls back to
/// Tor on any network failure.
pub(super) async fn handle_content_download_peer(
&self,
params: Option<serde_json::Value>,
@ -227,25 +229,19 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Failed to create SOCKS proxy")?;
let client = reqwest::Client::builder()
.proxy(socks_proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to build Tor HTTP client")?;
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let url = format!("http://{}/content/{}", onion, content_id);
let response = client
.get(&url)
.header("X-Federation-DID", &local_did)
.send()
.await
.context("Failed to connect to peer over Tor")?;
let path = format!("/content/{}", content_id);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120))
.send_get()
.await
.context("Failed to connect to peer")?;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
let body: serde_json::Value = response.json().await.unwrap_or_default();
@ -273,7 +269,8 @@ impl RpcHandler {
}))
}
/// Browse a peer's content catalog over Tor.
/// Browse a peer's content catalog. FIPS if the peer is federated,
/// otherwise Tor.
pub(super) async fn handle_content_browse_peer(
&self,
params: Option<serde_json::Value>,
@ -289,24 +286,17 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Failed to create SOCKS proxy")?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let client = reqwest::Client::builder()
.proxy(socks_proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build Tor HTTP client")?;
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
let url = format!("http://{}/content", onion);
debug!("Browsing peer content at {}", url);
let response = client
.get(&url)
.send()
.await
.context("Failed to connect to peer over Tor")?;
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
@ -354,26 +344,20 @@ impl RpcHandler {
.await
.context("Failed to create ecash payment token — check wallet balance")?;
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Failed to create SOCKS proxy")?;
let client = reqwest::Client::builder()
.proxy(socks_proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to build Tor HTTP client")?;
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let url = format!("http://{}/content/{}", onion, content_id);
let response = client
.get(&url)
.header("X-Federation-DID", &local_did)
.header("X-Payment-Token", &token_str)
.send()
.await
.context("Failed to connect to peer over Tor")?;
let path = format!("/content/{}", content_id);
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str)
.timeout(std::time::Duration::from_secs(120))
.send_get()
.await
.context("Failed to connect to peer")?;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected — token is spent but content not received
@ -420,23 +404,18 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.context("Failed to create SOCKS proxy")?;
let fips_npub =
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let client = reqwest::Client::builder()
.proxy(socks_proxy)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to build Tor HTTP client")?;
let path = format!("/content/{}/preview", content_id);
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
let url = format!("http://{}/content/{}/preview", onion, content_id);
debug!("Fetching content preview from {}", url);
let response = client
.get(&url)
.send()
.await
.context("Failed to connect to peer for preview")?;
let (response, _transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer for preview")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(

View File

@ -12,6 +12,9 @@ mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite};
pub use storage::{add_node, load_nodes, remove_node, save_nodes, set_trust_level, update_node};
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, remove_node, save_nodes, set_trust_level,
update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};

View File

@ -47,6 +47,19 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
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())
}
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let file = NodesFile {