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:
@@ -209,6 +209,194 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
|
||||
IpAddr::V6(v6)
|
||||
}
|
||||
|
||||
// ── High-level peer request helpers ────────────────────────────────────
|
||||
|
||||
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
||||
/// currently `systemctl is-active`? Async wrapper intended for the
|
||||
/// migration call sites; unlike `FipsTransport::is_available` this does
|
||||
/// not maintain a cache, so callers that poll frequently should cache
|
||||
/// themselves.
|
||||
pub async fn is_service_active() -> bool {
|
||||
for unit in [
|
||||
crate::fips::SERVICE_UNIT,
|
||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||
] {
|
||||
if crate::fips::service::unit_state(unit).await == "active" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
||||
/// Tor (fallback). The call sites migrating off direct-Tor dialing build
|
||||
/// one of these and call [`send_json`] / [`send_get`]; the helper handles
|
||||
/// dial, timeout, fallback, and cross-transport auth headers.
|
||||
pub struct PeerRequest<'a> {
|
||||
pub fips_npub: Option<&'a str>,
|
||||
pub onion_host: &'a str,
|
||||
pub path: &'a str,
|
||||
pub headers: Vec<(&'a str, String)>,
|
||||
pub timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl<'a> PeerRequest<'a> {
|
||||
pub fn new(
|
||||
fips_npub: Option<&'a str>,
|
||||
onion_host: &'a str,
|
||||
path: &'a str,
|
||||
) -> Self {
|
||||
Self {
|
||||
fips_npub,
|
||||
onion_host,
|
||||
path,
|
||||
headers: Vec::new(),
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn header(mut self, name: &'a str, value: impl Into<String>) -> Self {
|
||||
self.headers.push((name, value.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn timeout(mut self, t: std::time::Duration) -> Self {
|
||||
self.timeout = t;
|
||||
self
|
||||
}
|
||||
|
||||
/// POST a JSON body. Returns the `reqwest::Response` — caller decides
|
||||
/// how to interpret the status code.
|
||||
pub async fn send_json<B: serde::Serialize>(
|
||||
&self,
|
||||
body: &B,
|
||||
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||
if let Some(resp) = self.try_fips_post_json(body).await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
let resp = self.send_tor_post_json(body).await?;
|
||||
Ok((resp, crate::transport::TransportKind::Tor))
|
||||
}
|
||||
|
||||
/// GET with optional header-based auth.
|
||||
pub async fn send_get(
|
||||
&self,
|
||||
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||
if let Some(resp) = self.try_fips_get().await? {
|
||||
return Ok((resp, crate::transport::TransportKind::Fips));
|
||||
}
|
||||
let resp = self.send_tor_get().await?;
|
||||
Ok((resp, crate::transport::TransportKind::Tor))
|
||||
}
|
||||
|
||||
async fn try_fips_post_json<B: serde::Serialize>(
|
||||
&self,
|
||||
body: &B,
|
||||
) -> Result<Option<reqwest::Response>> {
|
||||
let Some(npub) = self.fips_npub else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_service_active().await {
|
||||
return Ok(None);
|
||||
}
|
||||
let base = match peer_base_url(npub).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client();
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS POST {} failed: {}, falling back to Tor", url, e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
||||
let Some(npub) = self.fips_npub else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_service_active().await {
|
||||
return Ok(None);
|
||||
}
|
||||
let base = match peer_base_url(npub).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client();
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
match rb.send().await {
|
||||
Ok(r) => Ok(Some(r)),
|
||||
Err(e) => {
|
||||
tracing::debug!("FIPS GET {} failed: {}, falling back to Tor", url, e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_tor_post_json<B: serde::Serialize>(
|
||||
&self,
|
||||
body: &B,
|
||||
) -> Result<reqwest::Response> {
|
||||
let url = self.tor_url();
|
||||
let client = self.tor_client()?;
|
||||
let mut rb = client.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
rb.send()
|
||||
.await
|
||||
.with_context(|| format!("Tor POST {}", url))
|
||||
}
|
||||
|
||||
async fn send_tor_get(&self) -> Result<reqwest::Response> {
|
||||
let url = self.tor_url();
|
||||
let client = self.tor_client()?;
|
||||
let mut rb = client.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
}
|
||||
rb.send()
|
||||
.await
|
||||
.with_context(|| format!("Tor GET {}", url))
|
||||
}
|
||||
|
||||
fn tor_url(&self) -> String {
|
||||
let host = if self.onion_host.ends_with(".onion") {
|
||||
self.onion_host.to_string()
|
||||
} else {
|
||||
format!("{}.onion", self.onion_host)
|
||||
};
|
||||
format!("http://{}{}", host, self.path)
|
||||
}
|
||||
|
||||
fn tor_client(&self) -> Result<reqwest::Client> {
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
||||
.context("Invalid Tor SOCKS proxy URL")?;
|
||||
reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("Build Tor HTTP client")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user