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}