//! Dial peers over the FIPS mesh. //! //! The FIPS daemon exposes a local DNS resolver on `127.0.0.1:5354` that //! answers AAAA queries for `.fips` with the peer's ULA address on //! the `fips0` TUN. Once resolved we speak plain HTTP to the peer on //! [`PEER_PORT`] — the same port `127.0.0.1:5678` where the archipelago //! backend serves the existing signed peer-to-peer endpoints //! (`/rpc/v1`, `/archipelago/node-message`, `/content/{id}`, …). The //! server-side binding to the `fips0` address is handled in `server.rs`. //! //! The module is deliberately dependency-free for DNS — one packet in, //! one packet out, standard RFC 1035 wire format — to avoid pulling //! hickory-resolver's transitive tree for a single AAAA query. //! //! On any failure (daemon down, peer not in the identity cache, TUN //! unreachable) callers fall back to the Tor transport. //! //! # Examples //! ```ignore //! let base = crate::fips::dial::peer_base_url("npub1…").await?; //! // base = "http://[fd9d:…]:5678" //! let client = crate::fips::dial::client(); //! let resp = client.get(format!("{}/content/abc", base)).send().await?; //! ``` #![allow(dead_code)] use anyhow::{Context, Result}; use std::net::{IpAddr, Ipv6Addr}; use std::time::Duration; use tokio::net::UdpSocket; /// Port the archipelago backend listens on for FIPS peer-to-peer traffic. /// Separate from the localhost-only internal port (5678) so the per-listener /// path filter can restrict the exposed surface. pub const PEER_PORT: u16 = 5679; /// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in /// `Auto` mode. A `404` over FIPS often means the peer's mesh listener /// doesn't expose that path (e.g. a peer on an older build with a stricter /// `is_peer_allowed_path`), and `5xx` is a server-side error — both are /// worth retrying over Tor, which reaches a different (less-filtered) route. /// Success, redirects, and other 4xx (auth / bad request) are authoritative /// and are returned as-is so we neither mask real errors nor double latency. fn fips_should_fall_back(status: reqwest::StatusCode) -> bool { status == reqwest::StatusCode::NOT_FOUND || status.is_server_error() } /// DNS suffix appended to a peer's bech32 npub. pub const FIPS_DNS_SUFFIX: &str = "fips"; /// FIPS daemon's local DNS resolver. pub const FIPS_DNS_ADDR: &str = "127.0.0.1:5354"; /// Short DNS query timeout — FIPS DNS is a local process; a slow answer /// almost certainly means the daemon is gone. const DNS_TIMEOUT: Duration = Duration::from_secs(2); /// DNS AAAA query type. const QTYPE_AAAA: u16 = 28; /// DNS IN class. const QCLASS_IN: u16 = 1; /// Resolve a peer's bech32 npub to their `fips0` ULA address via the local /// FIPS DNS resolver. pub async fn resolve(npub: &str) -> Result { let sock = UdpSocket::bind("127.0.0.1:0") .await .context("bind UDP socket for FIPS DNS")?; sock.connect(FIPS_DNS_ADDR) .await .context("connect to FIPS DNS")?; let id: u16 = rand::random(); let query = encode_query(id, npub)?; tokio::time::timeout(DNS_TIMEOUT, sock.send(&query)) .await .context("FIPS DNS query timed out on send")? .context("FIPS DNS send")?; let mut buf = [0u8; 512]; let n = tokio::time::timeout(DNS_TIMEOUT, sock.recv(&mut buf)) .await .context("FIPS DNS query timed out on recv")? .context("FIPS DNS recv")?; decode_response(id, &buf[..n], npub) } /// Return a peer's base URL on the FIPS overlay, e.g. `http://[fd9d:…]:5678`. pub async fn peer_base_url(npub: &str) -> Result { let ip = resolve(npub).await?; Ok(format!("http://[{}]:{}", ip, PEER_PORT)) } /// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy. /// `connect_timeout` is generous enough to let NAT hole-punching complete on /// the first dial (FIPS is UDP hole-punched; the path often isn't established /// until the first packets flow), so a reachable-but-cold peer isn't abandoned /// to Tor prematurely. Reliability over latency — FIPS is the preferred path. pub fn client() -> reqwest::Client { client_with_timeout(Duration::from_secs(20)) } /// FIPS client with a caller-chosen overall request timeout. The static 20s /// `client()` budget is fine for catalog browses and short calls, but a large /// content download (#38) needs the per-request timeout the caller asked for — /// otherwise a 178MB transfer is aborted at 20s and the whole download fails /// before the Tor fallback ever gets a chance. The generous `connect_timeout` /// is preserved so a cold hole-punched path still gets time to establish. pub fn client_with_timeout(timeout: Duration) -> reqwest::Client { reqwest::Client::builder() .timeout(timeout) .connect_timeout(Duration::from_secs(8)) .user_agent("archipelago-fips/1") .build() .expect("static reqwest client config") } /// Send a FIPS request with ONE retry on a connect/timeout error. /// /// The first dial to a peer typically triggers NAT hole-punching and can time /// out before the overlay path is established; a quick retry then lands on the /// now-warm path. Without this, a single cold-path failure drops the call to /// Tor even though the peer is FIPS-reachable — the main reason FIPS "isn't /// robust". Only connect/timeout errors are retried (a real HTTP response, /// including 4xx/5xx, is returned as-is for the caller to interpret). async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result { let retry = rb.try_clone(); match rb.send().await { Ok(resp) => Ok(resp), Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => { // Brief pause so the hole-punch packets from the first attempt can // traverse before we re-dial onto the warmed path. tokio::time::sleep(Duration::from_millis(600)).await; retry.expect("retry builder present").send().await } Err(e) => Err(e), } } /// Proactively warm the hole-punched FIPS path to a peer: resolve its overlay /// address and open a short connection to its peer listener. Hole-punched /// paths and NAT mappings go cold after ~30-60s of no traffic, after which the /// next real dial pays the full re-punch cost and often falls back to Tor. /// Keeping the path warm is what makes FIPS the transport that actually gets /// used. Best-effort: any error (peer offline, UDP blocked) is ignored — the /// connection attempt itself is what re-punches and refreshes the path. pub async fn warm_path(npub: &str) { if !is_service_active().await { return; } let Ok(base) = peer_base_url(npub).await else { return; }; let c = client(); // The response status is irrelevant; establishing the connection warms it. let _ = tokio::time::timeout(Duration::from_secs(8), c.get(&base).send()).await; } // ── DNS wire-format helpers ───────────────────────────────────────────── fn encode_query(id: u16, npub: &str) -> Result> { let mut out = Vec::with_capacity(64 + npub.len()); // Header out.extend_from_slice(&id.to_be_bytes()); out.extend_from_slice(&0x0100u16.to_be_bytes()); // RD=1, std query out.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT out.extend_from_slice(&0u16.to_be_bytes()); // ANCOUNT out.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT out.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT // QNAME — two labels: "" and "fips". encode_label(&mut out, npub)?; encode_label(&mut out, FIPS_DNS_SUFFIX)?; out.push(0); // root // QTYPE + QCLASS out.extend_from_slice(&QTYPE_AAAA.to_be_bytes()); out.extend_from_slice(&QCLASS_IN.to_be_bytes()); Ok(out) } fn encode_label(out: &mut Vec, label: &str) -> Result<()> { if label.is_empty() || label.len() > 63 { anyhow::bail!("invalid DNS label length: {}", label.len()); } out.push(label.len() as u8); out.extend_from_slice(label.as_bytes()); Ok(()) } fn decode_response(expected_id: u16, buf: &[u8], npub: &str) -> Result { if buf.len() < 12 { anyhow::bail!("DNS response too short"); } let id = u16::from_be_bytes([buf[0], buf[1]]); if id != expected_id { anyhow::bail!("DNS response id mismatch"); } let rcode = buf[3] & 0x0F; if rcode != 0 { anyhow::bail!("DNS rcode {} resolving {}.fips", rcode, npub); } let qdcount = u16::from_be_bytes([buf[4], buf[5]]) as usize; let ancount = u16::from_be_bytes([buf[6], buf[7]]) as usize; if ancount == 0 { anyhow::bail!("no AAAA record for {}.fips", npub); } let mut pos = 12; // Skip question section(s) for _ in 0..qdcount { pos = skip_name(buf, pos)?; pos = pos .checked_add(4) .ok_or_else(|| anyhow::anyhow!("qsection overflow"))?; if pos > buf.len() { anyhow::bail!("qsection past end"); } } // Walk answers; return the first valid AAAA rdata. for _ in 0..ancount { pos = skip_name(buf, pos)?; if pos + 10 > buf.len() { anyhow::bail!("answer RR past end"); } let rtype = u16::from_be_bytes([buf[pos], buf[pos + 1]]); let rclass = u16::from_be_bytes([buf[pos + 2], buf[pos + 3]]); let rdlength = u16::from_be_bytes([buf[pos + 8], buf[pos + 9]]) as usize; pos += 10; if pos + rdlength > buf.len() { anyhow::bail!("rdata past end"); } if rtype == QTYPE_AAAA && rclass == QCLASS_IN && rdlength == 16 { let mut octets = [0u8; 16]; octets.copy_from_slice(&buf[pos..pos + 16]); return Ok(Ipv6Addr::from(octets)); } pos += rdlength; } anyhow::bail!("no AAAA answer for {}.fips", npub) } /// Advance past a DNS name (handles compressed pointers). Returns the /// position immediately after the name. fn skip_name(buf: &[u8], mut pos: usize) -> Result { loop { if pos >= buf.len() { anyhow::bail!("name past end"); } let len = buf[pos]; if len == 0 { return Ok(pos + 1); } if len & 0xC0 == 0xC0 { // Compressed pointer — 2 bytes total, no further labels. if pos + 2 > buf.len() { anyhow::bail!("pointer past end"); } return Ok(pos + 2); } if len & 0xC0 != 0 { anyhow::bail!("reserved label type"); } pos = pos .checked_add(1 + len as usize) .ok_or_else(|| anyhow::anyhow!("name overflow"))?; } } /// Treat `IpAddr::V6` as the raw address for ergonomic callers. 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. /// /// The optional `service` field ties the request to a user-configurable /// transport preference (see `crate::settings::transport`). Leaving it /// unset picks Auto (FIPS preferred, Tor fallback) — the same default as /// before the Settings UI landed. 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, /// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung /// FIPS overlay fails fast within this budget so the Tor fallback still gets /// its full `timeout` — without it, a stuck FIPS dial can consume the whole /// caller budget (e.g. a 60s frontend RPC) and the request "times out" even /// though Tor would have answered (#6, the Pay-with-QR invoice request). /// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a /// large content download needs so its long FIPS transfer isn't truncated. pub fips_timeout: Option, pub service: Option, } 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), fips_timeout: None, service: None, } } /// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a /// cold/hung overlay path fails fast and the Tor fallback keeps its full /// budget. Use on short request/response calls (invoice, status); leave /// unset for large downloads that legitimately need a long FIPS transfer. pub fn fips_timeout(mut self, t: std::time::Duration) -> Self { self.fips_timeout = Some(t); self } /// Timeout to apply to the FIPS attempt — the explicit cap if set, else the /// overall request timeout. fn fips_attempt_timeout(&self) -> std::time::Duration { self.fips_timeout.unwrap_or(self.timeout) } /// Tie this request to a user-configurable service preference. If /// the user has set that service to `Fips` or `Tor`, the builder /// respects it. pub fn service(mut self, s: crate::settings::transport::PeerService) -> Self { self.service = Some(s); self } pub fn header(mut self, name: &'a str, value: impl Into) -> Self { self.headers.push((name, value.into())); self } pub fn timeout(mut self, t: std::time::Duration) -> Self { self.timeout = t; self } /// Resolved preference: user setting if `service` was set, else Auto. async fn preference(&self) -> crate::settings::transport::TransportPref { match self.service { Some(s) => crate::settings::transport::get(s).await, None => crate::settings::transport::TransportPref::Auto, } } /// POST a JSON body. Returns the `reqwest::Response` — caller decides /// how to interpret the status code. pub async fn send_json( &self, body: &B, ) -> Result<(reqwest::Response, crate::transport::TransportKind)> { use crate::settings::transport::TransportPref; let pref = self.preference().await; // FIPS-only or Auto: try FIPS first. if matches!(pref, TransportPref::Auto | TransportPref::Fips) { match self.try_fips_post_json(body).await? { Some(resp) => { // Use the FIPS reply unless it's one a Tor retry could // fix (404 path-not-served / 5xx) and we're allowed to // fall back. FIPS-only never falls back. if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { return Ok((resp, crate::transport::TransportKind::Fips)); } } None => { if pref == TransportPref::Fips { anyhow::bail!( "User set transport preference to FIPS only, but peer is unreachable over 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)> { use crate::settings::transport::TransportPref; let pref = self.preference().await; if matches!(pref, TransportPref::Auto | TransportPref::Fips) { match self.try_fips_get().await? { Some(resp) => { if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { return Ok((resp, crate::transport::TransportKind::Fips)); } } None => { if pref == TransportPref::Fips { anyhow::bail!( "User set transport preference to FIPS only, but peer is unreachable over FIPS" ); } } } } let resp = self.send_tor_get().await?; Ok((resp, crate::transport::TransportKind::Tor)) } async fn try_fips_post_json( &self, body: &B, ) -> Result> { 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_with_timeout(self.fips_attempt_timeout()); let mut rb = c.post(&url).json(body); for (k, v) in &self.headers { rb = rb.header(*k, v); } match send_with_retry(rb).await { Ok(r) => Ok(Some(r)), Err(e) => { tracing::debug!( "FIPS POST {} failed after retry: {}, falling back to Tor", url, e ); Ok(None) } } } async fn try_fips_get(&self) -> Result> { 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_with_timeout(self.fips_attempt_timeout()); let mut rb = c.get(&url); for (k, v) in &self.headers { rb = rb.header(*k, v); } match send_with_retry(rb).await { Ok(r) => Ok(Some(r)), Err(e) => { tracing::debug!( "FIPS GET {} failed after retry: {}, falling back to Tor", url, e ); Ok(None) } } } async fn send_tor_post_json(&self, body: &B) -> Result { 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 { 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 { 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::*; #[test] fn encode_query_round_trip_header_is_correct() { let q = encode_query(0x1234, "npub1abc").unwrap(); assert_eq!(&q[0..2], &[0x12, 0x34]); assert_eq!(&q[2..4], &[0x01, 0x00]); // flags RD=1 assert_eq!(&q[4..6], &[0x00, 0x01]); // QDCOUNT=1 // Tail: QTYPE=28, QCLASS=1 assert_eq!(&q[q.len() - 4..], &[0x00, 0x1C, 0x00, 0x01]); } #[test] fn encode_query_includes_both_labels() { let q = encode_query(0, "npub1xyz").unwrap(); assert!(q.windows(9).any(|w| w == b"\x08npub1xyz")); assert!(q.windows(5).any(|w| w == b"\x04fips")); } #[test] fn decode_response_returns_aaaa_rdata() { // Minimal crafted response: header + qsection + one AAAA answer. let id = 0xBEEFu16; let mut r = Vec::new(); r.extend_from_slice(&id.to_be_bytes()); r.extend_from_slice(&0x8180u16.to_be_bytes()); // QR=1, RD=1, RA=1, rcode=0 r.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT r.extend_from_slice(&1u16.to_be_bytes()); // ANCOUNT r.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT r.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT // Question: 1 label "a" + "fips" r.extend_from_slice(b"\x01a\x04fips\x00"); r.extend_from_slice(&QTYPE_AAAA.to_be_bytes()); r.extend_from_slice(&QCLASS_IN.to_be_bytes()); // Answer: compressed name pointing at question offset 12 r.extend_from_slice(&[0xC0, 0x0C]); r.extend_from_slice(&QTYPE_AAAA.to_be_bytes()); r.extend_from_slice(&QCLASS_IN.to_be_bytes()); r.extend_from_slice(&300u32.to_be_bytes()); // TTL r.extend_from_slice(&16u16.to_be_bytes()); // RDLENGTH let ip: Ipv6Addr = "fd9d:1192:e800:bad0:eed3:4b0e:b273:8e0e".parse().unwrap(); r.extend_from_slice(&ip.octets()); let got = decode_response(id, &r, "a").unwrap(); assert_eq!(got, ip); } #[test] fn decode_rejects_id_mismatch() { let r = vec![0u8; 12]; let err = decode_response(0x1234, &r, "x").unwrap_err(); assert!(err.to_string().contains("id mismatch")); } #[test] fn decode_rejects_rcode() { let mut r = vec![0u8; 12]; r[0] = 0xAA; r[1] = 0xBB; r[3] = 3; // NXDOMAIN let err = decode_response(0xAABB, &r, "x").unwrap_err(); assert!(err.to_string().contains("rcode 3")); } #[test] fn decode_rejects_empty_answer_section() { let mut r = vec![0u8; 12]; r[0] = 0xAA; r[1] = 0xBB; r[4] = 0; r[5] = 0; // QDCOUNT=0 r[6] = 0; r[7] = 0; // ANCOUNT=0 let err = decode_response(0xAABB, &r, "x").unwrap_err(); assert!(err.to_string().contains("no AAAA")); } }