Adds a user-configurable toggle for how each peer-to-peer service reaches federated peers. Three options per service: - Auto (default) — FIPS preferred, Tor fallback (current behavior). - FIPS only — fail rather than fall through to Tor. - Tor only — explicit opt-in to onion anonymity for that service. Services covered (matching the UI rows): - Federation — state sync, invites, peer notifications - Peers — address/DID rotation broadcasts - Peer Files — content catalog download/browse/preview - Messaging — archipelago channel + mesh bridge - Mesh File Sharing — content_ref blob fetches Implementation: - settings::transport — persisted struct + process-wide OnceLock handle (so deep call sites don't need data_dir threaded through signatures). On-disk file: <data_dir>/settings/transport_preferences.json; missing or corrupt → defaults (Auto everywhere). - settings::transport::init() called from main.rs after config load. - fips::dial::PeerRequest gains a .service(kind) builder; send_* checks the preference before choosing a transport. FIPS-only fails loudly when FIPS is unavailable (so users who pick it know when something falls back). - Every FIPS-first migration site tags its PeerRequest with the matching PeerService so the toggle actually applies. - transport.preferences + transport.set-preference RPCs added; wired into the dispatcher. - neode-ui/src/views/settings/TransportPrefsCard.vue — standalone card with a 5-row Auto/FIPS/Tor tri-state. Not wired into Settings.vue — the user places components themselves (see feedback_ui_entry_points). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
520 lines
18 KiB
Rust
520 lines
18 KiB
Rust
//! 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 `<npub>.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<Ipv6Addr> {
|
|
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<String> {
|
|
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<Vec<u8>> {
|
|
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: "<npub>" 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<u8>, 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<Ipv6Addr> {
|
|
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<usize> {
|
|
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<crate::settings::transport::PeerService>,
|
|
}
|
|
|
|
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<String>) -> 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<B: serde::Serialize>(
|
|
&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<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::*;
|
|
|
|
#[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"));
|
|
}
|
|
}
|