From 5fd0d6c3c8f1f8e1b6731680645a48d5d06c78eb Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 20 Jul 2026 14:47:22 -0400 Subject: [PATCH] refactor(fips): generate fips.yaml from typed structs; enable mDNS LAN discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon config was built by format!-ing a YAML string literal. Upstream's config structs are #[serde(deny_unknown_fields)], so a key we get wrong does not degrade — the daemon refuses to start and the node drops off the mesh. String-built config made that a runtime discovery on a live node. Replace it with a typed struct tree serialised via serde_yaml, verified field-by-field against jmcorgan/fips v0.4.1, plus an exact-output snapshot test so schema drift fails in CI rather than at boot. Also adds tests for determinism (server.rs compares the render against disk to detect drift, so instability would cause a reinstall+restart loop) and for the mDNS key path. Enables node.discovery.lan.enabled (mDNS/DNS-SD, added upstream in v0.4.0) so co-located nodes peer directly instead of depending on the public anchor being reachable — an anchor blackhole on one segment currently 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 it harmlessly and it activates on upgrade with no second config migration. Note: the first startup after this lands renders a config that differs from disk, so the existing drift check reinstalls it and restarts the daemon once. That is the intended self-healing path and settles immediately. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/fips/config.rs | 229 +++++++++++++++++++++++----- 1 file changed, 191 insertions(+), 38 deletions(-) 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();