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

nordisk/monetize

crates/mock-upcloud/src/kvm.rs

//! **The KVM half: the seam, and what a real guest has to do to fill it.**
//!
//! Behaviours 8 and 13–21 are not API behaviours. They are things a GUEST does,
//! and no amount of JSON reproduces them: a PID-1 panic that is invisible
//! except on the framebuffer is only invisible if there is a framebuffer, and an
//! installer that loops only loops if something really boots twice. So the mock
//! delegates the guest to a [`GuestEngine`], and a real one is a qemu.
//!
//! # Why the real engine is not in this crate's dependencies
//!
//! draupnir is the law's door to a hypervisor and this crate starts no qemu of
//! its own. But draupnir's live KVM backend (`backend-tunnr`) depends on
//! `tunnr-vm` by PATH, and tunnr is not published. An optional dependency is
//! still resolved into the lockfile, so naming draupnir here would make a fresh
//! clone of the open `monetize` workspace require a `tunnr` checkout beside it —
//! the exact failure this workspace already paid for with Skidbladnir and wrote
//! down in its root manifest.
//!
//! So the seam is here and the binding is not. The binding is the detached
//! `kvm-guest` crate beside this one (`DraupnirGuest`, rendered by draupnir's
//! own `draupnir::qemu`, not tunnr's argv — see the measurement below for why
//! tunnr's could not express this machine), and its `mock-upcloud-kvm` binary
//! serves this API with a real QEMU behind every server. `cargo test -p
//! mock-upcloud` needs no hypervisor: the default engine runs nothing.
//!
//! # What the real engine must do, and what each clause is for
//!
//! | clause | qemu | behaviour |
//! |---|---|---|
//! | no serial console | `-serial none` | 13 — a PID-1 panic reaches nobody |
//! | a framebuffer, and only that | `-vga std -display vnc=…` | 13 — the panic IS visible, on the pixels |
//! | SeaBIOS, never OVMF | no `-bios OVMF*`, no `pflash` | 15 — the BIOS path is the one that breaks |
//! | RTC two hours ahead | `-rtc base=<now+2h>` | 19 — the skew gunnar-clock exists for |
//! | no inbound UDP | a user-mode net with no `hostfwd` for udp | 18 — DNS and NTP are useless in there |
//! | the CD first while it is inserted | `-boot order=dc` | 17 — the loop |
//!
//! A [`GuestEngine`] that cannot honour a clause must REFUSE it by name rather
//! than quietly boot without it. A guest that silently got a serial console
//! would make behaviour 13 untestable while reporting that it had been tested,
//! which is the always-yes defect this estate has now found four times.
//!
//! # MEASURED 2026-09-20 on oden: draupnir/tunnr cannot express this machine
//!
//! `tunnr::boot_primitive` builds its argv unconditionally, and three of its
//! lines contradict three of the clauses above:
//!
//! * `args.push("-serial"); args.push("mon:stdio")` — **always**, with the
//!   comment "Serial → stdio so we capture the console and detect the BOOT-OK
//!   marker". Behaviour 13 says there is no serial console, and the marker
//!   protocol is built on there being one.
//! * `// UEFI via OVMF pflash, exactly as the design mandates.` — the firmware
//!   boot path is OVMF, unconditionally. Behaviour 15 says SeaBIOS, and UpCloud
//!   offers no knob.
//! * `if spec.headless { args.push("-nographic") }` — which removes the
//!   framebuffer entirely. Behaviour 13 says the framebuffer is the ONLY place
//!   a PID-1 panic appears.
//!
//! So the estate's KVM proofs have been run on a machine that is not the
//! machine UpCloud hands out — a UEFI box with a serial console and no screen,
//! against a BIOS box with a screen and no serial. That is a large part of why
//! an image that "booted fine locally" could panic invisibly up there.
//!
//! The faithful argv, run against the real `gunnar-installer-open-fullk.iso`
//! (59 496 448 bytes) on oden on 2026-09-20:
//!
//! ```text
//! qemu-system-x86_64 -enable-kvm -m 2048 -smp 2 -no-reboot \
//!   -bios /usr/share/seabios/bios-256k.bin \
//!   -drive file=disk.qcow2,if=virtio,format=qcow2 -cdrom <iso> -boot order=dc \
//!   -serial none -vga std -display none -vnc 127.0.0.1:91 \
//!   -rtc base=<host time + 2h> \
//!   -netdev user,id=n0,dns=10.0.2.99 -device virtio-net-pci,netdev=n0
//! ```
//!
//! What it measured: **zero bytes** on stdout across every run, and the
//! installer's whole narration present on the framebuffer — `INSTALLER:
//! payload app.znippy materialized off the ISO volume (8580685 bytes)`,
//! `policy: require_signed=NO`, and `FAILED hwdetect: no usable target disk`
//! on the run with no disk attached. Two screendumps twenty-five seconds apart
//! hashed differently, so the guest really progressed, and the qcow2 grew from
//! 91 951 104 to 93 786 112 bytes, so the install really wrote. There is no
//! log to quote for any of it. That is the point.
//!
//! `dns=10.0.2.99` is how behaviour 18 is modelled: slirp serves DNS on its own
//! `.3` and nothing else, so the guest's queries leave and no reply ever
//! arrives — which is what `systemd-timesyncd` experiences up there, and why
//! gunnar-clock syncs from the front over a signed nonce instead.
//!
//! **What tunnr would need** for draupnir to host this: a `firmware`
//! (`Bios`/`Uefi`) choice on its `BootSpec`, a `serial` (`Stdio`/`None`)
//! choice, and a headless mode that keeps `-vga std` and drops only the
//! display. Three fields. Until they exist the seam below is the honest shape,
//! and a `DraupnirGuest` would have to REFUSE `no-serial-console` and
//! `seabios` by name rather than boot without them.

