Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 8c78a7d4c2
1560 changed files with 331970 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
//! Seed-anchor management for FIPS bootstrap.
//!
//! A freshly-installed node can't reach the global mesh via npub
//! routing until it's connected to at least one peer that's already in
//! the DHT. Upstream `fips` solves this by dialing a public anchor
//! (e.g. `fips.v0l.io`) on first start. That's a single point of
//! failure and doesn't help nodes behind restrictive firewalls or
//! intermittent networks — archipelago operators reported fresh
//! installs failing to reach any public anchor.
//!
//! This module adds a local, operator-editable seed-anchor list. Each
//! entry is a `{npub, address, transport}` triple that archipelago
//! pushes into the running daemon via `fipsctl connect` on startup and
//! periodically thereafter. If one anchor falls over, the next one
//! seeds the DHT instead. A well-configured cluster (e.g. a VPS
//! running fips in anchor mode + a couple of home nodes) stops
//! depending on the global anchor entirely.
//!
//! The list is persisted at `<data_dir>/seed-anchors.json`. The
//! archipelago service user owns that directory, so no sudo is needed
//! to read or write it.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
/// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
/// Operators can remove it from the UI once their own cluster has
/// independent anchors (removal persists, see `load`/`remove`).
///
/// IMPORTANT transport details, learned the hard way (see git history /
/// the 2026-06-15 debugging on .116):
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
/// (host pings on both IP families but never completes a UDP FIPS
/// handshake). `fips/config.rs` always knew this; the old default
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
/// An IPv4 literal sidesteps the resolver entirely.
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
label: "Public anchor (fips.v0l.io)".to_string(),
}
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeedAnchor {
/// Bech32 `npub1...` of the anchor's FIPS identity.
pub npub: String,
/// Directly-dialable transport address, e.g. `192.168.1.116:8668`.
pub address: String,
/// Transport to use — almost always `"udp"`.
#[serde(default = "default_transport")]
pub transport: String,
/// Human-readable note shown in the UI (e.g. "Home anchor", "VPS").
#[serde(default)]
pub label: String,
}
fn default_transport() -> String {
"udp".to_string()
}
fn anchors_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_ANCHORS_FILE)
}
/// Load the seed-anchor list. A node that has never edited its anchor
/// list (no file yet) gets the default public anchor so it can bootstrap
/// the mesh out of the box. Once the operator edits anchors — including
/// removing the default — a file exists and is authoritative, so removal
/// persists and we never silently re-add it.
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(vec![default_public_anchor()]);
}
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let anchors: Vec<SeedAnchor> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
Ok(anchors)
}
/// Persist the list. Overwrites atomically via write-then-rename so a
/// crashed archipelago never leaves a half-written config.
pub async fn save(data_dir: &Path, anchors: &[SeedAnchor]) -> Result<()> {
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
let path = anchors_path(data_dir);
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(anchors).context("serialize seed anchors")?;
tokio::fs::write(&tmp, json)
.await
.with_context(|| format!("write {}", tmp.display()))?;
tokio::fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Add (or update) one anchor, keyed by npub. Returns the resulting list.
pub async fn add(data_dir: &Path, anchor: SeedAnchor) -> Result<Vec<SeedAnchor>> {
let mut list = load(data_dir).await?;
if let Some(existing) = list.iter_mut().find(|a| a.npub == anchor.npub) {
*existing = anchor;
} else {
list.push(anchor);
}
save(data_dir, &list).await?;
Ok(list)
}
/// Remove an anchor by npub. Returns the resulting list.
pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
let mut list = load(data_dir).await?;
list.retain(|a| a.npub != npub);
save(data_dir, &list).await?;
Ok(list)
}
/// Apply the seed anchors to the running FIPS daemon. For each entry,
/// asks `fipsctl connect` to dial the peer. Errors are logged but don't
/// fail the whole operation — a single unreachable anchor shouldn't
/// block the others.
///
/// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on
/// a timer is safe. Returns a list of per-anchor results for logging.
///
/// Invoked through `sudo -n`: the upstream daemon's control socket
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
/// archipelago service user is not in the `fips` group, so a bare
/// `fipsctl connect` fails with EACCES. This matches the privileged
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
/// Without it, seed anchors persist to disk but never actually dial,
/// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("sudo")
.args([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
Ok(o) if o.status.success() => ApplyResult {
npub: anchor.npub.clone(),
ok: true,
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
},
Ok(o) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!(
"sudo fipsctl connect exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
},
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!("sudo fipsctl launch failed: {}", e),
},
};
if result.ok {
tracing::debug!(npub = %result.npub, "Seed anchor applied");
} else {
tracing::warn!(
npub = %result.npub,
message = %result.message,
"Seed anchor apply failed (non-fatal)"
);
}
results.push(result);
}
results
}
/// Outcome of a single `fipsctl connect` call.
#[derive(Debug, Clone)]
pub struct ApplyResult {
pub npub: String,
pub ok: bool,
pub message: String,
}
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
const FIPS_UDP_PORT: u16 = 8668;
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
/// directly over their FIPS UDP transport. For each peer the registry knows both
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
/// instead of depending on the global anchor's spanning tree to route between
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
///
/// This is FIPS's own UDP transport over the LAN — not Tailscale, not the LAN
/// HTTP messaging port. NOT persisted to `seed-anchors.json`: recomputed each
/// apply tick from live LAN discovery, so a peer's changing IP self-corrects and
/// stale entries never accumulate. `fipsctl connect` is idempotent, so
/// re-applying just keeps the link warm.
pub fn lan_fips_anchors(peers: &[crate::transport::PeerRecord]) -> Vec<SeedAnchor> {
let mut out = Vec::new();
for p in peers {
let (Some(lan), Some(npub)) = (p.lan_address.as_deref(), p.fips_npub.as_deref()) else {
continue;
};
// lan_address is the peer's HTTP/LAN socket ("ip:port"); reuse only its IP
// and target the FIPS UDP port. SocketAddr::new(...).to_string() formats
// IPv6 with brackets correctly.
let Ok(sa) = lan.parse::<std::net::SocketAddr>() else {
continue;
};
out.push(SeedAnchor {
npub: npub.to_string(),
address: std::net::SocketAddr::new(sa.ip(), FIPS_UDP_PORT).to_string(),
transport: "udp".to_string(),
label: "LAN federation peer (direct FIPS)".to_string(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn mk(npub: &str) -> SeedAnchor {
SeedAnchor {
npub: npub.to_string(),
address: "example.test:8668".to_string(),
transport: "udp".to_string(),
label: "test".to_string(),
}
}
#[tokio::test]
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
}
#[tokio::test]
async fn save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let a = mk("npub1aaa");
let b = mk("npub1bbb");
save(dir.path(), &[a.clone(), b.clone()]).await.unwrap();
let got = load(dir.path()).await.unwrap();
assert_eq!(got, vec![a, b]);
}
#[tokio::test]
async fn add_replaces_existing_by_npub() {
let dir = tempfile::tempdir().unwrap();
let mut a = mk("npub1aaa");
save(dir.path(), &[a.clone()]).await.unwrap();
a.address = "newhost:8668".to_string();
let list = add(dir.path(), a.clone()).await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].address, "newhost:8668");
}
#[tokio::test]
async fn remove_by_npub() {
let dir = tempfile::tempdir().unwrap();
save(
dir.path(),
&[mk("npub1aaa"), mk("npub1bbb"), mk("npub1ccc")],
)
.await
.unwrap();
let list = remove(dir.path(), "npub1bbb").await.unwrap();
assert_eq!(list.len(), 2);
assert!(list.iter().all(|a| a.npub != "npub1bbb"));
}
#[test]
fn seed_anchor_uses_udp_by_default() {
let json = r#"{"npub":"npub1x","address":"h:8668"}"#;
let a: SeedAnchor = serde_json::from_str(json).unwrap();
assert_eq!(a.transport, "udp");
assert_eq!(a.label, "");
}
}
+214
View File
@@ -0,0 +1,214 @@
//! FIPS daemon config + key materialisation.
//!
//! Writes `/etc/fips/fips.yaml`, `/etc/fips/fips.key`, and
//! `/etc/fips/fips.pub` from the archipelago node's seed-derived FIPS
//! keypair, then chmod 0600 the private key.
//!
//! Privileged filesystem writes go through a `sudo install` invocation
//! rather than opening `/etc/fips/*` directly — the archipelago service
//! user cannot write `/etc` itself. The sudoers policy in the ISO
//! whitelists `install` into `/etc/fips/`.
use anyhow::{Context, Result};
use std::path::Path;
use tokio::process::Command;
use super::{
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
};
/// Write the FIPS daemon config based on the local npub and default
/// transports. Overwrites any existing file — callers are expected to
/// re-run this whenever the key or daemon version changes.
///
/// Schema is intentionally minimal: node identity comes from the key
/// file on disk (the daemon handles it), transports enable UDP + TCP
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
/// Static peer list is empty — archipelago feeds peers dynamically via
/// the seed-anchors apply loop and federation-invite hooks.
pub fn render_config_yaml() -> String {
// Schema matches upstream jmcorgan/fips as of 2026-04. With
// `node.identity.persistent: true` the daemon reuses the key file at
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
// rather than `enabled: true / port: N`. Both UDP and TCP are
// enabled by default because the public anchor (fips.v0l.io)
// currently answers on TCP/8443 only, and networks that block UDP
// outbound can still bootstrap via TCP. Upstream fips no longer
// has a `tor:` transport variant — archipelago's own Tor fallback
// handles that layer.
format!(
"# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n\
node:\n \
identity:\n \
persistent: true\n\
tun:\n \
enabled: true\n \
name: fips0\n \
mtu: 1280\n\
dns:\n \
enabled: true\n \
bind_addr: \"127.0.0.1\"\n\
transports:\n \
udp:\n \
bind_addr: \"0.0.0.0:{udp}\"\n \
tcp:\n \
bind_addr: \"0.0.0.0:{tcp}\"\n\
peers: []\n",
udp = DEFAULT_UDP_PORT,
tcp = DEFAULT_TCP_PORT,
)
}
/// Install the local FIPS key + rendered config into `/etc/fips/`.
/// Requires the seed-derived key to already exist at `identity_dir/fips_key`.
pub async fn install(identity_dir: &Path) -> Result<()> {
let src_key = identity_dir.join("fips_key");
let src_pub = identity_dir.join("fips_key.pub");
if !src_key.exists() {
anyhow::bail!(
"FIPS key not materialised at {} — run seed onboarding first",
src_key.display()
);
}
// Ensure /etc/fips exists with mode 0755.
sudo_install_dir("/etc/fips").await?;
// Render + write the yaml via a staging file the archipelago user owns,
// then `sudo install` it into place so we never need to write to
// /etc directly.
let yaml = render_config_yaml();
let stage = std::env::temp_dir().join(format!("fips-{}.yaml", std::process::id()));
tokio::fs::write(&stage, yaml)
.await
.context("Failed to stage fips.yaml")?;
let install_result = sudo_install_file(&stage, DAEMON_CONFIG_PATH, "0644").await;
let _ = tokio::fs::remove_file(&stage).await;
install_result?;
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
// fips expects 32 raw bytes; a text file silently passes through
// and then the daemon can't identify itself to peers. This
// rewrites the source file in place with the correct binary form
// derived from fips_key before staging it to /etc/fips/fips.pub.
normalize_pub_file(&src_key, &src_pub).await?;
sudo_install_file(&src_pub, DAEMON_PUB_PATH, "0644").await?;
Ok(())
}
/// Ensure `fips_key.pub` is 32 raw bytes. If it's a bech32 npub text
/// file (from the pre-fix writer), decode it and rewrite in place. If
/// the file is missing or its content doesn't match either format,
/// re-derive the public key from `fips_key` and write that.
pub async fn normalize_pub_file(key_path: &Path, pub_path: &Path) -> Result<()> {
// Happy path: already 32 raw bytes.
if let Ok(bytes) = tokio::fs::read(pub_path).await {
if bytes.len() == 32 {
return Ok(());
}
// bech32 npub text from the pre-fix writer: decode in place.
if let Ok(s) = std::str::from_utf8(&bytes) {
let trimmed = s.trim();
if trimmed.starts_with("npub1") {
if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) {
let raw: [u8; 32] = pk.to_bytes();
tokio::fs::write(pub_path, raw)
.await
.context("rewriting fips_key.pub as 32 raw bytes")?;
tracing::info!(
"Migrated legacy bech32 fips_key.pub to raw-byte form at {}",
pub_path.display()
);
return Ok(());
}
}
}
}
// Fallback: no pub file, or unreadable format. Re-derive from the
// private key file (already validated by load_fips_keys).
let secret_bytes = tokio::fs::read(key_path)
.await
.with_context(|| format!("read {} to derive public", key_path.display()))?;
let text = std::str::from_utf8(&secret_bytes)
.context("fips_key is not UTF-8 — can't derive public")?;
let secret = nostr_sdk::SecretKey::parse(text.trim())
.context("fips_key not parseable as bech32 nsec")?;
let keys = nostr_sdk::Keys::new(secret);
let raw: [u8; 32] = keys.public_key().to_bytes();
tokio::fs::write(pub_path, raw)
.await
.context("writing re-derived fips_key.pub")?;
tracing::info!("Re-derived fips_key.pub from fips_key");
Ok(())
}
async fn sudo_install_dir(path: &str) -> Result<()> {
let out = Command::new("sudo")
.args(["install", "-d", "-m", "0755", path])
.output()
.await
.with_context(|| format!("sudo install -d {}", path))?;
if !out.status.success() {
anyhow::bail!(
"sudo install -d {}: {}",
path,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
async fn sudo_install_file(src: &Path, dest: &str, mode: &str) -> Result<()> {
let out = Command::new("sudo")
.args([
"install",
"-m",
mode,
src.to_str().context("Non-UTF8 source path")?,
dest,
])
.output()
.await
.with_context(|| format!("sudo install {} -> {}", src.display(), dest))?;
if !out.status.success() {
anyhow::bail!(
"sudo install {} -> {}: {}",
src.display(),
dest,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rendered_yaml_matches_upstream_schema() {
let yaml = render_config_yaml();
assert!(yaml.contains("persistent: true"));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
assert!(yaml.contains("udp:"));
assert!(yaml.contains("tcp:"));
assert!(yaml.contains("tun:"));
assert!(yaml.contains("name: fips0"));
// Upstream fips dropped the `tor:` transport variant; archipelago
// handles Tor fallback itself. Make sure we didn't regress.
assert!(!yaml.contains("tor:"));
}
#[tokio::test]
async fn test_install_refuses_when_key_missing() {
let dir = tempfile::tempdir().unwrap();
let err = install(dir.path()).await.unwrap_err();
assert!(err.to_string().contains("FIPS key not materialised"));
}
}
+618
View File
@@ -0,0 +1,618 @@
//! 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"));
}
}
+109
View File
@@ -0,0 +1,109 @@
//! 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
}
}
+272
View File
@@ -0,0 +1,272 @@
//! FIPS (Free Internetworking Peering System) daemon integration.
//!
//! github.com/jmcorgan/fips — a spanning-tree mesh routing protocol that
//! uses Nostr secp256k1 keys as native node identity. Archipelago ships
//! the daemon as an apt package, feeds it the seed-derived key from
//! `/data/identity/fips_key`, and supervises it via
//! `archipelago-fips.service`.
//!
//! This module is the in-process bridge:
//! - [`service`]: systemctl status / start / stop / restart / unmask.
//! - [`config`]: materialise `/etc/fips/fips.yaml` + install the key.
//! - [`update`]: query GitHub (tracking `main`) for a newer build,
//! verify SHA256, install via dpkg, restart.
//!
//! Privileged operations shell out via `sudo systemctl …` and `sudo dpkg …`
//! (mirroring the vpn/update patterns already in the codebase); the
//! sudoers rule shipped in the ISO whitelists exactly those commands for
//! the `archipelago` service user.
//!
//! FIPS is dark on the wire until onboarding writes the key. Before that,
//! `FipsStatus::installed` reports the package state and `service_active`
//! returns false; the transport router keeps routing via Tor.
// Consumers land in the next phase (RPC endpoints + onboarding hookup);
// the module is deliberately API-ready ahead of those call-sites.
#![allow(dead_code)]
pub mod anchors;
pub mod config;
pub mod dial;
pub mod iface;
pub mod service;
pub mod update;
use serde::{Deserialize, Serialize};
/// Auto-activate FIPS with no user interaction. Once seed onboarding has
/// materialised the fips key, install the daemon config + start the service if
/// it isn't already up. Idempotent and best-effort: FIPS is the preferred
/// transport and should come up on its own — the UI "Activate" button is now a
/// manual fallback, not a requirement. No-op pre-onboarding (no key yet) or
/// when the service is already active.
pub async fn ensure_activated(data_dir: &std::path::Path) {
let identity_dir = identity_dir_from(data_dir);
if !identity_dir.join("fips_key").exists() {
return; // pre-onboarding: nothing to activate yet
}
if dial::is_service_active().await {
return; // already up
}
tracing::info!("FIPS inactive — auto-activating (no user interaction needed)");
if let Err(e) = config::install(&identity_dir).await {
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
return;
}
if let Err(e) = service::activate(SERVICE_UNIT).await {
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
return;
}
tracing::info!("FIPS auto-activated");
}
/// Spawn the FIPS supervisor: every 25s it (1) auto-activates FIPS if onboarding
/// is done but the service is down — so it comes up with zero user interaction,
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
/// so one slow/offline peer doesn't delay the rest.
///
/// The interval MUST be shorter than the NAT/hole-punch cold window
/// (`warm_path` docs it at ~30-60s). The previous 45s sat at the edge of that
/// window: a path that went cold at ~30s stayed cold until the next 45s tick,
/// so real peer dials in that gap hit a cold path and fell back to Tor (~18s
/// onion latency instead of FIPS's ~2-3s). 25s keeps every path refreshed
/// inside the minimum cold window, which is what actually makes FIPS — not Tor —
/// the transport peer requests land on. Measured: warm FIPS browse ~2.6s vs a
/// cold-path fallback browse ~18-22s over Tor to the same peer.
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
loop {
tick.tick().await;
// Bring FIPS up on its own once onboarding has materialised the key.
ensure_activated(&data_dir).await;
if !dial::is_service_active().await {
continue;
}
let nodes = crate::federation::load_nodes(&data_dir)
.await
.unwrap_or_default();
let mut handles = Vec::new();
for node in nodes {
if let Some(npub) = node.fips_npub.clone() {
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
}
}
for h in handles {
let _ = h.await;
}
}
});
}
use std::path::{Path, PathBuf};
/// Systemd unit name supervised by archipelago.
pub const SERVICE_UNIT: &str = "archipelago-fips.service";
/// Path the FIPS daemon reads its config from (Debian package default).
pub const DAEMON_CONFIG_PATH: &str = "/etc/fips/fips.yaml";
/// Path the FIPS daemon reads its private key from.
pub const DAEMON_KEY_PATH: &str = "/etc/fips/fips.key";
/// Path the FIPS daemon reads its public key from.
pub const DAEMON_PUB_PATH: &str = "/etc/fips/fips.pub";
/// Upstream repository the updater tracks (branch `main`).
pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
/// Default UDP port the daemon listens on.
pub const DEFAULT_UDP_PORT: u16 = 8668;
/// Default TCP port the daemon listens on. Used as a fallback when a
/// peer can't be reached over UDP — common on networks that block UDP
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
/// currently accepts. Upstream factory default enables both transports
/// and archipelago intentionally matches that baseline so fresh nodes
/// can reach the broader FIPS mesh without operator config.
pub const DEFAULT_TCP_PORT: u16 = 8443;
/// Upstream systemd unit shipped by the `fips` debian package. Archipelago
/// prefers its own supervision (`archipelago-fips.service`) but respects an
/// already-running upstream unit so legacy/dev nodes — where no seed-derived
/// key exists — still report FIPS as active in the UI.
pub const UPSTREAM_SERVICE_UNIT: &str = "fips.service";
/// Aggregated runtime status of the FIPS subsystem, surfaced to the dashboard.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FipsStatus {
/// Whether the `fips` debian package is installed on the host.
pub installed: bool,
/// Installed daemon version string reported by `fipsctl --version`,
/// or None if not installed / not queryable.
pub version: Option<String>,
/// `systemctl is-active archipelago-fips.service` result: "active",
/// "inactive", "failed", "masked", "unknown".
pub service_state: String,
/// State of the upstream `fips.service` (shipped by the debian package).
pub upstream_service_state: String,
/// True if either the archipelago-managed or upstream unit is active.
pub service_active: bool,
/// Whether the seed-derived FIPS key has been materialised on disk.
/// The archipelago-managed service cannot start meaningfully until
/// this is true; legacy nodes may still report FIPS active via the
/// upstream unit without this file.
pub key_present: bool,
/// Local FIPS npub (bech32). Prefers the seed-derived key when
/// present; falls back to the upstream daemon's own key on legacy
/// nodes where `/etc/fips/fips.pub` is readable.
pub npub: Option<String>,
/// Number of currently authenticated FIPS peers, per
/// `fipsctl show peers`. 0 → isolated / anchor unreachable;
/// >0 → DHT routing is viable.
#[serde(default)]
pub authenticated_peer_count: u32,
/// True when at least one peer in the identity cache is a known
/// public anchor (currently `fips.v0l.io`). Anchors bootstrap DHT
/// routing for general-case deployments, so a red anchor status is
/// the top UX indicator of "FIPS traffic will probably degrade to
/// Tor until the anchor is reachable."
#[serde(default)]
pub anchor_connected: bool,
}
impl FipsStatus {
/// Snapshot the current state across package, key, and service.
///
/// `data_dir` is the archipelago data-dir (used to load the
/// operator-configured seed-anchor list so "anchor_connected" means
/// "at least one authenticated peer matches a public or configured
/// seed anchor", not just "fips.v0l.io specifically").
pub async fn query(data_dir: &Path) -> Self {
let identity_dir = identity_dir_from(data_dir);
let installed = service::package_installed().await;
let version = if installed {
service::daemon_version().await.ok()
} else {
None
};
let service_state = service::unit_state(SERVICE_UNIT).await;
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
let service_active = service_state == "active" || upstream_service_state == "active";
let key_present = crate::identity::fips_key_exists(&identity_dir);
// Prefer the seed-derived npub; otherwise read the daemon's own
// key file at /etc/fips/fips.pub (world-readable per debian pkg).
let npub = match crate::identity::fips_npub(&identity_dir).await {
Ok(Some(n)) => Some(n),
_ => service::read_upstream_npub().await.ok().flatten(),
};
let (authenticated_peer_count, anchor_connected) = if service_active {
// Build the anchor-candidate list: hardcoded public anchor
// plus every entry in the operator's seed-anchors.json.
// The card lights up if any of them is authenticated.
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
if let Ok(seed) = anchors::load(data_dir).await {
anchor_npubs.extend(seed.into_iter().map(|a| a.npub));
}
service::peer_connectivity_summary(&anchor_npubs).await
} else {
(0, false)
};
Self {
installed,
version,
service_state,
upstream_service_state,
service_active,
key_present,
npub,
authenticated_peer_count,
anchor_connected,
}
}
}
/// Compose a data-dirrelative identity directory path.
/// Mirrors the convention used elsewhere in the codebase so callers don't
/// have to repeat the `.join("identity")` each time.
pub fn identity_dir_from(data_dir: &Path) -> PathBuf {
data_dir.join("identity")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_status_reports_no_key_pre_onboarding() {
let dir = tempfile::tempdir().unwrap();
// query() now takes a data_dir (parent) rather than identity_dir,
// since it also reads seed-anchors.json for the anchor check.
// No identity/ subdir → no key; no seed-anchors.json → public
// anchor is the only candidate.
let status = FipsStatus::query(dir.path()).await;
assert!(!status.key_present, "no key before onboarding");
// `npub` falls back to whatever an already-running local fips
// daemon advertises, so on a dev machine or node with fips
// installed this field can be Some(...) even when the test
// data_dir is empty. We only assert that key_present is false.
// `installed`, `service_state`, `version` depend on the host and are
// not asserted here — query() must return cleanly regardless.
}
#[test]
fn test_identity_dir_from() {
let data = Path::new("/var/lib/archipelago");
assert_eq!(
identity_dir_from(data),
Path::new("/var/lib/archipelago/identity")
);
}
#[test]
fn test_constants_have_expected_shape() {
assert!(SERVICE_UNIT.ends_with(".service"));
assert!(DAEMON_CONFIG_PATH.starts_with('/'));
assert!(DAEMON_KEY_PATH.starts_with('/'));
assert_eq!(UPSTREAM_REPO, "jmcorgan/fips");
}
}
+217
View File
@@ -0,0 +1,217 @@
//! systemctl + dpkg-query helpers for the FIPS daemon.
//!
//! Read-only queries (`is-active`, `--version`, `dpkg-query`) run as the
//! archipelago service user. Write operations (`unmask`, `start`, `stop`,
//! `restart`) go through `sudo`, matching the pattern established in
//! `src/vpn.rs` and `src/api/rpc/vpn.rs`. The sudoers rule shipped in the
//! ISO whitelists exactly these invocations.
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
use tokio::process::Command;
use super::DAEMON_PUB_PATH;
/// `systemctl is-active <unit>` → "active" / "inactive" / "failed" / "masked"
/// / "unknown". Never errors; returns "unknown" on any failure.
pub async fn unit_state(unit: &str) -> String {
match Command::new("systemctl")
.args(["is-active", unit])
.output()
.await
{
Ok(out) => {
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
"unknown".to_string()
} else {
s
}
}
Err(_) => "unknown".to_string(),
}
}
/// Whether the `fips` debian package is installed on the host.
pub async fn package_installed() -> bool {
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
let out = Command::new("dpkg-query")
.args(["-W", "-f=${Status}", "fips"])
.output()
.await;
match out {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).contains("install ok installed")
}
_ => false,
}
}
/// `fipsctl --version` output stripped of the "fipsctl " prefix if present.
pub async fn daemon_version() -> Result<String> {
let out = Command::new("fipsctl")
.arg("--version")
.output()
.await
.context("fipsctl --version failed to launch")?;
if !out.status.success() {
anyhow::bail!("fipsctl exited with non-zero status");
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(raw
.strip_prefix("fipsctl ")
.map(|s| s.to_string())
.unwrap_or(raw))
}
/// `sudo systemctl <verb> <unit>` — returns stderr on non-zero exit.
async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> {
let out = Command::new("sudo")
.args(["systemctl", verb, unit])
.output()
.await
.with_context(|| format!("sudo systemctl {} {} failed to launch", verb, unit))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
anyhow::bail!("systemctl {} {}: {}", verb, unit, stderr);
}
Ok(())
}
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
/// on every backend startup once the key is on disk.
pub async fn activate(unit: &str) -> Result<()> {
// Order matters: unmask before enable/start, otherwise enable fails
// on a masked unit.
sudo_systemctl("unmask", unit).await?;
sudo_systemctl("enable", unit).await?;
sudo_systemctl("start", unit).await?;
Ok(())
}
pub async fn stop(unit: &str) -> Result<()> {
sudo_systemctl("stop", unit).await
}
pub async fn restart(unit: &str) -> Result<()> {
sudo_systemctl("restart", unit).await
}
/// Resolve which systemd unit is actually supervising the fips daemon
/// on this host. Nodes installed from the archipelago ISO run
/// `archipelago-fips.service`; nodes that were apt-installed (or had
/// fips running before archipelago took over) may only have the
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
/// one is running, otherwise the UI button is a silent no-op.
///
/// Returns the archipelago-managed unit name if it's active,
/// else the upstream unit name if that's active,
/// else the archipelago-managed name as a default (so activate() can
/// bring it up).
pub async fn active_unit() -> &'static str {
if unit_state(super::SERVICE_UNIT).await == "active" {
return super::SERVICE_UNIT;
}
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
return super::UPSTREAM_SERVICE_UNIT;
}
super::SERVICE_UNIT
}
pub async fn mask(unit: &str) -> Result<()> {
let _ = sudo_systemctl("stop", unit).await;
let _ = sudo_systemctl("disable", unit).await;
sudo_systemctl("mask", unit).await
}
/// Known public anchor npub (fips.v0l.io as of 2026-04). Used to decide
/// whether the `anchor_connected` badge in the dashboard lights up.
pub const PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
/// Summarise peer connectivity from `fipsctl show peers`. Returns
/// `(authenticated_peer_count, anchor_connected)`.
///
/// `anchor_candidates` is the operator-controlled list of npubs this
/// node considers a valid mesh anchor — always includes the hard-coded
/// public anchor, plus any entries from `seed-anchors.json`. A node is
/// "anchor connected" when at least one currently-authenticated peer
/// matches one of these npubs. We used to check the identity cache
/// (which includes transient hearsay from other peers), but a cache
/// hit on `fips.v0l.io` didn't mean we could actually route through
/// it, and the card lied to users whose mesh was federated through
/// their own seed anchors instead.
pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bool) {
let peers_json = match Command::new("sudo")
.args(["-n", "fipsctl", "show", "peers"])
.output()
.await
{
Ok(o) if o.status.success() => o.stdout,
_ => return (0, false),
};
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return (0, false),
};
let peers = parsed
.get("peers")
.and_then(|p| p.as_array())
.cloned()
.unwrap_or_default();
let authenticated_peer_count = peers.len() as u32;
let anchor_connected = peers.iter().any(|p| {
let npub = p.get("npub").and_then(|n| n.as_str()).unwrap_or_default();
let connected = p
.get("connectivity")
.and_then(|c| c.as_str())
.map(|s| s == "connected")
.unwrap_or(true);
connected && anchor_candidates.iter().any(|a| a == npub)
});
(authenticated_peer_count, anchor_connected)
}
/// Read the upstream daemon's public key at `/etc/fips/fips.pub` and return
/// it as a bech32 npub. Returns `Ok(None)` if the file doesn't exist — used
/// as a fallback on legacy/dev nodes where no seed-derived key exists.
///
/// Upstream writes the key as a bech32 string (`npub1…`); older builds may
/// have written 32 raw bytes, so we accept either form.
pub async fn read_upstream_npub() -> Result<Option<String>> {
let bytes = match tokio::fs::read(DAEMON_PUB_PATH).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).context("read /etc/fips/fips.pub"),
};
if let Ok(s) = std::str::from_utf8(&bytes) {
let trimmed = s.trim();
if trimmed.starts_with("npub1") {
if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) {
return Ok(pk.to_bech32().ok());
}
}
}
let pk = nostr_sdk::PublicKey::from_slice(&bytes)
.context("parse /etc/fips/fips.pub as secp256k1 public key")?;
Ok(pk.to_bech32().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_unit_state_returns_string_for_bogus_unit() {
// Nonexistent unit: systemctl returns "inactive" or "unknown" — we
// just care that the helper doesn't panic and returns *something*.
let s = unit_state("archipelago-bogus-test.service").await;
assert!(!s.is_empty());
}
#[tokio::test]
async fn test_package_installed_is_bool() {
// Must not panic regardless of host state.
let _ = package_installed().await;
}
}
+401
View File
@@ -0,0 +1,401 @@
//! User-triggered FIPS upgrade from upstream GitHub releases.
//!
//! Flow (no auto-update, no background polling — user clicks a button):
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
//! (`/releases/latest` returns the newest non-prerelease, non-draft
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
//! reported by `fipsctl --version`. A dev/pre-release build of the
//! same number (`0.3.0-dev`) counts as older than the released tag.
//! 3. Pick the Debian package asset matching the host architecture
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
//! 4. Download both, SHA256-verify the .deb against the checksums file.
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
//!
//! Upstream began publishing tagged releases with `.deb` artefacts and
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
//! the apply path is fully wired against those assets.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{service, UPSTREAM_REPO};
const GITHUB_API: &str = "https://api.github.com";
const USER_AGENT: &str = "archipelago-fips-updater";
/// Result of `check()` — what the dashboard renders.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheck {
/// Currently installed daemon version (from `fipsctl --version`).
pub current: Option<String>,
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
pub latest_version: String,
/// True when the installed version is older than `latest_version`.
pub update_available: bool,
/// Release channel this check tracked. Currently always "stable".
pub channel: String,
/// Browser download URL of the architecture-matched .deb for the
/// latest release, when one exists (informational; apply() re-resolves).
pub asset_url: Option<String>,
/// Human-readable note for the UI.
pub notes: String,
}
/// One GitHub release as we consume it.
#[derive(Debug, Clone, Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Debug, Clone, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Build HTTP client")
}
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
/// asset lookup simply finds nothing and surfaces a clear error.
fn deb_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
}
}
/// Query GitHub for the latest stable release and compare to the installed
/// version. Never errors on "no package installed" — that is itself a valid
/// state where an update is available.
pub async fn check() -> Result<UpdateCheck> {
let current = service::daemon_version().await.ok();
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
let update_available = match &current {
Some(v) => version_is_older(v, &release.tag_name),
None => true,
};
let asset_url = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.map(|a| a.browser_download_url.clone());
let notes = if update_available {
format!(
"Update available: {} (installed: {})",
release.tag_name,
current.as_deref().unwrap_or("not installed")
)
} else {
format!("Up to date ({})", release.tag_name)
};
Ok(UpdateCheck {
current,
latest_version: release.tag_name,
update_available,
channel: "stable".to_string(),
asset_url,
notes,
})
}
/// Download, verify, and install the latest stable FIPS release, then
/// restart the daemon. Steps: resolve release → match .deb for this arch
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
pub async fn apply() -> Result<()> {
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
let deb = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.ok_or_else(|| {
anyhow::anyhow!(
"release {} has no .deb for architecture {}",
release.tag_name,
deb_arch()
)
})?;
let checksums = release
.assets
.iter()
.find(|a| a.name == "checksums-linux.txt")
.ok_or_else(|| {
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
})?;
// Download the .deb (bytes) and the checksums (text).
let deb_bytes = client
.get(&deb.browser_download_url)
.send()
.await
.context("download .deb")?
.error_for_status()
.context(".deb download HTTP error")?
.bytes()
.await
.context("read .deb body")?;
let checksums_text = client
.get(&checksums.browser_download_url)
.send()
.await
.context("download checksums")?
.error_for_status()
.context("checksums download HTTP error")?
.text()
.await
.context("read checksums body")?;
// Verify SHA256 against the checksums manifest (sha256sum format:
// "<hex>␠␠<filename>"). The filename column may include a leading
// "*" (binary mode) or a path prefix, so match on the basename.
let expected = checksums_text
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?.trim_start_matches('*');
let base = name.rsplit('/').next().unwrap_or(name);
(base == deb.name).then(|| hash.to_lowercase())
})
.next()
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
let actual = {
let mut hasher = Sha256::new();
hasher.update(&deb_bytes);
hex::encode(hasher.finalize())
};
if actual != expected {
anyhow::bail!(
"SHA256 mismatch for {}: expected {}, got {}",
deb.name,
expected,
actual
);
}
// Stage the verified .deb in /tmp (shared with the host — the
// service runs with PrivateTmp=no) and install it.
let dest = std::env::temp_dir().join(&deb.name);
tokio::fs::write(&dest, &deb_bytes)
.await
.with_context(|| format!("write {}", dest.display()))?;
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
// and `/var/lib/dpkg` are read-only *inside the service's mount
// namespace* — and a `sudo` child inherits that namespace, so a
// bare `sudo dpkg -i` fails with "Read-only file system" on the
// dpkg database. `systemd-run` asks PID 1 to launch the command in
// a fresh transient scope outside our sandbox, where the real
// (writable) host filesystem is visible. `--wait` blocks until it
// finishes and propagates the exit status; `--pipe` forwards
// dpkg's output; `--collect` reaps the unit even on failure.
//
// dpkg flags, both load-bearing for this package specifically:
// --force-confold: the fips package ships conffiles under
// /etc/fips that archipelago rewrites at install time, so dpkg
// hits an interactive "keep/replace?" conffile prompt. With our
// closed stdin that aborts the configure step ("EOF on stdin at
// conffile prompt") and leaves the package half-unpacked
// (status `iU`), which `fips.status` then reports as
// `installed:false`. confold = keep our managed config, no prompt.
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
// orders as NEWER than the stable tag `0.3.0` (a trailing
// `-dev` sorts above the bare release). Moving a dev build onto
// the stable line is therefore a dpkg "downgrade"; without this
// flag dpkg warns and exits non-zero. Our own version_is_older()
// gate already decided this is the wanted direction.
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
// other maintainer-script prompt.
let dpkg = tokio::process::Command::new("sudo")
.args([
"-n",
"systemd-run",
"--collect",
"--wait",
"--quiet",
"--pipe",
"--",
"env",
"DEBIAN_FRONTEND=noninteractive",
"dpkg",
"--force-confold",
"--force-downgrade",
"-i",
])
.arg(&dest)
.output()
.await
.context("sudo systemd-run dpkg -i failed to launch")?;
// Best-effort cleanup regardless of dpkg result.
let _ = tokio::fs::remove_file(&dest).await;
if !dpkg.status.success() {
anyhow::bail!(
"dpkg -i {} exited {}: {}",
deb.name,
dpkg.status,
String::from_utf8_lossy(&dpkg.stderr).trim()
);
}
// Restart whichever fips unit is supervising the daemon so the new
// binary takes over.
let unit = service::active_unit().await;
service::restart(unit)
.await
.with_context(|| format!("restart {} after install", unit))?;
Ok(())
}
/// `/releases/latest` returns the most recent non-prerelease, non-draft
/// release. We still re-check the flags defensively in case the endpoint
/// or repo settings change.
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("GitHub releases/latest API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
}
let release: Release = resp.json().await.context("Parse release JSON")?;
if release.draft || release.prerelease {
anyhow::bail!(
"releases/latest returned a {} release ({})",
if release.draft { "draft" } else { "prerelease" },
release.tag_name
);
}
Ok(release)
}
fn is_deb_for_arch(name: &str) -> bool {
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
}
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
// Take the first whitespace token, drop a leading 'v'.
let tok = s.split_whitespace().next().unwrap_or(s);
let tok = tok.strip_prefix('v').unwrap_or(tok);
// Split off any pre-release / build suffix.
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
Some(i) => (&tok[..i], &tok[i..]),
None => (tok, ""),
};
let mut it = core.split('.');
let major = it.next()?.parse::<u64>().ok()?;
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
let has_prerelease = rest.starts_with('-');
Some(((major, minor, patch), has_prerelease))
}
/// True when `installed` is strictly older than release tag `latest`.
/// Same numeric triple but `installed` carries a pre-release suffix while
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
/// If either side can't be parsed, fall back to "differs ⇒ update".
fn version_is_older(installed: &str, latest: &str) -> bool {
match (parse_version(installed), parse_version(latest)) {
(Some((ic, ipre)), Some((lc, lpre))) => {
if ic != lc {
ic < lc
} else {
// Equal cores: a pre-release is older than the final release.
ipre && !lpre
}
}
_ => {
// Unparseable: be conservative — offer the update unless the
// installed string already mentions the latest tag.
!installed.contains(latest.trim_start_matches('v'))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deb_arch_maps_known() {
// On the host running tests this is whatever the test arch is;
// just assert it returns a non-empty, lowercase token.
let a = deb_arch();
assert!(!a.is_empty());
assert_eq!(a, a.to_lowercase());
}
#[test]
fn test_version_older() {
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
assert!(version_is_older("0.2.1", "v0.3.0"));
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
assert!(!version_is_older("0.3.0", "v0.3.0"));
assert!(!version_is_older("0.4.0", "v0.3.0"));
assert!(!version_is_older("0.3.1", "v0.3.0"));
}
#[test]
fn test_parse_version() {
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
}
#[test]
fn test_is_deb_for_arch() {
let arch = deb_arch();
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
assert!(!is_deb_for_arch("checksums-linux.txt"));
assert!(!is_deb_for_arch(&format!(
"fips-0.3.0-linux-{}.tar.gz",
arch
)));
}
#[test]
fn test_update_check_serialises() {
let uc = UpdateCheck {
current: Some("0.3.0-dev".to_string()),
latest_version: "v0.3.0".to_string(),
update_available: true,
channel: "stable".to_string(),
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
notes: "test".to_string(),
};
let json = serde_json::to_string(&uc).unwrap();
assert!(json.contains("latest_version"));
assert!(json.contains("update_available"));
assert!(json.contains("stable"));
}
}