nordisk-pairing 0.5.25

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 tunnel the estate meets inside** — a driver trait, and the one
//! driver that needs no kernel: `none`.
//!
//! Only the FRONT needs a known address (its endpoint is in every seed); the
//! other boxes dial it and the driver learns their endpoints from the
//! handshake. Inside, the addresses are the overlay constants. The choice of
//! driver is a build-thing setting of the medium (the kernel module or the
//! user-space engine has to be IN the image), and the seed says which one the
//! image was composed for.
//!
//! **What is here and what is not** (2026-09-24):
//!
//! | kind        | driver (feature)                          | status |
//! |-------------|-------------------------------------------|--------|
//! | `none`      | [`NoTunnel`]                              | works — the cloud's own addresses |
//! | `wireguard` | `wireguard::WireGuard` (`wireguard`)      | works — wg-appliance-core, userspace |
//! | `mullvad`   | `mullvad::Mullvad` (`mullvad`)            | works — the estate MESH stays WireGuard via the front; Mullvad is each box's EXIT, a second interface |
//! | `tailscale` | `tailscale::Tailscale` (`tailscale`)      | REFUSED `[tunnel-tailscale-unimplemented]` — no pure-Rust coordination client exists |
//!
//! A kind the image was not composed with is `REFUSED [tunnel-not-built]`.
//! No driver answers "up" without bringing anything up — that would be the
//! failure shape this whole crate exists to end.
//!
//! **Configuration** is not the trait's business: [`driver_for`] builds the
//! driver from the seed's public choices (`[mullvad]`, `[tailscale]`) and the
//! door's FEED (the credentials — never on the medium). [`driver`] is the same
//! with neither, for the kinds that need none.

use crate::join::Peer;
use crate::seed::{Seed, TunnelKind};
use std::collections::BTreeMap;
use std::fmt;
use std::net::Ipv4Addr;

/// This box's tunnel identity: the public half goes in the join request; the
/// private half is minted on the box and never leaves it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
    pub overlay: Ipv4Addr,
    pub pubkey: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TunnelStatus {
    pub kind: TunnelKind,
    pub up: bool,
    /// Peers with a completed handshake, by name.
    pub reachable: Vec<String>,
    pub detail: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TunnelError {
    /// The image was not composed with this driver.
    NotBuilt(TunnelKind),
    /// The driver is built and cannot work yet; `missing` names the piece.
    Unimplemented { kind: TunnelKind, missing: &'static str },
    /// The driver needs a value the seed or the door's feed did not give.
    NotConfigured { kind: TunnelKind, what: String },
    Failed(String),
    /// 0.4.0: `tunnel = none` gives the installer no link, and the installer
    /// reaches a VM through its WireGuard link under mutual TLS ALWAYS.
    InstallerLinkNone,
}

impl fmt::Display for TunnelError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            TunnelError::NotBuilt(k) => write!(
                f,
                "REFUSED [tunnel-not-built] the seed asks for `{}` and this image carries no such driver — compose the medium with it (a build-thing setting), do not guess at runtime",
                k.as_str()
            ),
            TunnelError::Unimplemented { kind, missing } => write!(f, "REFUSED [tunnel-{}-unimplemented] the `{}` driver is built but cannot bring a tunnel up: missing {missing}", kind.as_str(), kind.as_str()),
            TunnelError::NotConfigured { kind, what } => write!(f, "REFUSED [tunnel-{}-unconfigured] {what}", kind.as_str()),
            TunnelError::Failed(e) => write!(f, "REFUSED [tunnel-failed] {e}"),
            TunnelError::InstallerLinkNone => write!(f, "REFUSED [installer-link-none] the installer reaches a VM through its WireGuard link, under mutual TLS both ways, ALWAYS (owner 2026-09-25); `tunnel = none` gives it no link and is refused — lay the estate with `tunnel = wireguard`"),
        }
    }
}

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

