//! 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; /// 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, /// short timeout — fall back to Tor on failure. pub fn client() -> reqwest::Client { reqwest::Client::builder() .timeout(Duration::from_secs(20)) .connect_timeout(Duration::from_secs(5)) .user_agent("archipelago-fips/1") .build() .expect("static reqwest client config") } // ── 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, 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), service: None, } } /// 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) { if let Some(resp) = self.try_fips_post_json(body).await? { return Ok((resp, crate::transport::TransportKind::Fips)); } 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) { if let Some(resp) = self.try_fips_get().await? { return Ok((resp, crate::transport::TransportKind::Fips)); } 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(); 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> { 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( &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")); } }