diff --git a/core/archipelago/src/fips/config.rs b/core/archipelago/src/fips/config.rs index 06480ad5..77b43e90 100644 --- a/core/archipelago/src/fips/config.rs +++ b/core/archipelago/src/fips/config.rs @@ -10,6 +10,7 @@ //! whitelists `install` into `/etc/fips/`. use anyhow::{Context, Result}; +use serde::Serialize; use std::path::Path; use tokio::process::Command; @@ -17,47 +18,145 @@ 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 +/// 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, +} + +#[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 { + 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 { + lan: LanDiscoverySection { enabled: true }, + }, + }, + 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:{DEFAULT_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. /// -/// 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. +/// 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 { - // 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, - ) + 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/`. @@ -205,6 +304,60 @@ mod tests { 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: + lan: + enabled: true +tun: + enabled: true + name: fips0 + mtu: 1280 +dns: + enabled: true + bind_addr: 127.0.0.1 +transports: + udp: + bind_addr: 0.0.0.0:8668 + 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();