Skip to main content

nordisk_pairing/
tunnel.rs

1//! **The tunnel the estate meets inside** — a driver trait, and the one
2//! driver that needs no kernel: `none`.
3//!
4//! Only the FRONT needs a known address (its endpoint is in every seed); the
5//! other boxes dial it and the driver learns their endpoints from the
6//! handshake. Inside, the addresses are the overlay constants. The choice of
7//! driver is a build-thing setting of the medium (the kernel module or the
8//! user-space engine has to be IN the image), and the seed says which one the
9//! image was composed for.
10//!
11//! **What is here and what is not** (2026-09-24):
12//!
13//! | kind        | driver (feature)                          | status |
14//! |-------------|-------------------------------------------|--------|
15//! | `none`      | [`NoTunnel`]                              | works — the cloud's own addresses |
16//! | `wireguard` | `wireguard::WireGuard` (`wireguard`)      | works — wg-appliance-core, userspace |
17//! | `mullvad`   | `mullvad::Mullvad` (`mullvad`)            | works — the estate MESH stays WireGuard via the front; Mullvad is each box's EXIT, a second interface |
18//! | `tailscale` | `tailscale::Tailscale` (`tailscale`)      | REFUSED `[tunnel-tailscale-unimplemented]` — no pure-Rust coordination client exists |
19//!
20//! A kind the image was not composed with is `REFUSED [tunnel-not-built]`.
21//! No driver answers "up" without bringing anything up — that would be the
22//! failure shape this whole crate exists to end.
23//!
24//! **Configuration** is not the trait's business: [`driver_for`] builds the
25//! driver from the seed's public choices (`[mullvad]`, `[tailscale]`) and the
26//! door's FEED (the credentials — never on the medium). [`driver`] is the same
27//! with neither, for the kinds that need none.
28
29use crate::join::Peer;
30use crate::seed::{Seed, TunnelKind};
31use std::collections::BTreeMap;
32use std::fmt;
33use std::net::Ipv4Addr;
34
35/// This box's tunnel identity: the public half goes in the join request; the
36/// private half is minted on the box and never leaves it.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Identity {
39    pub overlay: Ipv4Addr,
40    pub pubkey: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct TunnelStatus {
45    pub kind: TunnelKind,
46    pub up: bool,
47    /// Peers with a completed handshake, by name.
48    pub reachable: Vec<String>,
49    pub detail: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum TunnelError {
54    /// The image was not composed with this driver.
55    NotBuilt(TunnelKind),
56    /// The driver is built and cannot work yet; `missing` names the piece.
57    Unimplemented { kind: TunnelKind, missing: &'static str },
58    /// The driver needs a value the seed or the door's feed did not give.
59    NotConfigured { kind: TunnelKind, what: String },
60    Failed(String),
61    /// 0.4.0: `tunnel = none` gives the installer no link, and the installer
62    /// reaches a VM through its WireGuard link under mutual TLS ALWAYS.
63    InstallerLinkNone,
64}
65
66impl fmt::Display for TunnelError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            TunnelError::NotBuilt(k) => write!(
70                f,
71                "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",
72                k.as_str()
73            ),
74            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()),
75            TunnelError::NotConfigured { kind, what } => write!(f, "REFUSED [tunnel-{}-unconfigured] {what}", kind.as_str()),
76            TunnelError::Failed(e) => write!(f, "REFUSED [tunnel-failed] {e}"),
77            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`"),
78        }
79    }
80}
81
82impl std::error::Error for TunnelError {}
83
84pub trait Tunnel {
85    fn kind(&self) -> TunnelKind;
86    /// **The key this box presents in its join request** — the public half of
87    /// its mesh tunnel identity, known BEFORE `up` (the box joins first and
88    /// raises the tunnel second, `crate::pair`). Base64, WireGuard's own
89    /// encoding; empty when the kind has no such key (`none`; `tailscale`,
90    /// whose node key is the coordination server's business). Default: empty,
91    /// so an implementation that predates this method still compiles.
92    fn public_key(&self) -> String {
93        String::new()
94    }
95    /// Bring the interface up with this identity and these peers. Idempotent.
96    fn up(&mut self, me: &Identity, peers: &[Peer]) -> Result<(), TunnelError>;
97    /// A peer learned after `up` (the join reply names the ones that joined earlier;
98    /// later joiners arrive through the front's peer updates).
99    fn add_peer(&mut self, peer: &Peer) -> Result<(), TunnelError>;
100    fn status(&self) -> TunnelStatus;
101    /// **Re-point a tunnel at these peers, in this process** (0.5.17, A7 row 7; Gunnar
102    /// 907f4f99 `Tunnel::repoint`): the front's relay moved (a FRONT recharged onto new
103    /// addresses) or was re-keyed, and a box re-fed after it dials the new relay at once, never
104    /// after a restart. The running pump is stopped and the new one raised on the same
105    /// interface and address; the box keeps its own key. `Ok(true)` when the dial changed,
106    /// `Ok(false)` when it already dials these peers, or when no pump runs yet (the raise
107    /// takes them). Default: handed to [`Tunnel::up`] (`true`).
108    fn repoint(&mut self, me: &Identity, peers: &[Peer]) -> Result<bool, TunnelError> {
109        self.up(me, peers).map(|()| true)
110    }
111    /// **The pre-shared key of this box's link to the front** (0.5.17, A7 row 13; Gunnar
112    /// 356e8dd2), as the installer fed it (`crate::installer::FEED_WG_PSK`, base64, 32
113    /// bytes), mixed into every handshake: WireGuard's handshake is X25519 alone, and a key
114    /// both ends hold — fed over the installer's post-quantum TLS — is what a recording cannot
115    /// be opened without. `Ok(true)` when it changed (a running pump takes it at once). A value
116    /// that is not a key is refused by name, never quoted. Default: a driver with no such key
117    /// (`none`, `tailscale`) takes nothing (`false`).
118    fn set_preshared_key(&mut self, _psk: Option<&str>) -> Result<bool, TunnelError> {
119        Ok(false)
120    }
121    /// **How the installer reaches a VM of this kind during bootstrap**
122    /// (0.3.0): through its own WireGuard link (`wireguard`, `mullvad`),
123    /// straight to the public address (`none`), or refused by name
124    /// (`tailscale`). One answer per kind, [`crate::installer_link::reach`];
125    /// a driver overrides it only if its kind ever reaches otherwise.
126    fn installer_reach(&self) -> Result<crate::installer_link::Reach, TunnelError> {
127        crate::installer_link::reach(self.kind())
128    }
129}
130
131/// **No tunnel**: the boxes speak over the addresses the cloud gave them.
132/// `up` records the peers so `status` can name them; nothing else exists to
133/// bring up, and "reachable" is every peer, because there is no handshake to
134/// wait for.
135#[derive(Debug, Default)]
136pub struct NoTunnel {
137    peers: Vec<Peer>,
138    up: bool,
139}
140
141impl Tunnel for NoTunnel {
142    fn kind(&self) -> TunnelKind {
143        TunnelKind::None
144    }
145    fn up(&mut self, _me: &Identity, peers: &[Peer]) -> Result<(), TunnelError> {
146        self.peers = peers.to_vec();
147        self.up = true;
148        Ok(())
149    }
150    fn add_peer(&mut self, peer: &Peer) -> Result<(), TunnelError> {
151        if !self.peers.iter().any(|p| p.name == peer.name) {
152            self.peers.push(peer.clone());
153        }
154        Ok(())
155    }
156    fn status(&self) -> TunnelStatus {
157        TunnelStatus {
158            kind: TunnelKind::None,
159            up: self.up,
160            reachable: self.peers.iter().map(|p| p.name.clone()).collect(),
161            detail: "no tunnel: the cloud's own addresses".into(),
162        }
163    }
164}
165
166/// The driver for a seed's tunnel kind with NO configuration, or the refusal
167/// that the image was composed without it. For `mullvad` and `tailscale` use
168/// [`driver_for`]: without their seed section and the door's feed, their `up`
169/// refuses by name (`[tunnel-mullvad-unconfigured]`) rather than guess.
170pub fn driver(kind: TunnelKind) -> Result<Box<dyn Tunnel>, TunnelError> {
171    build(kind, None, &BTreeMap::new())
172}
173
174/// **The driver a box raises**, built from its seed's public choices and the
175/// credentials the door FED it at the join (`crate::seed::FEED_MULLVAD_ACCOUNT`,
176/// `crate::seed::FEED_TAILSCALE_AUTHKEY`). Keys are minted fresh; a box that
177/// kept its keys constructs the concrete driver itself (`WireGuard::with_key`,
178/// `Mullvad::new`).
179pub fn driver_for(seed: &Seed, feed: &BTreeMap<String, String>) -> Result<Box<dyn Tunnel>, TunnelError> {
180    build(seed.tunnel, Some(seed), feed)
181}
182
183#[allow(unused_variables)]
184fn build(kind: TunnelKind, seed: Option<&Seed>, feed: &BTreeMap<String, String>) -> Result<Box<dyn Tunnel>, TunnelError> {
185    match kind {
186        TunnelKind::None => Ok(Box::new(NoTunnel::default())),
187        #[cfg(feature = "wireguard")]
188        TunnelKind::Wireguard => Ok(Box::new(crate::wireguard::WireGuard::mint(None))),
189        #[cfg(feature = "mullvad")]
190        TunnelKind::Mullvad => Ok(Box::new(crate::mullvad::Mullvad::new(
191            crate::wireguard::WireGuard::mint(None),
192            None,
193            seed.and_then(|s| s.mullvad.clone()).unwrap_or_default(),
194            feed.get(crate::seed::FEED_MULLVAD_ACCOUNT).cloned(),
195        ))),
196        #[cfg(feature = "tailscale")]
197        TunnelKind::Tailscale => Ok(Box::new(crate::tailscale::Tailscale::new(
198            seed.and_then(|s| s.tailscale.clone()).unwrap_or_default(),
199            feed.get(crate::seed::FEED_TAILSCALE_AUTHKEY).cloned(),
200        ))),
201        #[allow(unreachable_patterns)]
202        other => Err(TunnelError::NotBuilt(other)),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::Role;
210
211    #[test]
212    fn no_tunnel_is_up_at_once_and_names_every_peer_and_a_missing_driver_is_refused_by_name() {
213        let mut t = driver(TunnelKind::None).unwrap();
214        assert_eq!(t.public_key(), "", "`none` has no key to present");
215        let me = Identity { overlay: Ipv4Addr::new(10, 13, 9, 90), pubkey: String::new() };
216        let front = Peer { name: "front".into(), role: Role::Front, tunnel_pubkey: String::new(), overlay: Ipv4Addr::new(10, 13, 8, 179), endpoint: None, utility: None };
217        t.up(&me, std::slice::from_ref(&front)).unwrap();
218        t.add_peer(&front).unwrap();
219        let s = t.status();
220        assert!(s.up && s.reachable == vec!["front".to_string()], "{s:?}");
221        for (kind, built) in [
222            (TunnelKind::Wireguard, cfg!(feature = "wireguard")),
223            (TunnelKind::Mullvad, cfg!(feature = "mullvad")),
224            (TunnelKind::Tailscale, cfg!(feature = "tailscale")),
225        ] {
226            match driver(kind) {
227                Ok(d) => assert!(built && d.kind() == kind, "{kind:?} built without its feature"),
228                Err(e) => {
229                    let e = e.to_string();
230                    assert!(!built && e.starts_with("REFUSED [tunnel-not-built]") && e.contains(kind.as_str()), "{e}");
231                }
232            }
233        }
234    }
235
236    #[test]
237    fn the_refusals_carry_their_names() {
238        let e = TunnelError::Unimplemented { kind: TunnelKind::Tailscale, missing: "x" }.to_string();
239        assert!(e.starts_with("REFUSED [tunnel-tailscale-unimplemented]"), "{e}");
240        let e = TunnelError::NotConfigured { kind: TunnelKind::Mullvad, what: "no account".into() }.to_string();
241        assert!(e.starts_with("REFUSED [tunnel-mullvad-unconfigured] no account"), "{e}");
242    }
243}