use std::fmt;

/// What the mock asks a hypervisor for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GuestSpec {
    pub name: String,
    pub mem_mb: u32,
    pub cores: u32,
    /// The disk image the server boots.
    pub disk: String,
    /// The medium in the cdrom device, if any. `None` is an ejected drive.
    pub medium: Option<String>,
    /// `true` while the CD is first in the boot order — behaviour 17's whole
    /// mechanism.
    pub cdrom_first: bool,
    /// `disk,cdrom` with a medium in: disk first, a reboot is a reset in place.
    pub disk_first: bool,
    /// Behaviour 19.
    pub rtc_skew_seconds: i64,
    /// The template the boot disk was cloned from, when it was — the front's
    /// Ubuntu. `None`: a blank disk, filled by an installer (the gunnar boxes).
    pub template: Option<String>,
}

impl GuestSpec {
    pub fn new(name: impl Into<String>, disk: impl Into<String>) -> GuestSpec {
        GuestSpec {
            name: name.into(),
            mem_mb: 2048,
            cores: 2,
            disk: disk.into(),
            medium: None,
            cdrom_first: false,
            disk_first: false,
            // +2 h. Measured on every yvra VM, which is why gunnar-clock syncs
            // from the FRONT over a signed nonce and never from the internet.
            rtc_skew_seconds: 7200,
            template: None,
        }
    }
}

/// What a guest did. Deliberately NOT a log: behaviour 20 is that nothing
/// narrates the install window, so an engine that returned the guest's output
/// would be lying about the one thing this half exists to prove.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuestOutcome {
    /// The installer ran and the box came up on its disk. `ms` is the measured
    /// install time — 10 011 ms under KVM for korp-installer.
    Installed { ms: u64 },
    /// The installer ran again because the CD was still first. Behaviour 17.
    Looped { rounds: u32 },
    /// PID 1 died. Visible only as pixels, so `frame_sha256` is the ONLY
    /// evidence — there is no serial log to quote.
    Panicked { frame_sha256: String },
    /// The engine refused a clause of the spec by name rather than booting
    /// without it.
    Refused { clause: &'static str, why: String },
}

impl fmt::Display for GuestOutcome {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            GuestOutcome::Installed { ms } => write!(f, "installed in {ms} ms"),
            GuestOutcome::Looped { rounds } => write!(f, "installer looped {rounds} times"),
            GuestOutcome::Panicked { frame_sha256 } => {
                write!(
                    f,
                    "PID 1 panicked; the only evidence is the framebuffer ({frame_sha256})"
                )
            }
            GuestOutcome::Refused { clause, why } => write!(f, "refused {clause}: {why}"),
        }
    }
}

