nordisk-pairing 0.5.2

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **The seed: what a MEDIUM is told at compose time, and nothing more.**
//!
//! The owner's ruling (2026-09-24): one seed per medium, nothing per box. An
//! estate installs the same medium on every gunnar box, so a seed that named
//! a box's own address would need a per-box channel that does not exist.
//! Instead the seed names the ESTATE and how to reach its installer: the
//! tunnel kind, the join door on the utility network, the installer CA the
//! box pins, and the bootstrap client certificate it joins with. The door
//! assigns each box its NAME and OVERLAY ADDRESS by the utility IP it joins
//! from, and FEEDS it every estate value (monetize, clock and admin keys, the
//! front's tunnel key and endpoint, …) — so one medium serves any estate.
//!
//! **The medium is CONSTANT across lays** (owner, 2026-09-24 evening): nothing
//! in a seed may change from one lay of the estate to the next, or every
//! rebuild recomposes, re-uploads and re-gates the ISO. So the seed carries
//! only what reaches the door — and the front's tunnel identity (its relay key
//! is minted with the front, its endpoint is the front's public address) is
//! NOT here: the door hands it out in the join reply, as the `front` peer
//! ([`crate::join::JoinReply::front`]). A seed from 0.1 that still carries
//! `front.tunnel_pubkey` / `front.endpoint` parses; the two are ignored.
//!
//! The bootstrap certificate and its key are on the medium, and a key on a
//! medium is only as secret as the medium (it is uploaded to the cloud's
//! storage). So they are valid during bootstrap ONLY (owner 2026-09-24): they
//! may only join, only for a slot that has not joined, and the door withdraws
//! them the moment every slot has joined. That rule is the DOOR's (the ledger,
//! [`crate::join`]), not an expiry: the installer CA is minted per MEDIUM and
//! lives as long as the medium is used (the caller's choice), because a CA
//! that died in hours made every lay a new medium. See [`crate::mtls`].

use serde::{Deserialize, Serialize};
use std::fmt;

/// Which tunnel the estate meets inside. WireGuard is the default (owner
/// 2026-09-24); `none` means the boxes speak over the addresses the cloud
/// gave them (UpCloud's utility network).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TunnelKind {
    #[default]
    Wireguard,
    Tailscale,
    Mullvad,
    None,
}

impl TunnelKind {
    pub fn as_str(self) -> &'static str {
        match self {
            TunnelKind::Wireguard => "wireguard",
            TunnelKind::Tailscale => "tailscale",
            TunnelKind::Mullvad => "mullvad",
            TunnelKind::None => "none",
        }
    }
}

/// What kind of box this medium installs. The front is the rendezvous; a
/// gunnar box's role (primary or twin) is not in the seed — it is elected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
    Front,
    Gunnar,
}

/// The one box every other box can reach before the tunnel exists.
///
/// Only its DOOR: an address fixed before the front exists (the estate
/// declares it), so a medium composed before the lay reaches the front of any
/// lay. The front's tunnel key and endpoint change with every lay and are FED
/// at the join ([`crate::join::FrontTunnel`]).
///
/// **Unused since 0.3.0** (owner 2026-09-24): the installer connects OUT to
/// every VM, so no VM dials a join service on the front. A 0.3 medium leaves
/// `door` empty; a 0.2 seed that names one still parses.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Front {
    /// The 0.2 join service on the utility network, `ip:port` — empty in 0.3.
    #[serde(default)]
    pub door: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seed {
    /// The estate's name, e.g. `gunnar.rs`. A box refuses a join reply for
    /// another estate.
    pub estate: String,
    #[serde(default)]
    pub tunnel: TunnelKind,
    #[serde(default)]
    pub front: Front,
    pub kind: Kind,
    /// The installer CA (PEM, public): the ONLY root a box trusts for the door.
    pub installer_ca_pem: String,
    /// The bootstrap client certificate and its key (PEM), the same for every
    /// box of the medium — valid during bootstrap only (see the module doc).
    pub bootstrap_cert_pem: String,
    pub bootstrap_key_pem: String,
    /// How many boxes the estate has.
    pub peers_expected: u8,
    /// **Mullvad** (`tunnel = "mullvad"`): which relay to leave through. The
    /// ACCOUNT NUMBER is not here — a seed carries nothing private; the door
    /// FEEDS it (`feed.mullvad_account`) over mutual TLS at the join.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mullvad: Option<MullvadSeed>,
    /// **Tailscale** (`tunnel = "tailscale"`): which coordination server. The
    /// AUTH KEY is fed (`feed.tailscale_authkey`), never on the medium.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tailscale: Option<TailscaleSeed>,
    /// **The installer's link into the tunnel** (0.3.0; required when the
    /// tunnel is `wireguard` or `mullvad`): the installer is a TEMPORARY PEER,
    /// and every VM raises this link from its first boot so the installer's
    /// mutual TLS never touches a public interface. See
    /// [`crate::installer_link`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub installer_link: Option<LinkSeed>,
}

