Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-front/src/pid1.rs

//! `gunnar-front` — **the FRONT medium's PID 1** (vm1). No arguments: the
//! medium's cmdline says everything (`korp.env.GUNNAR_DATA_ROOT=/data`,
//! `korp.env.GUNNAR_NET=eth0:dhcp,eth1:dhcp`, `korp.env.GUNNAR_CLOCK=` when
//! the site names clock sources), and the installer feeds the rest.
//!
//! In order: the boot log is kept for the installer from the first line; both
//! NICs come up (DHCP, renewed); the medium's seed is read; a fresh
//! installation clears the previous estate's pairing; the clock starts on the
//! utility address; the installer's listener opens on its WireGuard link —
//! the install, then the log door for the life of the machine (the log door
//! alone on a restart after the installer's close); and the front's
//! role runs from whatever was fed, for the life of the machine.
//!
//! As PID 1 this process never returns: a refusal is said on every console
//! and the machine stays up, so the installer can still collect the boot log
//! that says why.
//!
//! **A library module** (no-dwarf, the owner 2026-10-08): `src/main.rs` runs it. The
//! 2026-10-08 commercial front (monetize-impl `gunnar-front-commercial`, a shop on vm1) is
//! LEGACY: no estate runs a monetize (ONE monetize, owner 13:35Z 2026-10-10).

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

/// Say `msg` on stderr (`/dev/console`, the serial line) AND the VGA text
/// screen, which is the only console a cloud VM shows — the same two places
/// gunnar-server's PID 1 says its last word.
fn shout(msg: &str) {
    use std::io::Write;
    // Straight to the console, not fd 2: fd 2 is the stderr capture's pipe,
    // and this line is handed to the boot log below already.
    gunnar_common::logqueue::console_write(msg);
    // Outside tracing: handed to the boot log the installer reads, too (the
    // panic and the top-level refusal are the lines it most needs).
    gunnar_common::logqueue::tap_line(msg.trim_matches('\n'));
    if let Ok(mut tty0) = std::fs::OpenOptions::new().write(true).open("/dev/tty0") {
        let _ = tty0.write_all(msg.replace('\n', "\r\n").as_bytes());
    }
}

fn data_root() -> PathBuf {
    std::env::var("GUNNAR_DATA_ROOT")
        .ok()
        .filter(|v| !v.trim().is_empty())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/data"))
}

/// Stay up, for ever: PID 1 must not exit (the kernel would panic and the
/// boot log with it).
fn idle(why: &str) -> ! {
    shout(&format!("gunnar-front: {why}\n"));
    loop {
        std::thread::sleep(Duration::from_secs(3600));
    }
}

pub fn main() {
    gunnar_server::installer_listener::capture();
    // vm1 has no narrator and no data volume of its own: its log is kept on
    // its disk too (bounded), and what the last boot said before it stopped —
    // a crash included — is carried into this boot's log door (RUNTIME LOGS).
    let carried = gunnar_server::installer_listener::keep_on_disk(&data_root());
    // DEV builds only: the same log (stderr and the console's lines
    // included, once captured below), sealed to the installer's key, over UDP
    // from the moment the NIC has an address — before the installer's link.
    #[cfg(feature = "dev-narrate")]
    gunnar_server::dev_narrate::start("gunnar-front");
    // fd 2 becomes a pipe whose lines are log lines: whatever in this process
    // says it with eprintln! reaches the boot log the installer reads. Before tracing, so tracing
    // keeps the real console.
    let captured = gunnar_common::stderr_capture::start();
    std::panic::set_hook(Box::new(|info| {
        shout(&format!("\ngunnar-front PANIC: {info}\n"))
    }));
    gunnar_common::init_tracing(false);
    // The previous stop's record (the ESP survives a read-only /data), said once and removed.
    gunnar_server::stop_record::say_previous();
    if let Err(e) = captured {
        tracing::error!(error = %e, "front: stderr could not be captured — eprintln! lines reach the serial console only, not the installer's log door");
    }
    tracing::info!(
        build = gunnar_common::BUILD_REF,
        "gunnar-front: the FRONT medium (vm1) starts"
    );
    tracing::info!(carried, file = %data_root().join(gunnar_server::installer_listener::KEEP_REL).display(), "front: this box's log is kept on its disk; the previous boot's last lines are carried above");
    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    {
        Ok(r) => r,
        Err(e) => idle(&format!("no async runtime: {e}")),
    };
    runtime.block_on(run(data_root()))
}

