//! 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, } #[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 // framework-pt). 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::>() .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}"), _ => {} } } 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")); } }