Skip to main content

gunnar_server/
boot.rs

1//! **Gunnar's end of PID 1.** Everything an OS-less PID 1 owes the kernel —
2//! the signals, the reaper, the netlink writer, the DHCPv4 client and its
3//! renewal, the resolver file, the stop sources and the power-off, the install
4//! record, the graphics-console mirror, the boot narration's secret door, the
5//! panic as a fault — is edda's `appliance-pid1` (0.1.5, lanes G4 and G6), the one PID 1
6//! Gunnar and Holger both run. Gunnar's own `pid1.rs` and `dhcp.rs` were moved
7//! there whole and deleted here (lane L10).
8//!
9//! What stays Gunnar's is what Gunnar says about itself: the variable the
10//! kernel command line names the network in ([`NET_VAR`]), the host-name
11//! prefix, the name the resolver is proven on, the T1 test seam's variable,
12//! where the boot narration and the install line go (the console log tap and
13//! the dashboard's `install.mode` row), and the panic's code (`GN-99`) with
14//! Gunnar's own crash file and console line. The narrator (the `Stage` story,
15//! the ESP and `/data` log lines) is [`crate::narrate`].
16
17use std::path::{Path, PathBuf};
18
19use anyhow::Result;
20
21pub use appliance_pid1::net::DhcpSession;
22
23/// The variable the kernel command line declares the network in:
24/// `GUNNAR_NET=eth0:dhcp,eth1:dhcp` or `GUNNAR_NET=eth0:10.0.2.15/24:10.0.2.2`.
25pub const NET_VAR: &str = "GUNNAR_NET";
26/// The DHCP T1 test seam's variable (read only behind the appliance gate).
27pub const T1_OVERRIDE_VAR: &str = "GUNNAR_DHCP_T1_OVERRIDE_SECS";
28/// A box with no lease name is called `gunnar-<last three MAC bytes>`.
29pub const HOSTNAME_PREFIX: &str = "gunnar";
30/// The name the published resolver is proven on, once, at boot.
31pub const RESOLVER_PROBE: &str = "github.com";
32/// **The name this build asks** (AIRGAP B3, owner rule 2026-10-25): a DNS question for a
33/// public name is an outbound call, so only a build with `resolver-probe` (every appliance
34/// row names it) proves the resolver; a default build asks nothing.
35pub const RESOLVER_PROBE_IN_THIS_BUILD: Option<&str> = if cfg!(feature = "resolver-probe") {
36    Some(RESOLVER_PROBE)
37} else {
38    None
39};
40/// Gunnar's panic code.
41pub const PANIC_CODE: &str = "GN-99";
42
43/// The plans `GUNNAR_NET` declares (empty when it declares none).
44pub fn net_plans() -> Result<Vec<appliance_pid1::net::NetConfig>> {
45    appliance_pid1::net::NetConfig::from_env(NET_VAR)
46}
47
48/// Pure: **a bind by ROLE becomes this box's NIC** (the lean estate, A19 row 17):
49/// `utility:<port>` is the last interface the net spec `net` (the medium's [`NET_VAR`])
50/// declares that this box has (`exists`). Any other spelling is returned unchanged.
51pub fn role_bound(
52    spec: gunnar_common::bindspec::BindSpec,
53    net: Option<&str>,
54    exists: impl Fn(&str) -> bool,
55) -> Result<gunnar_common::bindspec::BindSpec> {
56    if !matches!(spec, gunnar_common::bindspec::BindSpec::Role { .. }) {
57        return Ok(spec);
58    }
59    let declared: Vec<String> = match net.map(str::trim).filter(|n| !n.is_empty()) {
60        Some(n) => appliance_pid1::net::NetConfig::parse_list(n)?
61            .iter()
62            .map(|c| c.interface().to_string())
63            .collect(),
64        None => Vec::new(),
65    };
66    spec.for_this_box(&declared, exists)
67        .map_err(|why| anyhow::anyhow!("{NET_VAR}: {why}"))
68}
69
70/// [`role_bound`] on this box: its [`NET_VAR`] and the NICs the kernel has
71/// (`/sys/class/net/<name>`).
72pub fn role_bound_here(
73    spec: gunnar_common::bindspec::BindSpec,
74) -> Result<gunnar_common::bindspec::BindSpec> {
75    let net = std::env::var(NET_VAR).ok();
76    role_bound(spec, net.as_deref(), |i| {
77        std::path::Path::new("/sys/class/net").join(i).exists()
78    })
79}
80
81/// **Everything PID 1 owes the system before the listeners bind**: edda's
82/// `net::bring_up_network_with` over `GUNNAR_NET`, with Gunnar's host-name
83/// prefix, its resolver probe and the boot narration handed to the console log
84/// tap. A boot DHCP that failed and is asked again in the background lands the
85/// same way ([`late_lease`], appliance-pid1 0.1.5): said through the boot
86/// narration, renewed on Gunnar's T1 seam and its resolver proven.
87pub fn bring_up_network() -> Result<Vec<DhcpSession>> {
88    let narrate = |note: &str| ship_boot_narration(note);
89    let how = appliance_pid1::net::Bringup {
90        hostname_prefix: HOSTNAME_PREFIX,
91        resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,
92        narrate: &narrate,
93    };
94    appliance_pid1::net::bring_up_network_with(net_plans()?, &how, late_lease())
95}
96
97/// What a late DHCP lease (the boot's failed ask, retried in the background)
98/// says and renews on: the boot narration's own door and Gunnar's T1 seam.
99pub fn late_lease() -> appliance_pid1::net::LateLease {
100    appliance_pid1::net::LateLease {
101        narrate: ship_boot_narration,
102        t1_override_var: Some(T1_OVERRIDE_VAR),
103    }
104}
105
106/// The kernel's story to the boot log the installer collects (edda's
107/// `narration::ship_boot_narration`, tapped into `gunnar_common::logqueue`).
108pub fn ship_boot_narration(network_note: &str) {
109    appliance_pid1::narration::ship_boot_narration(network_note, &|l: &str| {
110        gunnar_common::logqueue::tap_line(l)
111    });
112}
113
114/// Keep the boot's lease alive, forever (edda's `runtime::dhcp_renewal`, with
115/// Gunnar's T1 test seam).
116#[cfg(feature = "dhcp-client")]
117pub async fn dhcp_renewal(session: DhcpSession) {
118    appliance_pid1::runtime::dhcp_renewal(
119        session,
120        appliance_pid1::runtime::dhcp_t1_override(T1_OVERRIDE_VAR),
121    )
122    .await
123}
124
125/// With `dhcp-client` off there is no lease to renew: the session type is
126/// uninhabited, so this cannot be called.
127#[cfg(not(feature = "dhcp-client"))]
128pub async fn dhcp_renewal(session: DhcpSession) {
129    match session {}
130}
131
132/// **Say whether this /data is the one the box had before its last install**
133/// (edda's `install::announce_install_mode`): the set's shape is read off the
134/// md sysfs under `data_root` (`dashboard::probe::disk_kind`), and the line is
135/// said as the structured `install.mode` event the dashboard's `install` row
136/// keys on. Silent when the record is absent.
137pub fn announce_install_mode(data_root: &Path) {
138    if !Path::new(appliance_pid1::install::INSTALL_RECORD).exists() {
139        return;
140    }
141    let set = crate::dashboard::probe::disk_kind(data_root)
142        .and_then(|k| k.set)
143        .map(|s| {
144            format!(
145                "{}\u{d7}{}",
146                s.members,
147                crate::dashboard::fmt_bytes(s.member_bytes)
148            )
149        });
150    appliance_pid1::install::announce_install_mode(set.as_deref(), &install_mode_event);
151}
152
153/// One `install.mode` line (`install.mode mode=… kept=… …`) as the structured
154/// event the dashboard reads: each `key=value` token a field, `-` when absent.
155pub fn install_mode_event(line: &str) {
156    let f = install_mode_fields(line);
157    tracing::info!(
158        event = "install.mode",
159        mode = %f[0],
160        kept = %f[1],
161        kept_partno = %f[2],
162        kept_gib = %f[3],
163        kept_set = %f[4],
164        "install record read off {}",
165        appliance_pid1::install::INSTALL_RECORD
166    );
167}
168
169/// `mode`, `kept`, `kept_partno`, `kept_gib`, `kept_set` off an edda
170/// `install.mode` line, `-` for each one it does not name. Pure.
171pub fn install_mode_fields(line: &str) -> [String; 5] {
172    let get = |k: &str| {
173        line.split_whitespace()
174            .find_map(|t| t.strip_prefix(k).and_then(|r| r.strip_prefix('=')))
175            .unwrap_or("-")
176            .to_owned()
177    };
178    [
179        get("mode"),
180        get("kept"),
181        get("kept_partno"),
182        get("kept_gib"),
183        get("kept_set"),
184    ]
185}
186
187/// **A panic on the appliance ends as a fault** (`GN-99`): edda's
188/// `fault::install_panic_fault`, armed only as PID 1 with the narrator
189/// installed (never in a test, a laptop's `gunnar-solo` or a container), the
190/// crash record under `data`. `shout` is the binary's every-console writer.
191///
192/// Gunnar's own half rides in `say`: the panic line on every console with
193/// Gunnar's wording, its own `crash/panic-<ms>.txt` (`slow::record_crash`),
194/// and every fault line through the narrator (console, ESP, `/data`'s boot
195/// log) — `shout` when no narrator is installed.
196pub fn install_panic_fault(data: Option<PathBuf>, shout: fn(&str)) {
197    // The crash record (`crash/fault.txt`) holds the narrator's memory under
198    // the fault line, as `narrate::fault`'s record does (appliance-pid1 0.1.5).
199    appliance_pid1::fault::narration_from(crate::narrate::memory);
200    let p = appliance_pid1::fault::PanicFault {
201        code: PANIC_CODE,
202        meaning: crate::narrate::code_meaning(PANIC_CODE).unwrap_or("the app panicked"),
203        data,
204    };
205    appliance_pid1::fault::install_panic_fault(
206        p,
207        || appliance_pid1::net::is_init() && crate::narrate::narrator().is_some(),
208        move |line: &str| say_panic_line(line, shout),
209    );
210}
211
212/// What Gunnar does with each line edda's panic hook says. See [`install_panic_fault`].
213fn say_panic_line(line: &str, shout: fn(&str)) {
214    if let Some(text) = line.strip_prefix("PANIC: ") {
215        let text = format!("\ngunnar PANIC (exit 101): {text}\n");
216        shout(&text);
217        // And written down, off the stripe: a console message is gone the moment
218        // the VNC session closes. A no-op before `ServerStores::open` registered
219        // the diagnostics directory.
220        if let Some(path) = crate::slow::record_crash(&text) {
221            shout(&format!(
222                "gunnar: crash record written to {}\n",
223                path.display()
224            ));
225        }
226        return;
227    }
228    match crate::narrate::narrator() {
229        Some(n) => n.say(line),
230        None => shout(&format!("gunnar: {line}\n")),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    /// ★ **The control plane's `utility:<port>` becomes this box's utility NIC** (the lean
239    /// estate, A19 row 17): read from the net spec the medium declares and the NICs the box
240    /// has, before anything binds. A spelling that is not a role passes unchanged.
241    #[test]
242    fn the_utility_role_becomes_the_nic_this_box_has() {
243        use gunnar_common::bindspec::BindSpec;
244        let role: BindSpec = "utility:50051".parse().unwrap();
245        let iface = |i: &str| BindSpec::Interface {
246            interface: i.into(),
247            port: 50051,
248        };
249        assert_eq!(
250            role_bound(role.clone(), Some("eth0:dhcp,eth1:dhcp"), |_| true).unwrap(),
251            iface("eth1")
252        );
253        assert_eq!(
254            role_bound(role.clone(), Some("eth0:dhcp,eth1:dhcp"), |i| i == "eth0").unwrap(),
255            iface("eth0")
256        );
257        assert!(role_bound(role.clone(), None, |_| true)
258            .unwrap_err()
259            .to_string()
260            .contains(NET_VAR));
261        let kept: BindSpec = "127.0.0.1:50051".parse().unwrap();
262        assert_eq!(role_bound(kept.clone(), None, |_| false).unwrap(), kept);
263    }
264
265    /// The names Gunnar gives edda's PID 1 are the ones every medium, kit and
266    /// test already spells.
267    #[test]
268    fn gunnar_names_its_network_variable_its_host_and_its_panic() {
269        assert_eq!(NET_VAR, "GUNNAR_NET");
270        assert_eq!(T1_OVERRIDE_VAR, "GUNNAR_DHCP_T1_OVERRIDE_SECS");
271        assert_eq!(
272            appliance_pid1::net::hostname_for(
273                HOSTNAME_PREFIX,
274                None,
275                [0, 0x11, 0x22, 0xaa, 0xbb, 0xcc]
276            ),
277            "gunnar-aabbcc"
278        );
279        assert_eq!(
280            appliance_pid1::net::hostname_for(HOSTNAME_PREFIX, Some("vm1.example"), [0; 6]),
281            "vm1.example"
282        );
283        assert_eq!(RESOLVER_PROBE, "github.com");
284        assert_eq!(PANIC_CODE, "GN-99");
285        assert_eq!(
286            crate::narrate::code_meaning(PANIC_CODE),
287            Some("the app panicked")
288        );
289    }
290
291    /// edda's `install.mode` line becomes the fields the dashboard's `install`
292    /// row reads, for every shape the record can spell — never a fabricated value.
293    #[test]
294    fn the_install_line_becomes_the_dashboards_fields() {
295        use appliance_pid1::install::InstallRecord;
296        let p = InstallRecord::parse(
297            "mode=upgrade\nkept=korp-data\nkept_partno=3\nkept_bytes=241591910400\n",
298        )
299        .line(None);
300        assert_eq!(
301            install_mode_fields(&p),
302            ["upgrade", "korp-data", "3", "225.0", "-"].map(String::from)
303        );
304        let s = InstallRecord::parse("mode=upgrade\nkept=data-set\n").line(Some("4\u{d7}75G"));
305        assert_eq!(
306            install_mode_fields(&s),
307            ["upgrade", "data-set", "-", "-", "4\u{d7}75G"].map(String::from)
308        );
309        let s = InstallRecord::parse("mode=upgrade\nkept=data-set\n").line(None);
310        assert_eq!(
311            install_mode_fields(&s),
312            ["upgrade", "data-set", "-", "-", "-"].map(String::from)
313        );
314        let f = InstallRecord::parse("mode=fresh\n").line(None);
315        assert_eq!(
316            install_mode_fields(&format!("{f} (install record read off the ESP)")),
317            ["fresh", "-", "-", "-", "-"].map(String::from)
318        );
319    }
320
321    /// ★ **A late lease is said, renewed on Gunnar's T1 seam and its resolver
322    /// proven** (lane L10, appliance-pid1 0.1.5): the boot hands edda a
323    /// `LateLease` whose line goes through the same boot narration door as the
324    /// boot lease's (into the log the installer collects) and whose renewal reads
325    /// `GUNNAR_DHCP_T1_OVERRIDE_SECS`; the resolver probe rides on the `Bringup`
326    /// edda copies onto the retry. edda's `late_lease_tests` prove what it does
327    /// with these; this proves Gunnar hands them over.
328    #[test]
329    fn a_late_lease_is_narrated_into_the_installers_boot_log() {
330        let late = late_lease();
331        assert_eq!(late.t1_override_var, Some(T1_OVERRIDE_VAR));
332        // The installer's boot log exists with the pairing door (`wg-client`).
333        #[cfg(feature = "wg-client")]
334        {
335            crate::installer_listener::capture();
336            (late.narrate)("network up: eth0 10.0.2.15/24 gw Some(10.0.2.2) via DHCP from 10.0.2.2 (lease 3600s) — after the boot's failure, 7 asks l10-late");
337            let log = crate::installer_listener::BOOT_LOG
338                .since(0, usize::MAX)
339                .lines
340                .join("\n");
341            assert!(
342                log.contains("after the boot's failure, 7 asks l10-late"),
343                "the late line reaches the boot log: {log}"
344            );
345        }
346        let src = include_str!("boot.rs");
347        let up = &src[src.find("pub fn bring_up_network()").unwrap()..];
348        let up = &up[..up.find("\n}\n").unwrap()];
349        assert!(
350            up.contains("bring_up_network_with(net_plans()?, &how, late_lease())"),
351            "{up}"
352        );
353        assert!(
354            up.contains("resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,"),
355            "{up}"
356        );
357    }
358
359    /// `GUNNAR_NET` is parsed by edda with Gunnar's variable: absent is no plan,
360    /// and the estate's two-NIC declaration is two plans, eth0 first.
361    #[test]
362    fn gunnar_net_is_read_from_its_own_variable() {
363        // Not set in a test process: no plans, and no interface touched.
364        if std::env::var_os(NET_VAR).is_none() {
365            assert!(net_plans().unwrap().is_empty());
366        }
367        let plans = appliance_pid1::net::NetConfig::parse_list(
368            "eth0:81.27.107.63/23:81.27.106.1,eth1:10.13.8.78/22",
369        )
370        .unwrap();
371        assert_eq!(
372            plans.iter().map(|p| p.interface()).collect::<Vec<_>>(),
373            ["eth0", "eth1"]
374        );
375    }
376}
377
378/// **AIRGAP B3** (owner rule 2026-10-25): the boot's resolver proof is a DNS question for
379/// [`RESOLVER_PROBE`], a public name, so a default build asks none; the `resolver-probe`
380/// feature (named by every appliance row) asks it as before.
381#[cfg(test)]
382mod airgap {
383    #[test]
384    fn the_bring_up_asks_the_name_this_build_names() {
385        let src = include_str!("boot.rs");
386        let up = &src[src.find("pub fn bring_up_network()").unwrap()..];
387        let up = &up[..up.find("\n}\n").unwrap()];
388        assert!(
389            up.contains("resolver_probe: RESOLVER_PROBE_IN_THIS_BUILD,"),
390            "{up}"
391        );
392    }
393
394    #[cfg(not(feature = "resolver-probe"))]
395    #[test]
396    fn a_default_build_proves_no_resolver_and_asks_no_public_name() {
397        assert_eq!(super::RESOLVER_PROBE_IN_THIS_BUILD, None);
398    }
399
400    #[cfg(feature = "resolver-probe")]
401    #[test]
402    fn a_resolver_probe_build_asks_github_com_as_before() {
403        assert_eq!(super::RESOLVER_PROBE_IN_THIS_BUILD, Some("github.com"));
404    }
405}