archipelago 1bce694ebb feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes
UI (this session):
- Global audio player now scales the whole interface into the space above it
  on desktop (sidebar + main) and docks directly above the tab bar on mobile;
  it stays visible while navigating.
- Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip
  with a single fixed, internally-scrolling pane (page no longer scrolls);
  tabs hide while a conversation is open; floating back button; collapsible
  Device panel (starts collapsed); keyboard-aware conversation sizing via
  VisualViewport so the chat sits just above the keyboard.
- Cloud file grid: uniform 4/3 card heights (folders + images match).
- Swipe left/right switches tabs on the Apps and Web5 screens.
- Map tool fills its pane (no bottom gap); fix skewed Share Location toggle
  on mobile (global min-height rule was deforming the switch).
- Trim redundant helper copy from the mesh AI tab.

Also bundles pre-existing in-progress work that was already in the tree:
mesh listener/session + wallet + container + bitcoin-status backend changes,
docker UI updates, and assorted other UI tweaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:52:26 -04:00

619 lines
23 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;
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
/// worth retrying over Tor, which reaches a different (less-filtered) route.
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
/// and are returned as-is so we neither mask real errors nor double latency.
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// 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.
/// `connect_timeout` is generous enough to let NAT hole-punching complete on
/// the first dial (FIPS is UDP hole-punched; the path often isn't established
/// until the first packets flow), so a reachable-but-cold peer isn't abandoned
/// to Tor prematurely. Reliability over latency — FIPS is the preferred path.
pub fn client() -> reqwest::Client {
client_with_timeout(Duration::from_secs(20))
}
/// FIPS client with a caller-chosen overall request timeout. The static 20s
/// `client()` budget is fine for catalog browses and short calls, but a large
/// content download (#38) needs the per-request timeout the caller asked for —
/// otherwise a 178MB transfer is aborted at 20s and the whole download fails
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
/// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1")
.build()
.expect("static reqwest client config")
}
/// Send a FIPS request with ONE retry on a connect/timeout error.
///
/// The first dial to a peer typically triggers NAT hole-punching and can time
/// out before the overlay path is established; a quick retry then lands on the
/// now-warm path. Without this, a single cold-path failure drops the call to
/// Tor even though the peer is FIPS-reachable — the main reason FIPS "isn't
/// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret).
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
let retry = rb.try_clone();
match rb.send().await {
Ok(resp) => Ok(resp),
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
// Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await;
retry.expect("retry builder present").send().await
}
Err(e) => Err(e),
}
}
/// Proactively warm the hole-punched FIPS path to a peer: resolve its overlay
/// address and open a short connection to its peer listener. Hole-punched
/// paths and NAT mappings go cold after ~30-60s of no traffic, after which the
/// next real dial pays the full re-punch cost and often falls back to Tor.
/// Keeping the path warm is what makes FIPS the transport that actually gets
/// used. Best-effort: any error (peer offline, UDP blocked) is ignored — the
/// connection attempt itself is what re-punches and refreshes the path.
pub async fn warm_path(npub: &str) {
if !is_service_active().await {
return;
}
let Ok(base) = peer_base_url(npub).await else {
return;
};
let c = client();
// The response status is irrelevant; establishing the connection warms it.
let _ = tokio::time::timeout(Duration::from_secs(8), c.get(&base).send()).await;
}
// ── 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,
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
/// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<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),
fips_timeout: None,
service: None,
}
}
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
/// budget. Use on short request/response calls (invoice, status); leave
/// unset for large downloads that legitimately need a long FIPS transfer.
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
self.fips_timeout = Some(t);
self
}
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
/// overall request timeout.
fn fips_attempt_timeout(&self) -> std::time::Duration {
self.fips_timeout.unwrap_or(self.timeout)
}
/// 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) {
match self.try_fips_post_json(body).await? {
Some(resp) => {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
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) {
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
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_with_timeout(self.fips_attempt_timeout());
let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!(
"FIPS POST {} failed after retry: {}, 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_with_timeout(self.fips_attempt_timeout());
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!(
"FIPS GET {} failed after retry: {}, 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"));
}
}