async fn run(root: PathBuf) -> ! {
    // ── the network, by hand: nothing else brings an interface up here ──────
    match gunnar_server::boot::bring_up_network() {
        Ok(sessions) => {
            tracing::info!(
                leases = sessions.len(),
                "front: the declared interfaces are up"
            );
            // One renewal per lease (RFC 2131 §4.4.5), as gunnar-server keeps its own.
            #[cfg(feature = "dhcp-client")]
            for s in sessions {
                // A32-14, stays a task: PID 1's lease renewal, one per lease, for the life of the box.
                tokio::spawn(gunnar_server::boot::dhcp_renewal(s));
            }
            // Without the client there is no lease to renew. (Not `match s {}`: a library built
            // without this crate's `dhcp-client` may still meet gunnar-server's client, turned on
            // by another dependent — features unify per package.)
            #[cfg(not(feature = "dhcp-client"))]
            drop(sessions);
        }
        Err(e) => {
            tracing::error!(error = %format!("{e:#}"), "front: the network could not be brought up — the installer cannot reach this VM")
        }
    }
    // A32-14, stays a task: PID 1's zombie reaper, for the life of the box.
    tokio::spawn(appliance_pid1::runtime::reaper());
    // A utility NIC whose boot DHCP failed (vm1 rebooted while the network was down) is
    // waited for while PID 1 asks again in the background: the clock, the plugin door and
    // the relay are offered on its address, so starting without it starts them on nothing.
    if appliance_pid1::net::interface_ipv4(crate::UTILITY_INTERFACE)
        .ok()
        .flatten()
        .is_none()
        && appliance_pid1::net::dhcp_pending(crate::UTILITY_INTERFACE)
    {
        tracing::info!(
            interface = crate::UTILITY_INTERFACE,
            "front: waits for its utility address (PID 1 asks DHCP again in the background)"
        );
        while appliance_pid1::net::interface_ipv4(crate::UTILITY_INTERFACE)
            .ok()
            .flatten()
            .is_none()
            && appliance_pid1::net::dhcp_pending(crate::UTILITY_INTERFACE)
        {
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    }
    let utility = appliance_pid1::net::interface_ipv4(crate::UTILITY_INTERFACE)
        .ok()
        .flatten();
    tracing::info!(interface = crate::UTILITY_INTERFACE, utility = ?utility, "front: the utility address");
    // ★ WireGuard-only (owner 2026-10-04): the front's own overlay interface,
    //   before anything binds on it. Every box-to-box connection runs over the
    //   tunnel; the utility address carries only WireGuard's own udp/51820.
    let overlay = match gunnar_pairing_door::front::open_hub() {
        Ok(at) => {
            tracing::info!(interface = gunnar_pairing_door::front::HUB_IFACE, address = %at, "front: the overlay interface is up — the boxes are reached over the tunnel");
            Some(at)
        }
        Err(e) => {
            tracing::error!(error = %e, "front: the overlay interface could not be opened — the front cannot reach a box over the tunnel");
            None
        }
    };
    // ★ **The egress door** (the lean estate, A19 rows 10-14): main and the twin may be laid
    //   with no public IPv4; they leave through this front — their mail, webhooks, mirrors
    //   and identity provider — over the tunnel, to TLS ports on outside addresses only.
    if !crate::egress_door::open_here() {
        tracing::info!("{}", crate::egress_door::SHUT);
    } else if let Some(door) = gunnar_pairing_door::front::egress_door_bind(overlay) {
        match nordisk_pairing::egress::serve(door, nordisk_pairing::egress::Policy::OUT) {
            Ok(at) => {
                tracing::info!(address = %at, "front: the egress door is open — the boxes behind this front leave through it (TLS ports, outside addresses only)")
            }
            Err(e) => {
                tracing::error!(error = %e, "front: the egress door could not open — a box with no public IPv4 reaches nothing outside the estate")
            }
        }
    }
    // The clock and the plugin door are served on the overlay (on the utility
    // address only if the overlay could not be opened, and said above).
    let serve_on = overlay.or(utility);

    // ── the medium's seed: without it there is no installer to answer ──────
    let seed = match gunnar_server::pairing::seed(&root) {
        Ok(Some(s)) => s,
        Ok(None) => idle(&format!("no pairing seed at {} — this medium was composed without `--param pairing_seed=@<seed.toml>`, so no installer can reach it", root.join(gunnar_server::pairing::SEED_REL).display())),
        Err(e) => idle(&format!("the pairing seed is present and unusable: {e:#}")),
    };
    if let Some(cleared) = crate::vm::first_boot(Path::new("/boot"), &root, &seed, &|p| {
        gunnar_server::narrate::write_esp_file(
            p,
            b"the pairing state of the previous installation was cleared\n",
        )
    }) {
        tracing::info!(cleared = ?cleared, "front: the first boot of this installation — it starts UNPAIRED");
    }
    let vm = match crate::vm::FrontVm::open(&root, seed.clone()) {
        Ok(v) => Arc::new(v),
        Err(e) => idle(&format!("this box cannot prepare its own key: {e:#}")),
    };

    // ── the clock: its key and peer are offered, so start it first ─────────
    match crate::clock::start(&root, serve_on, &seed.installer_ca_pem) {
        Ok(offer) => vm.clock_started(offer),
        Err(e) => {
            tracing::error!(error = %e, "front: the clock could not start — the boxes will be fed no clock")
        }
    }

    let estate = seed.estate.clone();
    #[cfg_attr(not(feature = "metered"), allow(unused_variables))]
    let installer_ca = seed.installer_ca_pem.clone();
    #[cfg_attr(not(feature = "console"), allow(unused_variables))]
    let estate_web = estate.clone();
    // ── the installer's listener, and the front's role ──────────────────────
    // The link and the listener stay up for the life of vm1: the installer
    // reads this box's log through them after the close too (nordisk-pairing
    // 0.5.4). A restart after the close opens the log door alone.
    let door = gunnar_server::installer_listener::door_for(vm.closed());
    if door == gunnar_server::installer_listener::Door::LogOnly {
        tracing::info!("installer: the installer closed the install on this installation — the log door opens (the installer reads this VM's log over its link, for life)");
    }
    gunnar_server::installer_listener::serve_installer(
        Arc::clone(&vm) as Arc<dyn nordisk_pairing::bootstrap::Vm>,
        seed,
        door,
    );
    // The front's jera ledger (RA3-5): its long duties are rows in `<root>/jobs`.
    if let Err(e) = gunnar_common::jobs::set_ledger(&vm.root().join("jobs")) {
        tracing::error!("ALERT jobs.ledger {e}: the front's jobs run, unrecorded");
    }
    crate::role::start(Arc::clone(&vm));
    // Live update: a front started on trial confirms once it has served (alive) a minute, or
    // is rolled back at the deadline.
    #[cfg(target_os = "linux")]
    if appliance_pid1::stop::on_appliance() {
        gunnar_server::live_update::spawn_confirm(|| true);
    }
    // WireGuard-only: the front tries a plain-IP connection to each box, and
    // the tunnel, and says what it saw (the kit's bench proof reads it).
    crate::mesh_probe::start(Arc::clone(&vm));
    // The forge's public door (git.gunnar.rs:2222 → the primary), from the verdict.
    crate::forward::start(Arc::clone(&vm));

    // A SOLD estate re-admits a lost box itself (F4): the shop lays it again, the front pairs it.
    #[cfg(feature = "metered")]
    crate::recharge::watch(Arc::clone(&vm), tokio::runtime::Handle::current());
    // The ONE box's update of this estate (owner 2026-10-14): pulled, done box by box, reported.
    #[cfg(feature = "metered")]
    crate::update::watch(Arc::clone(&vm), tokio::runtime::Handle::current());
    // ★ Main rebuild (2026-10-12): with the estate's auto-fix fed, a re-laid main that awaits
    // its twin is filled from it by the front (the console's key, the one pump).
    #[cfg(feature = "metered")]
    crate::autofill::watch(Arc::clone(&vm), tokio::runtime::Handle::current());
    // The operator door (mTLS, the operator only): the hand-over, the backup
    // stream, the twin's health, a fed credential's rotation. No monetize runs
    // here (ONE monetize, owner 13:35Z 2026-10-10).
    #[cfg(feature = "metered")]
    crate::plugin_door::start(Arc::clone(&vm), installer_ca.clone(), utility).await;

    // ── the web face's HTTPS on the public 80/443: this process holds the
    //    certificate (fed, kept, or from Let's Encrypt) — no Caddy here ──────
    let fed = Arc::clone(&vm);
    let feed: crate::web::Feed = Arc::new(move || fed.latest().map(|r| r.feed).unwrap_or_default());
    let any = std::net::Ipv4Addr::UNSPECIFIED;
    match crate::web::start(
        &root,
        estate,
        feed,
        (any, crate::web::HTTP_PORT).into(),
        (any, crate::web::HTTPS_PORT).into(),
    )
    .await
    {
        Ok(web) => {
            tracing::info!("front: the web face is served on :80 and :443");
            // The CLI's gRPC hole on :443 follows the primary the console dials (A6).
            #[cfg(feature = "console")]
            crate::web::hole::follow(web.app.hole(), Arc::clone(&vm));
            // Who may use it (RA1-1, RA1-14): the estate's own network unless the SITE's feed says
            // `public` (gunnar.rs); the follow job above sets it from the feed.
            #[cfg(feature = "console")]
            crate::console::start(web, Arc::clone(&vm), estate_web.clone());
            #[cfg(not(feature = "console"))]
            let _ = web;
        }
        Err(e) => tracing::error!(error = %e, "front: the web face cannot be served"),
    }
    // **The power button is heard** (release-3 test2: a soft stop was ignored, hard-stopped
    // after 60 s, XFS recovery at the next boot): stop cleanly and power off.
    // The stop is said first; on the appliance the disk is then synced and FROZEN (edda
    // quiesce_for_power_off, each step bounded and said) and the power goes with no sync
    // after the freeze; anywhere else /data is synced and the process exits.
    let appliance = appliance_pid1::stop::on_appliance();
    let data = root.clone();
    // A LIVE UPDATE's stop only syncs /data (it stays mounted across the re-exec); every other
    // stop on the appliance freezes it for the power-off.
    let quiesce = move || {
        if appliance && !gunnar_server::live_update::staged() {
            crate::stop::quiesce_for_power_off(&data)
        } else {
            crate::stop::quiesce(&data, false)
        }
    };
    let within = crate::stop::QUIESCE_WITHIN * 2 + crate::stop::DISK_LOG_CATCHES_UP;
    let (line, after) = crate::stop::stop_cleanly(
        appliance_pid1::runtime::stop_request(),
        quiesce,
        appliance,
        within,
    )
    .await;
    gunnar_common::logqueue::flush(Duration::from_secs(2));
    // **A LIVE UPDATE ends in a RE-EXEC** of korp-start (owner 2026-10-12): PID 1 stays PID 1.
    #[cfg(target_os = "linux")]
    if appliance
        && gunnar_server::live_update::after_stop(gunnar_server::live_update::staged())
            == gunnar_server::live_update::AfterStop::ReExec
    {
        tracing::warn!("front: stopped cleanly for a LIVE UPDATE — re-exec korp-start into the staged slot ({line})");
        gunnar_common::logqueue::flush(Duration::from_secs(1));
        let e = gunnar_server::live_update::reexec_now();
        tracing::error!(
            "front: the live update's re-exec FAILED ({e}) — powering off as on any clean stop"
        );
    }
    if after == appliance_pid1::stop::AfterStop::PowerOff {
        // The last write before the power: the stop's steps and the power-off time, on the
        // ESP — the narration and the disk log end with the read-only remount.
        gunnar_server::stop_record::keep(&line);
        appliance_pid1::power::power_off_quiesced();
    }
    std::process::exit(0)
}