Skip to main content

gunnar_server/
wg.rs

1//! **WireGuard on the appliance**: a stable address that outlives the DHCP lease.
2//!
3//! The appliance's outer address is whatever a DHCP server felt like granting
4//! (`appliance_pid1::dhcp`, `crate::boot::bring_up_network`), and it can change mid-life —
5//! `appliance_pid1::runtime::dhcp_renewal` logs exactly that event and re-applies it. An address
6//! nobody can predict is an address nobody can put in a config file, so this
7//! module gives the machine a **second, declared** address on a tunnel: ssh and
8//! the control plane already bind `0.0.0.0`, so the moment `wg0` exists with an
9//! address on it they answer there too, and nobody has to read a lease table.
10//!
11//! # The lane: USERSPACE, compiled in
12//!
13//! The WireGuard data plane is **linked into this binary** (`boringtun`, through
14//! `wg-appliance-core` — the one implementation the suite has, shared with
15//! tunnr's `wg-appliance` PID-1 payload). The tunnel needs:
16//!
17//! ```text
18//!   CONFIG_TUN         builtin (=y) on the kernel the ISO packs, MEASURED 2026-08-12
19//!                      — and on every ordinary distribution kernel
20//!   CONFIG_WIREGUARD   NOTHING. Not y, not m, not present.
21//!   modules            NONE. No finit_module, no /lib/modules, no staged .ko.zst
22//!   tooling            NONE. No wg(8), no wg-quick, no ip(8), no shell
23//! ```
24//!
25//! This is the correction of the kernel lane that stood here until now
26//! (8aa6103a). That lane worked, and it worked on **one box**: it needed
27//! `CONFIG_WIREGUARD=m` plus four `.ko.zst` files matched to the exact kernel
28//! release, staged into the image by hand. The files were declared nowhere and
29//! shipped nowhere, so a RELEASED ISO had no tunnel — the capability existed
30//! only for whoever rebuilt the image on the same machine that had the modules.
31//! OS-LESS means everything is in the compiled heap; a tunnel that depends on
32//! what the packed kernel was compiled with is not.
33//!
34//! The cost is honest and was paid on purpose: boringtun brings x25519-dalek,
35//! chacha20poly1305, blake2 and ring into the appliance's dependency graph.
36//! Measured, not estimated — see `.nornir/build-variants.toml`.
37//!
38//! # Seeding, and why not the kernel command line
39//!
40//! A WireGuard private key is a **secret**, and `/proc/cmdline` is world
41//! readable — so `GUNNAR_WG=…` would publish the key to every process on the
42//! machine, which is the opposite of what the tunnel is for. The configuration
43//! is a file in the data root instead, arriving by the same route as every other
44//! piece of appliance state:
45//!
46//! ```text
47//!   ISO        assets/appliance-root/data/wg0.conf   → /data/wg0.conf   (RAM)
48//!   container  the /data volume
49//!   service    $GUNNAR_DATA_ROOT/wg0.conf on real disk
50//! ```
51//!
52//! On the ISO that tree is `[[build.iso]].staging`, and it ships holding a
53//! `.keep` and nothing else — deliberately, because a committed key is a key
54//! everybody has. An operator who wants a tunnel builds their own ISO with their
55//! own keys in it, which is also the only shape in which the private key is not
56//! in this repository.
57//!
58//! # Strictly additive
59//!
60//! No `wg0.conf` ⇒ one INFO line and nothing else changes. A config that is
61//! present but **malformed** is an error, matching how a malformed `GUNNAR_NET`
62//! is fatal: a declaration that lies must stop the boot. A config that is well
63//! formed but that the KERNEL refuses (no `/dev/net/tun`, no `CAP_NET_ADMIN`)
64//! is a WARN and the boot goes on — the DHCP posture, because there the world
65//! failed to provide, not the declaration.
66//!
67//! # What this does NOT do
68//!
69//! * **No routes beyond the address's own.** `Address = 10.99.0.2/24` gives the
70//!   kernel a connected route for `10.99.0.0/24` when [`appliance_pid1::net::apply`]
71//!   installs it, and that covers the ordinary case where `AllowedIPs` is inside
72//!   the tunnel subnet. An `AllowedIPs` OUTSIDE it needs a route this module
73//!   does not add, and says so at startup rather than silently blackholing.
74//!   (The data plane's AllowedIPs table decides which PEER an outbound packet
75//!   belongs to; it does not decide which packets the kernel hands to `wg0`.)
76//! * **No endpoint re-resolution.** An `Endpoint` is parsed as `IP:port`, not a
77//!   hostname: the appliance has no resolver configured at this point in the
78//!   boot and a DNS lookup here would be a new failure mode before the network
79//!   is proven.
80//! * **No IPv6 transport.** The outer socket is `AF_INET`. Inner IPv6 would be
81//!   carried, but nothing configures it and `Address`/`AllowedIPs` are IPv4.
82//! * **Nothing about the outer address changing.** When DHCP renewal moves the
83//!   appliance's outer address (`appliance_pid1::runtime::dhcp_renewal`'s `Renewed { changed:
84//!   Some(..) }`), the tunnel keeps working in the direction the appliance
85//!   initiates — WireGuard's own roaming updates the PEER's idea of the endpoint
86//!   from the next authenticated packet. What breaks is the peer reaching the
87//!   appliance FIRST after such a move, until the appliance sends something;
88//!   `PersistentKeepalive` is the operator's answer and is why it is honoured.
89//!   Not solved here, and not papered over.
90
91use std::net::{Ipv4Addr, SocketAddrV4};
92use std::path::Path;
93use std::sync::Arc;
94
95use anyhow::{bail, Context, Result};
96use wg_appliance_core::dataplane::{self, PeerSpec, TunnelHandle, TunnelSpec};
97use wg_appliance_core::ini;
98use wg_appliance_core::keys;
99use wg_appliance_core::tun::{TunDevice, DEFAULT_MTU};
100
101
102
103/// A WireGuard key: 32 raw bytes, however it was spelled.
104const KEY_LEN: usize = keys::KEY_LEN;
105
106/// The file, relative to the data root, that turns the tunnel on by existing.
107pub const CONFIG_FILE: &str = "wg0.conf";
108
109/// The **private key alone**, relative to the data root: one base64 line.
110///
111/// The companion of [`BOOT_PARAM`]. The split exists because exactly one field
112/// of a WireGuard configuration is a secret, and the other five are the ones an
113/// operator wants to declare, version and repeat per site.
114pub const KEY_FILE: &str = "wg0.key";
115
116/// The boot parameter carrying the **non-secret half** of the tunnel.
117///
118/// `GUNNAR_WG=addr=10.99.0.2/24,peer=<base64>,endpoint=10.0.2.2:51820,allowed=10.99.0.0/24,keepalive=25`
119///
120/// Linux turns an unrecognised `key=value` boot parameter into an environment
121/// variable for init (`init/main.c`, `unknown_bootoption`), which is how
122/// `GUNNAR_NET` already arrives — so this rides a channel that is proven and
123/// declared (`.nornir/build-variants.toml`, `[[build.iso]].boot_params`).
124///
125/// **What may and may not travel this way, and why the line is exactly here.**
126/// `/proc/cmdline` is world readable. A hub's public key, an endpoint and an
127/// AllowedIPs list are not secrets — they are published to every peer of the
128/// network by definition, and WireGuard's security does not rest on hiding
129/// them. A PRIVATE key is a secret, and putting it here would hand it to every
130/// process on the machine, which is the opposite of what a tunnel is for. So
131/// the private key comes from [`KEY_FILE`] in the data root and nothing else
132/// does.
133pub const BOOT_PARAM: &str = "GUNNAR_WG";
134
135/// **The one read of [`BOOT_PARAM`]**, and the only line in this module that
136/// touches the process environment.
137///
138/// Blank is absent: a boot parameter Linux passed through as `GUNNAR_WG=` (or
139/// one an operator left empty) declares nothing, and treating it as a
140/// declaration is how a machine ends up refusing to boot because two lanes are
141/// "both armed" when one of them is an empty string.
142///
143/// Every decision that depends on it takes the answer as an argument —
144/// [`WgConfig::resolve_with`], [`seeded_lane_conflict`] — so the rules are
145/// testable without a process-wide mutation. See `resolve`'s note for the
146/// flake that made this a rule.
147pub fn boot_param() -> Option<String> {
148    std::env::var(BOOT_PARAM)
149        .ok()
150        .filter(|s| !s.trim().is_empty())
151}
152
153// ── the configuration ───────────────────────────────────────────────────────
154
155/// One peer of the tunnel.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Peer {
158    /// The peer's public key, 32 raw bytes.
159    pub public_key: [u8; KEY_LEN],
160    /// An optional pre-shared key, 32 raw bytes, mixed in on top of the
161    /// Curve25519 handshake.
162    pub preshared_key: Option<[u8; KEY_LEN]>,
163    /// What this peer is allowed to send from and be routed to: `(address,
164    /// prefix)` pairs.
165    pub allowed_ips: Vec<(Ipv4Addr, u8)>,
166    /// Where to reach the peer, if this end initiates. `IP:port` only — see the
167    /// module docs on why a hostname is refused.
168    pub endpoint: Option<SocketAddrV4>,
169    /// Seconds between keepalives, or `None` for off.
170    pub persistent_keepalive: Option<u16>,
171}
172
173/// A whole `wg0.conf`, parsed.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct WgConfig {
176    /// The interface to create, taken from the config FILE's stem so the name
177    /// and the file cannot disagree.
178    pub interface: String,
179    /// This end's private key, 32 raw bytes.
180    pub private_key: [u8; KEY_LEN],
181    /// The tunnel address to put on the interface, and its prefix.
182    pub address: Ipv4Addr,
183    /// The tunnel address's prefix length.
184    pub prefix: u8,
185    /// The UDP port to listen on. `None` lets the kernel choose, which is right
186    /// for a machine that only ever initiates.
187    pub listen_port: Option<u16>,
188    /// The peers. At least one, or there is no tunnel to speak of.
189    pub peers: Vec<Peer>,
190}
191
192impl WgConfig {
193    /// Parse the `wg-quick(8)` INI subset gunnar understands.
194    ///
195    /// A **subset**, named exhaustively so an operator is never guessing which
196    /// half of their file was read:
197    ///
198    /// ```text
199    ///   [Interface]  PrivateKey  Address  ListenPort
200    ///   [Peer]       PublicKey  PresharedKey  AllowedIPs  Endpoint  PersistentKeepalive
201    /// ```
202    ///
203    /// Everything `wg-quick` itself implements with a shell — `DNS`, `MTU`,
204    /// `Table`, `PreUp`, `PostUp`, `SaveConfig` — is **refused by name** rather
205    /// than ignored. An ignored `PostUp` is a command the operator believes ran;
206    /// The no-OS rule bans gunnar from running one at all, so the honest answer is to say so
207    /// at parse time and not at 3 a.m.
208    ///
209    /// The line SCANNER is `wg_appliance_core::ini`, shared with tunnr's payload
210    /// so there is one answer to "where does a comment end" — the POLICY below
211    /// is gunnar's alone, because tunnr's payload deliberately accepts keys this
212    /// one refuses.
213    ///
214    /// `interface` is the config file's stem, not a field: two sources for one
215    /// name is a way for them to disagree.
216    pub fn parse(interface: &str, text: &str) -> Result<WgConfig> {
217        /// The keys `wg-quick` has and gunnar deliberately does not.
218        const REFUSED: [&str; 8] = [
219            "dns",
220            "mtu",
221            "table",
222            "preup",
223            "postup",
224            "predown",
225            "postdown",
226            "saveconfig",
227        ];
228
229        #[derive(PartialEq)]
230        enum Section {
231            None,
232            Interface,
233            Peer,
234        }
235
236        let mut section = Section::None;
237        let mut private_key: Option<[u8; KEY_LEN]> = None;
238        let mut cidr: Option<(Ipv4Addr, u8)> = None;
239        let mut listen_port: Option<u16> = None;
240        let mut peers: Vec<Peer> = Vec::new();
241
242        for item in ini::items(text) {
243            let line = item.line();
244            let (key, value) = match item {
245                ini::Item::Section { name, raw_name, .. } => {
246                    section = match name.as_str() {
247                        "interface" => Section::Interface,
248                        "peer" => {
249                            peers.push(Peer {
250                                public_key: [0; KEY_LEN],
251                                preshared_key: None,
252                                allowed_ips: Vec::new(),
253                                endpoint: None,
254                                persistent_keepalive: None,
255                            });
256                            Section::Peer
257                        }
258                        _ => bail!("{interface}.conf:{line}: unknown section [{raw_name}]"),
259                    };
260                    continue;
261                }
262                ini::Item::Malformed { text, .. } => {
263                    bail!("{interface}.conf:{line}: {text:?} is not `Key = value`")
264                }
265                ini::Item::Pair { key, value, .. } => (key, value),
266            };
267            if REFUSED.contains(&key.as_str()) {
268                bail!(
269                    "{interface}.conf:{line}: `{key}` is a wg-quick key gunnar does NOT implement. \
270                     It is refused rather than ignored, because an ignored key reads as one that \
271                     was honoured. (PreUp/PostUp/PostDown run commands, and this server spawns \
272                     nothing; DNS/MTU/Table are not configured here.)"
273                );
274            }
275            match (&section, key.as_str()) {
276                (Section::None, _) => {
277                    bail!(
278                        "{interface}.conf:{line}: `{key}` before any [Interface] or [Peer] section"
279                    )
280                }
281                (Section::Interface, "privatekey") => {
282                    private_key = Some(
283                        parse_key(value)
284                            .with_context(|| format!("{interface}.conf:{line}: PrivateKey"))?,
285                    );
286                }
287                (Section::Interface, "address") => {
288                    cidr = Some(
289                        parse_cidr(value)
290                            .with_context(|| format!("{interface}.conf:{line}: Address"))?,
291                    );
292                }
293                (Section::Interface, "listenport") => {
294                    listen_port = Some(value.parse::<u16>().with_context(|| {
295                        format!("{interface}.conf:{line}: ListenPort {value:?} is not a port")
296                    })?);
297                }
298                (Section::Peer, "publickey") => {
299                    let k = parse_key(value)
300                        .with_context(|| format!("{interface}.conf:{line}: PublicKey"))?;
301                    peers
302                        .last_mut()
303                        .expect("a [Peer] section pushed one")
304                        .public_key = k;
305                }
306                (Section::Peer, "presharedkey") => {
307                    let k = parse_key(value)
308                        .with_context(|| format!("{interface}.conf:{line}: PresharedKey"))?;
309                    peers
310                        .last_mut()
311                        .expect("a [Peer] section pushed one")
312                        .preshared_key = Some(k);
313                }
314                (Section::Peer, "allowedips") => {
315                    let mut ips = Vec::new();
316                    for one in value.split(',') {
317                        let one = one.trim();
318                        if one.is_empty() {
319                            continue;
320                        }
321                        ips.push(parse_cidr(one).with_context(|| {
322                            format!("{interface}.conf:{line}: AllowedIPs entry {one:?}")
323                        })?);
324                    }
325                    if ips.is_empty() {
326                        bail!("{interface}.conf:{line}: AllowedIPs is empty");
327                    }
328                    peers
329                        .last_mut()
330                        .expect("a [Peer] section pushed one")
331                        .allowed_ips = ips;
332                }
333                (Section::Peer, "endpoint") => {
334                    let addr: SocketAddrV4 = value.parse().with_context(|| {
335                        format!(
336                            "{interface}.conf:{line}: Endpoint {value:?} is not <IPv4>:<port>. A \
337                             HOSTNAME is deliberately not accepted: the appliance has no resolver \
338                             configured this early in the boot"
339                        )
340                    })?;
341                    peers
342                        .last_mut()
343                        .expect("a [Peer] section pushed one")
344                        .endpoint = Some(addr);
345                }
346                (Section::Peer, "persistentkeepalive") => {
347                    let secs: u16 = value.parse().with_context(|| {
348                        format!(
349                            "{interface}.conf:{line}: PersistentKeepalive {value:?} is not a \
350                             number of seconds"
351                        )
352                    })?;
353                    peers
354                        .last_mut()
355                        .expect("a [Peer] section pushed one")
356                        .persistent_keepalive = (secs != 0).then_some(secs);
357                }
358                (_, other) => {
359                    bail!("{interface}.conf:{line}: unknown key `{other}` in this section")
360                }
361            }
362        }
363
364        let private_key = private_key
365            .with_context(|| format!("{interface}.conf: [Interface] has no PrivateKey"))?;
366        let (address, prefix) =
367            cidr.with_context(|| format!("{interface}.conf: [Interface] has no Address"))?;
368        if peers.is_empty() {
369            bail!("{interface}.conf: no [Peer] section — a tunnel with no peer reaches nothing");
370        }
371        for (i, peer) in peers.iter().enumerate() {
372            if peer.public_key == [0; KEY_LEN] {
373                bail!("{interface}.conf: [Peer] #{} has no PublicKey", i + 1);
374            }
375            if peer.allowed_ips.is_empty() {
376                bail!(
377                    "{interface}.conf: [Peer] #{} has no AllowedIPs — nothing would be routed \
378                     into the tunnel for it and nothing accepted from it",
379                    i + 1
380                );
381            }
382        }
383        Ok(WgConfig {
384            interface: interface.to_string(),
385            private_key,
386            address,
387            prefix,
388            listen_port,
389            peers,
390        })
391    }
392
393    /// Parse the [`BOOT_PARAM`] form: the non-secret half from the kernel
394    /// command line, the private key from [`KEY_FILE`] in the data root.
395    ///
396    /// ```text
397    ///   addr=10.99.0.2/24,peer=<base64>,endpoint=10.0.2.2:51820,allowed=10.99.0.0/24,keepalive=25
398    /// ```
399    ///
400    /// Comma-separated because a WireGuard endpoint contains a colon and a
401    /// base64 key contains `+`, `/` and `=` — a colon-separated grammar cannot
402    /// hold both without quoting, and a kernel command line has no quoting.
403    /// Each field splits on its FIRST `=` so a key's trailing padding survives.
404    ///
405    /// `addr`, `peer` and `allowed` are required; `endpoint` and `keepalive`
406    /// are not. One peer only: this form is for the ordinary appliance shape —
407    /// one machine dialling one hub — and a second peer wants the file.
408    pub fn parse_boot_param(
409        interface: &str,
410        spec: &str,
411        private_key: [u8; KEY_LEN],
412    ) -> Result<WgConfig> {
413        let (mut addr, mut peer, mut allowed, mut endpoint, mut keepalive) =
414            (None, None, None, None, None);
415        for field in spec.split(',') {
416            let field = field.trim();
417            if field.is_empty() {
418                continue;
419            }
420            let (key, value) = field
421                .split_once('=')
422                .with_context(|| format!("{BOOT_PARAM}: {field:?} is not `key=value`"))?;
423            match key.trim().to_ascii_lowercase().as_str() {
424                "addr" => addr = Some(parse_cidr(value).context("addr=")?),
425                "peer" => peer = Some(parse_key(value).context("peer=")?),
426                "allowed" => {
427                    let mut ips = Vec::new();
428                    // `allowed=` may be repeated rather than list-separated,
429                    // because the list separator is already the field separator.
430                    for one in value.split_whitespace() {
431                        ips.push(parse_cidr(one).context("allowed=")?);
432                    }
433                    let mut all: Vec<_> = allowed.unwrap_or_default();
434                    all.extend(ips);
435                    allowed = Some(all);
436                }
437                "endpoint" => {
438                    endpoint = Some(value.parse::<SocketAddrV4>().with_context(|| {
439                        format!(
440                            "{BOOT_PARAM}: endpoint={value:?} is not <IPv4>:<port>; a hostname \
441                             is deliberately not accepted (no resolver this early in the boot)"
442                        )
443                    })?)
444                }
445                "keepalive" => {
446                    let secs: u16 = value.parse().with_context(|| {
447                        format!("{BOOT_PARAM}: keepalive={value:?} is not a number of seconds")
448                    })?;
449                    keepalive = (secs != 0).then_some(secs);
450                }
451                other => bail!(
452                    "{BOOT_PARAM}: unknown field `{other}`. This form takes addr, peer, allowed, \
453                     endpoint and keepalive — and deliberately NOT a private key, which would be \
454                     world-readable in /proc/cmdline"
455                ),
456            }
457        }
458        let (address, prefix) =
459            addr.with_context(|| format!("{BOOT_PARAM}: no `addr=<ip>/<prefix>`"))?;
460        let public_key =
461            peer.with_context(|| format!("{BOOT_PARAM}: no `peer=<hub public key>`"))?;
462        let allowed_ips = allowed
463            .filter(|v: &Vec<_>| !v.is_empty())
464            .with_context(|| format!("{BOOT_PARAM}: no `allowed=<cidr>`"))?;
465        Ok(WgConfig {
466            interface: interface.to_string(),
467            private_key,
468            address,
469            prefix,
470            // The kernel picks a port. A machine that only ever dials out does
471            // not need a fixed one, and a fixed one is a thing to collide.
472            listen_port: None,
473            peers: vec![Peer {
474                public_key,
475                preshared_key: None,
476                allowed_ips,
477                endpoint,
478                persistent_keepalive: keepalive,
479            }],
480        })
481    }
482
483    /// Read `<data_root>/wg0.conf`, if it is there.
484    ///
485    /// `Ok(None)` is "no tunnel was asked for" and is the shipped state.
486    /// `Err` is "a tunnel was asked for and the request does not parse".
487    pub fn from_data_root(data_root: &Path) -> Result<Option<WgConfig>> {
488        let path = data_root.join(CONFIG_FILE);
489        let text = match std::fs::read_to_string(&path) {
490            Ok(t) => t,
491            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
492            Err(err) => {
493                return Err(err).with_context(|| format!("reading {}", path.display()));
494            }
495        };
496        let interface = Path::new(CONFIG_FILE)
497            .file_stem()
498            .and_then(|s| s.to_str())
499            .expect("CONFIG_FILE is a literal with a stem");
500        Ok(Some(WgConfig::parse(interface, &text)?))
501    }
502
503    /// **The one resolver**: whichever of the two declared sources is present.
504    ///
505    /// | source | non-secret half | private key |
506    /// |---|---|---|
507    /// | [`CONFIG_FILE`] | the file | the file |
508    /// | [`BOOT_PARAM`] | the kernel command line | [`KEY_FILE`] |
509    ///
510    /// Both present is an **error**, not a precedence rule. Two configurations
511    /// that disagree is the failure a precedence rule hides: the operator who
512    /// edited the one that lost would see a working tunnel with the wrong
513    /// settings and no way to tell from the console.
514    ///
515    /// Neither present is `Ok(None)` — the shipped state, and the only reason
516    /// this is safe to call unconditionally.
517    ///
518    /// **The env read happens here and nowhere below.** This is the edge; the
519    /// decision is [`WgConfig::resolve_with`], which takes the boot parameter as
520    /// an argument. The split is the same one [`seeded_lane_conflict`] already
521    /// makes and for the same measured reason: a rule that can only be
522    /// exercised by `set_var` on the test process is a rule whose tests flake
523    /// the moment `cargo test` runs them on two threads — `std::env::set_var`
524    /// is process-wide, and two tests that both own `GUNNAR_WG` are one test
525    /// run twice. It was red intermittently in exactly that shape.
526    pub fn resolve(data_root: &Path) -> Result<Option<WgConfig>> {
527        WgConfig::resolve_with(data_root, boot_param().as_deref())
528    }
529
530    /// [`WgConfig::resolve`] with the boot parameter handed in.
531    ///
532    /// `None` is "the parameter is not set"; `Some(spec)` is its value, already
533    /// known to be non-blank. Nothing here reads the environment, so every rule
534    /// below is testable on any number of threads at once.
535    pub fn resolve_with(data_root: &Path, param: Option<&str>) -> Result<Option<WgConfig>> {
536        let file = WgConfig::from_data_root(data_root)?;
537        let param = param.map(str::to_owned);
538        match (file, param) {
539            (Some(_), Some(_)) => bail!(
540                "both {} and {BOOT_PARAM} configure WireGuard. They are two ways to say the same \
541                 thing and this refuses to guess which one you meant: delete the file to use the \
542                 boot parameter, or drop {BOOT_PARAM} from the command line to use the file",
543                data_root.join(CONFIG_FILE).display()
544            ),
545            (Some(config), None) => Ok(Some(config)),
546            (None, Some(spec)) => {
547                let path = data_root.join(KEY_FILE);
548                let text = std::fs::read_to_string(&path).with_context(|| {
549                    format!(
550                        "{BOOT_PARAM} is set, so the private key must be seeded at {} — it is \
551                         the ONE field that may not ride the world-readable kernel command line",
552                        path.display()
553                    )
554                })?;
555                let private_key = parse_key(text.trim())
556                    .with_context(|| format!("reading the private key from {}", path.display()))?;
557                let interface = Path::new(CONFIG_FILE)
558                    .file_stem()
559                    .and_then(|s| s.to_str())
560                    .expect("CONFIG_FILE is a literal with a stem");
561                Ok(Some(WgConfig::parse_boot_param(
562                    interface,
563                    spec.trim(),
564                    private_key,
565                )?))
566            }
567            (None, None) => Ok(None),
568        }
569    }
570
571    /// The `AllowedIPs` entries that fall OUTSIDE the interface's own subnet,
572    /// and therefore need a route nothing here installs.
573    ///
574    /// Returned rather than logged from inside, so the caller can state it once
575    /// and a test can assert it without reading a log.
576    pub fn unrouted_allowed_ips(&self) -> Vec<(Ipv4Addr, u8)> {
577        let mine = masked(self.address, self.prefix);
578        self.peers
579            .iter()
580            .flat_map(|p| p.allowed_ips.iter().copied())
581            .filter(|(addr, prefix)| *prefix < self.prefix || masked(*addr, self.prefix) != mine)
582            .collect()
583    }
584
585    /// **This appliance's WireGuard identity**, derived from the seeded private
586    /// key.
587    ///
588    /// Computed here rather than read back from a kernel device, which is what
589    /// the deleted kernel lane did: the curve is linked into this binary now, so
590    /// there is nothing to ask. `wg pubkey` computes exactly this, and
591    /// `keys::tests::derives_the_public_keys_wg_pubkey_prints` pins it against
592    /// two vectors that came out of the tool.
593    pub fn public_key(&self) -> [u8; KEY_LEN] {
594        keys::public_from_private_bytes(&self.private_key)
595    }
596
597    /// The data plane's view of this config — the seam between gunnar's declared
598    /// config surface and the shared userspace WireGuard implementation.
599    fn tunnel_spec(&self) -> TunnelSpec {
600        TunnelSpec {
601            private_key: self.private_key,
602            listen_port: self.listen_port,
603            peers: self
604                .peers
605                .iter()
606                .map(|p| PeerSpec {
607                    public_key: p.public_key,
608                    preshared_key: p.preshared_key,
609                    endpoint: p.endpoint,
610                    allowed_ips: p.allowed_ips.clone(),
611                    persistent_keepalive: p.persistent_keepalive,
612                })
613                .collect(),
614        }
615    }
616}
617
618/// `addr` with everything below `prefix` cleared.
619fn masked(addr: Ipv4Addr, prefix: u8) -> u32 {
620    let bits = u32::from(addr);
621    match prefix {
622        0 => 0,
623        p if p >= 32 => bits,
624        p => bits & !(u32::MAX >> p),
625    }
626}
627
628/// A base64 WireGuard key → 32 raw bytes, through the shared decoder.
629///
630/// Length is checked **after** decoding, not by counting characters: a 44-char
631/// string that decodes to 33 bytes is a real thing base64 can express, and a
632/// key of the wrong length handed to the crypto is a tunnel that never
633/// handshakes with no error anywhere.
634fn parse_key(text: &str) -> Result<[u8; KEY_LEN]> {
635    keys::decode_key(text).map_err(|e| anyhow::anyhow!("{e}"))
636}
637
638/// `10.99.0.2/24` → `(10.99.0.2, 24)`.
639fn parse_cidr(text: &str) -> Result<(Ipv4Addr, u8)> {
640    let (addr, prefix) = text
641        .split_once('/')
642        .with_context(|| format!("{text:?} is not <address>/<prefix>"))?;
643    let addr: Ipv4Addr = addr
644        .trim()
645        .parse()
646        .with_context(|| format!("{addr:?} is not an IPv4 address"))?;
647    let prefix: u8 = prefix
648        .trim()
649        .parse()
650        .with_context(|| format!("{prefix:?} is not a prefix length"))?;
651    if prefix > 32 {
652        bail!("/{prefix} is not a legal IPv4 prefix length");
653    }
654    Ok((addr, prefix))
655}
656
657// ── minting an identity ─────────────────────────────────────────────────────
658
659/// **Mint a WireGuard keypair.** `wg genkey | wg pubkey`, without a shell.
660///
661/// The curve is linked into this binary for the tunnel itself, so this costs
662/// nothing that was not already there — and the alternative costs a shell, a
663/// `wg(8)` binary and a pipe, none of which exist on an appliance or in the
664/// image that builds one. The installer epic needs exactly this: seed a machine
665/// with an identity before it has a filesystem worth speaking of.
666///
667/// Entropy is `getrandom(2)`, not a read of `/dev/urandom` — an appliance mints
668/// its identity before `/dev` is necessarily populated.
669pub fn generate_keypair() -> ([u8; KEY_LEN], [u8; KEY_LEN]) {
670    keys::generate_keypair()
671}
672
673/// `gunnar wg keygen [--private <path>]`.
674///
675/// With `--private`, the secret goes to the file (mode 0600) and only the
676/// public half is printed — the [`KEY_FILE`] shape. Without it, both are
677/// printed, which is what a person pasting into two config files wants.
678///
679/// **An existing file is refused, never overwritten.** A regenerated identity
680/// is a silent fleet-wide outage: every hub still allowlists the old public key
681/// and a peer whose key is unknown gets silence, not an error.
682pub fn keygen_verb(command: gunnar_common::cli::WgCommand) -> Result<()> {
683    let gunnar_common::cli::WgCommand::Keygen { private } = command;
684    let (secret, public) = generate_keypair();
685    let Some(path) = private else {
686        println!("private {}", base64_key(&secret));
687        println!("public  {}", base64_key(&public));
688        return Ok(());
689    };
690    if path.exists() {
691        bail!(
692            "{} already exists. This refuses to overwrite an identity: every hub that peers \
693             with this machine allowlists its CURRENT public key, and a peer whose key is \
694             unknown to the other end gets silence rather than an error. Delete the file \
695             deliberately if you really mean to mint a new identity.",
696            path.display()
697        );
698    }
699    write_secret(&path, &format!("{}\n", base64_key(&secret)))
700        .with_context(|| format!("writing the private key to {}", path.display()))?;
701    println!("public  {}", base64_key(&public));
702    Ok(())
703}
704
705/// Write a secret so it is unreadable to anyone but its owner FROM THE MOMENT
706/// IT EXISTS — the mode goes in the `open(2)`, not in a `chmod` afterwards.
707/// A create-then-chmod leaves a window in which the key is world readable, and
708/// on the box this is used to provision a fleet that window is enough.
709fn write_secret(path: &Path, body: &str) -> std::io::Result<()> {
710    use std::io::Write as _;
711    use std::os::unix::fs::OpenOptionsExt as _;
712    let mut f = std::fs::OpenOptions::new()
713        .write(true)
714        .create_new(true)
715        .mode(0o600)
716        .open(path)?;
717    f.write_all(body.as_bytes())
718}
719
720// ── the orchestrator ────────────────────────────────────────────────────────
721
722/// A tunnel that was brought up, and what is needed to report on it later.
723#[derive(Clone)]
724pub struct Tunnel {
725    /// The interface, e.g. `wg0`.
726    pub interface: String,
727    /// The tunnel address ssh and the control plane now also answer on.
728    pub address: Ipv4Addr,
729    /// The tunnel address's prefix length.
730    pub prefix: u8,
731    /// How many peers were configured.
732    pub peers: usize,
733    /// **This appliance's WireGuard public key**, derived from the seeded
734    /// private key.
735    ///
736    /// It goes in the boot banner, and the reason is a property of WireGuard
737    /// rather than a nicety: peering is mutual allowlisting BY PUBLIC KEY, and
738    /// a key the hub does not know produces **silence**, not an error. So the
739    /// one question an operator asks when a tunnel does not come up — "is the
740    /// key on this box the key I put in the hub's `[Peer]` stanza?" — has to be
741    /// answerable from the console, which on a shell-less appliance is the only
742    /// channel there is.
743    pub public_key: [u8; KEY_LEN],
744    /// The UDP port the data plane bound.
745    pub local_port: u16,
746    /// The configuration the pump runs on — what a later [`raise_from_config`]
747    /// compares the configuration on disk against. Shared by every clone, so
748    /// a re-point is seen by all of them.
749    config: Arc<std::sync::Mutex<WgConfig>>,
750    /// The running pump. Shared rather than owned, because the handshake watch
751    /// polls it from a task while `serve` holds it for the process lifetime —
752    /// and dropping the last one deletes the interface.
753    ///
754    /// A SLOT since 2026-10-01 (recharge-front-new-key-deadlock part 2): a
755    /// re-feed that names another front relay stops this pump and starts the
756    /// next one in the same slot ([`Tunnel::repoint`]), and every clone — the
757    /// one `serve` holds, the pairing loop's — runs on the new one. `None`
758    /// while a re-point is between the two, or after one that failed.
759    handle: Arc<std::sync::Mutex<Option<TunnelHandle>>>,
760}
761
762impl std::fmt::Debug for Tunnel {
763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
764        f.debug_struct("Tunnel")
765            .field("interface", &self.interface)
766            .field("address", &self.address)
767            .field("prefix", &self.prefix)
768            .field("peers", &self.peers)
769            .field("public_key", &base64_key(&self.public_key))
770            .field("local_port", &self.local_port)
771            .finish()
772    }
773}
774
775impl Tunnel {
776    /// The banner line: what a person reads off the console to check the
777    /// appliance against their hub.
778    pub fn banner_line(&self) -> String {
779        banner_line(
780            &self.interface,
781            self.address,
782            self.prefix,
783            &self.public_key,
784            self.peers,
785            self.local_port,
786        )
787    }
788
789    /// True when any peer has completed a handshake — "the tunnel WORKS", as
790    /// opposed to "the tunnel is configured".
791    pub fn handshaken(&self) -> bool {
792        self.handle.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(TunnelHandle::handshaken).unwrap_or(false)
793    }
794
795    /// Every peer as the running pump last saw it (none while no pump runs).
796    pub fn peer_stats(&self) -> Vec<dataplane::PeerStats> {
797        self.handle.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(TunnelHandle::peers).unwrap_or_default()
798    }
799
800    /// The configuration the pump runs on, or `None` when no pump runs.
801    pub fn running_config(&self) -> Option<WgConfig> {
802        let up = self.handle.lock().unwrap_or_else(|p| p.into_inner()).is_some();
803        up.then(|| self.config.lock().unwrap_or_else(|p| p.into_inner()).clone())
804    }
805
806    /// **Re-point the tunnel at `wanted`, in this process** — the front's
807    /// relay moved (a front recharged onto new addresses) or its key changed.
808    /// The old pump is stopped first (its tun fd closes, which deletes the
809    /// interface), then `wanted` is raised on the same interface; the address
810    /// is this box's own and does not change. A failure leaves no pump: the
811    /// next re-feed, the pairing loop or the next start raises it.
812    pub fn repoint(&self, wanted: &WgConfig) -> Result<()> {
813        let mut slot = self.handle.lock().unwrap_or_else(|p| p.into_inner());
814        if let Some(mut old) = slot.take() {
815            old.shutdown();
816        }
817        let handle = open_pump(wanted)?;
818        *slot = Some(handle);
819        *self.config.lock().unwrap_or_else(|p| p.into_inner()) = wanted.clone();
820        crate::tunnel_addr::publish(Some(wanted.address));
821        Ok(())
822    }
823}
824
825/// The peers of a configuration, for a log line: `<key fingerprint>@<endpoint>`.
826fn peers_said(c: &WgConfig) -> String {
827    c.peers
828        .iter()
829        .map(|p| format!("{}@{}", &base64_key(&p.public_key)[..8], p.endpoint.map(|e| e.to_string()).unwrap_or_else(|| "-".into())))
830        .collect::<Vec<_>>()
831        .join(", ")
832}
833
834/// **Bring the tunnel up**, if the data root asks for one.
835///
836/// Called from `serve::run` AFTER the outer address is applied, because a
837/// tunnel whose endpoint is reached over the outer address cannot handshake
838/// before that address exists.
839///
840/// The three outcomes, and they are deliberately different grades:
841///
842/// * **no config** ⇒ `Ok(None)` and one INFO line. The shipped state.
843/// * **config that does not parse** ⇒ `Err`. A declaration that lies stops the
844///   boot, exactly as a malformed `GUNNAR_NET` does.
845/// * **config the kernel refuses** ⇒ `Ok(None)` and a WARN naming the reason.
846///   The world failed to provide; the declaration did not lie, and an appliance
847///   that refuses to serve git because a tunnel would not come up is worse than
848///   one that serves on its outer address and says so.
849///
850/// # Two lanes, and only one of them may be armed
851///
852/// With `GUNNAR_APPLIANCE=1` (declared by the `[[build.installer]]` cell) the
853/// **ceremony** owns the identity: [`crate::appliance::run`] mints or loads it
854/// on the durable root, announces it, and hands back the config a declared
855/// `hub.conf` implies. Without the gate the **seeded** lane below is what runs,
856/// unchanged — `wg0.conf` or [`BOOT_PARAM`] + [`KEY_FILE`].
857///
858/// Both armed at once is REFUSED rather than resolved by precedence. It is the
859/// same ruling [`WgConfig::resolve`] already makes between its own two sources
860/// and for a sharper reason: the field that loses here is the machine's
861/// IDENTITY, so the operator who registered the losing key with the hub would
862/// watch a healthy boot present a key nobody trusts.
863pub fn bring_up(data_root: &Path) -> Result<Option<Tunnel>> {
864    bring_up_with(crate::appliance::gate_enabled(), data_root)
865}
866
867/// [`bring_up`] with the gate handed in (testable without the env).
868pub fn bring_up_with(gate: bool, data_root: &Path) -> Result<Option<Tunnel>> {
869    if boot_lane(gate, data_root) == BootLane::Ceremony {
870        return ceremony_lane(data_root);
871    }
872    let Some(config) = WgConfig::resolve(data_root)? else {
873        tracing::info!(
874            path = %data_root.join(CONFIG_FILE).display(),
875            "no WireGuard configuration: serving on the outer address only. Either drop a \
876             wg-quick style {CONFIG_FILE} in the data root, or set {BOOT_PARAM} on the kernel \
877             command line and seed {KEY_FILE} beside it. Nothing else is needed — the WireGuard \
878             data plane is compiled into this binary and wants no kernel module."
879        );
880        return Ok(None);
881    };
882    match establish(&config) {
883        Ok(tunnel) => {
884            // Kept where the pairing loop's driver sees it: `raise_from_config`
885            // answers "already up", and `config_tunnel_handshaken` can say
886            // whether the front has answered.
887            *LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()) = Some(tunnel.clone());
888            Ok(Some(tunnel))
889        }
890        Err(err) => {
891            tracing::warn!(
892                interface = %config.interface,
893                "the WireGuard configuration parsed and the tunnel would NOT come up: {err:#}. \
894                 Serving on the outer address only — no tunnel address exists, so a client \
895                 pointed at {} will not connect.",
896                config.address
897            );
898            Ok(None)
899        }
900    }
901}
902
903/// Which lane raises the tunnel at boot.
904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905pub enum BootLane {
906    /// The identity ceremony (`GUNNAR_APPLIANCE=1`, no pairing).
907    Ceremony,
908    /// `wg0.conf` / [`BOOT_PARAM`] — and the pairing loop's `wg0.conf`.
909    Config,
910}
911
912/// **Does the pairing loop own this box's tunnel?** A pairing seed on the
913/// data root, or a `wg0.conf` the pairing loop wrote.
914pub fn pairing_owns_tunnel(data_root: &Path) -> bool {
915    data_root.join(crate::pairing::SEED_REL).exists()
916        || std::fs::read_to_string(data_root.join(CONFIG_FILE))
917            .map(|t| t.starts_with(crate::pairing::CONF_MARK))
918            .unwrap_or(false)
919}
920
921/// **The boot's lane, decided once** (2026-09-24). The appliance medium sets
922/// `GUNNAR_APPLIANCE=1`, and a PAIRED box has a `wg0.conf` its pairing loop
923/// wrote: taking the ceremony lane there refused the boot ("both lanes armed")
924/// and PID 1 faulted — a reboot bricked every paired box. When the pairing
925/// loop owns the tunnel ([`pairing_owns_tunnel`]) the CONFIG lane raises its
926/// `wg0.conf` (or, before the first join, nothing — the loop raises it later)
927/// and the ceremony stays out, whatever the gate says.
928pub fn boot_lane(gate: bool, data_root: &Path) -> BootLane {
929    if gate && !pairing_owns_tunnel(data_root) {
930        BootLane::Ceremony
931    } else {
932        BootLane::Config
933    }
934}
935
936/// [`pairing_tunnel`]'s answer: public key, address and prefix (if assigned),
937/// and whether `wg0` carries that address now.
938pub type PairingTunnel = (String, Option<(Ipv4Addr, u8)>, bool);
939
940/// **The tunnel the pairing loop owns, as `tunnel show` reports it**: this
941/// box's public key off `wg0.key`, the address off `wg0.conf` (none before the
942/// door assigned one), and whether `wg0` carries that address NOW. `None` when
943/// the pairing loop does not own the tunnel (the ceremony's answer stands).
944pub fn pairing_tunnel(data_root: &Path) -> Option<PairingTunnel> {
945    if !pairing_owns_tunnel(data_root) {
946        return None;
947    }
948    let public_b64 = std::fs::read_to_string(data_root.join(KEY_FILE))
949        .ok()
950        .and_then(|k| wg_appliance_core::public_from_private(k.trim()).ok())
951        .unwrap_or_default();
952    let config = WgConfig::resolve_with(data_root, None).ok().flatten();
953    let address = config.as_ref().map(|c| (c.address, c.prefix));
954    let up = config
955        .as_ref()
956        .map(|c| appliance_pid1::net::interface_ipv4(&c.interface).ok().flatten() == Some(c.address))
957        .unwrap_or(false);
958    Some((public_b64, address, up))
959}
960
961/// **Are both lanes armed?** `Some(refusal)` when they are.
962///
963/// Pure — the two "is it present" answers arrive as booleans rather than being
964/// read here — so the refusal's WORDING is testable without a process-wide env
965/// mutation. A guard that can only be exercised by setting `GUNNAR_APPLIANCE`
966/// on the test process is a guard that gets an `#[ignore]` the first time it
967/// races another test, and then it guards nothing.
968fn seeded_lane_conflict(
969    data_root: &Path,
970    seeded_file_present: bool,
971    seeded_param_present: bool,
972) -> Option<String> {
973    if !seeded_file_present && !seeded_param_present {
974        return None;
975    }
976    let named = if seeded_param_present {
977        BOOT_PARAM.to_string()
978    } else {
979        data_root.join(CONFIG_FILE).display().to_string()
980    };
981    let gate = crate::appliance::GATE_ENV;
982    Some(format!(
983        "{gate}=1 arms the identity ceremony, and {named} is also declared. They are two \
984         different answers to what this machine's identity IS, and this refuses to guess: \
985         the ceremony MINTS a key on {} and announces it, the seeded lane takes one you \
986         supplied. Drop the seeded configuration to use the ceremony, or unset {gate} to \
987         keep seeding.",
988        crate::appliance::wg_dir(data_root).display(),
989    ))
990}
991
992/// **The ceremony lane** — the gate is on, so the identity is MINTED here and
993/// announced, never seeded from outside.
994///
995/// The announcement goes to **stdout and the log**, formatted once through the
996/// shared crate's writer and written twice (one implementation). stdout is load-bearing and
997/// not a convenience: on the appliance it is `/dev/console`, the serial line
998/// `installtest/src/iso.rs` greps its boot proof off, and the identity line is
999/// the ONE thing an operator must read off a shell-less machine before the hub
1000/// can be configured at all. `tracing` alone would put it behind a filter.
1001fn ceremony_lane(data_root: &Path) -> Result<Option<Tunnel>> {
1002    // The seeded lane must be DISARMED, not merely out-ranked. Checked before
1003    // the ceremony runs so a misconfigured appliance refuses BEFORE minting a
1004    // key the operator might see and register.
1005    let seeded_param = boot_param();
1006    if let Some(refusal) = seeded_lane_conflict(
1007        data_root,
1008        data_root.join(CONFIG_FILE).exists(),
1009        seeded_param.is_some(),
1010    ) {
1011        bail!(refusal);
1012    }
1013
1014    // The proof row is the ceremony's OWN verdict, both ways: `Ok` carries the
1015    // key it announced, `Err` carries the refusal text verbatim. Never a
1016    // hardcoded `true` — a row that cannot go red proves nothing, and the
1017    // rollup's whole discipline is that "no evidence" and "proven" must be
1018    // impossible to confuse.
1019    let outcome = match crate::appliance::run(data_root, &mut |line| {
1020        println!("{line}");
1021        tracing::info!("{line}");
1022    }) {
1023        Ok(outcome) => {
1024            crate::functional_status(
1025                "gunnar-appliance",
1026                "identity_ready",
1027                true,
1028                &format!(
1029                    "pubkey={} minted={} hub={}",
1030                    outcome.public_b64,
1031                    outcome.minted,
1032                    outcome.config.is_some()
1033                ),
1034            );
1035            outcome
1036        }
1037        Err(err) => {
1038            crate::functional_status(
1039                "gunnar-appliance",
1040                "identity_ready",
1041                false,
1042                &format!("{err:#}"),
1043            );
1044            return Err(err);
1045        }
1046    };
1047
1048    let Some(config) = outcome.config else {
1049        return Ok(None);
1050    };
1051    match establish(&config) {
1052        Ok(tunnel) => {
1053            CEREMONY_TUNNEL_UP.store(true, std::sync::atomic::Ordering::SeqCst);
1054            Ok(Some(tunnel))
1055        }
1056        Err(err) => {
1057            tracing::warn!(
1058                interface = %config.interface,
1059                "the hub declaration parsed and the tunnel would NOT come up: {err:#}. \
1060                 Serving on the outer address only — the identity above is minted and \
1061                 durable, so registering it with the hub is still the right next move.",
1062            );
1063            Ok(None)
1064        }
1065    }
1066}
1067
1068/// Set once the ceremony lane has a tunnel up — at start, or after the
1069/// address arrived at bootstrap.
1070static CEREMONY_TUNNEL_UP: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1071
1072/// The tunnel raised AFTER start, by [`raise_after_assignment`]. `serve` holds
1073/// the start-up one for the process lifetime; this is the same lifetime for
1074/// the one that arrived later (dropping it deletes `wg0`).
1075static LATE_TUNNEL: std::sync::Mutex<Option<Tunnel>> = std::sync::Mutex::new(None);
1076
1077/// The config lane's tunnel (raised at boot or by [`raise_from_config`]):
1078/// `None` when there is none in this process, else whether a peer has
1079/// completed a handshake.
1080pub fn config_tunnel_handshaken() -> Option<bool> {
1081    LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()).as_ref().map(Tunnel::handshaken)
1082}
1083
1084/// Is the ceremony's tunnel up in this process?
1085pub fn ceremony_tunnel_up() -> bool {
1086    CEREMONY_TUNNEL_UP.load(std::sync::atomic::Ordering::SeqCst)
1087}
1088
1089/// **The address just arrived** (`Bootstrap.AssignTunnelAddress`): raise the
1090/// tunnel NOW, without a restart. `Ok(true)` when it is up (already, or just
1091/// now); `Ok(false)` when the ceremony still has nothing to raise or the
1092/// kernel refused (said by [`ceremony_lane`]'s own WARN).
1093pub fn raise_after_assignment(data_root: &Path) -> Result<bool> {
1094    if ceremony_tunnel_up() {
1095        return Ok(true);
1096    }
1097    let Some(tunnel) = ceremony_lane(data_root)? else {
1098        return Ok(false);
1099    };
1100    if let Ok(rt) = tokio::runtime::Handle::try_current() {
1101        rt.spawn(handshake_watch(tunnel.clone(), 20, std::time::Duration::from_secs(3)));
1102    }
1103    *LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()) = Some(tunnel);
1104    Ok(true)
1105}
1106
1107/// **What [`raise_from_config`] does with the configuration on disk**, given
1108/// the tunnel this process already runs (if any). Pure, so the decision is
1109/// held without a tun device.
1110#[derive(Debug, Clone, PartialEq, Eq)]
1111pub enum LateRaise {
1112    /// No configuration to raise.
1113    NoConfig,
1114    /// The tunnel is up as the configuration says.
1115    AlreadyUp,
1116    /// Raise it now.
1117    Raise,
1118    /// The tunnel runs, and the configuration names other peers (or another
1119    /// address): re-point it. `from`/`to` say the peers, for the log.
1120    Repoint { from: String, to: String },
1121}
1122
1123/// [`LateRaise`], decided.
1124pub fn late_raise(running: Option<&WgConfig>, wanted: Option<&WgConfig>, interface_carries_address: bool) -> LateRaise {
1125    if let Some(running) = running {
1126        return match wanted {
1127            // A configuration that went away does not take a running tunnel down.
1128            None => LateRaise::AlreadyUp,
1129            Some(w) if w.peers == running.peers && w.address == running.address && w.prefix == running.prefix && w.private_key == running.private_key => {
1130                LateRaise::AlreadyUp
1131            }
1132            Some(w) => LateRaise::Repoint { from: peers_said(running), to: peers_said(w) },
1133        };
1134    }
1135    match wanted {
1136        None => LateRaise::NoConfig,
1137        Some(_) if interface_carries_address => LateRaise::AlreadyUp,
1138        Some(_) => LateRaise::Raise,
1139    }
1140}
1141
1142/// **A `wg0.conf` that appeared after boot: raise it NOW.** The pairing loop
1143/// (`crate::pairing`) learns this box's tunnel address from the installer's
1144/// door, writes the configuration, and calls this — the same config lane
1145/// [`bring_up`] runs at start, without a restart. `Ok(true)` when the tunnel is
1146/// up (already — raised at boot from a configuration a previous boot wrote —
1147/// or just now).
1148pub fn raise_from_config(data_root: &Path) -> Result<bool> {
1149    let late = LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()).clone();
1150    let running = late.as_ref().and_then(Tunnel::running_config);
1151    let wanted = WgConfig::resolve(data_root)?;
1152    let carries = wanted
1153        .as_ref()
1154        .map(|c| appliance_pid1::net::interface_ipv4(&c.interface).ok().flatten() == Some(c.address))
1155        .unwrap_or(false);
1156    match late_raise(running.as_ref(), wanted.as_ref(), carries) {
1157        LateRaise::NoConfig => return Ok(false),
1158        LateRaise::AlreadyUp => return Ok(true),
1159        LateRaise::Repoint { from, to } => {
1160            let tunnel = late.expect("Repoint is planned only for a running tunnel");
1161            let wanted = wanted.expect("Repoint is planned only for a configuration");
1162            tunnel.repoint(&wanted).with_context(|| format!("re-pointing {} from [{from}] to [{to}]", wanted.interface))?;
1163            tracing::info!(
1164                interface = %wanted.interface,
1165                from = %from,
1166                to = %to,
1167                "WireGuard RE-POINTED in this process: the configuration names another peer (the front's relay \
1168                 moved or was re-keyed — a front recharged onto new addresses); the old pump is stopped and \
1169                 the new one dials it now, without a restart"
1170            );
1171            if let Ok(rt) = tokio::runtime::Handle::try_current() {
1172                rt.spawn(handshake_watch(tunnel, 20, std::time::Duration::from_secs(3)));
1173            }
1174            return Ok(true);
1175        }
1176        LateRaise::Raise => {}
1177    }
1178    let config = wanted.expect("Raise is planned only for a configuration");
1179    let tunnel = establish(&config)?;
1180    if let Ok(rt) = tokio::runtime::Handle::try_current() {
1181        rt.spawn(handshake_watch(tunnel.clone(), 20, std::time::Duration::from_secs(3)));
1182    }
1183    *LATE_TUNNEL.lock().unwrap_or_else(|p| p.into_inner()) = Some(tunnel);
1184    Ok(true)
1185}
1186
1187/// The part that touches the machine: the tun device, the address, the pump.
1188fn open_pump(config: &WgConfig) -> Result<TunnelHandle> {
1189    // 1. The tun device. The ONE kernel feature this lane needs, and it is
1190    //    builtin on the appliance kernel — no module, no modprobe, no staged
1191    //    `.ko`. Held by the pump for the process lifetime: closing this fd is
1192    //    what deletes the interface.
1193    let tun = TunDevice::create(&config.interface, DEFAULT_MTU).with_context(|| {
1194        format!(
1195            "creating the tun device {}. This needs CAP_NET_ADMIN and /dev/net/tun \
1196             (CONFIG_TUN, builtin on the appliance kernel) — and nothing else: no wireguard \
1197             module, no wg(8)",
1198            config.interface
1199        )
1200    })?;
1201
1202    // 2. The address and the link state, through the SAME writer every other
1203    //    address on this appliance goes through (one implementation). `gateway: None` — a
1204    //    tunnel is not a default route, and making it one would black-hole the
1205    //    machine the moment the peer went away.
1206    //
1207    //    Chosen over the ioctl path (`SIOCSIFADDR`/`SIOCSIFNETMASK`) tunnr's
1208    //    payload uses: netlink ACKNOWLEDGES every message, so "the address is
1209    //    on" is a fact read back from the kernel rather than a `send` that
1210    //    succeeded. gunnar already has that writer and every other address on
1211    //    the box goes through it.
1212    appliance_pid1::net::apply(&appliance_pid1::net::NetPlan {
1213        interface: config.interface.clone(),
1214        address: config.address,
1215        prefix: config.prefix,
1216        gateway: None,
1217    })
1218    .with_context(|| {
1219        format!(
1220            "putting {}/{} on {}",
1221            config.address, config.prefix, config.interface
1222        )
1223    })?;
1224
1225    // 3. The data plane: boringtun, in this process, on one thread.
1226    let handle = dataplane::spawn(tun, &config.tunnel_spec()).with_context(|| {
1227        format!(
1228            "starting the userspace WireGuard pump on {}",
1229            config.interface
1230        )
1231    })?;
1232    Ok(handle)
1233}
1234
1235fn establish(config: &WgConfig) -> Result<Tunnel> {
1236    let handle = open_pump(config)?;
1237    for (addr, prefix) in config.unrouted_allowed_ips() {
1238        tracing::warn!(
1239            allowed_ip = %format!("{addr}/{prefix}"),
1240            interface = %config.interface,
1241            tunnel_subnet = %format!("{}/{}", config.address, config.prefix),
1242            "an AllowedIPs entry falls outside this interface's own subnet, so the kernel has \
1243             no route to it. gunnar installs no routes beyond the address's connected one; \
1244             traffic to that prefix will not enter the tunnel"
1245        );
1246    }
1247
1248    let tunnel = Tunnel {
1249        interface: config.interface.clone(),
1250        address: config.address,
1251        prefix: config.prefix,
1252        peers: config.peers.len(),
1253        public_key: config.public_key(),
1254        local_port: handle.local_port(),
1255        config: Arc::new(std::sync::Mutex::new(config.clone())),
1256        handle: Arc::new(std::sync::Mutex::new(Some(handle))),
1257    };
1258    tracing::info!(
1259        interface = %tunnel.interface,
1260        address = %tunnel.address,
1261        prefix = tunnel.prefix,
1262        public_key = base64_key(&tunnel.public_key),
1263        listen_port = tunnel.local_port,
1264        peers = tunnel.peers,
1265        mtu = DEFAULT_MTU,
1266        "WireGuard up in USERSPACE: boringtun compiled into this binary, no kernel module, no \
1267         wg(8), no wg-quick, no shell. This address is SEEDED state and does not move when the \
1268         DHCP lease does — but it is only reachable once a peer whose [Peer] stanza carries the \
1269         public key above completes a handshake"
1270    );
1271    // The listeners that follow the tunnel (the replication door) learn where it is.
1272    crate::tunnel_addr::publish(Some(tunnel.address));
1273    Ok(tunnel)
1274}
1275
1276/// Watch for the first completed handshake and say so, once, on the console.
1277///
1278/// Spawned by `serve::run` beside the DHCP renewal task. It exists because
1279/// "the tunnel is configured" and "the tunnel WORKS" are different claims and
1280/// only a completed handshake settles the second: boringtun reports no
1281/// handshake time at all until a peer has actually answered, which is the same
1282/// fact `wg show latest-handshake` prints on the other end.
1283///
1284/// Gives up after `attempts` polls and says that too — an appliance that keeps
1285/// printing "still no handshake" forever is noise, and one that goes quiet
1286/// without a verdict is worse.
1287pub async fn handshake_watch(tunnel: Tunnel, attempts: usize, every: std::time::Duration) {
1288    for attempt in 1..=attempts {
1289        tokio::time::sleep(every).await;
1290        let peers = tunnel.peer_stats();
1291        if let Some(up) = peers.iter().find(|p| p.handshaken()) {
1292            tracing::info!(
1293                interface = %tunnel.interface,
1294                address = %tunnel.address,
1295                peer = %base64_key(&up.public_key),
1296                handshake_age_secs = up.last_handshake_ago.map(|d| d.as_secs()),
1297                rx_bytes = up.rx_bytes,
1298                tx_bytes = up.tx_bytes,
1299                endpoint = ?up.endpoint,
1300                "WireGuard HANDSHAKE COMPLETED: this appliance is reachable at {} \
1301                 over the tunnel, and that address does not change when the DHCP \
1302                 lease does",
1303                tunnel.address
1304            );
1305            return;
1306        }
1307        tracing::debug!(
1308            interface = %tunnel.interface,
1309            attempt,
1310            peers = peers.len(),
1311            "no WireGuard handshake yet"
1312        );
1313    }
1314    tracing::warn!(
1315        interface = %tunnel.interface,
1316        peers = tunnel.peers,
1317        "WireGuard is configured and NO peer has completed a handshake. The tunnel address {} \
1318         is on the interface but nothing is on the other end of it — check the peer's endpoint, \
1319         its AllowedIPs, and that UDP reaches this machine",
1320        tunnel.address
1321    );
1322}
1323
1324/// A key as `wg(8)` prints it, for a log line a human can compare by eye.
1325pub(crate) fn base64_key(key: &[u8; KEY_LEN]) -> String {
1326    keys::encode_key(key)
1327}
1328
1329/// The banner sentence, as a pure function of what it says.
1330///
1331/// Split out from [`Tunnel::banner_line`] so the ONE line an operator actually
1332/// reads off a shell-less console can be asserted without creating a network
1333/// device — a banner that only exists inside a live tunnel is a banner nobody
1334/// checks.
1335fn banner_line(
1336    interface: &str,
1337    address: Ipv4Addr,
1338    prefix: u8,
1339    public_key: &[u8; KEY_LEN],
1340    peers: usize,
1341    local_port: u16,
1342) -> String {
1343    format!(
1344        "wg    {address}/{prefix} on {interface}  key {}  ({peers} peer{}, udp {local_port})",
1345        base64_key(public_key),
1346        if peers == 1 { "" } else { "s" },
1347    )
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352
1353    /// **A re-feed naming another front relay re-points the running tunnel**
1354    /// (stinkies recharge-front-new-key-deadlock part 2 / R3). MEASURED d-life
1355    /// lf10 R3 2026-10-01: after `estate recharge front` laid the front again
1356    /// on NEW addresses, main and the twin kept dialling the dead relay until
1357    /// they restarted ("the mesh did not carry within 120 s") — this process's
1358    /// tunnel answered "already up" to any configuration.
1359    #[test]
1360    fn a_refeed_naming_another_front_relay_repoints_the_running_tunnel() {
1361        let conf = |endpoint: &str, key: &str| {
1362            WgConfig::parse(
1363                "wg0",
1364                &format!(
1365                    "[Interface]\nPrivateKey = {}\nAddress = 10.44.0.2/24\n\n[Peer]\nPublicKey = {key}\nAllowedIPs = 10.44.0.0/24\nEndpoint = {endpoint}\nPersistentKeepalive = 25\n",
1366                    base64_key(&[7u8; KEY_LEN])
1367                ),
1368            )
1369            .unwrap()
1370        };
1371        let old = conf("127.86.33.51:51821", &base64_key(&[1u8; KEY_LEN]));
1372        let moved = conf("127.86.33.10:51821", &base64_key(&[1u8; KEY_LEN]));
1373        let rekeyed = conf("127.86.33.10:51821", &base64_key(&[2u8; KEY_LEN]));
1374        assert_eq!(late_raise(Some(&old), Some(&old), true), LateRaise::AlreadyUp, "the same front: nothing to do");
1375        assert!(matches!(late_raise(Some(&old), Some(&moved), true), LateRaise::Repoint { .. }), "the front moved: re-point, not 'already up'");
1376        assert!(matches!(late_raise(Some(&old), Some(&rekeyed), true), LateRaise::Repoint { .. }), "the relay's key changed: re-point");
1377        assert_eq!(late_raise(None, Some(&old), false), LateRaise::Raise);
1378        assert_eq!(late_raise(None, Some(&old), true), LateRaise::AlreadyUp);
1379        assert_eq!(late_raise(None, None, false), LateRaise::NoConfig);
1380        assert_eq!(late_raise(Some(&old), None, true), LateRaise::AlreadyUp, "a configuration that went away does not take the running tunnel down");
1381    }
1382
1383    use super::*;
1384
1385    /// A data root as a paired box leaves it: the seed, `wg0.key`, and the
1386    /// `wg0.conf` the pairing loop wrote for the door's address.
1387    fn paired_root() -> tempfile::TempDir {
1388        let dir = tempfile::tempdir().unwrap();
1389        let root = dir.path();
1390        std::fs::create_dir_all(root.join("pairing")).unwrap();
1391        std::fs::write(root.join(crate::pairing::SEED_REL), "# the medium's seed\n").unwrap();
1392        let private = crate::pairing::ensure_tunnel_key(root).unwrap();
1393        let conf = format!(
1394            "{}: test\n[Interface]\nPrivateKey = {private}\nAddress = 10.44.0.2/24\n\n[Peer]\nPublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=\nAllowedIPs = 10.44.0.0/24\nEndpoint = 95.111.211.189:51820\nPersistentKeepalive = 25\n",
1395            crate::pairing::CONF_MARK
1396        );
1397        std::fs::write(root.join(CONFIG_FILE), conf).unwrap();
1398        dir
1399    }
1400
1401    /// Fix 2026-09-24: the appliance gate + the pairing's `wg0.conf` boots the
1402    /// CONFIG lane — never the ceremony, which refused "both lanes armed" and
1403    /// faulted PID 1 on every reboot of a paired box.
1404    #[test]
1405    fn a_paired_data_root_boots_the_config_lane_and_never_the_ceremony() {
1406        let paired = paired_root();
1407        assert_eq!(boot_lane(true, paired.path()), BootLane::Config);
1408        // The pairing's conf alone (no seed) is enough; so is the seed alone
1409        // (before the first join: nothing to raise yet, the loop raises it).
1410        std::fs::remove_file(paired.path().join(crate::pairing::SEED_REL)).unwrap();
1411        assert_eq!(boot_lane(true, paired.path()), BootLane::Config);
1412        let seed_only = tempfile::tempdir().unwrap();
1413        std::fs::create_dir_all(seed_only.path().join("pairing")).unwrap();
1414        std::fs::write(seed_only.path().join(crate::pairing::SEED_REL), "x").unwrap();
1415        assert_eq!(boot_lane(true, seed_only.path()), BootLane::Config);
1416        // Unchanged: no pairing ⇒ the gate means the ceremony, and an
1417        // OPERATOR's wg0.conf beside the gate is still the refused conflict.
1418        let bare = tempfile::tempdir().unwrap();
1419        assert_eq!(boot_lane(true, bare.path()), BootLane::Ceremony);
1420        assert_eq!(boot_lane(false, bare.path()), BootLane::Config);
1421        std::fs::write(bare.path().join(CONFIG_FILE), "[Interface]\n").unwrap();
1422        assert_eq!(boot_lane(true, bare.path()), BootLane::Ceremony);
1423    }
1424
1425    /// The real boot entry, gate ON, over a paired root: it does not refuse
1426    /// (the old fault), and the ceremony never minted its identity.
1427    #[test]
1428    fn booting_a_paired_root_with_the_gate_on_does_not_refuse_and_mints_no_ceremony_identity() {
1429        if rustix::process::geteuid().is_root() {
1430            return; // would create a real wg0 on the test host
1431        }
1432        let paired = paired_root();
1433        let got = bring_up_with(true, paired.path());
1434        assert!(got.is_ok(), "a paired box must boot: {:?}", got.err());
1435        assert!(!crate::appliance::wg_dir(paired.path()).exists(), "the ceremony must stay out");
1436    }
1437
1438    /// `tunnel show` on a paired box reports the pairing loop's tunnel, and an
1439    /// address handed over beside the door's is refused by name.
1440    #[test]
1441    fn tunnel_show_on_a_paired_box_reports_the_pairing_tunnel() {
1442        let paired = paired_root();
1443        let root = paired.path();
1444        let private = crate::pairing::ensure_tunnel_key(root).unwrap();
1445        let want = wg_appliance_core::public_from_private(&private).unwrap();
1446        let shown = crate::appliance::tunnel_address_answer(root, "", false).expect("show answers");
1447        assert_eq!(shown.wg_public_key, want);
1448        assert_eq!(shown.address, "10.44.0.2/24");
1449        assert!(!shown.changed);
1450        let refused = crate::appliance::tunnel_address_answer(root, "10.44.0.9/24", true).expect_err("refused");
1451        assert!(format!("{refused:?}").contains("tunnel-owned-by-pairing"), "{refused:?}");
1452    }
1453
1454    /// **The two lanes may not both be armed** — and the refusal names which
1455    /// seeded source it found, plus the ceremony directory it would otherwise
1456    /// have minted into.
1457    ///
1458    /// The field that loses a precedence rule here is the machine's IDENTITY,
1459    /// so a silent winner means the operator registers one key with the hub and
1460    /// the appliance presents another, on a console that looks healthy.
1461    ///
1462    /// **RED-when-broken**: the "neither present" case is asserted too, because
1463    /// a guard that refuses unconditionally would break every ordinary
1464    /// gate-on boot and a guard that never refuses is the defect itself.
1465    #[test]
1466    fn arming_both_lanes_is_refused_and_the_refusal_names_the_source() {
1467        let root = Path::new("/srv/gunnar-data");
1468
1469        assert_eq!(
1470            seeded_lane_conflict(root, false, false),
1471            None,
1472            "the ordinary ceremony boot — no seeded source — must NOT refuse"
1473        );
1474
1475        let by_file = seeded_lane_conflict(root, true, false).expect("wg0.conf conflicts");
1476        assert!(
1477            by_file.contains("/srv/gunnar-data/wg0.conf"),
1478            "name the file the operator has to remove: {by_file}"
1479        );
1480        assert!(
1481            by_file.contains("/srv/gunnar-data/wg"),
1482            "and the ceremony directory it would have minted into: {by_file}"
1483        );
1484        assert!(
1485            by_file.contains(crate::appliance::GATE_ENV),
1486            "and the gate to unset instead: {by_file}"
1487        );
1488
1489        let by_param = seeded_lane_conflict(root, false, true).expect("the boot param conflicts");
1490        assert!(
1491            by_param.contains(BOOT_PARAM),
1492            "name the boot parameter, not the absent file: {by_param}"
1493        );
1494        assert!(
1495            !by_param.contains("wg0.conf"),
1496            "a file that is not there must not be blamed: {by_param}"
1497        );
1498
1499        // Both at once still names something actionable rather than panicking
1500        // or producing an empty complaint.
1501        let both = seeded_lane_conflict(root, true, true).expect("both conflict");
1502        assert!(both.contains(BOOT_PARAM), "{both}");
1503    }
1504
1505    /// A whole, ordinary config — the one in the guide.
1506    const GOOD: &str = "\
1507[Interface]
1508PrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=
1509Address = 10.99.0.2/24
1510ListenPort = 51820
1511
1512# the box on the other end
1513[Peer]
1514PublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=
1515AllowedIPs = 10.99.0.1/32, 10.99.0.0/24
1516Endpoint = 10.0.2.2:51820
1517PersistentKeepalive = 25
1518";
1519
1520    #[test]
1521    fn a_full_config_parses_into_every_field() {
1522        let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
1523        assert_eq!(cfg.interface, "wg0");
1524        assert_eq!(cfg.address, Ipv4Addr::new(10, 99, 0, 2));
1525        assert_eq!(cfg.prefix, 24);
1526        assert_eq!(cfg.listen_port, Some(51820));
1527        assert_eq!(cfg.peers.len(), 1);
1528        let peer = &cfg.peers[0];
1529        assert_eq!(
1530            peer.allowed_ips,
1531            vec![
1532                (Ipv4Addr::new(10, 99, 0, 1), 32),
1533                (Ipv4Addr::new(10, 99, 0, 0), 24),
1534            ]
1535        );
1536        assert_eq!(peer.endpoint, Some("10.0.2.2:51820".parse().unwrap()));
1537        assert_eq!(peer.persistent_keepalive, Some(25));
1538        assert_eq!(peer.preshared_key, None);
1539        // Both keys really are 32 bytes, not "44 characters that looked right".
1540        assert_eq!(cfg.private_key.len(), KEY_LEN);
1541        assert_ne!(cfg.private_key, [0; KEY_LEN]);
1542    }
1543
1544    /// Measure, never assert on the parser: it must REFUSE, or "parsed" means nothing.
1545    #[test]
1546    fn malformed_configs_are_refused_rather_than_defaulted() {
1547        for (why, bad) in [
1548            ("no PrivateKey", "[Interface]\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
1549            ("no Address", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
1550            ("no peer at all", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n"),
1551            ("a peer with no PublicKey", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n[Peer]\nAllowedIPs = 10.0.0.2/32\n"),
1552            ("a peer with no AllowedIPs", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\n"),
1553            ("a key that is not 32 bytes", "[Interface]\nPrivateKey = c2hvcnQ=\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
1554            ("a key that is not base64", "[Interface]\nPrivateKey = ****\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
1555            ("a /33 address", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/33\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\n"),
1556            ("a hostname endpoint", "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\nAllowedIPs = 10.0.0.2/32\nEndpoint = peer.example.com:51820\n"),
1557            ("a key before any section", "PrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n"),
1558            ("a line that is not Key = value", "[Interface]\nPrivateKey\n"),
1559            ("an unknown section", "[Tunnel]\nPrivateKey = x\n"),
1560            ("an unknown key", "[Interface]\nWibble = 3\n"),
1561        ] {
1562            assert!(
1563                WgConfig::parse("wg0", bad).is_err(),
1564                "WgConfig::parse accepted a config with {why}; a parser that accepts everything \
1565                 configures nothing and reports success"
1566            );
1567        }
1568    }
1569
1570    /// A wg-quick key gunnar does not implement must be REFUSED, not skipped.
1571    ///
1572    /// This is the difference between an operator whose `PostUp` never ran and
1573    /// an operator who was told it never ran.
1574    #[test]
1575    fn wg_quick_keys_gunnar_does_not_implement_are_refused_by_name() {
1576        for key in [
1577            "DNS = 1.1.1.1",
1578            "MTU = 1380",
1579            "Table = off",
1580            "PostUp = /bin/true",
1581        ] {
1582            let text = format!(
1583                "[Interface]\nPrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n\
1584                 Address = 10.0.0.1/24\n{key}\n[Peer]\n\
1585                 PublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\n\
1586                 AllowedIPs = 10.0.0.2/32\n"
1587            );
1588            let err = WgConfig::parse("wg0", &text).expect_err("must be refused");
1589            let msg = format!("{err:#}");
1590            assert!(
1591                msg.contains("does NOT implement"),
1592                "{key} was refused for the wrong reason: {msg}"
1593            );
1594        }
1595    }
1596
1597    /// The shared tokenizer must not have changed what a comment is, what a
1598    /// trailing `=` means, or which line a message names. Three rules, on one
1599    /// config that exercises all of them.
1600    #[test]
1601    fn comments_padding_and_line_numbers_survive_the_shared_tokenizer() {
1602        let cfg = WgConfig::parse(
1603            "wg0",
1604            "# a leading comment\n\
1605             [Interface]   ; and a trailing one\n\
1606             PrivateKey = gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n\
1607             Address = 10.0.0.1/24\n\
1608             \n\
1609             [Peer]\n\
1610             PublicKey = aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=\n\
1611             AllowedIPs = 10.0.0.2/32\n\
1612             PersistentKeepalive = 25  # keep the NAT open\n",
1613        )
1614        .expect("comments and blank lines are not errors");
1615        assert_eq!(
1616            cfg.private_key,
1617            parse_key("gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=").unwrap(),
1618            "a base64 key's trailing `=` padding must survive the key/value split"
1619        );
1620        assert_eq!(cfg.peers[0].persistent_keepalive, Some(25));
1621
1622        // And the line number in an error names the offending line, not the
1623        // count of lines the tokenizer chose to emit.
1624        let err = WgConfig::parse("wg0", "# one\n\n[Interface]\nWibble = 3\n").unwrap_err();
1625        assert!(format!("{err:#}").contains("wg0.conf:4:"), "{err:#}");
1626    }
1627
1628    #[test]
1629    fn allowed_ips_outside_the_tunnel_subnet_are_reported_as_unrouted() {
1630        let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
1631        // 10.99.0.1/32 and 10.99.0.0/24 are both inside 10.99.0.0/24.
1632        assert!(
1633            cfg.unrouted_allowed_ips().is_empty(),
1634            "the ordinary case must be silent, or the warning is noise: {:?}",
1635            cfg.unrouted_allowed_ips()
1636        );
1637        // A prefix somewhere else entirely gets no route from the address.
1638        let wide = GOOD.replace(
1639            "AllowedIPs = 10.99.0.1/32, 10.99.0.0/24",
1640            "AllowedIPs = 192.168.7.0/24",
1641        );
1642        let cfg = WgConfig::parse("wg0", &wide).expect("a legal config");
1643        assert_eq!(
1644            cfg.unrouted_allowed_ips(),
1645            vec![(Ipv4Addr::new(192, 168, 7, 0), 24)]
1646        );
1647    }
1648
1649    // ── the boot-parameter form ─────────────────────────────────────────────
1650
1651    const HUB_PUB: &str = "aD8IQp7Jf8KgM5RqJUP1NaPJu6SQ1EUkPEqk118XIRo=";
1652
1653    #[test]
1654    fn the_boot_param_carries_the_whole_non_secret_half() {
1655        let spec = format!(
1656            "addr=10.99.0.2/24,peer={HUB_PUB},endpoint=10.0.2.2:51820,\
1657             allowed=10.99.0.0/24,keepalive=25"
1658        );
1659        let cfg = WgConfig::parse_boot_param("wg0", &spec, [0x11; KEY_LEN]).expect("a legal spec");
1660        assert_eq!(cfg.address, Ipv4Addr::new(10, 99, 0, 2));
1661        assert_eq!(cfg.prefix, 24);
1662        assert_eq!(
1663            cfg.private_key, [0x11; KEY_LEN],
1664            "the key comes from the FILE, not the spec"
1665        );
1666        assert_eq!(
1667            cfg.listen_port, None,
1668            "a dial-out appliance needs no fixed port"
1669        );
1670        assert_eq!(cfg.peers.len(), 1);
1671        assert_eq!(cfg.peers[0].public_key, parse_key(HUB_PUB).unwrap());
1672        assert_eq!(
1673            cfg.peers[0].endpoint,
1674            Some("10.0.2.2:51820".parse().unwrap())
1675        );
1676        assert_eq!(cfg.peers[0].persistent_keepalive, Some(25));
1677        assert_eq!(
1678            cfg.peers[0].allowed_ips,
1679            vec![(Ipv4Addr::new(10, 99, 0, 0), 24)]
1680        );
1681    }
1682
1683    #[test]
1684    fn the_boot_param_needs_only_addr_peer_and_allowed() {
1685        let spec = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
1686        let cfg = WgConfig::parse_boot_param("wg0", &spec, [0x11; KEY_LEN]).expect("a legal spec");
1687        assert_eq!(
1688            cfg.peers[0].endpoint, None,
1689            "a hub that dials US needs no endpoint here"
1690        );
1691        assert_eq!(cfg.peers[0].persistent_keepalive, None);
1692    }
1693
1694    /// Measure, never assert, and the security-relevant half: the boot parameter must REFUSE a
1695    /// private key rather than quietly accept one, because `/proc/cmdline` is
1696    /// world readable and an accepted `key=` would publish it.
1697    #[test]
1698    fn the_boot_param_refuses_garbage_and_refuses_to_carry_a_private_key() {
1699        let good = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
1700        for (why, bad) in [
1701            ("no addr", format!("peer={HUB_PUB},allowed=10.99.0.0/24")),
1702            (
1703                "no peer",
1704                "addr=10.99.0.2/24,allowed=10.99.0.0/24".to_string(),
1705            ),
1706            ("no allowed", format!("addr=10.99.0.2/24,peer={HUB_PUB}")),
1707            ("a field that is not key=value", format!("{good},nonsense")),
1708            (
1709                "a bad endpoint",
1710                format!("{good},endpoint=hub.example.com:51820"),
1711            ),
1712            (
1713                "a bad prefix",
1714                format!("addr=10.99.0.2/33,peer={HUB_PUB},allowed=10.0.0.0/8"),
1715            ),
1716            (
1717                "a peer key that is not 32 bytes",
1718                "addr=10.99.0.2/24,peer=c2hvcnQ=,allowed=10.0.0.0/8".to_string(),
1719            ),
1720        ] {
1721            assert!(
1722                WgConfig::parse_boot_param("wg0", &bad, [0x11; KEY_LEN]).is_err(),
1723                "the boot parameter accepted a spec with {why}"
1724            );
1725        }
1726        // The one that matters most: a private key offered on the command line
1727        // must be refused BY NAME, not ignored.
1728        for secret in ["privatekey", "private", "key", "PrivateKey"] {
1729            let bad = format!("{good},{secret}=gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=");
1730            let err = WgConfig::parse_boot_param("wg0", &bad, [0x11; KEY_LEN])
1731                .expect_err("a private key on the cmdline must be refused");
1732            assert!(
1733                format!("{err:#}").contains("world-readable"),
1734                "the refusal must say WHY: {err:#}"
1735            );
1736        }
1737    }
1738
1739    /// Two sources that disagree is the failure a precedence rule hides.
1740    ///
1741    /// The boot parameter is HANDED IN. It used to be `set_var`'d and removed
1742    /// again around the call, which made this test and the one below two
1743    /// writers of one process-wide variable: on a threaded `cargo test` either
1744    /// could clear `GUNNAR_WG` while the other was inside `resolve`, and the
1745    /// refusal it exists to prove would simply not happen. That is the flake,
1746    /// and `resolve_with` is why it cannot come back.
1747    #[test]
1748    fn a_file_and_a_boot_param_together_are_refused_rather_than_ranked() {
1749        let dir = tempfile::tempdir().expect("a scratch directory");
1750        let dir = dir.path();
1751        std::fs::write(dir.join(CONFIG_FILE), GOOD).expect("write a config");
1752        let spec = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
1753        let err = WgConfig::resolve_with(dir, Some(&spec))
1754            .expect_err("two configurations must not be ranked");
1755        assert!(format!("{err:#}").contains("refuses to guess"), "{err:#}");
1756    }
1757
1758    /// The boot-param path without a seeded key must name the file it wants —
1759    /// this is the whole stable-identity story, and a vague error here is an
1760    /// operator minting a new identity by accident.
1761    #[test]
1762    fn the_boot_param_without_a_seeded_key_says_which_file_is_missing() {
1763        let dir = tempfile::tempdir().expect("a scratch directory");
1764        let dir = dir.path();
1765        let spec = format!("addr=10.99.0.2/24,peer={HUB_PUB},allowed=10.99.0.0/24");
1766        let err =
1767            WgConfig::resolve_with(dir, Some(&spec)).expect_err("no key file means no tunnel");
1768        assert!(
1769            format!("{err:#}").contains(KEY_FILE),
1770            "the error must name the file: {err:#}"
1771        );
1772        // And with the key seeded it resolves.
1773        std::fs::write(
1774            dir.join(KEY_FILE),
1775            "gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=\n",
1776        )
1777        .expect("seed the key");
1778        let ok = WgConfig::resolve_with(dir, Some(&spec))
1779            .expect("a seeded key completes the boot-param form")
1780            .expect("a configuration");
1781        assert_eq!(
1782            ok.private_key,
1783            parse_key("gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=").unwrap()
1784        );
1785        assert_eq!(ok.address, Ipv4Addr::new(10, 99, 0, 2));
1786    }
1787
1788    /// **Both other arms of the resolver**, so the two refusals above are not
1789    /// the only thing this function is seen to do: neither source is
1790    /// `Ok(None)` (the shipped state), and the file alone resolves.
1791    ///
1792    /// Without this a `resolve_with` that returned an error for everything
1793    /// would pass the two tests above.
1794    #[test]
1795    fn neither_source_is_none_and_the_file_alone_resolves() {
1796        let dir = tempfile::tempdir().expect("a scratch directory");
1797        let dir = dir.path();
1798        assert!(
1799            WgConfig::resolve_with(dir, None)
1800                .expect("no sources is not a failure")
1801                .is_none(),
1802            "an unconfigured data root must mean no tunnel, not a broken boot"
1803        );
1804        std::fs::write(dir.join(CONFIG_FILE), GOOD).expect("write a config");
1805        let cfg = WgConfig::resolve_with(dir, None)
1806            .expect("a legal config file")
1807            .expect("present");
1808        assert_eq!(cfg.interface, "wg0");
1809        assert_eq!(cfg.address, Ipv4Addr::new(10, 99, 0, 2));
1810    }
1811
1812    #[test]
1813    fn a_missing_config_file_is_none_and_not_an_error() {
1814        let dir = std::env::temp_dir().join(format!("gunnar-wg-none-{}", std::process::id()));
1815        std::fs::create_dir_all(&dir).expect("a scratch directory");
1816        assert!(
1817            WgConfig::from_data_root(&dir)
1818                .expect("absence is not a failure")
1819                .is_none(),
1820            "no wg0.conf must mean no tunnel, not a broken boot"
1821        );
1822        std::fs::write(dir.join(CONFIG_FILE), GOOD).expect("write a config");
1823        let cfg = WgConfig::from_data_root(&dir)
1824            .expect("a legal config")
1825            .expect("present");
1826        assert_eq!(
1827            cfg.interface, "wg0",
1828            "the interface name comes from the file's stem"
1829        );
1830        std::fs::remove_dir_all(&dir).ok();
1831    }
1832
1833    // ── the identity ────────────────────────────────────────────────────────
1834
1835    /// **The public key in the banner is the key `wg pubkey` prints.**
1836    ///
1837    /// This is the assertion the deleted kernel lane could only make by asking
1838    /// a live kernel to derive the key, in a root-only `#[ignore]` probe. The
1839    /// curve is linked in now, so it is an ordinary unit test that runs on every
1840    /// `cargo test` — and `R/1Rs…` is what the `wg` tool prints for `gEi6…`,
1841    /// recorded from the tool rather than computed here.
1842    #[test]
1843    fn the_banner_prints_the_public_key_wg_pubkey_derives() {
1844        const ITS_PUBLIC: &str = "R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=";
1845        let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
1846        assert_eq!(
1847            base64_key(&cfg.public_key()),
1848            ITS_PUBLIC,
1849            "the identity gunnar prints is not the one wg derives from the same private key"
1850        );
1851        // A DIFFERENT private key must derive a different identity — the
1852        // control that keeps the equality above from passing on a constant.
1853        let other = GOOD.replace(
1854            "gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=",
1855            "cGVlcktleVRoYXRJc0RpZmZlcmVudEZyb21UaGVPbjA=",
1856        );
1857        let other = WgConfig::parse("wg0", &other).expect("a legal config");
1858        assert_ne!(other.public_key(), cfg.public_key());
1859    }
1860
1861    /// The console line an operator compares against their hub, asserted for
1862    /// every field it is supposed to carry. Two peer counts, because the plural
1863    /// is the sort of thing that only ever reads right in the singular case the
1864    /// author tested.
1865    #[test]
1866    fn the_banner_line_carries_address_key_port_and_peer_count() {
1867        let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
1868        let line = banner_line("wg0", cfg.address, cfg.prefix, &cfg.public_key(), 1, 51820);
1869        assert!(line.contains("10.99.0.2/24"), "{line}");
1870        assert!(line.contains("on wg0"), "{line}");
1871        assert!(
1872            line.contains("R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU="),
1873            "the banner must print the identity a hub allowlists: {line}"
1874        );
1875        assert!(line.contains("(1 peer,"), "singular: {line}");
1876        assert!(line.contains("udp 51820"), "{line}");
1877        let two = banner_line("wg0", cfg.address, cfg.prefix, &cfg.public_key(), 2, 40000);
1878        assert!(two.contains("(2 peers,"), "plural: {two}");
1879    }
1880
1881    #[test]
1882    fn base64_round_trips_the_way_wg_prints_it() {
1883        let key = [0x22u8; KEY_LEN];
1884        let text = base64_key(&key);
1885        assert_eq!(
1886            text.len(),
1887            44,
1888            "a 32-byte key is 44 padded base64 characters"
1889        );
1890        assert_eq!(parse_key(&text).expect("its own output"), key);
1891    }
1892
1893    /// The spec handed to the shared data plane must carry every field the
1894    /// config declared. A translation that dropped `AllowedIPs` would produce a
1895    /// tunnel that comes up and routes nothing, with no error anywhere.
1896    #[test]
1897    fn the_data_plane_spec_carries_every_configured_field() {
1898        let cfg = WgConfig::parse("wg0", GOOD).expect("a legal config");
1899        let spec = cfg.tunnel_spec();
1900        assert_eq!(spec.private_key, cfg.private_key);
1901        assert_eq!(spec.listen_port, Some(51820));
1902        assert_eq!(spec.peers.len(), 1);
1903        assert_eq!(spec.peers[0].public_key, cfg.peers[0].public_key);
1904        assert_eq!(
1905            spec.peers[0].endpoint,
1906            Some("10.0.2.2:51820".parse().unwrap())
1907        );
1908        assert_eq!(spec.peers[0].persistent_keepalive, Some(25));
1909        assert_eq!(
1910            spec.peers[0].allowed_ips,
1911            vec![
1912                (Ipv4Addr::new(10, 99, 0, 1), 32),
1913                (Ipv4Addr::new(10, 99, 0, 0), 24)
1914            ],
1915            "every AllowedIPs entry must reach the router, or the tunnel carries nothing"
1916        );
1917        // And the router built from it really claims the tunnel subnet.
1918        let router = dataplane::Router::new(&spec.peers);
1919        assert_eq!(router.select("10.99.0.1".parse().unwrap()), Some(0));
1920        assert_eq!(router.select("10.99.0.77".parse().unwrap()), Some(0));
1921        assert_eq!(
1922            router.select("8.8.8.8".parse().unwrap()),
1923            None,
1924            "a config with no default route must not silently acquire one"
1925        );
1926    }
1927}