520 lines
18 KiB
Rust
Raw Normal View History

feat(fips): peer dialing + dedicated fips0 listener with path whitelist Wires the FIPS transport end-to-end so peer-to-peer calls can reach other nodes over the mesh without going through Tor: - fips::dial — raw RFC 1035 DNS client (zero new deps) that queries the FIPS daemon's local resolver at 127.0.0.1:5354 for `<npub>.fips` AAAA records. Exposes peer_base_url(npub) → "http://[fd9d:…]:5679" plus a reqwest client factory for call-site migrations. - fips::iface — parses /proc/net/if_inet6 to find the ULA address on `fips0`. Runs under the archipelago service user without extra caps. - FipsTransport::is_available() — live probe of archipelago-fips and upstream fips.service via `systemctl is-active`, cached 10s so the send hot path doesn't thrash DBus. - FipsTransport::send() — resolve npub, POST TransportMessage JSON to the peer's /transport/inbox. Today /transport/inbox isn't wired on the receive side, so call-site migrations use dial::peer_base_url directly against the already-signed endpoints (/rpc/v1, /archipelago/node-message, /content/*). The inbox handler lands as part of the Settings/transport work. - server::serve_with_shutdown — takes an optional peer_addr and spawns a second listener bound specifically to the fips0 ULA on port 5679. The peer listener applies is_peer_allowed_path() — a whitelist of endpoints that already do per-request signature auth — and returns 404 for everything else. Shutdown cascades to both listeners via a watch channel; 5s drain window preserved. - main.rs — if fips0 has a ULA at startup, pass the peer SocketAddr to serve_with_shutdown; otherwise run the main listener only. Security: the peer listener is bound to the fips0 ULA directly, not wildcard, so it's unreachable from WAN IPv6. The path whitelist limits exposure to endpoints whose handlers verify ed25519 signatures or federation DID headers server-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:12:39 -04:00
//! 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)
}
2026-04-19 01:20:44 -04:00
// ── 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.
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
///
/// 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.
2026-04-19 01:20:44 -04:00
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,
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
pub service: Option<crate::settings::transport::PeerService>,
2026-04-19 01:20:44 -04:00
}
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),
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
service: None,
2026-04-19 01:20:44 -04:00
}
}
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
/// 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
}
2026-04-19 01:20:44 -04:00
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
}
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
/// 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,
}
}
2026-04-19 01:20:44 -04:00
/// 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)> {
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
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"
);
}
2026-04-19 01:20:44 -04:00
}
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)> {
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
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"
);
}
2026-04-19 01:20:44 -04:00
}
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")
}
}
feat(fips): peer dialing + dedicated fips0 listener with path whitelist Wires the FIPS transport end-to-end so peer-to-peer calls can reach other nodes over the mesh without going through Tor: - fips::dial — raw RFC 1035 DNS client (zero new deps) that queries the FIPS daemon's local resolver at 127.0.0.1:5354 for `<npub>.fips` AAAA records. Exposes peer_base_url(npub) → "http://[fd9d:…]:5679" plus a reqwest client factory for call-site migrations. - fips::iface — parses /proc/net/if_inet6 to find the ULA address on `fips0`. Runs under the archipelago service user without extra caps. - FipsTransport::is_available() — live probe of archipelago-fips and upstream fips.service via `systemctl is-active`, cached 10s so the send hot path doesn't thrash DBus. - FipsTransport::send() — resolve npub, POST TransportMessage JSON to the peer's /transport/inbox. Today /transport/inbox isn't wired on the receive side, so call-site migrations use dial::peer_base_url directly against the already-signed endpoints (/rpc/v1, /archipelago/node-message, /content/*). The inbox handler lands as part of the Settings/transport work. - server::serve_with_shutdown — takes an optional peer_addr and spawns a second listener bound specifically to the fips0 ULA on port 5679. The peer listener applies is_peer_allowed_path() — a whitelist of endpoints that already do per-request signature auth — and returns 404 for everything else. Shutdown cascades to both listeners via a watch channel; 5s drain window preserved. - main.rs — if fips0 has a ULA at startup, pass the peer SocketAddr to serve_with_shutdown; otherwise run the main listener only. Security: the peer listener is bound to the fips0 ULA directly, not wildcard, so it's unreachable from WAN IPv6. The path whitelist limits exposure to endpoints whose handlers verify ed25519 signatures or federation DID headers server-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:12:39 -04:00
#[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"));
}
}