/// **One server's machine, as the estate describes it at power-on.**
///
/// Everything is named by the ESTATE's ids — server and storage uuids — and
/// never by a path: where a disk lives is the engine's business, so this crate
/// stays free of any hypervisor and of any filesystem layout.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Machine {
    /// The server uuid.
    pub server: String,
    pub mem_mb: u32,
    pub cores: u32,
    /// The attached disks, boot disk first, each at the size its STORAGE record
    /// says now (not the size it was attached at: a resize changes the record).
    pub disks: Vec<MachineDisk>,
    /// The storage uuid in the cdrom tray, if any. `None` is an ejected drive.
    pub medium: Option<String>,
    /// `true` while the CD is first in the boot order (behaviour 17).
    pub cdrom_first: bool,
    /// `true` for `disk,cdrom` with a medium in the tray: the disk is tried
    /// first, and a guest reboot is a reset in place (the blank-disk install).
    pub disk_first: bool,
    /// Behaviour 19.
    pub rtc_skew_seconds: i64,
    /// The utility NIC's MAC, as the API reports it (`ip_addresses`/`networking`).
    /// An engine puts this NIC on the estate's ONE utility L2 with this MAC, and
    /// [`crate::fabric`] answers its DHCP with [`Machine::utility_offer`].
    pub utility_mac: String,
    /// What the utility network offers this NIC — the API's own address for it.
    pub utility_offer: crate::net::DhcpOffer,
    /// **Its first boot's utility DHCP is late** by this many wall ms from the first ask
    /// ([`crate::faults::Fault::UtilityDhcpLateFirstBoot`]); 0 = answered at once.
    pub dhcp_late_first_boot_ms: u64,
    /// The hostname asked for at create — what a template boot calls itself.
    pub hostname: String,
    /// The keys `login_user` lets in on a template boot.
    pub ssh_keys: Vec<String>,
    /// **What the metadata service answers this guest as its `user_data`**
    /// (`169.254.169.254/metadata/v1/user_data`), when the server was created with
    /// `metadata: "yes"` and a `user_data`: a release image's per-order pairing seed.
    pub user_data: Option<String>,
    /// The PUBLIC address the API reports for this server. With
    /// [`crate::estate::Estate::with_public_on_loopback`] it is a loopback
    /// address of its own, and an engine serves [`INSTALLER_PUBLIC_PORTS`] on
    /// it at the real port numbers — so the installer dials exactly what the
    /// API says, as it does on UpCloud. Otherwise TEST-NET, reached by nobody.
    pub public_ip: String,
    /// Which of [`INSTALLER_PUBLIC_PORTS`] the server's firewall admits NOW
    /// ([`admitted_public_ports`]). An engine serves these on
    /// [`Machine::public_ip`] and no others; a rule written later reaches it
    /// through [`GuestEngine::firewall`].
    pub public_admitted: Vec<(&'static str, u16)>,
}

/// **Where the mock's public traffic comes from.** Every packet to a
/// `--public-on-loopback` address is sent by this host — the installer runs on
/// the operator's machine, and the guests cannot route to 127/8 — and the
/// kernel gives it this source (`ip route get 127.80.99.5` → `src 127.0.0.1`).
/// So a rule is judged against it as UpCloud judges one against the operator's
/// address: a rule scoped to some other source does not admit the installer.
pub const OPERATOR_SOURCE: &str = "127.0.0.1";

/// **The installer's ports the firewall lets the operator reach**, judged by
/// [`crate::net::firewall_admits`] exactly as an inbound packet from
/// [`OPERATOR_SOURCE`] would be: off or no rules = all of them, first match
/// wins, a catch-all drop closes every port no rule above it opens.
pub fn admitted_public_ports(
    firewall_on: bool,
    rules: &[crate::estate::Rule],
) -> Vec<(&'static str, u16)> {
    INSTALLER_PUBLIC_PORTS
        .iter()
        .copied()
        .filter(|(proto, port)| {
            crate::net::firewall_admits(firewall_on, rules, proto, OPERATOR_SOURCE, 0, *port)
                == crate::net::Admit::Accept
        })
        .collect()
}

/// **The ports the installer (nordisk-pairing 0.3) dials on a VM's PUBLIC
/// address**, at these numbers and no other: the bootstrap listener
/// (`bootstrap::PORT`, tcp/8474) in direct mode, the VM's end of the
/// installer's WireGuard link (`installer_link::LINK_PORT`, udp/51821) in
/// tunnel mode, and the front's relay (udp/51820). The installer knows a VM by
/// the address it dials and uses one port for all of them, so each server
/// needs an address of its own — a loopback port per server would not do.
///
/// And the paid FRONT's plugin insert door, tcp/8475 (terraform: `tcp/8475 admin`,
/// mutual TLS on the installer CA): the operator asks it whether the primary has
/// REGISTERED gunnar's module before vm1 is restarted to take it. Without it on
/// the mock the functional test restarted vm1 blind — MEASURED loki 2026-09-26,
/// a restart a minute after pairing came up `REFUSED [plugin-no-module]` and
/// monetize never started. Served only where the firewall admits it, like the rest.
///
/// And DEV-NARRATE's udp/51822 (Skidbladnir nordisk-narrate, 2026-09-30): the
/// installer knocks there and the box streams its sealed early-boot log back to
/// the knocking address. A DEV estate's terraform admits it; a release estate's
/// never does — so on the mock, as on UpCloud, a release box's port is closed.
///
/// And the FRONT's downstream WireGuard interface, udp/51830 (holger `via::HUB_UDP_PORT`,
/// lane HOL-WGDS/OX-WGDS3): a WireGuard downstream outside the estate dials
/// `<front public>:51830` from its signed invite. Served on the public address like the
/// rest, only where the firewall admits it (the shop's lay writes no rules: admitted).
pub const INSTALLER_PUBLIC_PORTS: &[(&str, u16)] = &[
    ("tcp", 8474),
    ("udp", 51820),
    ("udp", 51821),
    ("tcp", 8475),
    ("udp", 51822),
    ("udp", 51830),
];

