Dorian 683553dfde feat(settings): per-service FIPS/Tor transport preference
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>
2026-04-19 01:44:41 -04:00

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"));
}
}