/// **The medium's half of the installer's WireGuard link** (0.3.0). Both keys
/// are per MEDIUM and constant across lays, like the installer CA:
///
/// * `installer_pubkey` — the installer's public key. Its private half stays
///   on the operator's machine (0600, beside the CA);
/// * `vm_private` — the key EVERY VM of the medium answers the link with.
///   The installer (behind NAT) initiates, so it must know the key it dials
///   before the VM exists — one VM key per medium. Like the bootstrap
///   certificate it is only as secret as the medium, and it is good for
///   bootstrap only: each VM drops the link when the installer closes it, and
///   the mutual TLS inside still demands the installer's own certificate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkSeed {
    pub installer_pubkey: String,
    pub vm_private: String,
    /// The UDP port each VM's end of the link listens on.
    #[serde(default = "link_default_port")]
    pub port: u16,
}

fn link_default_port() -> u16 {
    crate::installer_link::LINK_PORT
}

fn looks_like_key(s: &str) -> bool {
    let t = s.trim();
    t.len() == 44 && t.ends_with('=') && t[..43].chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')
}

/// The feed key the door hands a box its Mullvad account number under.
pub const FEED_MULLVAD_ACCOUNT: &str = "mullvad_account";
/// The feed key the door hands a box its Tailscale auth key under.
pub const FEED_TAILSCALE_AUTHKEY: &str = "tailscale_authkey";

/// The public half of a Mullvad choice — no credential.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct MullvadSeed {
    /// A relay location (`se-sto`) or a country (`se`); empty = any usable relay.
    #[serde(default)]
    pub location: String,
    /// The relay port (one of Mullvad's measured ranges); default 51820.
    #[serde(default = "mullvad_default_port")]
    pub port: u16,
}

fn mullvad_default_port() -> u16 {
    51820
}

/// The public half of a Tailscale choice — no credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TailscaleSeed {
    /// The coordination server: Tailscale's own, or a self-hosted headscale.
    #[serde(default = "tailscale_default_control")]
    pub control_url: String,
}

impl Default for TailscaleSeed {
    fn default() -> Self {
        TailscaleSeed { control_url: tailscale_default_control() }
    }
}

fn tailscale_default_control() -> String {
    "https://controlplane.tailscale.com".into()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedError {
    NotToml(String),
    /// `field`, `why`
    Bad(&'static str, String),
}

impl fmt::Display for SeedError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SeedError::NotToml(e) => write!(f, "REFUSED [seed-not-toml] the seed is not TOML: {e}"),
            SeedError::Bad(field, why) => write!(f, "REFUSED [seed-{field}] the seed's `{field}` is unusable: {why}"),
        }
    }
}

impl std::error::Error for SeedError {}

impl Seed {
    pub fn parse(text: &str) -> Result<Seed, SeedError> {
        let s: Seed = toml::from_str(text).map_err(|e| SeedError::NotToml(e.to_string()))?;
        s.check()?;
        Ok(s)
    }

    pub fn render(&self) -> Result<String, SeedError> {
        self.check()?;
        toml::to_string(self).map_err(|e| SeedError::NotToml(e.to_string()))
    }

    /// The door's address.
    pub fn door_addr(&self) -> Result<std::net::SocketAddrV4, SeedError> {
        self.front.door.parse().map_err(|_| SeedError::Bad("front.door", format!("`{}` is not ipv4:port", self.front.door)))
    }