/// Is `ip` a loopback address — the public-on-loopback mode's pool?
pub fn is_loopback_public(ip: &str) -> bool {
    ip.parse::<std::net::Ipv4Addr>()
        .map(|a| a.is_loopback())
        .unwrap_or(false)
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MachineDisk {
    pub storage: String,
    pub size_gib: u64,
    /// The TEMPLATE this disk was cloned from (its title, e.g. `Ubuntu Server
    /// 24.04 LTS (Noble Numbat)`), or `None` for a blank disk. An engine boots
    /// a cloned disk as that operating system.
    pub template: Option<String>,
}

/// **The seam.** `boot` is the one-shot question a storm asks; the lifecycle
/// methods below are what the estate calls when an API request changes a
/// server, so that behind the JSON there is a machine.
///
/// Every lifecycle method has a default that does NOTHING, and
/// [`running`](GuestEngine::running) defaults to `None` ("this engine has no
/// machine to ask"), which is how the estate knows to keep its own state
/// machine. So the default engine is exactly the mock as it was, and
/// `cargo test -p mock-upcloud` needs no hypervisor.
///
/// All calls are made with the estate's lock held and must return promptly: a
/// soft stop SENDS the ACPI event and returns, it does not wait for the guest.
pub trait GuestEngine: Send + Sync {
    fn name(&self) -> &'static str;
    fn boot(&self, spec: &GuestSpec) -> GuestOutcome;

    /// Power the machine on: create any disk that does not exist yet, sized
    /// from its storage record, grow any that is smaller, and boot. An `Err` is
    /// a refusal by name, and the estate reports the server `stopped` — a
    /// server whose machine never started must not read `started`.
    fn power_on(&self, _machine: &Machine) -> Result<(), String> {
        Ok(())
    }
    /// `hard`: kill it now. Soft: press the ACPI power button and return.
    fn power_off(&self, _server: &str, _hard: bool) {}
    /// `Some(true)` a machine is running for this server, `Some(false)` one was
    /// started and is gone (it powered itself off, or died), `None` this engine
    /// has never run one — the estate's own state machine then stands.
    fn running(&self, _server: &str) -> Option<bool> {
        None
    }
    /// Take the medium out of a running (or stopped) machine's tray.
    fn eject(&self, _server: &str) {}
    /// Where the server's console REALLY listens — a loopback host and port
    /// for a real guest's VNC. `None` (the default) leaves the estate's
    /// RFC 2606 `<zone>.vnc.mock.invalid`.
    fn console(&self, _server: &str) -> Option<(String, u16)> {
        None
    }
    /// Put the medium of `storage` into the server's tray (`cdrom/load`). A
    /// running machine gets it live; a stopped one reads it at the next
    /// power-on either way, because [`Machine::medium`] is built from the
    /// device row.
    fn load(&self, _server: &str, _storage: &str) {}
    /// A storage's size record grew. An engine with hot-plug grows the disk
    /// under the running machine (QMP `block_resize` on its node); one without
    /// grows it only while no running machine has it open, and otherwise at
    /// the next power-on.
    fn resize_disk(&self, _storage: &str, _size_gib: u64) -> Result<(), String> {
        Ok(())
    }
    /// **One storage's bytes written over another's** — a storage backup
    /// (`from` the volume, `to` the new backup) and its restore (`from` the
    /// backup, `to` its origin). The default takes it on paper.
    fn copy_disk(&self, _from: &str, _to: &str) -> Result<(), String> {
        Ok(())
    }
    /// **A virtio disk hot-plugged onto a RUNNING server** — `storage/attach`
    /// while the machine is up, which is how a data-set growth puts a member
    /// back (T21r, 2026-09-22: UpCloud detaches, resizes and re-attaches under
    /// a running server, so the mock's guest must see the same). Called after
    /// every refusal the estate makes on its own (the address, the 511 fault)
    /// and with the device row already written. `Err` is the guest not taking
    /// the card: the estate answers `511 HOTPLUG_FAILED` and removes the row.
    /// The default takes it on paper, as the mock always did.
    fn attach(
        &self,
        _server: &str,
        _storage: &str,
        _address: &str,
        _size_gib: u64,
    ) -> Result<(), String> {
        Ok(())
    }
    /// **The unplug** — `storage/detach` on a running server: `device_del`
    /// and the WAIT for the guest's acknowledgement. `Err` is no ack (a kernel
    /// without PCI hot-plug), and the estate answers `511 HOTPLUG_FAILED` with
    /// the device still on. The default acknowledges on paper.
    fn detach(&self, _server: &str, _storage: &str, _address: &str) -> Result<(), String> {
        Ok(())
    }
    /// The bytes an upload session received, kept as that storage's medium.
    fn store_medium(&self, _storage: &str, _bytes: &[u8]) -> Result<(), String> {
        Ok(())
    }
    /// **The metadata service for a running server changed** (`PUT /server` `metadata`):
    /// `Some(user_data)` answered from now on, `None` no longer. An engine that serves no
    /// metadata ignores it.
    fn metadata(&self, _server: &str, _user_data: Option<&str>) {}
    /// **The server's firewall changed** (a rule set written, `firewall`
    /// switched): `admitted` is what [`admitted_public_ports`] now says. A
    /// running machine gets it live — each port no longer admitted stops
    /// answering on the public address, each newly admitted one starts — and a
    /// stopped one reads [`Machine::public_admitted`] at its next power-on.
    /// `Err` is a machine that could not be changed, said by name: the mock
    /// then no longer matches the provider for that server.
    fn firewall(&self, _server: &str, _admitted: &[(&'static str, u16)]) -> Result<(), String> {
        Ok(())
    }
    /// The server is being deleted: kill its machine and drop its records.
    fn forget_server(&self, _server: &str) {}
    /// The storage is gone from the account: delete its disk and medium.
    fn forget_storage(&self, _storage: &str) {}
    /// The whole estate is being replaced (`/mock/seed`): every machine and
    /// every disk goes with it, because nothing can refer to them any more.
    fn forget_all(&self) {}
    /// What the machine left behind, for `/mock/guest/{uuid}`: argv, frame
    /// hashes, stdout bytes, disk sizes. `None` from an engine with no machines.
    fn evidence(&self, _server: &str) -> Option<serde_json::Value> {
        None
    }
}

/// **The guest as a state machine, for the runs that are not about the guest.**
///
/// It does not boot anything, and it says so in its name. Its whole value is
/// that the 100 000-run storm is about the API and the money, and paying ten
/// seconds of real qemu per iteration would make the storm a three-week job. The
/// outcomes it produces are the ones the real engine produces, in the same
/// proportions, drawn from the run's seed.
pub struct VirtualGuest {
    pub install_ms: u64,
}

impl Default for VirtualGuest {
    fn default() -> Self {
        VirtualGuest { install_ms: 10_011 }
    }
}

impl GuestEngine for VirtualGuest {
    fn name(&self) -> &'static str {
        "virtual"
    }

    fn boot(&self, spec: &GuestSpec) -> GuestOutcome {
        match (spec.cdrom_first, &spec.medium) {
            // The CD is first AND a medium is in the drive: the installer runs,
            // finishes, and the next boot finds the CD first again.
            (true, Some(_)) => GuestOutcome::Looped { rounds: 2 },
            (_, _) => GuestOutcome::Installed {
                ms: self.install_ms,
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_cd_first_with_a_medium_in_it_is_the_loop() {
        let g = VirtualGuest::default();
        let mut s = GuestSpec::new("appliance", "/var/lib/mock/appliance.qcow2");
        s.medium = Some("/var/lib/mock/gunnar.iso".into());
        s.cdrom_first = true;
        assert!(matches!(g.boot(&s), GuestOutcome::Looped { .. }));
        // The eject is the cure: no medium, no loop, whatever the boot order.
        s.medium = None;
        assert!(matches!(g.boot(&s), GuestOutcome::Installed { .. }));
    }

    #[test]
    fn the_measured_install_is_ten_thousand_and_eleven_milliseconds() {
        let g = VirtualGuest::default();
        let s = GuestSpec::new("front", "/var/lib/mock/front.qcow2");
        assert_eq!(g.boot(&s), GuestOutcome::Installed { ms: 10_011 });
    }

    /// **A soft stop waits for the guest, up to the stop's own timeout in REAL seconds**
    /// (UpCloud: the ACPI button, then the guest's own power-off; a hard stop only after
    /// `timeout`). Until 2026-10-01 the mock read `stopped` and killed the guest
    /// `stop_soft_ms` of VIRTUAL time after the button — at `--speed 20000` well inside a
    /// second, so a front that stops cleanly (says so, syncs and freezes its disk, powers
    /// off) was hard-stopped mid-way and its next boot replayed the disk's log.
    #[test]
    fn a_soft_stop_waits_for_the_guest_until_its_timeout_and_only_then_kills() {
        use crate::{Clock, Estate, Faults};
        use std::sync::{Arc, Mutex};
        #[derive(Default)]
        struct M {
            alive: Mutex<Option<bool>>,
            hard_kills: Mutex<u32>,
        }
        impl GuestEngine for M {
            fn name(&self) -> &'static str {
                "soft-stop"
            }
            fn boot(&self, _: &GuestSpec) -> GuestOutcome {
                GuestOutcome::Installed { ms: 0 }
            }
            fn power_on(&self, _: &Machine) -> Result<(), String> {
                *self.alive.lock().unwrap() = Some(true);
                Ok(())
            }
            fn power_off(&self, _: &str, hard: bool) {
                // A kill is of a RUNNING machine; one already off is not killed again.
                if hard && *self.alive.lock().unwrap() == Some(true) {
                    *self.hard_kills.lock().unwrap() += 1;
                    *self.alive.lock().unwrap() = Some(false);
                }
            }
            fn running(&self, _: &str) -> Option<bool> {
                *self.alive.lock().unwrap()
            }
        }
        let m = Arc::new(M::default());
        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 9).with_engine(m.clone());
        let uuid = e
            .create_server("f", "f", "2xCPU-4GB", "se-sto1", vec![], "boot", 8)
            .unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "started");

        // The button, a 60 s timeout: the guest is still stopping, so it is not `stopped`.
        e.stop_server_within(&uuid, false, Some(60)).unwrap();
        e.run_to_quiet();
        assert_ne!(
            e.server(&uuid).unwrap().state,
            "stopped",
            "a guest still stopping is not stopped"
        );
        assert_eq!(
            *m.hard_kills.lock().unwrap(),
            0,
            "nothing killed inside the timeout"
        );
        // The guest powers itself off: now it is.
        *m.alive.lock().unwrap() = Some(false);
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped");

        // A timeout of 0: the button, then the kill at once (the provider's hard stop).
        e.start_server(&uuid).unwrap();
        e.run_to_quiet();
        e.stop_server_within(&uuid, false, Some(0)).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped");
        assert_eq!(*m.hard_kills.lock().unwrap(), 1);
    }

    /// **The estate drives its engine, and the server's state follows it.**
    /// A recording engine, no hypervisor: power-on at `started` with the disk
    /// sized from its record, a guest that powered itself off reads `stopped`,
    /// and a refused power-on never reads `started` at all.
    #[test]
    fn the_estate_boots_its_engine_and_follows_the_machine() {
        use crate::{Clock, Estate, Faults};
        use std::sync::{Arc, Mutex};

        #[derive(Default)]
        struct Rec {
            calls: Mutex<Vec<String>>,
            alive: Mutex<Option<bool>>,
            refuse: bool,
        }
        impl GuestEngine for Rec {
            fn name(&self) -> &'static str {
                "recording"
            }
            fn boot(&self, _: &GuestSpec) -> GuestOutcome {
                GuestOutcome::Installed { ms: 0 }
            }
            fn power_on(&self, m: &Machine) -> Result<(), String> {
                self.calls
                    .lock()
                    .unwrap()
                    .push(format!("on {} {}G", m.server, m.disks[0].size_gib));
                if self.refuse {
                    return Err("seabios: refused".into());
                }
                *self.alive.lock().unwrap() = Some(true);
                Ok(())
            }
            fn power_off(&self, server: &str, hard: bool) {
                self.calls
                    .lock()
                    .unwrap()
                    .push(format!("off {server} hard={hard}"));
                if hard {
                    *self.alive.lock().unwrap() = Some(false);
                }
            }
            fn running(&self, _: &str) -> Option<bool> {
                *self.alive.lock().unwrap()
            }
        }

        let rec = Arc::new(Rec::default());
        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 5).with_engine(rec.clone());
        let uuid = e
            .create_server("a", "a", "2xCPU-4GB", "se-sto1", vec![], "boot", 8)
            .unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "started");
        assert_eq!(rec.calls.lock().unwrap()[0], format!("on {uuid} 8G"));

        // The installer powers the box off: nobody called stop, and the API
        // must still say so — `poweroff_notice_ms` later (lane T13's timing
        // rule: the API runs behind the machine, never ahead of it).
        *rec.alive.lock().unwrap() = Some(false);
        e.settle();
        assert_eq!(e.server(&uuid).unwrap().state, "started", "not yet noticed");
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped");

        e.start_server(&uuid).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "started");
        e.stop_server(&uuid, true).unwrap();
        e.run_to_quiet();
        assert_eq!(e.server(&uuid).unwrap().state, "stopped");
        assert!(rec
            .calls
            .lock()
            .unwrap()
            .iter()
            .any(|c| c == &format!("off {uuid} hard=true")));

        let refusing = Arc::new(Rec {
            refuse: true,
            ..Rec::default()
        });
        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 6).with_engine(refusing);
        let uuid = e
            .create_server("b", "b", "2xCPU-4GB", "se-sto1", vec![], "boot", 8)
            .unwrap();
        e.run_to_quiet();
        assert_eq!(
            e.server(&uuid).unwrap().state,
            "stopped",
            "a machine that never started is not `started`"
        );
    }

    /// **The public forwards follow the firewall** (the gap LANE-C-NOTES named):
    /// a server laid with `firewall: on` and no rules is wide open, so all three
    /// installer ports answer; the terraform's rule set then writes a catch-all
    /// drop, and only the ports a rule above it admits for the operator stay.
    /// A rule scoped to another source does not admit the operator; switching
    /// the firewall off opens everything again. Each change reaches the engine,
    /// and the next power-on starts with exactly the admitted set.
    #[test]
    fn the_public_forwards_follow_the_servers_firewall() {
        use crate::estate::Rule;
        use crate::{Clock, Estate, Faults};
        use std::sync::{Arc, Mutex};

        #[derive(Default)]
        struct Fw {
            seen: Mutex<Vec<Vec<(&'static str, u16)>>>,
            booted: Mutex<Vec<Vec<(&'static str, u16)>>>,
        }
        impl GuestEngine for Fw {
            fn name(&self) -> &'static str {
                "firewall-recording"
            }
            fn boot(&self, _: &GuestSpec) -> GuestOutcome {
                GuestOutcome::Installed { ms: 0 }
            }
            fn power_on(&self, m: &Machine) -> Result<(), String> {
                self.booted.lock().unwrap().push(m.public_admitted.clone());
                Ok(())
            }
            fn firewall(&self, _: &str, admitted: &[(&'static str, u16)]) -> Result<(), String> {
                self.seen.lock().unwrap().push(admitted.to_vec());
                Ok(())
            }
        }
        let accept = |proto: &str, port: &str, src: &str| Rule {
            direction: "in".into(),
            action: "accept".into(),
            family: "IPv4".into(),
            protocol: proto.into(),
            source_address_start: src.into(),
            source_address_end: src.into(),
            destination_port_start: port.into(),
            destination_port_end: port.into(),
            ..Rule::default()
        };
        let drop_all = Rule {
            direction: "in".into(),
            action: "drop".into(),
            ..Rule::default()
        };

        let fw = Arc::new(Fw::default());
        let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 11)
            .with_public_on_loopback(80, 99)
            .with_engine(fw.clone());
        let uuid = e
            .create_server("main", "main", "2xCPU-4GB", "se-sto1", vec![], "boot", 8)
            .unwrap();
        e.configure_server(&uuid, vec![], None, true, false, "UTC")
            .unwrap();
        e.run_to_quiet();
        let all: Vec<(&str, u16)> = INSTALLER_PUBLIC_PORTS.to_vec();
        assert_eq!(fw.booted.lock().unwrap()[0], all, "on, no rules: wide open");

        // main's rules as lane B writes them: 8474 + 51821 from any, 51820 not
        // at all (it is the front's relay), a git port, and the catch-all.
        e.set_rules(
            &uuid,
            vec![
                accept("tcp", "2222", ""),
                accept("tcp", "8474", ""),
                accept("udp", "51821", ""),
                drop_all.clone(),
            ],
        )
        .unwrap();
        assert_eq!(
            fw.seen.lock().unwrap().last().unwrap(),
            &vec![("tcp", 8474), ("udp", 51821)]
        );

        // A rule for another source does not admit the operator; nothing at all is admitted.
        e.set_rules(
            &uuid,
            vec![accept("udp", "51821", "10.13.8.99"), drop_all.clone()],
        )
        .unwrap();
        assert_eq!(
            fw.seen.lock().unwrap().last().unwrap(),
            &Vec::<(&str, u16)>::new()
        );

        // A rule AFTER the drop is dead.
        e.set_rules(&uuid, vec![drop_all.clone(), accept("udp", "51821", "")])
            .unwrap();
        assert!(fw.seen.lock().unwrap().last().unwrap().is_empty());

        // The next power-on starts with what the rules admit now.
        e.set_rules(&uuid, vec![accept("udp", "51821", ""), drop_all.clone()])
            .unwrap();
        e.stop_server(&uuid, true).unwrap();
        e.run_to_quiet();
        e.start_server(&uuid).unwrap();
        e.run_to_quiet();
        assert_eq!(
            fw.booted.lock().unwrap().last().unwrap(),
            &vec![("udp", 51821)]
        );

        // DEV-NARRATE (2026-09-30): udp/51822, the sealed early-boot narration,
        // is served ONLY where a rule admits it — a DEV estate's terraform
        // writes that rule, a release estate's never does, and then the port is
        // closed on the mock exactly as on UpCloud.
        e.set_rules(
            &uuid,
            vec![
                accept("udp", "51821", ""),
                accept("udp", "51822", ""),
                drop_all.clone(),
            ],
        )
        .unwrap();
        assert_eq!(
            fw.seen.lock().unwrap().last().unwrap(),
            &vec![("udp", 51821), ("udp", 51822)],
            "a dev estate's rule opens the narration port"
        );
        e.set_rules(&uuid, vec![accept("udp", "51821", ""), drop_all.clone()])
            .unwrap();
        assert!(
            !fw.seen
                .lock()
                .unwrap()
                .last()
                .unwrap()
                .contains(&("udp", 51822)),
            "a release estate's rules leave it closed"
        );

        // Firewall off: everything, whatever the rules say.
        e.configure_server(&uuid, vec![], None, false, false, "UTC")
            .unwrap();
        assert_eq!(fw.seen.lock().unwrap().last().unwrap(), &all);
    }

    /// ★ **A server laid with no public interface has no public address** (owner 2026-10-23, the
    /// lean estate: main and the twin sit behind the front on the utility network only). The API
    /// reports a utility IPv4 alone, nothing is served on a public address, and the engine is
    /// handed a machine with no public IP (so the guest is given no public NIC at all).
    #[test]
    fn a_server_with_no_public_interface_has_no_public_address() {
        use crate::{Clock, Estate, Faults};
        let mut e =
            Estate::new(Clock::virtual_only(), Faults::none(), 12).with_public_on_loopback(80, 99);
        let uuid = e
            .create_server(
                "twin",
                "twin",
                "STARTER-1xCPU-1GB",
                "se-sto1",
                vec![],
                "boot",
                10,
            )
            .unwrap();
        e.configure_server(
            &uuid,
            vec![crate::estate::Iface {
                index: 1,
                kind: "utility".into(),
                family: "IPv4".into(),
            }],
            None,
            true,
            false,
            "UTC",
        )
        .unwrap();
        let s = e.server(&uuid).unwrap();
        assert!(s.public_ip.is_empty(), "no public address: {}", s.public_ip);
        assert!(!s.utility_ip.is_empty());
        let v = crate::render::server(s, true, false);
        let rows: Vec<&str> = v["ip_addresses"]["ip_address"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|a| a["access"].as_str())
            .collect();
        assert_eq!(
            rows,
            vec!["utility"],
            "the API reports the utility address alone: {v}"
        );
        // A server that names its public interface keeps it.
        let main = e
            .create_server(
                "main",
                "main",
                "STARTER-1xCPU-2GB",
                "se-sto1",
                vec![],
                "boot",
                10,
            )
            .unwrap();
        e.configure_server(
            &main,
            crate::estate::Iface::default_pair(),
            None,
            true,
            false,
            "UTC",
        )
        .unwrap();
        assert!(!e.server(&main).unwrap().public_ip.is_empty());
    }

    /// ★ **The front's downstream WireGuard interface is a public port** (lane OX-WGDS3, holger
    /// `via::HUB_UDP_PORT`): a WireGuard downstream dials `<front public>:51830`/udp, so the mock
    /// serves it as UpCloud does, and with no rules (the shop's lay writes none) it is admitted.
    #[test]
    fn the_fronts_downstream_wireguard_port_is_served_on_its_public_address() {
        assert!(
            INSTALLER_PUBLIC_PORTS.contains(&("udp", 51830)),
            "{INSTALLER_PUBLIC_PORTS:?}"
        );
        assert!(
            admitted_public_ports(true, &[]).contains(&("udp", 51830)),
            "no rules: admitted"
        );
        assert!(
            admitted_public_ports(false, &[]).contains(&("udp", 51830)),
            "firewall off: admitted"
        );
    }

    /// The default skew is the measured one. A mock that booted its guests at
    /// the right time would make gunnar-clock look unnecessary.
    #[test]
    fn a_guest_is_two_hours_ahead() {
        assert_eq!(GuestSpec::new("x", "y").rtc_skew_seconds, 7200);
    }
}