pub trait Tunnel {
    fn kind(&self) -> TunnelKind;
    /// **The key this box presents in its join request** — the public half of
    /// its mesh tunnel identity, known BEFORE `up` (the box joins first and
    /// raises the tunnel second, `crate::pair`). Base64, WireGuard's own
    /// encoding; empty when the kind has no such key (`none`; `tailscale`,
    /// whose node key is the coordination server's business). Default: empty,
    /// so an implementation that predates this method still compiles.
    fn public_key(&self) -> String {
        String::new()
    }
    /// Bring the interface up with this identity and these peers. Idempotent.
    fn up(&mut self, me: &Identity, peers: &[Peer]) -> Result<(), TunnelError>;
    /// A peer learned after `up` (the join reply names the ones that joined earlier;
    /// later joiners arrive through the front's peer updates).
    fn add_peer(&mut self, peer: &Peer) -> Result<(), TunnelError>;
    fn status(&self) -> TunnelStatus;
    /// **Re-point a tunnel at these peers, in this process** (0.5.17, A7 row 7; Gunnar
    /// 907f4f99 `Tunnel::repoint`): the front's relay moved (a FRONT recharged onto new
    /// addresses) or was re-keyed, and a box re-fed after it dials the new relay at once, never
    /// after a restart. The running pump is stopped and the new one raised on the same
    /// interface and address; the box keeps its own key. `Ok(true)` when the dial changed,
    /// `Ok(false)` when it already dials these peers, or when no pump runs yet (the raise
    /// takes them). Default: handed to [`Tunnel::up`] (`true`).
    fn repoint(&mut self, me: &Identity, peers: &[Peer]) -> Result<bool, TunnelError> {
        self.up(me, peers).map(|()| true)
    }
    /// **The pre-shared key of this box's link to the front** (0.5.17, A7 row 13; Gunnar
    /// 356e8dd2), as the installer fed it (`crate::installer::FEED_WG_PSK`, base64, 32
    /// bytes), mixed into every handshake: WireGuard's handshake is X25519 alone, and a key
    /// both ends hold — fed over the installer's post-quantum TLS — is what a recording cannot
    /// be opened without. `Ok(true)` when it changed (a running pump takes it at once). A value
    /// that is not a key is refused by name, never quoted. Default: a driver with no such key
    /// (`none`, `tailscale`) takes nothing (`false`).
    fn set_preshared_key(&mut self, _psk: Option<&str>) -> Result<bool, TunnelError> {
        Ok(false)
    }
    /// **How the installer reaches a VM of this kind during bootstrap**
    /// (0.3.0): through its own WireGuard link (`wireguard`, `mullvad`),
    /// straight to the public address (`none`), or refused by name
    /// (`tailscale`). One answer per kind, [`crate::installer_link::reach`];
    /// a driver overrides it only if its kind ever reaches otherwise.
    fn installer_reach(&self) -> Result<crate::installer_link::Reach, TunnelError> {
        crate::installer_link::reach(self.kind())
    }
}

/// **No tunnel**: the boxes speak over the addresses the cloud gave them.
/// `up` records the peers so `status` can name them; nothing else exists to
/// bring up, and "reachable" is every peer, because there is no handshake to
/// wait for.
#[derive(Debug, Default)]
pub struct NoTunnel {
    peers: Vec<Peer>,
    up: bool,
}

impl Tunnel for NoTunnel {
    fn kind(&self) -> TunnelKind {
        TunnelKind::None
    }
    fn up(&mut self, _me: &Identity, peers: &[Peer]) -> Result<(), TunnelError> {
        self.peers = peers.to_vec();
        self.up = true;
        Ok(())
    }
    fn add_peer(&mut self, peer: &Peer) -> Result<(), TunnelError> {
        if !self.peers.iter().any(|p| p.name == peer.name) {
            self.peers.push(peer.clone());
        }
        Ok(())
    }
    fn status(&self) -> TunnelStatus {
        TunnelStatus {
            kind: TunnelKind::None,
            up: self.up,
            reachable: self.peers.iter().map(|p| p.name.clone()).collect(),
            detail: "no tunnel: the cloud's own addresses".into(),
        }
    }
}

