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 Tailscale driver — built, and refusing by name.**
//!
//! Tailscale is not "WireGuard with a config file": a node gets its peers,
//! their keys, their endpoints and its own address from a COORDINATION server
//! (Tailscale's control plane, or a self-hosted headscale), over a protocol of
//! its own, and keeps a long-poll open for every change. `tailscaled` is that
//! client. An OS-less gunnar box cannot run it, and the LAW forbids shelling
//! out to it anyway (Njord's deployer drives the `tailscale` CLI — fine on an
//! OS, useless here).
//!
//! **Measured 2026-09-24**: no Rust Tailscale / headscale / DERP client exists
//! in the suite (`grep -rli tailscale ~/git --include=*.rs` finds only Njord's
//! CLI calls) nor in the local crates mirror (`~/.cargo/registry/src/*/` has
//! nothing named tailscale, headscale, derp, tsnet or ts2021). So this driver
//! carries the seed's control URL and the fed auth key, and `up` answers
//! [`TunnelError::Unimplemented`] naming [`MISSING`] — never a fake "up".
//!
//! What would make it work, in order: (1) the ts2021 control client (HTTP
//! upgrade to a Noise-IK channel keyed by the machine key, `RegisterRequest`
//! with the auth key); (2) the `MapRequest` long-poll → the netmap → a
//! `TunnelSpec` for wg-appliance-core, the same data plane as `wireguard`;
//! (3) a DERP client for relayed paths; (4) disco for direct paths. (1)+(2)
//! alone give a working mesh wherever the peers have reachable endpoints.

use crate::join::Peer;
use crate::seed::{TailscaleSeed, TunnelKind};
use crate::tunnel::{Identity, Tunnel, TunnelError, TunnelStatus};

/// The exact piece that is missing.
pub const MISSING: &str = "a pure-Rust Tailscale coordination client — the ts2021 Noise-IK control protocol (machine key, RegisterRequest with the auth key), the MapRequest long-poll that yields the netmap, and a DERP client for relayed paths; none exists in the suite or the local crates mirror (2026-09-24), and tailscaled cannot run on an OS-less box";

pub struct Tailscale {
    seed: TailscaleSeed,
    /// Fed by the door; held only to say whether it came. Never printed.
    authkey: Option<String>,
}

impl Tailscale {
    pub fn new(seed: TailscaleSeed, authkey: Option<String>) -> Tailscale {
        Tailscale { seed, authkey: authkey.filter(|k| !k.trim().is_empty()) }
    }

    pub fn control_url(&self) -> &str {
        &self.seed.control_url
    }

    pub fn has_authkey(&self) -> bool {
        self.authkey.is_some()
    }
}

impl std::fmt::Debug for Tailscale {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Tailscale").field("control_url", &self.seed.control_url).field("authkey", &self.authkey.as_ref().map(|_| "[fed]")).finish()
    }
}

impl Tunnel for Tailscale {
    fn kind(&self) -> TunnelKind {
        TunnelKind::Tailscale
    }

    /// A missing auth key is named first (it is the operator's to fix today);
    /// with one, the refusal is the missing client itself.
    fn up(&mut self, _me: &Identity, _peers: &[Peer]) -> Result<(), TunnelError> {
        if self.authkey.is_none() {
            return Err(TunnelError::NotConfigured {
                kind: TunnelKind::Tailscale,
                what: format!("no auth key: the door feeds it as `{}` (never on the medium) — and even with one: missing {MISSING}", crate::seed::FEED_TAILSCALE_AUTHKEY),
            });
        }
        Err(TunnelError::Unimplemented { kind: TunnelKind::Tailscale, missing: MISSING })
    }

    fn add_peer(&mut self, _peer: &Peer) -> Result<(), TunnelError> {
        Err(TunnelError::Unimplemented { kind: TunnelKind::Tailscale, missing: MISSING })
    }

    fn status(&self) -> TunnelStatus {
        TunnelStatus {
            kind: TunnelKind::Tailscale,
            up: false,
            reachable: Vec::new(),
            detail: format!("tailscale via {} · auth key {} · not up: no coordination client", self.seed.control_url, if self.authkey.is_some() { "fed" } else { "absent" }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    use std::net::Ipv4Addr;

    fn me() -> Identity {
        Identity { overlay: crate::OVERLAY_BOX_A, pubkey: String::new() }
    }

    fn front() -> Peer {
        Peer { name: "front".into(), role: crate::Role::Front, tunnel_pubkey: String::new(), overlay: crate::OVERLAY_FRONT, endpoint: None, utility: None }
    }

    /// PINNED: the day a coordination client lands, this test is the one that
    /// has to change — the refusal is the contract until then.
    #[test]
    fn with_an_auth_key_up_is_refused_as_unimplemented_naming_the_missing_client_and_status_is_never_up() {
        let mut t = Tailscale::new(TailscaleSeed { control_url: "https://hs.example.net".into() }, Some("tskey-auth-SECRET".into()));
        let e = t.up(&me(), &[front()]).unwrap_err();
        assert_eq!(e, TunnelError::Unimplemented { kind: TunnelKind::Tailscale, missing: MISSING });
        let text = e.to_string();
        assert!(text.starts_with("REFUSED [tunnel-tailscale-unimplemented]") && text.contains("ts2021") && text.contains("DERP"), "{text}");
        assert!(t.add_peer(&front()).is_err());
        let s = t.status();
        assert!(!s.up && s.reachable.is_empty(), "{s:?}");
        assert!(s.detail.contains("hs.example.net") && s.detail.contains("fed"), "{}", s.detail);
        assert!(!format!("{t:?}{}", s.detail).contains("SECRET"), "the auth key is never printed");
        assert_eq!(t.public_key(), "", "the node key is the coordination server's business");
    }

    #[test]
    fn without_an_auth_key_up_names_the_feed_key_first() {
        let mut t = Tailscale::new(TailscaleSeed::default(), Some("  ".into()));
        let e = t.up(&me(), &[]).unwrap_err().to_string();
        assert!(e.starts_with("REFUSED [tunnel-tailscale-unconfigured]") && e.contains("tailscale_authkey") && e.contains("ts2021"), "{e}");
        assert_eq!(t.control_url(), "https://controlplane.tailscale.com");
    }

    #[test]
    fn driver_for_builds_it_from_the_seed_and_the_feed() {
        let (mut seed, _) = crate::seed::tests::a_seed(crate::seed::Kind::Gunnar);
        seed.tunnel = TunnelKind::Tailscale;
        seed.tailscale = Some(TailscaleSeed { control_url: "https://hs.example.net".into() });
        let text = seed.render().unwrap();
        assert!(text.contains("[tailscale]") && !text.contains("tskey"), "the seed carries the URL, never the key:\n{text}");
        let back = crate::seed::Seed::parse(&text).unwrap();
        let mut feed = BTreeMap::new();
        feed.insert(crate::seed::FEED_TAILSCALE_AUTHKEY.to_string(), "tskey-auth-x".to_string());
        let mut d = crate::tunnel::driver_for(&back, &feed).unwrap();
        assert_eq!(d.kind(), TunnelKind::Tailscale);
        let e = d.up(&Identity { overlay: Ipv4Addr::new(10, 44, 0, 2), pubkey: String::new() }, &[]).unwrap_err().to_string();
        assert!(e.starts_with("REFUSED [tunnel-tailscale-unimplemented]"), "{e}");
    }
}