Files
archy/core/archipelago/src/fips/config.rs
T
archipelago 9ac46a69f8 feat(fips): SSH-over-mesh toggle + manifest-driven package metadata
fips/ssh_mesh.rs owns the 90-ssh.nft drop-in lifecycle: off by default,
any-peer scope behind the UI's danger confirmation or an explicit mesh
address list, reconciled on every daemon config install. The scanner now
takes installed apps' icons from their real manifest metadata (Cuprate's
Services tile) and classifies manifest-declared UI apps as launchable
even when the address probe misses (Alby Hub).
2026-09-01 02:41:55 -04:00

508 lines
19 KiB
Rust

//! 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 serde::Serialize;
use std::path::Path;
use tokio::process::Command;
use super::{
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT,
};
/// Header prepended to the generated YAML. serde doesn't emit comments, so
/// this is concatenated onto the serialised body.
const CONFIG_HEADER: &str = "# Generated by archipelago — do not edit by hand.\n\
# Regenerated on every key change and daemon upgrade.\n";
/// Typed mirror of the subset of upstream `fips.yaml` that archipelago owns.
///
/// This was previously built by `format!`-ing a string literal. Upstream's
/// config structs are `#[serde(deny_unknown_fields)]`, so a key we get wrong
/// doesn't degrade gracefully — the daemon refuses to start and the node drops
/// off the mesh. Serialising from typed structs lets the compiler and the
/// tests below catch drift, instead of a node discovering it at boot after an
/// upgrade.
///
/// Schema verified field-by-field against jmcorgan/fips **v0.4.1** (2026-07-20).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FipsConfig {
pub node: NodeSection,
pub tun: TunSection,
pub dns: DnsSection,
pub transports: TransportsSection,
/// Static peers. Always empty: archipelago feeds peers dynamically via the
/// seed-anchors apply loop and federation-invite hooks.
pub peers: Vec<PeerEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NodeSection {
pub identity: IdentitySection,
pub discovery: DiscoverySection,
pub retry: RetrySection,
pub rate_limit: RateLimitSection,
}
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
/// doubling to 300s) are tuned for stable always-on links; a node redialing
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RetrySection {
pub base_interval_secs: u64,
pub max_backoff_secs: u64,
pub max_retries: u32,
}
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
/// gaps out to 8-16s between resends exactly when a route has just
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
/// window (phone measured session-after-route: 225ms).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RateLimitSection {
pub handshake_resend_interval_ms: u64,
pub handshake_resend_backoff: f64,
pub handshake_max_resends: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct IdentitySection {
/// With `persistent: true` the daemon reuses the key file at
/// config-dir/fips.key (= `DAEMON_KEY_PATH`) instead of generating an
/// ephemeral identity on every start.
pub persistent: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DiscoverySection {
/// Lookup completion timeout. Lookups fired while the tree position is
/// still settling are doomed; failing them fast (5s, not 10s) lets the
/// 1s-backoff retry find the route the moment it exists.
pub timeout_secs: u64,
pub backoff_base_secs: u64,
pub backoff_max_secs: u64,
pub retry_interval_secs: u64,
pub max_attempts: u8,
pub lan: LanDiscoverySection,
}
/// mDNS / DNS-SD discovery on the local link (`node.discovery.lan.*`), added
/// upstream in v0.4.0 and opt-in there (upstream default is `false`).
///
/// We enable it so co-located nodes peer directly instead of depending on the
/// public anchor being reachable — an anchor blackhole on one network segment
/// otherwise islands a node completely.
///
/// Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
/// has no `lan` field *and* no `deny_unknown_fields`, so a v0.3.0 daemon ignores
/// this key harmlessly (verified against the v0.3.0 source). It therefore starts
/// working on its own when a node upgrades, with no second config migration.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct LanDiscoverySection {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TunSection {
pub enabled: bool,
pub name: String,
pub mtu: u16,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DnsSection {
pub enabled: bool,
pub bind_addr: String,
}
/// Both UDP and TCP are enabled: the public anchor answers on TCP/8443 only,
/// and networks that block outbound UDP can still bootstrap over TCP.
/// Upstream dropped the `tor:` transport variant — archipelago's own Tor
/// fallback handles that layer.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TransportsSection {
pub udp: TransportBind,
pub tcp: TransportBind,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TransportBind {
/// Upstream takes `bind_addr` ("host:port"), not `enabled` + `port`.
pub bind_addr: String,
}
/// A static peer entry. Never constructed today (see `FipsConfig::peers`), but
/// typed so the shape is checked if static peering is ever needed.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PeerEntry {
pub npub: String,
pub address: String,
pub transport: String,
}
impl Default for FipsConfig {
fn default() -> Self {
Self {
node: NodeSection {
identity: IdentitySection { persistent: true },
discovery: DiscoverySection {
timeout_secs: 5,
backoff_base_secs: 1,
backoff_max_secs: 30,
retry_interval_secs: 2,
max_attempts: 3,
lan: LanDiscoverySection { enabled: true },
},
retry: RetrySection {
base_interval_secs: 1,
max_backoff_secs: 30,
max_retries: 30,
},
rate_limit: RateLimitSection {
handshake_resend_interval_ms: 400,
handshake_resend_backoff: 1.5,
handshake_max_resends: 10,
},
},
tun: TunSection {
enabled: true,
name: "fips0".to_string(),
mtu: 1280,
},
dns: DnsSection {
enabled: true,
bind_addr: "127.0.0.1".to_string(),
},
transports: TransportsSection {
udp: TransportBind {
bind_addr: format!("0.0.0.0:{PUBLISHED_UDP_PORT}"),
},
tcp: TransportBind {
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
},
},
peers: Vec::new(),
}
}
}
/// Render the FIPS daemon config. Overwrites any existing file — callers
/// re-run this whenever the key or daemon version changes.
///
/// Node identity comes from the key file on disk; the static peer list stays
/// empty because peers are fed dynamically at runtime.
pub fn render_config_yaml() -> String {
let body = serde_yaml::to_string(&FipsConfig::default())
.expect("FipsConfig is a plain struct tree and cannot fail to serialise");
format!("{CONFIG_HEADER}{body}")
}
/// 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?;
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
// out-of-band) default-denies inbound on fips0 — without an explicit
// allowance the node's web UI is unreachable over the mesh (phones got
// RST on :80 with a healthy session; root-caused 2026-07-26 on
// a test node). Ship the allowance as a fips.d drop-in on every
// install/upgrade so no node ever regresses to a UI-less mesh.
sudo_install_dir("/etc/fips/fips.d").await?;
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
// mesh envelopes, DWN and invoices. It was missing from this allowlist
// while the comment claimed "web UI + peer API" — so every hardened
// node silently dropped peers' FIPS dials at the firewall and the whole
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
let dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Allows the web UI + peer API through the fips0\n\
# default-deny inbound baseline (fips.nft).\n\
tcp dport 80 accept\n\
tcp dport 8443 accept\n\
tcp dport {peer_port} accept\n",
peer_port = crate::fips::dial::PEER_PORT
);
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
tokio::fs::write(&nft_stage, dropin)
.await
.context("Failed to stage web-ui nft drop-in")?;
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
let _ = tokio::fs::remove_file(&nft_stage).await;
nft_install?;
// App launch ports: the companion opens catalog apps by direct port
// over the mesh. Ports of apps that aren't installed have no listener,
// so the allowance is inert until an app exists to answer.
let port_list = super::app_ports::APP_LAUNCH_PORTS
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
let app_dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Catalog app launch ports (web UIs) allowed through the fips0\n\
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
tcp dport {{ {port_list} }} accept\n"
);
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
tokio::fs::write(&app_stage, app_dropin)
.await
.context("Failed to stage app-ports nft drop-in")?;
let app_install =
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
let _ = tokio::fs::remove_file(&app_stage).await;
app_install?;
// Make the allowance live immediately; a no-op error when the
// hardening baseline isn't installed on this node yet.
if tokio::fs::try_exists("/etc/fips/fips.nft")
.await
.unwrap_or(false)
{
match Command::new("sudo")
.args(["nft", "-f", "/etc/fips/fips.nft"])
.output()
.await
{
Ok(out) if !out.status.success() => tracing::warn!(
"nft reload after web-ui drop-in failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
_ => {}
}
}
// SSH-over-mesh rides every config install so the on-state survives
// upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs —
// this module owns the 90-ssh.nft slot exclusively).
let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir);
if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await {
tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}");
}
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:{}", PUBLISHED_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:"));
}
/// Exact-output snapshot. Upstream's config structs are
/// `deny_unknown_fields`, so an accidental key rename/addition means the
/// daemon won't start. Pinning the full rendering makes any such change
/// fail here — where it's cheap — instead of on a node after an upgrade.
/// If this fails, re-verify against the upstream schema before updating it.
#[test]
fn test_rendered_yaml_exact_snapshot() {
let expected = "\
# Generated by archipelago — do not edit by hand.
# Regenerated on every key change and daemon upgrade.
node:
identity:
persistent: true
discovery:
timeout_secs: 5
backoff_base_secs: 1
backoff_max_secs: 30
retry_interval_secs: 2
max_attempts: 3
lan:
enabled: true
retry:
base_interval_secs: 1
max_backoff_secs: 30
max_retries: 30
rate_limit:
handshake_resend_interval_ms: 400
handshake_resend_backoff: 1.5
handshake_max_resends: 10
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: 127.0.0.1
transports:
udp:
bind_addr: 0.0.0.0:2121
tcp:
bind_addr: 0.0.0.0:8443
peers: []
";
assert_eq!(render_config_yaml(), expected);
}
/// The rendered config must parse as YAML and carry the mDNS opt-in at the
/// exact path upstream reads (`node.discovery.lan.enabled`) — a typo there
/// would silently leave LAN discovery off rather than erroring.
#[test]
fn test_lan_discovery_enabled_at_upstream_path() {
let yaml = render_config_yaml();
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("renders valid YAML");
assert_eq!(
parsed["node"]["discovery"]["lan"]["enabled"],
serde_yaml::Value::Bool(true),
);
}
/// Rendering is deterministic: the startup drift check in server.rs compares
/// the freshly rendered config against what's on disk, so any instability
/// here would cause an endless reinstall+restart loop of the daemon.
#[test]
fn test_render_is_deterministic() {
assert_eq!(render_config_yaml(), render_config_yaml());
}
#[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"));
}
}