/// The driver for a seed's tunnel kind with NO configuration, or the refusal
/// that the image was composed without it. For `mullvad` and `tailscale` use
/// [`driver_for`]: without their seed section and the door's feed, their `up`
/// refuses by name (`[tunnel-mullvad-unconfigured]`) rather than guess.
pub fn driver(kind: TunnelKind) -> Result<Box<dyn Tunnel>, TunnelError> {
    build(kind, None, &BTreeMap::new())
}

/// **The driver a box raises**, built from its seed's public choices and the
/// credentials the door FED it at the join (`crate::seed::FEED_MULLVAD_ACCOUNT`,
/// `crate::seed::FEED_TAILSCALE_AUTHKEY`). Keys are minted fresh; a box that
/// kept its keys constructs the concrete driver itself (`WireGuard::with_key`,
/// `Mullvad::new`).
pub fn driver_for(seed: &Seed, feed: &BTreeMap<String, String>) -> Result<Box<dyn Tunnel>, TunnelError> {
    build(seed.tunnel, Some(seed), feed)
}

#[allow(unused_variables)]
fn build(kind: TunnelKind, seed: Option<&Seed>, feed: &BTreeMap<String, String>) -> Result<Box<dyn Tunnel>, TunnelError> {
    match kind {
        TunnelKind::None => Ok(Box::new(NoTunnel::default())),
        #[cfg(feature = "wireguard")]
        TunnelKind::Wireguard => Ok(Box::new(crate::wireguard::WireGuard::mint(None))),
        #[cfg(feature = "mullvad")]
        TunnelKind::Mullvad => Ok(Box::new(crate::mullvad::Mullvad::new(
            crate::wireguard::WireGuard::mint(None),
            None,
            seed.and_then(|s| s.mullvad.clone()).unwrap_or_default(),
            feed.get(crate::seed::FEED_MULLVAD_ACCOUNT).cloned(),
        ))),
        #[cfg(feature = "tailscale")]
        TunnelKind::Tailscale => Ok(Box::new(crate::tailscale::Tailscale::new(
            seed.and_then(|s| s.tailscale.clone()).unwrap_or_default(),
            feed.get(crate::seed::FEED_TAILSCALE_AUTHKEY).cloned(),
        ))),
        #[allow(unreachable_patterns)]
        other => Err(TunnelError::NotBuilt(other)),
    }
}

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

    #[test]
    fn no_tunnel_is_up_at_once_and_names_every_peer_and_a_missing_driver_is_refused_by_name() {
        let mut t = driver(TunnelKind::None).unwrap();
        assert_eq!(t.public_key(), "", "`none` has no key to present");
        let me = Identity { overlay: Ipv4Addr::new(10, 13, 9, 90), pubkey: String::new() };
        let front = Peer { name: "front".into(), role: Role::Front, tunnel_pubkey: String::new(), overlay: Ipv4Addr::new(10, 13, 8, 179), endpoint: None, utility: None };
        t.up(&me, std::slice::from_ref(&front)).unwrap();
        t.add_peer(&front).unwrap();
        let s = t.status();
        assert!(s.up && s.reachable == vec!["front".to_string()], "{s:?}");
        for (kind, built) in [
            (TunnelKind::Wireguard, cfg!(feature = "wireguard")),
            (TunnelKind::Mullvad, cfg!(feature = "mullvad")),
            (TunnelKind::Tailscale, cfg!(feature = "tailscale")),
        ] {
            match driver(kind) {
                Ok(d) => assert!(built && d.kind() == kind, "{kind:?} built without its feature"),
                Err(e) => {
                    let e = e.to_string();
                    assert!(!built && e.starts_with("REFUSED [tunnel-not-built]") && e.contains(kind.as_str()), "{e}");
                }
            }
        }
    }

    #[test]
    fn the_refusals_carry_their_names() {
        let e = TunnelError::Unimplemented { kind: TunnelKind::Tailscale, missing: "x" }.to_string();
        assert!(e.starts_with("REFUSED [tunnel-tailscale-unimplemented]"), "{e}");
        let e = TunnelError::NotConfigured { kind: TunnelKind::Mullvad, what: "no account".into() }.to_string();
        assert!(e.starts_with("REFUSED [tunnel-mullvad-unconfigured] no account"), "{e}");
    }
}