crates/installer/nordisk-pairing/src/tailscale.rs
//! **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}");
}
}