Archipelago — open-source initial import
This commit is contained in:
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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-dir–relative 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ¤t {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user