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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
becdb1af5a
commit
5479e225d7
@@ -0,0 +1,289 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Detect the `fips0` TUN interface's ULA (fd00::/8) IPv6 address.
|
||||
//!
|
||||
//! The `fips` daemon configures the TUN device with an address derived
|
||||
//! from the node's identity key. We need that address to bind a
|
||||
//! peer-facing listener that is only reachable from the FIPS overlay —
|
||||
//! WAN IPv6 addresses never carry ULA prefixes, so binding specifically
|
||||
//! to the fips0 address keeps the peer surface off the public internet.
|
||||
//!
|
||||
//! We read `/proc/net/if_inet6` rather than shelling out to `ip` so
|
||||
//! this can run under the `archipelago` service user without extra
|
||||
//! capabilities.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
/// Interface name the FIPS daemon creates (matches upstream default in
|
||||
/// `/etc/fips/fips.yaml: tun.name`).
|
||||
pub const FIPS_IFACE: &str = "fips0";
|
||||
|
||||
/// Return the first ULA (fd00::/8) address assigned to `fips0`, if any.
|
||||
///
|
||||
/// - `None` if the interface is missing, has no address, or only has
|
||||
/// link-local addresses.
|
||||
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
|
||||
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
|
||||
pub fn fips0_ula() -> Option<Ipv6Addr> {
|
||||
addresses_on(FIPS_IFACE)
|
||||
.into_iter()
|
||||
.find(|a| is_ula(a))
|
||||
}
|
||||
|
||||
/// List every IPv6 address bound to a given interface from
|
||||
/// `/proc/net/if_inet6`. Returns empty on any parse failure.
|
||||
pub fn addresses_on(iface: &str) -> Vec<Ipv6Addr> {
|
||||
let contents = match std::fs::read_to_string("/proc/net/if_inet6") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
contents
|
||||
.lines()
|
||||
.filter_map(|line| parse_line(line, iface))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `fd00::/8` test — covers the full ULA range.
|
||||
pub fn is_ula(addr: &Ipv6Addr) -> bool {
|
||||
(addr.octets()[0] & 0xFE) == 0xFC
|
||||
}
|
||||
|
||||
fn parse_line(line: &str, iface: &str) -> Option<Ipv6Addr> {
|
||||
// /proc/net/if_inet6 format (whitespace-separated):
|
||||
// <32 hex chars addr> <idx> <prefixlen> <scope> <flags> <devname>
|
||||
// e.g. "fdd8...cd85 6f 80 00 80 fips0"
|
||||
let mut parts = line.split_whitespace();
|
||||
let hex = parts.next()?;
|
||||
let _idx = parts.next()?;
|
||||
let _prefix = parts.next()?;
|
||||
let _scope = parts.next()?;
|
||||
let _flags = parts.next()?;
|
||||
let name = parts.next()?;
|
||||
if name != iface {
|
||||
return None;
|
||||
}
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut octets = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
octets[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(Ipv6Addr::from(octets))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_line_extracts_address() {
|
||||
let line = "fdd83d5aabe08c0ee67f75fcf0d4cd85 6f 80 00 80 fips0";
|
||||
let addr = parse_line(line, "fips0").unwrap();
|
||||
assert_eq!(
|
||||
addr,
|
||||
"fdd8:3d5a:abe0:8c0e:e67f:75fc:f0d4:cd85"
|
||||
.parse::<Ipv6Addr>()
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_rejects_other_iface() {
|
||||
let line = "fdd83d5aabe08c0ee67f75fcf0d4cd85 6f 80 00 80 eth0";
|
||||
assert!(parse_line(line, "fips0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_ignores_malformed() {
|
||||
assert!(parse_line("garbage", "fips0").is_none());
|
||||
assert!(parse_line("shorthex 6f 80 00 80 fips0", "fips0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ula_classifier_matches_fd_range() {
|
||||
assert!(is_ula(&"fd00::1".parse().unwrap()));
|
||||
assert!(is_ula(&"fdff::".parse().unwrap()));
|
||||
assert!(is_ula(&"fc00::1".parse().unwrap()));
|
||||
assert!(!is_ula(&"fe80::1".parse().unwrap())); // link-local
|
||||
assert!(!is_ula(&"2001:db8::1".parse().unwrap())); // global
|
||||
assert!(!is_ula(&"::1".parse().unwrap())); // loopback
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
pub mod service;
|
||||
pub mod update;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user