    /// **Every field is checked before a box acts on it**, by name.
    pub fn check(&self) -> Result<(), SeedError> {
        if self.estate.trim().is_empty() {
            return Err(SeedError::Bad("estate", "empty".into()));
        }
        let pem = |field: &'static str, text: &str, begin: &str| -> Result<(), SeedError> {
            if text.contains(begin) {
                Ok(())
            } else {
                Err(SeedError::Bad(field, format!("has no `{begin}`")))
            }
        };
        pem("installer_ca_pem", &self.installer_ca_pem, "-----BEGIN CERTIFICATE-----")?;
        pem("bootstrap_cert_pem", &self.bootstrap_cert_pem, "-----BEGIN CERTIFICATE-----")?;
        pem("bootstrap_key_pem", &self.bootstrap_key_pem, "PRIVATE KEY-----")?;
        if self.peers_expected < 2 {
            return Err(SeedError::Bad("peers_expected", "an estate is at least a front and one box".into()));
        }
        // The front's WireGuard key and endpoint are NOT checked here: they are
        // the door's to feed (`JoinReply::front`), because they change with
        // every lay and a seed must not.
        if !self.front.door.trim().is_empty() {
            self.door_addr()?;
        }
        if let Some(m) = &self.mullvad {
            if m.location.chars().any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')) {
                return Err(SeedError::Bad("mullvad.location", format!("`{}` is not a Mullvad location code (`se-sto`) or country (`se`)", m.location)));
            }
        }
        if let Some(t) = &self.tailscale {
            if !t.control_url.starts_with("https://") {
                return Err(SeedError::Bad("tailscale.control_url", format!("`{}` is not an https:// URL", t.control_url)));
            }
        }
        // 0.4.0 (owner 2026-09-25): WireGuard + mutual TLS ALWAYS — a medium
        // with no installer link, or `tunnel = none`, is refused by name.
        if let Err(e @ crate::tunnel::TunnelError::InstallerLinkNone) = crate::installer_link::reach(self.tunnel) {
            return Err(SeedError::Bad("tunnel", e.to_string()));
        }
        match &self.installer_link {
            Some(l) => {
                if !looks_like_key(&l.installer_pubkey) {
                    return Err(SeedError::Bad("installer_link.installer_pubkey", "not a WireGuard key (44 base64)".into()));
                }
                if !looks_like_key(&l.vm_private) {
                    return Err(SeedError::Bad("installer_link.vm_private", "not a WireGuard key (44 base64)".into()));
                }
                if l.port == 0 {
                    return Err(SeedError::Bad("installer_link.port", "0".into()));
                }
            }
            None => {
                return Err(SeedError::Bad(
                    "installer_link",
                    format!("REFUSED [installer-link-none] a `{}` medium carries no installer link: the installer reaches every VM through its WireGuard link only", self.tunnel.as_str()),
                ))
            }
        }
        Ok(())
    }
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;

    /// A link with well-formed (not usable) keys.
    pub(crate) fn test_link() -> LinkSeed {
        LinkSeed { installer_pubkey: format!("{}=", "A".repeat(43)), vm_private: format!("{}=", "B".repeat(43)), port: 51821 }
    }

    /// A seed with a real installer CA and bootstrap certificate.
    pub(crate) fn a_seed(kind: Kind) -> (Seed, crate::mtls::InstallerCa) {
        let ca = crate::mtls::InstallerCa::mint("gunnar.rs", std::time::Duration::from_secs(3600)).unwrap();
        let boot = ca.issue_bootstrap(std::time::Duration::from_secs(3600)).unwrap();
        let s = Seed {
            estate: "gunnar.rs".into(),
            tunnel: TunnelKind::Wireguard,
            front: Front { door: "10.13.8.179:8474".into() },
            kind,
            installer_ca_pem: ca.cert_pem.clone(),
            bootstrap_cert_pem: boot.cert_pem,
            bootstrap_key_pem: boot.key_pem,
            peers_expected: 3,
            mullvad: None,
            tailscale: None,
            installer_link: Some(test_link()),
        };
        (s, ca)
    }

    #[test]
    fn a_seed_round_trips_through_toml_names_no_box_and_wireguard_is_the_default() {
        let (s, _) = a_seed(Kind::Gunnar);
        let text = s.render().unwrap();
        assert!(!text.contains("overlay") && !text.contains("\nname"), "nothing per box in a seed");
        assert_eq!(Seed::parse(&text).unwrap(), s);
        let without = text.replace("tunnel = \"wireguard\"\n", "");
        assert_eq!(Seed::parse(&without).unwrap().tunnel, TunnelKind::Wireguard);
    }

    /// **Nothing per LAY in a seed** (owner 2026-09-24 evening: the medium is
    /// constant): the front's tunnel key and endpoint are the door's to feed.
    /// A 0.1 seed that still carries them parses, and they are dropped.
    #[test]
    fn a_seed_carries_no_tunnel_key_or_endpoint_and_an_old_one_still_parses() {
        let (s, _) = a_seed(Kind::Gunnar);
        let text = s.render().unwrap();
        assert!(!text.contains("tunnel_pubkey") && !text.contains("endpoint"), "per-lay values are fed, not seeded:\n{text}");
        let old = text.replace("[front]\n", "[front]\ntunnel_pubkey = \"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=\"\nendpoint = \"95.111.211.189:51820\"\n");
        assert!(old.contains("tunnel_pubkey"), "the fixture really is an old seed:\n{old}");
        assert_eq!(Seed::parse(&old).unwrap(), s, "the old fields are ignored");
    }

    #[test]
    fn a_missing_certificate_and_a_bad_door_are_refused_by_name_and_no_tunnel_key_is_asked_for() {
        let (mut s, _) = a_seed(Kind::Gunnar);
        s.installer_ca_pem = "nope".into();
        assert!(matches!(s.check(), Err(SeedError::Bad("installer_ca_pem", _))));
        let (mut s, _) = a_seed(Kind::Gunnar);
        s.bootstrap_key_pem.clear();
        assert!(matches!(s.check(), Err(SeedError::Bad("bootstrap_key_pem", _))));
        let (mut s, _) = a_seed(Kind::Gunnar);
        s.front.door = "front:8474".into();
        assert!(matches!(s.check(), Err(SeedError::Bad("front.door", _))));
        let (mut s, _) = a_seed(Kind::Front);
        assert_eq!(s.check(), Ok(()), "a WireGuard seed needs no front key: the door feeds it");
        s.tunnel = TunnelKind::None;
        assert!(matches!(s.check(), Err(SeedError::Bad("tunnel", ref why)) if why.starts_with("REFUSED [installer-link-none]")), "WireGuard + mutual TLS always");
        s.tunnel = TunnelKind::Wireguard;
        s.installer_link = None;
        assert!(matches!(s.check(), Err(SeedError::Bad("installer_link", _))), "every medium carries the link");
    }

    #[test]
    fn the_mullvad_and_tailscale_sections_are_optional_public_and_checked_by_name() {
        let (mut s, _) = a_seed(Kind::Gunnar);
        assert!(!s.render().unwrap().contains("[mullvad]"), "absent sections are not written");
        s.tunnel = TunnelKind::Mullvad;
        s.mullvad = Some(MullvadSeed { location: "se-sto".into(), port: 51820 });
        let text = s.render().unwrap();
        assert!(text.contains("[mullvad]") && !text.contains("account"), "no credential in a seed:\n{text}");
        assert_eq!(Seed::parse(&text).unwrap(), s);
        let bare = text.replace("port = 51820\n", "");
        assert_eq!(Seed::parse(&bare).unwrap().mullvad.unwrap().port, 51820, "the default port");
        s.mullvad = Some(MullvadSeed { location: "Stockholm; rm".into(), port: 51820 });
        assert!(matches!(s.check(), Err(SeedError::Bad("mullvad.location", _))));
        s.mullvad = None;
        assert_eq!(s.check(), Ok(()), "mullvad's mesh is WireGuard via the front, whose key the door feeds");
        s.tunnel = TunnelKind::Tailscale;
        assert_eq!(s.check(), Ok(()), "tailscale needs no front WireGuard key");
        s.tailscale = Some(TailscaleSeed { control_url: "http://plain".into() });
        assert!(matches!(s.check(), Err(SeedError::Bad("tailscale.control_url", _))));
    }
}