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)
}