crates/mock-upcloud/kvm-guest/src/lib.rs
//! **`DraupnirGuest` — the real KVM behind the mock's `GuestEngine` seam.**
//!
//! [`mock_upcloud::kvm`] says what a faithful guest must be and why: a BIOS box
//! with a screen and no serial console, two hours ahead, whose DNS queries are
//! answered by nobody and whose CD stays first until someone ejects it. This
//! crate is the engine that really boots it, through draupnir — the law's door
//! to a hypervisor. Nothing here starts a qemu behind draupnir's back.
//!
//! # The refusals are the point
//!
//! A `GuestEngine` that cannot honour a clause must REFUSE IT BY NAME rather
//! than quietly boot without it, because a guest that silently got a serial
//! console would make behaviour 13 untestable *while reporting that it had been
//! tested* — the always-yes defect this estate has found four times.
//!
//! So [`DraupnirGuest::boot`] does not assume draupnir did what it was asked.
//! It renders the argv, **reads it back**, and checks every clause against it
//! ([`preflight`]). A missing clause is a [`GuestOutcome::Refused`] carrying that
//! clause's name — `no-serial-console`, `seabios`, `framebuffer`, `rtc-skew`,
//! `no-inbound-udp`, `cd-first`, `machine-pc` — and no QEMU is started. The two the brief
//! names, `no-serial-console` and `seabios`, are the ones a tunnr-shaped guest
//! could never honour at all, and they are checked as NEGATIVES (`mon:stdio` and
//! `pflash` absent) because those are the spellings that would mean the machine
//! silently reverted.
//!
//! # What a verdict is made of, given there is no log
//!
//! There is no serial console, so there is nothing to grep. The evidence is:
//!
//! * **the qcow2's size on disk** — an install writes, and a guest that wrote
//! did something;
//! * **two screendumps hashed** — different means the guest progressed, equal
//! means it stopped;
//! * **whether the QEMU is still alive** — `-no-reboot` means a guest that
//! powered itself off is gone.
//!
//! Each [`GuestOutcome`] below says which of those three produced it. Nothing
//! here returns the guest's output, because the guest has none.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use draupnir::qemu::{self, QemuGuest};
use draupnir::{BootSpec, Rtc};
use mock_upcloud::digest::sha256_hex;
use mock_upcloud::kvm::{GuestEngine, GuestOutcome, GuestSpec, Machine};
use serde_json::{json, Value};
/// **The longest path a Unix socket can have on Linux**: `sun_path` is 108
/// bytes and one of them is the NUL. QEMU given a longer `-qmp unix:` path
/// still starts the guest, but nothing can connect to its QMP — so every
/// screendump fails, the frames stay empty, and a working guest reads exactly
/// like a hung one. That is why a root that is too long is REFUSED, by name.
pub const UNIX_SOCKET_PATH_MAX: usize = 107;
/// The QMP socket path under `root` for one server's boot `n`.
fn qmp_path(root: &Path, server: &str, n: usize) -> PathBuf {
root.join("servers").join(server).join(format!("boot-{n}")).join("qmp.sock")
}
/// The longest QMP socket path a root can produce: a 36-character server uuid
/// and a four-digit boot number.
pub fn longest_qmp_path(root: &Path) -> usize {
qmp_path(root, &"x".repeat(36), 9999).as_os_str().len()
}
/// A socket path that is too long, refused by name with the limit and the
/// length — never discovered later as an empty screen.
fn refuse_long_socket(path: &Path) -> Option<String> {
let n = path.as_os_str().len();
(n > UNIX_SOCKET_PATH_MAX).then(|| {
format!(
"qmp-socket-path: {} is {n} bytes and a Unix socket path is at most \
{UNIX_SOCKET_PATH_MAX}; QEMU would start with a QMP nobody can reach, and the \
guest's screen would read as a hang",
path.display()
)
})
}
/// `progressed`, from what the sampler saw. `None` — "unknown", never `false` —
/// when no frame was ever read and QMP failed: an unreadable screen is not a
/// frozen one.
pub fn progressed_verdict(frames: usize, qmp_failures: u64) -> Option<bool> {
if frames == 0 && qmp_failures > 0 {
None
} else {
Some(frames >= PROGRESS_FRAMES)
}
}
/// How many never-seen-before frames it takes to call a guest "progressing".
/// A blinking cursor on a frozen screen is two, so two proves nothing.
pub const PROGRESS_FRAMES: usize = 3;
/// The clauses a faithful guest must honour, by the names a refusal uses.
pub const CLAUSES: &[&str] = &[
"no-serial-console",
"seabios",
"framebuffer",
"rtc-skew",
"no-inbound-udp",
"cd-first",
"machine-pc",
];
/// A real QEMU, through draupnir, on the machine shape UpCloud hands out.
pub struct DraupnirGuest {
/// Where each guest's workdir, screendumps and captured streams go.
pub root: PathBuf,
/// How long an install is given before the guest is judged on what it did.
pub install_budget: Duration,
/// How often the framebuffer is photographed, from the moment the guest
/// starts. Every frame that differs from the one before it is kept, so
/// "progressed" means at least two DISTINCT frames were seen. It is a
/// sampling interval rather than one fixed gap because under KVM the whole
/// install (SeaBIOS, kernel, write, power-off) took 2.2 s on t14s: a first
/// frame at 3 s saw nothing at all.
pub frame_gap: Duration,
/// The `-vnc` display this engine serves its guests on, e.g. `127.0.0.1:91`.
/// Loopback only, and never the estate's: a rig that could be reached from
/// the network is a rig that can be reached by something real.
pub vnc: Option<String>,
/// **The machines behind the mock's servers**, one per server uuid. Empty
/// for the one-shot [`DraupnirGuest::run`]; filled by the lifecycle calls
/// the estate makes through [`GuestEngine`]. See [`DraupnirGuest::for_estate`].
pub fleet: Mutex<Fleet>,
/// **The chipset.** `Pc` (i440fx, the cdrom on PIIX IDE) is what UpCloud
/// hands out (ledger L46, L49), and the default. `Q35` only when a test
/// asks for it by name (`--machine q35`); the clause `machine-pc` then
/// stands down, because the caller chose the other machine on purpose.
pub machine: draupnir::MachineType,
/// **The mTLS door** (`--mtls`): also forward the guest's eth1 `:50052` — the primary's
/// mutual-TLS-1.3-only listener (gunnar `agent/wg-mtls` 3521a39) — on 127.0.0.1, next to
/// 50051. OFF by default: every guest keeps exactly the argv it had unless a caller asks.
pub mtls: bool,
/// **Hot-plug (`--hotplug`)**: the data disks ride explicit `-blockdev`
/// nodes behind their own `virtio-blk-pci` devices, so the estate's
/// `storage/detach` and `storage/attach` on a RUNNING server really unplug
/// (`device_del`, waited for the guest's ack) and re-plug (`device_add`)
/// the card, and a resize of a detached member is QMP `block_resize` on
/// its surviving node — the shape UpCloud has and a data-set growth needs
/// (T21r, 2026-09-22). OFF by default: without it the disks are the
/// implicit `-drive if=virtio` they always were, attach/detach are records
/// and a disk under a running machine is never grown — the guest that did
/// not ask for hot-plug keeps exactly the machine it had.
pub hotplug: bool,
/// **The estate's ONE utility L2** (`--utility-fabric`): every guest's eth1 on one
/// `dgram` multicast group, with the MAC the API reports, and
/// [`mock_upcloud::fabric::Fabric`] answering its DHCP with the API's address. As on
/// UpCloud, the door sees each box's own utility address and the boxes dial each other
/// directly. `None` (the default): each guest's own slirp, exactly as before.
pub utility: Option<(std::sync::Arc<Mutex<mock_upcloud::fabric::Fabric>>, std::net::SocketAddrV4)>,
/// **Template boots** (`--template-image`): a boot disk cloned from a
/// public template (the front's Ubuntu) is booted as that image, with the
/// create's keys and hostname. `None`: a template clone has no image and is
/// refused by name at power-on, never booted as a blank disk.
pub templates: Option<Templates>,
/// **No way out** (`--offline`, 2026-09-26): every user-mode NIC is `restrict=on` — the
/// guest reaches nothing but what is forwarded to it and the relays in [`Offline`]. `None`
/// (the default): each guest's slirp NATs to the internet, as before.
pub offline: Option<Offline>,
/// **On-prem** (`--lan-publish`): TCP ports of ONE named server served on this
/// machine's LAN address, so a home router that forwards 80/443/2222 to this
/// machine reaches the front as the internet reaches it on UpCloud. Empty (the
/// default): nothing is served beyond loopback, exactly as before.
pub lan_publish: Vec<LanPublish>,
/// **The host's own caps** (`--max-cores`, `--max-mem-mb`, round 5.3.1): a
/// guest gets at most this many cores and MiB, whatever its plan says — so
/// a busy host (or a shared box's lane) runs the estate's plans without
/// pinning. The API still reports the PLAN, as UpCloud does: the cap is the
/// test host's, not the provider's. `None` (the default): the plan, exactly.
pub max_cores: Option<u32>,
pub max_mem_mb: Option<u32>,
}
/// One `--lan-publish` forward: `bind:host_port` on this machine to `guest_port` of the
/// server whose hostname is `hostname`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanPublish {
pub hostname: String,
pub bind: std::net::Ipv4Addr,
pub host_port: u16,
pub guest_port: u16,
}
/// Parse `HOSTNAME=BIND:HOSTPORT-GUESTPORT[,BIND:HOSTPORT-GUESTPORT…]`, refusing by name.
/// A loopback bind is refused: that is `--public-on-loopback`'s job, and a LAN
/// publish that reaches nobody is a publish that proves nothing.
pub fn parse_lan_publish(v: &str) -> Result<Vec<LanPublish>, String> {
let (hostname, rest) = v.split_once('=').ok_or("--lan-publish wants HOSTNAME=BIND:HOSTPORT-GUESTPORT[,...]")?;
if hostname.is_empty() {
return Err("--lan-publish: the server's hostname is empty".into());
}
let mut out = Vec::new();
for f in rest.split(',').filter(|s| !s.is_empty()) {
let (hostpart, guest) = f.split_once('-').ok_or_else(|| format!("--lan-publish `{f}`: BIND:HOSTPORT-GUESTPORT"))?;
let (bind, hp) = hostpart.rsplit_once(':').ok_or_else(|| format!("--lan-publish `{f}`: BIND:HOSTPORT-GUESTPORT"))?;
let bind: std::net::Ipv4Addr = bind.parse().map_err(|_| format!("--lan-publish `{f}`: `{bind}` is not an IPv4 address"))?;
if bind.is_loopback() {
return Err(format!("--lan-publish `{f}`: {bind} is loopback; a LAN publish is for the router to reach"));
}
let host_port: u16 = hp.parse().map_err(|_| format!("--lan-publish `{f}`: `{hp}` is not a port"))?;
let guest_port: u16 = guest.parse().map_err(|_| format!("--lan-publish `{f}`: `{guest}` is not a port"))?;
out.push(LanPublish { hostname: hostname.to_string(), bind, host_port, guest_port });
}
if out.is_empty() {
return Err("--lan-publish names no forward".into());
}
Ok(out)
}
/// Pure: the slirp `hostfwd` rules (`tcp:BIND:HP-:GP`) `lan` publishes for the server
/// called `hostname`; none for any other server.
pub fn lan_forwards(lan: &[LanPublish], hostname: &str) -> Vec<String> {
lan.iter().filter(|l| l.hostname == hostname).map(|l| format!("tcp:{}:{}-:{}", l.bind, l.host_port, l.guest_port)).collect()
}
/// **What an offline guest may still reach**: the listed ports of THIS machine's loopback,
/// each at [`OFFLINE_HOST`]`:<port>` inside the guest, through a `guestfwd` whose command is
/// this program's own `--relay 127.0.0.1:<port>` (one relay per connection). The NoCloud
/// listener of a template boot is added by the engine.
///
/// MEASURED 2026-09-26 (loki, gunnar bench run 8 retry): the three laid VMs' first boot of the
/// Ubuntu template reached Canonical (185.125.188.x/189.x:443) through slirp's NAT. The owner's
/// rule: a mock run leaves this machine for nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Offline {
/// The program QEMU runs per connection (`<relay> --relay 127.0.0.1:<port>`).
pub relay: PathBuf,
/// Loopback ports an offline guest reaches at [`OFFLINE_HOST`].
pub ports: Vec<u16>,
}
/// **The relay an offline guest's `guestfwd` runs**, one process per connection: this
/// process's stdin to one TCP connection to `to` (loopback), and that connection to stdout,
/// until either side ends.
pub fn relay_stdio(to: &str) -> Result<(), String> {
relay(std::io::stdin(), std::io::stdout(), to)
}
/// [`relay_stdio`] over any two ends (the test drives it with pipes).
pub fn relay<R: std::io::Read + Send + 'static, W: std::io::Write>(mut from: R, mut into: W, to: &str) -> Result<(), String> {
let addr: std::net::SocketAddr = to.parse().map_err(|e| format!("--relay {to}: {e}"))?;
if !addr.ip().is_loopback() {
return Err(format!("--relay {to}: an offline guest is relayed to this machine's loopback only"));
}
let tcp = std::net::TcpStream::connect(addr).map_err(|e| format!("--relay {to}: {e}"))?;
let mut up = tcp.try_clone().map_err(|e| e.to_string())?;
std::thread::spawn(move || {
let _ = std::io::copy(&mut from, &mut up);
let _ = up.shutdown(std::net::Shutdown::Write);
});
let mut down = tcp;
let mut buf = [0u8; 16 * 1024];
loop {
match std::io::Read::read(&mut down, &mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if into.write_all(&buf[..n]).and_then(|_| into.flush()).is_err() {
break;
}
}
}
}
Ok(())
}
/// The file in a server's directory its `user_data` is kept in: the utility fabric answers it
/// at `169.254.169.254:80` while it stands (`mock_upcloud::fabric::Fabric::serve_metadata`).
pub const USER_DATA_FILE: &str = "user-data";
/// A server's `user_data` into its file, 0600 (it is a pairing seed).
fn write_user_data(file: &std::path::Path, user_data: &str) -> Result<(), String> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
if let Some(d) = file.parent() {
std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
}
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(file).map_err(|e| format!("{}: {e}", file.display()))?;
f.write_all(user_data.as_bytes()).map_err(|e| format!("{}: {e}", file.display()))
}
/// The marker in a server's directory: it has booted a medium at least once.
pub const MEDIUM_BOOTED: &str = "medium-booted";
/// **Whether an offline boot is sealed** (`restrict=on`), pure: only a template clone that
/// boots neither a medium nor a disk a medium already wrote — the Ubuntu image itself.
pub fn template_seal(template: bool, medium_now: bool, medium_before: bool) -> bool {
template && !medium_now && !medium_before
}
/// The address an offline guest reaches this machine's relayed ports at. Not `10.0.2.2`:
/// slirp refuses a `guestfwd` on its host alias, and `restrict=on` drops every other
/// connection to it.
pub const OFFLINE_HOST: &str = "10.0.2.100";
/// **One user-mode `-netdev` made offline**, pure: `restrict=on` when `restrict`, and on `n0`
/// a `guestfwd` per port to the relay. Any other argument is returned as it was.
///
/// `restrict` is for a TEMPLATE boot (the Ubuntu image, which phones Canonical) and not for a
/// MEDIUM boot: slirp's `restrict=on` drops EVERY datagram a guest sends, the replies to a UDP
/// hostfwd included, so the installer's WireGuard link (udp/51821) never handshakes.
/// MEASURED loki 2026-09-26 (bench run 8 retry 2): all three VMs installed and booted from
/// disk, and the installer saw "no WireGuard handshake yet" for minutes. A medium boot keeps an
/// unrestricted eth0 with the relays, and the run's socket recorder is the proof that it
/// reaches nothing away from this machine.
pub fn offline_netdev(netdev: &str, o: &Offline, extra_ports: &[u16], restrict: bool) -> String {
if !netdev.starts_with("user,") {
return netdev.to_string();
}
let mut n = if restrict { format!("{netdev},restrict=on") } else { netdev.to_string() };
if netdev.starts_with("user,id=n0") {
let mut ports: Vec<u16> = o.ports.iter().chain(extra_ports).copied().collect();
ports.sort_unstable();
ports.dedup();
for p in ports {
n.push_str(&format!(",guestfwd=tcp:{OFFLINE_HOST}:{p}-cmd:{} --relay 127.0.0.1:{p}", o.relay.display()));
}
}
n
}
/// The template images this engine can boot, and the metadata their first
/// boot asks for.
///
/// **The one deliberate difference from UpCloud, and it is the front's first
/// boot only — never the installer's.** UpCloud's Ubuntu reads its metadata at
/// `169.254.169.254`. A user-mode NIC can forward that address only to ONE
/// chardev connection, which cloud-init's several requests cannot share. So
/// the mock names its own metadata to cloud-init as NoCloud over HTTP — the
/// SMBIOS serial `ds=nocloud;s=http://10.0.2.2:<port>/nocloud/<server>/` — and
/// serves the same facts: the hostname, and the keys `login_user` named.
pub struct Templates {
/// Title prefix → the image a clone of that template boots.
images: Vec<(String, PathBuf)>,
/// Where the NoCloud listener is, on the host's loopback (`10.0.2.2` to a guest).
port: u16,
/// Server uuid → (hostname, keys).
meta: std::sync::Arc<Mutex<BTreeMap<String, NoCloudMeta>>>,
}
impl Templates {
fn image_for(&self, title: &str) -> Option<&PathBuf> {
self.images.iter().find(|(p, _)| title.starts_with(p.as_str())).map(|(_, i)| i)
}
}
/// **A qcow2 overlay of `backing`**, at least `size_bytes` large: the disk a
/// template clone is, without copying the template. draupnir's writer makes
/// the empty image; this adds the two header fields and the backing-format
/// extension (`qcow2`) that point it at `backing`, read-only.
pub fn create_overlay(path: &Path, size_bytes: u64, backing: &Path) -> Result<(), String> {
use std::io::{Seek, SeekFrom, Write};
let backing = backing.canonicalize().map_err(|e| format!("{}: {e}", backing.display()))?;
let virt = size_bytes.max(qemu::qcow2_virtual_size(&backing).map_err(|e| e.to_string())?);
qemu::create_qcow2(path, virt).map_err(|e| e.to_string())?;
let name = backing.display().to_string();
// Header extensions begin at header_length (104): the backing format, then
// the end marker. The backing file name follows them.
let mut ext = Vec::new();
ext.extend_from_slice(&0xe279_2acau32.to_be_bytes());
ext.extend_from_slice(&5u32.to_be_bytes());
ext.extend_from_slice(b"qcow2\0\0\0");
ext.extend_from_slice(&[0u8; 8]);
let name_off = 104 + ext.len() as u64;
if name_off as usize + name.len() > 65_536 {
return Err(format!("overlay: backing path too long: {name}"));
}
let mut f = std::fs::OpenOptions::new().write(true).open(path).map_err(|e| e.to_string())?;
let mut put = |off: u64, b: &[u8]| -> Result<(), String> {
f.seek(SeekFrom::Start(off)).map_err(|e| e.to_string())?;
f.write_all(b).map_err(|e| e.to_string())
};
put(104, &ext)?;
put(name_off, name.as_bytes())?;
put(8, &name_off.to_be_bytes())?;
put(16, &(name.len() as u32).to_be_bytes())
}
/// What one template boot is told: what the create said about it.
#[derive(Clone, Debug)]
pub struct NoCloudMeta {
pub hostname: String,
pub keys: Vec<String>,
/// The utility NIC, by the MAC the API reports.
pub utility_mac: String,
}
/// The NoCloud files for one server.
///
/// **The utility NIC is brought up by user-data, not by `network-config`.**
/// NoCloud over HTTP is read at cloud-init's NETWORK stage, after the network
/// is already configured, so its `network-config` is never applied (measured
/// 2026-09-24: `ens4`, the utility NIC, stayed DOWN and only the first NIC got
/// DHCP). UpCloud's own datasource configures both NICs at the local stage;
/// here a netplan file for the utility MAC is written and applied instead,
/// which ends in the same machine: both NICs by DHCP.
fn nocloud_file(uuid: &str, m: &NoCloudMeta, file: &str) -> Option<String> {
match file {
"meta-data" => Some(format!("instance-id: {uuid}\nlocal-hostname: {}\n", m.hostname)),
"user-data" => {
let mut u = String::from("#cloud-config\ndisable_root: false\nssh_pwauth: false\nssh_authorized_keys:\n");
for k in &m.keys {
u.push_str(&format!(" - {}\n", k.trim()));
}
u.push_str(&format!(
"write_files:\n - path: /etc/netplan/60-utility.yaml\n permissions: '0600'\n content: |\n network:\n version: 2\n ethernets:\n utility:\n match:\n macaddress: \"{}\"\n dhcp4: true\nruncmd:\n - [netplan, apply]\n",
m.utility_mac
));
Some(u)
}
"vendor-data" => Some(String::new()),
_ => None,
}
}
fn serve_nocloud(listener: std::net::TcpListener, meta: std::sync::Arc<Mutex<BTreeMap<String, NoCloudMeta>>>) {
use std::io::{BufRead, BufReader, Write};
for conn in listener.incoming().flatten() {
let meta = meta.clone();
std::thread::spawn(move || {
let mut line = String::new();
let mut r = BufReader::new(&conn);
if r.read_line(&mut line).is_err() {
return;
}
// Drain the headers.
let mut h = String::new();
while r.read_line(&mut h).map(|n| n > 2).unwrap_or(false) {
h.clear();
}
let path = line.split_whitespace().nth(1).unwrap_or("");
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
let body = match parts.as_slice() {
["nocloud", uuid, file] => meta
.lock()
.ok()
.and_then(|m| m.get(*uuid).cloned())
.and_then(|m| nocloud_file(uuid, &m, file)),
_ => None,
};
let mut w = &conn;
let _ = match body {
Some(b) => write!(w, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()),
None => write!(w, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"),
};
});
}
}
impl Default for DraupnirGuest {
fn default() -> Self {
DraupnirGuest {
root: std::env::temp_dir().join("draupnir-guest"),
// The measured install is 10 011 ms under KVM; the budget is the
// window a guest gets to do it in, not a claim about how long it takes.
install_budget: Duration::from_secs(90),
frame_gap: Duration::from_millis(200),
vnc: Some("127.0.0.1:91".to_string()),
fleet: Mutex::new(Fleet::default()),
machine: draupnir::MachineType::Pc,
mtls: false,
hotplug: false,
utility: None,
templates: None,
offline: None,
lan_publish: Vec::new(),
max_cores: None,
max_mem_mb: None,
}
}
}
/// What a guest run left behind — the only evidence there is, kept so a caller
/// can quote it instead of a log that does not exist.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Evidence {
pub argv: Vec<String>,
pub workdir: PathBuf,
/// sha256 of each screendump never seen before in this run, in the order
/// first seen. A cursor blink adds at most two, however long it blinks.
pub frames: Vec<String>,
/// The qcow2's size on disk before and after.
pub disk_bytes: (u64, u64),
/// Bytes the guest put on the host's stdout. On this machine shape it is 0,
/// and that number IS behaviour 13.
pub stdout_bytes: u64,
pub outcome: GuestOutcome,
}
impl DraupnirGuest {
/// The draupnir spec for one mock [`GuestSpec`] — the whole machine shape in
/// one place, so the clause table has exactly one rendering.
pub fn spec_for(&self, spec: &GuestSpec) -> BootSpec {
let mut b = BootSpec::kvm_bootloader_disk(&spec.name, &spec.disk);
b.mem_mb = spec.mem_mb;
b.cores = spec.cores;
if let Some(medium) = &spec.medium {
b.medium = Some(medium.clone());
}
let mut b = b.cloud_machine_shape(self.vnc.clone());
b.machine = self.machine;
// The mock decides the skew and the boot order; the shape supplies the
// rest. `cdrom_first` false with a medium in the tray is the ejected-CD
// case only if the medium is gone, so the order is taken verbatim.
b.rtc = Rtc::SkewedBy {
seconds: spec.rtc_skew_seconds,
};
if spec.medium.is_some() && !spec.cdrom_first {
b.boot_order = draupnir::BootOrder::Auto;
}
// `disk,cdrom` (the blank-disk install): the disk first, and no `-no-reboot` —
// the installer's reboot resets the machine in place, as the cloud does.
if spec.medium.is_some() && spec.disk_first {
b.boot_order = draupnir::BootOrder::DiskThenMedium;
}
b
}
/// **Check the rendered argv against the clause table, before any guest runs.**
///
/// Returns the offending clause's name and why, or `None` when the machine
/// really is the machine. This reads the ARGV rather than the spec on
/// purpose: the spec is what was asked for, the argv is what will happen.
pub fn preflight(&self, spec: &GuestSpec, argv: &[String]) -> Option<(&'static str, String)> {
let j = argv.join(" ");
if !j.contains("-serial none") || j.contains("mon:stdio") {
return Some((
"no-serial-console",
format!(
"the argv gives this guest a serial console, so a PID-1 panic would reach \
the host and behaviour 13 could not be observed: {j}"
),
));
}
if j.contains("pflash") || j.contains("OVMF") {
return Some((
"seabios",
format!(
"the argv boots UEFI (pflash), and the BIOS path is the one that breaks \
on UpCloud — a proof on OVMF proves the wrong machine: {j}"
),
));
}
if !j.contains("-vga std") || j.contains("-nographic") {
return Some((
"framebuffer",
format!("no framebuffer: the only surface a PID-1 panic appears on is gone: {j}"),
));
}
if !j.contains("-rtc base=") {
return Some((
"rtc-skew",
"the guest boots at the right time, which is the one thing every yvra VM does \
not do — gunnar-clock exists for this skew"
.to_string(),
));
}
// The front (a template boot) resolves: its apt works on UpCloud. The
// DNS-to-nowhere is the gunnar boxes' clause, and inbound UDP is
// refused to every guest alike.
// A guest with no user-mode NIC (the lean estate's main and twin: the utility fabric
// alone) has nothing that could answer its DNS or carry a UDP reply: behaviour 18 holds.
let user_nic = j.contains("user,id=");
if (user_nic && !j.contains("dns=") && spec.template.is_none()) || !udp_forwards_are_wireguard_only(&j) {
return Some((
"no-inbound-udp",
format!(
"the guest's UDP replies would arrive, so DNS and NTP would work and \
behaviour 18 could not happen: {j}"
),
));
}
if spec.cdrom_first && spec.medium.is_some() && !j.contains("-boot order=dc") {
return Some((
"cd-first",
format!(
"the CD is supposed to be first while it is inserted; without it the \
installer loop (behaviour 17) cannot happen: {j}"
),
));
}
// L46: i440fx, so the cdrom is a PIIX IDE drive. On q35 it rides AHCI,
// and a kernel that reads one may not read the other.
if self.machine == draupnir::MachineType::Pc && !j.starts_with("-machine pc,") {
return Some((
"machine-pc",
format!(
"the argv is not the i440fx machine UpCloud hands out (`-machine pc`, the cdrom on \
IDE): a medium proven here would be proven on the wrong chipset: {j}"
),
));
}
None
}
/// Boot the guest and keep everything it left behind.
pub fn run(&self, spec: &GuestSpec) -> Evidence {
let workdir = self.root.join(&spec.name);
if let Some(why) = refuse_long_socket(&workdir.join("qmp.sock")) {
return Evidence {
argv: Vec::new(),
workdir,
frames: Vec::new(),
disk_bytes: (0, 0),
stdout_bytes: 0,
outcome: GuestOutcome::Refused { clause: "qmp-socket-path", why },
};
}
let boot = self.spec_for(spec);
let argv = match qemu::argv(&boot, None, Some(&workdir.join("qmp.sock"))) {
Ok(a) => a,
Err(e) => {
return Evidence {
argv: Vec::new(),
workdir,
frames: Vec::new(),
disk_bytes: (0, 0),
stdout_bytes: 0,
outcome: GuestOutcome::Refused {
clause: "spec",
why: e.to_string(),
},
}
}
};
if let Some((clause, why)) = self.preflight(spec, &argv) {
return Evidence {
argv,
workdir,
frames: Vec::new(),
disk_bytes: (0, 0),
stdout_bytes: 0,
outcome: GuestOutcome::Refused { clause, why },
};
}
let disk = Path::new(&spec.disk);
let before = file_len(disk);
let mut guest = match QemuGuest::start(&boot, &workdir) {
Ok(g) => g,
Err(e) => {
return Evidence {
argv,
workdir,
frames: Vec::new(),
disk_bytes: (before, before),
stdout_bytes: 0,
// A host that cannot run the machine is REFUSED by the name of
// the thing it cannot do, never reported as a guest that failed.
outcome: GuestOutcome::Refused {
clause: "kvm-host",
why: e.to_string(),
},
}
}
};
let started = Instant::now();
let mut frames: Vec<String> = Vec::new();
let deadline = started + self.install_budget;
let mut exited_at: Option<Duration> = None;
// Keep at most this many distinct frames: enough to show a narration,
// bounded so a guest animating a spinner cannot fill the disk.
const MAX_FRAMES: usize = 64;
while Instant::now() < deadline {
if !guest.is_running() {
exited_at = Some(started.elapsed());
break;
}
if frames.len() < MAX_FRAMES {
// Written to frame-{n}.ppm where n is the next distinct slot, so
// an unchanged screen overwrites its own file and every kept
// hash has its picture beside it.
if let Some(h) = self.frame_hash(&guest, frames.len()) {
if !frames.contains(&h) {
frames.push(h);
}
}
}
std::thread::sleep(self.frame_gap);
}
let stdout_bytes = guest.stdout_bytes();
let after = file_len(disk);
// THREE, not two: a frozen text screen with a blinking cursor is two
// images, so "two frames differ" is true of a dead PID 1 too. Measured
// on the installed system's panic screen, which alternated between
// exactly two hashes for twenty seconds.
let progressed = frames.len() >= PROGRESS_FRAMES;
let wrote = after > before;
let outcome = match (exited_at, wrote, progressed) {
// The guest powered itself off (`-no-reboot`) and the disk grew: the
// installer ran and the box came down. The ms is measured, not a
// constant — there is nothing else to measure it with.
(Some(d), true, _) => GuestOutcome::Installed {
ms: d.as_millis() as u64,
},
// Still running at the budget, having written, with the CD still
// first: it installed and the firmware went straight back to the CD.
// `rounds: 2` means "at least twice" — nothing outside the guest can
// count them, and inventing a number would be the lie this whole
// half exists to avoid.
(None, true, _) if spec.cdrom_first && spec.medium.is_some() => {
GuestOutcome::Looped { rounds: 2 }
}
// Alive, wrote nothing, and the screen stopped changing. There is no
// log to quote, so the frame IS the evidence.
(None, false, false) => GuestOutcome::Panicked {
frame_sha256: frames.last().cloned().unwrap_or_default(),
},
// Alive and still moving at the budget: it ran and did not finish in
// the window it was given. Refused by name rather than called either
// an install or a panic — both would be a guess.
(None, _, true) => GuestOutcome::Refused {
clause: "install-budget",
why: format!(
"the guest was still progressing after {:?}: frames {:?} differ and the \
disk grew by {} bytes, which is neither an install that finished nor a \
PID 1 that died",
self.install_budget,
frames,
after.saturating_sub(before)
),
},
// Exited without writing: it came up, did nothing, and went away.
(Some(_), false, _) => GuestOutcome::Panicked {
frame_sha256: frames.last().cloned().unwrap_or_default(),
},
// Alive, wrote, and the screen stopped changing with no CD to loop
// back to: it is doing something the screen no longer narrates, and
// neither "installed" nor "panicked" is a fact yet.
(None, true, false) => GuestOutcome::Refused {
clause: "install-budget",
why: format!(
"the guest wrote {} bytes and then froze the screen within {:?}: that is \
neither a finished install nor a dead PID 1",
after.saturating_sub(before),
self.install_budget
),
},
};
let _ = guest.kill();
Evidence {
argv,
workdir,
frames,
disk_bytes: (before, after),
stdout_bytes,
outcome,
}
}
fn frame_hash(&self, guest: &QemuGuest, n: usize) -> Option<String> {
let to = guest.workdir().join(format!("frame-{n}.ppm"));
guest.screendump(&to).ok()?;
let bytes = std::fs::read(&to).ok()?;
Some(sha256_hex(&bytes))
}
}
impl GuestEngine for DraupnirGuest {
fn name(&self) -> &'static str {
"draupnir"
}
fn boot(&self, spec: &GuestSpec) -> GuestOutcome {
self.run(spec).outcome
}
fn power_on(&self, m: &Machine) -> Result<(), String> {
self.fleet_power_on(m)
}
fn power_off(&self, server: &str, hard: bool) {
self.fleet_power_off(server, hard)
}
fn running(&self, server: &str) -> Option<bool> {
self.fleet_running(server)
}
fn eject(&self, server: &str) {
self.fleet_eject(server)
}
fn console(&self, server: &str) -> Option<(String, u16)> {
let fleet = self.fleet.lock().unwrap();
fleet.machines.get(server)?.boots.last()?.vnc.clone()
}
fn load(&self, server: &str, storage: &str) {
self.fleet_load(server, storage)
}
fn resize_disk(&self, storage: &str, size_gib: u64) -> Result<(), String> {
self.fleet_resize(storage, size_gib)
}
fn copy_disk(&self, from: &str, to: &str) -> Result<(), String> {
let (src, fmt) = self.disk_file(from);
if !src.is_file() {
// A storage the engine never made a file for holds nothing yet.
return Ok(());
}
let dst = if self.medium_path(to).is_file() || (fmt == "raw" && !self.disk_path(to).is_file()) { self.medium_path(to) } else { self.disk_path(to) };
if let Some(p) = dst.parent() {
std::fs::create_dir_all(p).map_err(|e| format!("{}: {e}", p.display()))?;
}
// A reflink where the filesystem has one (instant), a copy otherwise.
let tmp = dst.with_extension("copying");
let st = std::process::Command::new("cp").arg("--reflink=auto").arg(&src).arg(&tmp).status().map_err(|e| format!("cp: {e}"))?;
if !st.success() {
let _ = std::fs::remove_file(&tmp);
return Err(format!("cp {} → {}: {st}", src.display(), tmp.display()));
}
std::fs::rename(&tmp, &dst).map_err(|e| format!("{} → {}: {e}", tmp.display(), dst.display()))
}
fn attach(&self, server: &str, storage: &str, address: &str, size_gib: u64) -> Result<(), String> {
self.fleet_attach(server, storage, address, size_gib)
}
fn detach(&self, server: &str, storage: &str, address: &str) -> Result<(), String> {
self.fleet_detach(server, storage, address)
}
fn store_medium(&self, storage: &str, bytes: &[u8]) -> Result<(), String> {
let p = self.medium_path(storage);
std::fs::create_dir_all(p.parent().expect("media dir")).map_err(|e| e.to_string())?;
std::fs::write(&p, bytes).map_err(|e| format!("{}: {e}", p.display()))
}
fn firewall(&self, server: &str, admitted: &[(&'static str, u16)]) -> Result<(), String> {
self.fleet_firewall(server, admitted)
}
/// The fabric reads the file per connection: written, the user_data is answered; removed,
/// the next connection is refused.
fn metadata(&self, server: &str, user_data: Option<&str>) {
let file = self.server_dir(server).join(USER_DATA_FILE);
match user_data {
Some(u) => {
let _ = write_user_data(&file, u);
}
None => {
let _ = std::fs::remove_file(&file);
}
}
}
fn forget_server(&self, server: &str) {
self.fleet_forget_server(server)
}
fn forget_storage(&self, storage: &str) {
let _ = std::fs::remove_file(self.disk_path(storage));
let _ = std::fs::remove_file(self.medium_path(storage));
}
fn forget_all(&self) {
self.kill_all();
for d in ["servers", "disks", "media"] {
let _ = std::fs::remove_dir_all(self.root.join(d));
}
}
fn evidence(&self, server: &str) -> Option<Value> {
self.fleet_evidence(server)
}
}
// ── the fleet: a machine behind every server of the mock ────────────────────
//
// Layout under `root`, and nothing else is written anywhere:
//
// disks/<storage-uuid>.qcow2 one per disk storage, sized from its record
// media/<storage-uuid>.iso the bytes an upload session received
// servers/<server-uuid>/qemu.pid the live QEMU's pid (the leftover check)
// servers/<server-uuid>/boot-N/ one per power-on: QMP socket, streams, frames
//
// **Lifetime.** The engine owns every QEMU it starts. `kill_all` is called when
// the mock exits (the binary traps SIGINT/SIGTERM), and each `QemuGuest` kills
// its QEMU when dropped. A mock that is SIGKILLed cannot run either, so its
// QEMUs survive it — and the NEXT start refuses by name ([`for_estate`]) until
// they are gone, rather than serving a new estate beside machines nothing
// can name.
//
// [`for_estate`]: DraupnirGuest::for_estate
/// Every server's machine.
#[derive(Default)]
pub struct Fleet {
machines: BTreeMap<String, Running>,
/// **Who holds the mTLS door** (`--mtls`): the server whose guest `:50052`
/// is forwarded from host `127.0.0.1:50052` — the first one to take it, and
/// then that one at every boot it has. MEASURED 2026-09-22 (T19r, the
/// from-zero ladder on the mock): main was RESTARTED for its role while its
/// old QEMU still held the door, so main's new boot fell back to an adjacent
/// pair and the twin's restart, a moment later, took `:50052` — and the twin,
/// dialling `10.0.2.2:50052`, reached ITSELF: same issuer name, another key,
/// `invalid peer certificate: BadSignature` on every poll, never followed.
mtls_door: Option<String>,
}
/// What [`wire_nics`] may do with host `:50052`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mtls {
/// No mTLS door on the utility NIC (the default).
Off,
/// This server holds the door, or nobody does yet: take host `:50052`,
/// waiting briefly for a previous QEMU of the same server to let go of it.
Door,
/// Another server holds the door: never take `:50052`, an adjacent pair only.
Other,
}
struct Running {
guest: Option<QemuGuest>,
/// The disk files the live QEMU has open — never grown under it, unless a
/// hot-plug node ([`Running::nodes`]) lets `block_resize` do it.
open_disks: Vec<String>,
/// **The hot-plug nodes** (`hotplug` on): storage uuid → the `-blockdev`
/// node the live QEMU holds for it and whether its `virtio-blk-pci` card
/// is plugged right now. A node outlives its card's unplug, so a detached
/// member is resized and re-plugged over the SAME node.
nodes: BTreeMap<String, Node>,
boots: Vec<Boot>,
/// The last refusal, by name, if the last power-on did not start.
refused: Option<String>,
ejected: bool,
}
/// One `-blockdev` node of a running machine: `id` names the format node
/// (`f<id>` its file node, `v<id>` the card over it); `bus` is the PCIe root
/// port the card sits behind on q35, none on i440fx (the card goes on `pci.0`).
#[derive(Clone, Debug, PartialEq, Eq)]
struct Node {
id: String,
plugged: bool,
bus: Option<String>,
/// The card's PCI slot on i440fx (`pci.0`), pinned so a re-plug lands where the
/// card was and the boot disk keeps the lowest slot; none on q35 (the root port
/// is the slot).
addr: Option<String>,
}
/// **The first PCI slot a hot-pluggable data card takes on i440fx** (`0x10`, then
/// one per card up to `0x1f`). Left to QEMU, the `-device` cards took LOWER slots
/// than the boot disk's implicit `-drive if=virtio`: the guest named the data
/// members vda..vdd and SeaBIOS booted the first of them — MEASURED loki
/// 2026-09-26, a 4-member appliance hung at "Booting from Hard Disk..." after its
/// install pass (the one-member twin happened to boot). Pinned high, the boot disk
/// is the first disk the firmware and the kernel see, as on UpCloud.
pub const HOTPLUG_SLOT_BASE: usize = 0x10;
/// The last slot i440fx has.
pub const HOTPLUG_SLOT_LAST: usize = 0x1f;
fn pc_slot(taken: &[String]) -> Option<String> {
(HOTPLUG_SLOT_BASE..=HOTPLUG_SLOT_LAST).map(|s| format!("0x{s:x}")).find(|a| !taken.contains(a))
}
/// **A QEMU node name for a storage uuid.** Node names must start with a
/// letter and be under 32 bytes; a uuid is 32 hex digits, and the mock's uuids
/// vary in their first 8 and last 12 (`01000009-0000-4000-8000-0000000714a1`),
/// so those 20 are kept: `d` + 20 hex = 21 bytes, distinct for distinct uuids.
pub fn node_id(storage: &str) -> String {
let hex: String = storage.chars().filter(|c| *c != '-').collect();
if hex.len() <= 20 {
format!("d{hex}")
} else {
format!("d{}{}", &hex[..8], &hex[hex.len() - 12..])
}
}
/// Spare PCIe root ports rendered on a q35 hot-plug machine beyond its data
/// disks, so a volume attached later has a slot to plug into.
const SPARE_ROOT_PORTS: usize = 4;
/// **The data disks' argv, in the shape the engine runs.** Boot disk excluded
/// (draupnir renders it). Without hot-plug: the implicit `-drive if=virtio` of
/// every earlier run. With it: `-blockdev` file + format nodes and a
/// `virtio-blk-pci` card per disk (behind a root port on q35), the shape QMP
/// `device_del` / `device_add` / `block_resize` can address; `nodes` is filled
/// with what was rendered. Pure: a test renders it without a hypervisor.
pub fn data_disk_args(hotplug: bool, machine: draupnir::MachineType, disks: &[(String, PathBuf, &'static str)], nodes: &mut BTreeMap<String, Node>) -> Vec<String> {
let mut a = Vec::new();
nodes.clear();
if !hotplug {
for (_, path, fmt) in disks {
a.push("-drive".into());
a.push(format!("file={},if=virtio,format={fmt}", path.display()));
}
return a;
}
let q35 = machine == draupnir::MachineType::Q35;
if q35 {
// The rig's shape (gunnar-growbench `rig.rs`): q35's `pcie.0` refuses
// `device_del`, a root port is what a hot-pluggable slot IS on PCIe,
// and the port stays when its card is unplugged.
for i in 0..disks.len() + SPARE_ROOT_PORTS {
a.push("-device".into());
a.push(format!("pcie-root-port,id=rp{i},chassis={},slot={}", i + 1, i + 1));
}
}
for (i, (storage, path, fmt)) in disks.iter().enumerate() {
let id = node_id(storage);
let bus = q35.then(|| format!("rp{i}"));
let addr = (!q35 && HOTPLUG_SLOT_BASE + i <= HOTPLUG_SLOT_LAST).then(|| format!("0x{:x}", HOTPLUG_SLOT_BASE + i));
a.push("-blockdev".into());
a.push(format!("driver=file,filename={},node-name=f{id}", path.display()));
a.push("-blockdev".into());
a.push(format!("driver={fmt},node-name={id},file=f{id}"));
a.push("-device".into());
a.push(format!(
"virtio-blk-pci,drive={id},id=v{id}{}{}",
bus.as_deref().map(|b| format!(",bus={b}")).unwrap_or_default(),
addr.as_deref().map(|s| format!(",addr={s}")).unwrap_or_default()
));
nodes.insert(storage.clone(), Node { id, plugged: true, bus, addr });
}
a
}
/// One power-on, and everything it left: there is no log, so this is it.
struct Boot {
argv: Vec<String>,
workdir: PathBuf,
started: Instant,
ran_ms: Option<u64>,
frames: Vec<String>,
/// ms after power-on at which each frame was FIRST seen. The last one is
/// when the screen stopped telling anything new — on a PID-1 panic, the
/// moment of the panic, which no log records.
frame_ms: Vec<u64>,
stdout_bytes: u64,
disks: Vec<(String, u64, Option<u64>)>,
/// guest port → `127.0.0.1:<host port>` on the utility NIC.
forwards: Vec<(u16, String)>,
/// `(proto, guest port, host address)` on the server's own public address
/// (`--public-on-loopback`), at the real port numbers.
public: Vec<(String, u16, String)>,
/// The public address eth0 (`n0`) serves them on, when it does — the one a
/// firewall change adds forwards on or takes them off.
public_ip: Option<String>,
/// Every firewall change applied to this boot live, in order: what was
/// asked, and what QEMU answered. Evidence, not a log line.
firewall: Vec<Value>,
/// `(mac, address)` on the estate's one utility L2 (`--utility-fabric`).
utility: Option<(String, String)>,
/// Where this boot's VNC listens: loopback, a fresh port every boot —
/// which is behaviour 14's premise, and now true of a real socket.
vnc: Option<(String, u16)>,
/// Screendumps that FAILED, and the last error. Evidence, not a log line:
/// a sampler that cannot reach QMP must say so on the server, or its
/// empty frames read as a guest that froze.
qmp_failures: u64,
qmp_error: Option<String>,
}
/// **The guest ports reachable from the host, each on the NIC it is served on**
/// — as on UpCloud, where the appliance serves git on its PUBLIC address and
/// its control plane on the UTILITY network: `--grpc-bind eth1:50051`, and the
/// forge's `:2222` on eth0. Forwarding 2222 on the utility NIC reached a port
/// nothing listens on there, and git got "kex_exchange_identification:
/// Connection reset by peer" (T15's TD8). Every forward is bound to 127.0.0.1
/// and nowhere else: the appliance's bootstrap window is open to anyone who
/// can reach 50051, and "anyone" must not include the LAN this rig sits on.
///
/// :80/:443 on EVERY public NIC (2026-09-26): the front is no longer a template
/// clone — every VM is laid on a BLANK disk and boots its medium — so "the
/// web is served by the template boot" stopped naming the front. A port
/// nothing listens on is refused by the guest, as on UpCloud.
const PUBLIC_FORWARDS: &[u16] = &[80, 443, 2222];
/// A template boot (the front) is administered over SSH, and serves the web and
/// the forge's DNAT on its public address.
const TEMPLATE_PUBLIC_FORWARDS: &[u16] = &[22, 80, 443, 2222];
const UTILITY_FORWARDS: &[u16] = &[50051];
/// eth1's own subnet (UpCloud's utility network is not the public one) and its DNS-to-nowhere.
const UTILITY_NET: &str = "10.0.3.0/24";
const UTILITY_DNS: &str = "10.0.3.99";
/// With [`DraupnirGuest::mtls`]: the utility NIC's second door.
const UTILITY_MTLS_FORWARDS: &[u16] = &[50051, 50052];
/// gunnar's mutual-TLS replication listener (`--grpc-replication-bind`, default `:50052`).
const MTLS_PORT: u16 = 50052;
/// **The two NICs UpCloud gives a server** (ledger L46/L86): eth0 public —
/// draupnir's own `n0`, its forwards pinned to loopback — and eth1 utility, a
/// second user-mode NIC with the same DNS-to-nowhere. Each guest port is
/// forwarded on the NIC it is served on ([`PUBLIC_FORWARDS`],
/// [`UTILITY_FORWARDS`]). Returns guest port → `127.0.0.1:<host port>`.
fn wire_nics(boot: &mut BootSpec, mtls: Mtls, fabric: Option<(std::net::SocketAddrV4, &str)>, template: bool) -> Vec<(u16, String)> {
let mut forwards: Vec<(u16, String)> = Vec::new();
let mut held = HeldPorts::default();
// eth0, the PUBLIC NIC, is draupnir's own `n0`: its forwards go through
// the spec, pinned to loopback (`forward_bind`).
boot.forward_bind = Some("127.0.0.1".into());
for guest_port in if template { TEMPLATE_PUBLIC_FORWARDS } else { PUBLIC_FORWARDS } {
if let Some(hp) = held.port() {
boot.port_maps.push(draupnir::PortMap::new(hp, *guest_port));
forwards.push((*guest_port, format!("127.0.0.1:{hp}")));
}
}
// **eth1 on the estate's ONE utility L2** (`--utility-fabric`): a `dgram` multicast group,
// this server's MAC as the API reports it, and the fabric answering its DHCP with the API's
// address. No host forwards on it: as on UpCloud, the utility network is reached from the
// front, not from the laptop.
if let Some((group, mac)) = fabric {
boot.extra_qemu_args.extend([
"-netdev".to_string(),
format!("dgram,id=n1,remote.type=inet,remote.host={},remote.port={}", group.ip(), group.port()),
"-device".into(),
format!("virtio-net-pci,netdev=n1,mac={mac}"),
]);
return forwards;
}
// **eth1 is its OWN network**, as UpCloud's utility network is a different subnet from the
// public one. Both NICs on slirp's default 10.0.2.0/24 gave the guest the SAME address on
// eth0 and eth1 (10.0.2.15 twice), so a reply to a connection that came in on eth1 could be
// routed out of eth0 and lost. MEASURED 2026-09-22 (growbench TD8): the twin's TLS
// ClientHello sat unread (Recv-Q 234) in the primary's slirp at host :50052 and the twin
// never followed; on the OVMF rig, whose second NIC is 10.0.3.0/24, the same medium follows.
// The DNS stays pointed at nothing, inside this subnet (slirp requires it there).
let mut n1 = format!("user,id=n1,net={UTILITY_NET},dns={UTILITY_DNS}");
if mtls != Mtls::Off {
// **The mTLS door is dialled at the port the PRIMARY BINDS**, not at a port derived from
// its control plane: `twin certify` hands the twin the replication port the primary
// listens on inside its guest (50052), and the twin dials `<primary host>:50052`.
// MEASURED 2026-09-22 (growbench, gunnar bf16b8e): `ConnectError(10.0.2.2:50052, Connection
// refused)` while the forward sat one port above the control plane. So the host side is
// 127.0.0.1:50052 itself, for the first guest that can take it — the primary, booted first
// — and from then on for THAT server only (`Fleet::mtls_door`): a later guest (the twin,
// which nobody dials for mTLS) gets an adjacent pair, even when the door is momentarily
// free because its holder is between two boots.
let fixed = mtls == Mtls::Door && mtls_door_free();
// the pair `p`, `p + 1`: `p + 1` never one of this server's other ports
let mut hp = None;
for _ in 0..16 {
match held.port() {
Some(p) if p < u16::MAX && !held.holds(p + 1) => {
hp = Some(p);
break;
}
Some(_) => continue,
None => break,
}
}
for guest_port in UTILITY_MTLS_FORWARDS {
let h = match (*guest_port, fixed, hp) {
(MTLS_PORT, true, _) => Some(MTLS_PORT),
(MTLS_PORT, false, Some(p)) => Some(p + 1),
(_, _, p) => p,
};
if let Some(h) = h {
n1.push_str(&format!(",hostfwd=tcp:127.0.0.1:{h}-:{guest_port}"));
forwards.push((*guest_port, format!("127.0.0.1:{h}")));
}
}
} else {
for guest_port in UTILITY_FORWARDS {
if let Some(hp) = held.port() {
n1.push_str(&format!(",hostfwd=tcp:127.0.0.1:{hp}-:{guest_port}"));
forwards.push((*guest_port, format!("127.0.0.1:{hp}")));
}
}
}
boot.extra_qemu_args.extend(["-netdev".to_string(), n1, "-device".into(), "virtio-net-pci,netdev=n1".into()]);
forwards
}
/// **The server's own PUBLIC address, served at the installer's real ports**
/// (`--public-on-loopback`): eth0's user-mode NIC also forwards
/// [`mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS`] from `public` — the loopback
/// address the API reports for this server — to the same port in the guest.
/// So the installer on this host dials `<public>:8474` (direct) or
/// `<public>:51821`/udp (its WireGuard link), and the front's relay at
/// `<front public>:51820`/udp, exactly the addresses and ports it dials on
/// UpCloud.
///
/// draupnir renders only TCP forwards, all on one bind address, so eth0 is
/// rendered here instead (`-nic none` from draupnir, this `n0` beside it),
/// carrying the same loopback TCP forwards and the same DNS-to-nowhere it
/// always had. Inbound UDP is admitted to the WireGuard ports ONLY, as the
/// estate's firewall admits it on UpCloud; replies to the guest's own DNS and
/// NTP still go nowhere (behaviour 18 is the `dns=` clause, untouched).
///
/// **Only the ports the server's firewall admits** (`admitted`, from
/// [`mock_upcloud::kvm::admitted_public_ports`]): on UpCloud a port no rule
/// opens above the catch-all drop is silence, so here it is not forwarded at
/// all. eth0 is rendered even when nothing is admitted, so a rule written later
/// can open a port on it live ([`DraupnirGuest::fleet_firewall`]).
///
/// Returns `(proto, guest port, host address)` for each public forward.
fn serve_public_on(boot: &mut BootSpec, public: &str, admitted: &[(&'static str, u16)]) -> Vec<(String, u16, String)> {
let bind = boot.forward_bind.clone().unwrap_or_else(|| "127.0.0.1".into());
let mut n0 = "user,id=n0".to_string();
for (h, g) in boot.tcp_forwards() {
n0.push_str(&format!(",hostfwd=tcp:{bind}:{h}-:{g}"));
}
let mut public_forwards = Vec::new();
for (proto, port) in public_ports(admitted) {
n0.push_str(&format!(",hostfwd={}", hostfwd_rule(proto, public, port)));
public_forwards.push((proto.to_string(), port, format!("{public}:{port}")));
}
if let Some(dns) = &boot.guest_dns {
n0.push_str(&format!(",dns={dns}"));
}
// The forwards now live in this n0; draupnir refuses `-nic none` with port maps left over.
boot.ports.clear();
boot.port_maps.clear();
boot.net = draupnir::NetMode::None;
boot.extra_qemu_args.splice(0..0, ["-netdev".to_string(), n0, "-device".into(), "virtio-net-pci,netdev=n0".into()]);
public_forwards
}
/// **A server with no public address gets no public NIC** (owner 2026-10-23, the lean estate:
/// main and the twin are laid with a utility interface alone). Draupnir's own NIC stands down
/// (`-nic none`) and the host forwards [`wire_nics`] gave the public NIC go with it, so the
/// utility fabric's NIC is the guest's only one (its eth0) and nothing NATs the guest out.
/// Returns the forwards that are left: none.
fn no_public_nic(boot: &mut BootSpec, _public_forwards: Vec<(u16, String)>) -> Vec<(u16, String)> {
boot.ports.clear();
boot.port_maps.clear();
boot.net = draupnir::NetMode::None;
Vec::new()
}
/// The installer's ports in `admitted`, in [`mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS`]
/// order — and never anything else, whatever a caller passes: a public forward
/// is an installer port or it is not rendered.
fn public_ports(admitted: &[(&str, u16)]) -> Vec<(&'static str, u16)> {
mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS.iter().copied().filter(|p| admitted.contains(p)).collect()
}
/// **Is a public forward already held on this host?** (stinkies mock-boot-udp-port-collision:
/// the forwards sit at the real port numbers, 51820-51822, inside Linux's ephemeral range, and
/// a process bound to the WILDCARD address — kdeconnectd on 0.0.0.0:51821/udp — makes QEMU's
/// `hostfwd` fail and the machine exit at once on every boot.) Each forward is bound for a
/// moment where QEMU will bind it; the first that cannot be is named, with how to see who
/// holds it. `None`: every forward is free.
fn public_port_taken(public: &[(String, u16, String)]) -> Option<String> {
for (proto, port, at) in public {
let held = match proto.as_str() {
"udp" => std::net::UdpSocket::bind(at.as_str()).err(),
_ => std::net::TcpListener::bind(at.as_str()).err(),
};
if let Some(e) = held.filter(|e| e.kind() == std::io::ErrorKind::AddrInUse) {
let flag = if proto == "udp" { "u" } else { "t" };
return Some(format!(
"mock-public-port-in-use: {proto} {at} cannot be forwarded to the guest — a process on this host already holds port {port} (on this address or the wildcard: `ss -l{flag}np | grep :{port}`), and QEMU would exit at once on every boot ({e})"
));
}
}
None
}
/// One public forward as slirp spells it, `<proto>:<public>:<port>-:<port>` —
/// the same text for `-netdev user,hostfwd=` at start and for HMP
/// `hostfwd_add` live; `hostfwd_remove` takes the part before `-`.
fn hostfwd_rule(proto: &str, public: &str, port: u16) -> String {
format!("{proto}:{public}:{port}-:{port}")
}
/// Is every UDP forward in this argv one of the installer's WireGuard ports on
/// a public address? The only inbound UDP UpCloud's estate firewall admits.
fn udp_forwards_are_wireguard_only(j: &str) -> bool {
j.split("hostfwd=udp:").skip(1).all(|rest| {
let rule = rest.split([',', ' ']).next().unwrap_or("");
// `<bind>:<host port>-:<guest port>`
let Some((host, guest)) = rule.split_once("-:") else { return false };
let Some((bind, hp)) = host.rsplit_once(':') else { return false };
mock_upcloud::kvm::is_loopback_public(bind)
&& hp == guest
&& mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS.iter().any(|(p, n)| *p == "udp" && n.to_string() == guest)
})
}
/// **Whose door is it.** Off without `--mtls`; the holder's at every boot it has; and until
/// there is a holder, the first server booted WITH A MEDIUM in its tray — the appliance
/// rung installs main from the medium before anything else is booted from it, while the
/// front (laid from a template, no tray) boots first of all and must never take it.
/// MEASURED 2026-09-22 (T19r, from-zero on the mock, third run): the front's guest held
/// host `:50052`, main got an adjacent pair, and the twin dialled a machine with no gunnar.
fn mtls_role(mtls: bool, holder: Option<&str>, server: &str, from_medium: bool) -> Mtls {
match (mtls, holder) {
(false, _) => Mtls::Off,
(true, None) if from_medium => Mtls::Door,
(true, None) => Mtls::Other,
(true, Some(h)) if h == server => Mtls::Door,
(true, Some(_)) => Mtls::Other,
}
}
/// Is host `:50052` free for the door's holder? A holder that is being RESTARTED
/// probes while its previous QEMU may still be letting go of the port, so the
/// answer is waited for — briefly, and said out loud when it stays no: a door
/// held by something else on this box (another mock with `--mtls`) is a run
/// whose twin can never follow, and that must be read in the transcript, not
/// discovered 600 s later.
fn mtls_door_free() -> bool {
let t0 = Instant::now();
loop {
if std::net::TcpListener::bind(("127.0.0.1", MTLS_PORT)).is_ok() {
return true;
}
if t0.elapsed() > Duration::from_secs(5) {
eprintln!(
"mock-upcloud host 127.0.0.1:{MTLS_PORT} is held by another process (a second mock with --mtls on \
this box?): this guest gets an adjacent pair instead, and no twin can reach its mTLS door at :{MTLS_PORT}"
);
return false;
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Two ADJACENT free loopback ports `(p, p + 1)`, held together while checked.
#[allow(dead_code)]
fn free_loopback_pair() -> Option<u16> {
for _ in 0..64 {
let a = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
let p = a.local_addr().ok()?.port();
if p < u16::MAX && std::net::TcpListener::bind(("127.0.0.1", p + 1)).is_ok() {
return Some(p);
}
}
None
}
/// A free loopback port, by asking the kernel for one and letting it go.
fn free_loopback_port() -> Option<u16> {
std::net::TcpListener::bind("127.0.0.1:0").ok()?.local_addr().ok().map(|a| a.port())
}
/// **Free loopback ports for ONE server, each HELD until the server's ports are all picked** —
/// so the kernel never hands the same port out twice for one QEMU (dwarf 2026-10-12: 35453 for
/// both `-:80` and `-:2222`). Dropped right before QEMU binds them; a port another process takes
/// in that gap is a [`FirstBreath::TakenPort`] and the server boots again with fresh ones.
#[derive(Default)]
struct HeldPorts(Vec<std::net::TcpListener>);
impl HeldPorts {
fn port(&mut self) -> Option<u16> {
let l = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
let p = l.local_addr().ok()?.port();
self.0.push(l);
Some(p)
}
/// `p` is one of the held ones (an adjacent `p + 1` must not be).
fn holds(&self, p: u16) -> bool {
self.0.iter().any(|l| l.local_addr().is_ok_and(|a| a.port() == p))
}
}
/// Did this QEMU exit at once because a port it was told to bind was taken? It
/// binds its `-vnc` and `hostfwd` ports while starting, so a lost port shows
/// within the first second, as `Address already in use` in its stderr.
fn first_breath(guest: &mut QemuGuest, workdir: &std::path::Path) -> FirstBreath {
let t0 = Instant::now();
while t0.elapsed() < std::time::Duration::from_millis(1500) {
if !guest.is_running() {
let err = std::fs::read_to_string(workdir.join("stderr.log")).unwrap_or_default();
return breath_of(&err);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
FirstBreath::Alive
}
/// How a QEMU's first second and a half went ([`first_breath`]).
#[derive(Debug, Clone, PartialEq, Eq)]
enum FirstBreath {
/// Still running (or gone with nothing said: a guest's own power-off).
Alive,
/// It lost a port to another socket: boot again with fresh ports.
TakenPort,
/// **The HOST refused it** (round 5.3.1): QEMU said why on stderr and
/// exited — out of memory on a busy host, a missing accelerator. The
/// mock says this by name; it is never UpCloud's capacity.
Died(String),
}
/// Pure: [`FirstBreath`] of a QEMU that exited at once, from its stderr.
fn breath_of(stderr: &str) -> FirstBreath {
// QEMU says a lost host port in either words: the socket's own, or only that the rule
// could not be set up (a port named twice, or taken between the pick and its bind).
if stderr.contains("Address already in use") || stderr.contains("Could not set up host forwarding rule") {
return FirstBreath::TakenPort;
}
let said: Vec<&str> = stderr.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
if said.is_empty() {
return FirstBreath::Alive;
}
FirstBreath::Died(said[said.len().saturating_sub(3)..].join(" | "))
}
/// Pure: **a plan's cores and MiB under the host's caps** — never above the
/// plan, never below one core.
fn capped(cores: u32, mem_mb: u32, max_cores: Option<u32>, max_mem_mb: Option<u32>) -> (u32, u32) {
(max_cores.map_or(cores, |c| cores.min(c.max(1))), max_mem_mb.map_or(mem_mb, |m| mem_mb.min(m)))
}
/// At most this many distinct frames per boot: enough to show the narration,
/// bounded so a spinner cannot fill the disk.
const MAX_FRAMES: usize = 64;
impl DraupnirGuest {
/// **The engine for a whole mock estate**, rooted at `root`.
///
/// REFUSES to start, naming each one, if a QEMU from a previous run is
/// still alive under this root: a new estate beside machines that nothing
/// in it can name would be a leak the API could never report. Dead
/// leftovers (disks, media, workdirs of a run that did not exit cleanly)
/// cannot be referred to by the fresh estate either, so they are removed —
/// and returned, so the caller can print what was reclaimed.
pub fn for_estate(root: PathBuf) -> Result<(DraupnirGuest, Vec<String>), String> {
let longest = longest_qmp_path(&root);
if longest > UNIX_SOCKET_PATH_MAX {
let room = UNIX_SOCKET_PATH_MAX - (longest - root.as_os_str().len());
return Err(format!(
"qmp-socket-path: root {} ({} bytes) makes QMP sockets up to {longest} bytes \
(<root>/servers/<36-char uuid>/boot-9999/qmp.sock), and a Unix socket path is at \
most {UNIX_SOCKET_PATH_MAX}. Give a root of at most {room} bytes, e.g. \
/tmp/mkvm-<name>",
root.display(),
root.as_os_str().len()
));
}
let mut live = Vec::new();
let mut dead = Vec::new();
let root_s = root.display().to_string();
if let Ok(rd) = std::fs::read_dir(root.join("servers")) {
for ent in rd.flatten() {
let server = ent.file_name().to_string_lossy().to_string();
let pid = std::fs::read_to_string(ent.path().join("qemu.pid"))
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
match pid {
Some(pid) if qemu_alive_under(pid, &root_s) => {
live.push(format!("server {server}: qemu pid {pid}"))
}
_ => dead.push(format!("servers/{server}")),
}
}
}
if !live.is_empty() {
return Err(format!(
"leftover guests from a previous run are still running under {root_s}: {}. \
Kill them (kill <pid>) and start again; this mock will not serve a new \
estate beside machines it cannot name",
live.join("; ")
));
}
for d in ["disks", "media"] {
if let Ok(rd) = std::fs::read_dir(root.join(d)) {
for ent in rd.flatten() {
dead.push(format!("{d}/{}", ent.file_name().to_string_lossy()));
}
}
}
for d in ["servers", "disks", "media"] {
let _ = std::fs::remove_dir_all(root.join(d));
}
std::fs::create_dir_all(&root).map_err(|e| format!("{root_s}: {e}"))?;
Ok((
DraupnirGuest {
root,
// Loopback VNC is the one-shot proof's; behind the mock the
// console is answered by the wire fake, and a VNC per guest
// would be a port to collide on for nothing.
vnc: None,
..DraupnirGuest::default()
},
dead,
))
}
/// **Put every guest's utility NIC on one L2**, and serve that L2's DHCP,
/// ARP and routing from a thread of this process. The group is derived from
/// [`DraupnirGuest::root`], so two mocks on one machine never share it.
pub fn with_utility_fabric(mut self) -> Result<DraupnirGuest, String> {
// Said at once, not discovered as guests with no address 10 minutes in.
if let Err(why) = mock_upcloud::fabric::multicast_loops_back("utility-fabric") {
eprintln!("mock-upcloud-kvm WARNING: --utility-fabric on a host that carries no multicast L2 — no guest will be leased an address: {why}. Run it in a network namespace that loops multicast (or add `ip route add 239.0.0.0/8 dev lo`).");
}
let group = mock_upcloud::fabric::group_for(&self.root.display().to_string());
let fabric = std::sync::Arc::new(Mutex::new(mock_upcloud::fabric::Fabric::new()));
let serving = fabric.clone();
std::thread::Builder::new()
.name("utility-fabric".into())
.spawn(move || {
if let Err(e) = mock_upcloud::fabric::Fabric::serve(serving, group) {
eprintln!("mock-upcloud-kvm: the utility fabric on {group} stopped: {e}");
}
})
.map_err(|e| format!("utility-fabric: {e}"))?;
self.utility = Some((fabric, group));
Ok(self)
}
/// **Boot a clone of the template titled `prefix…` as `image`** (a qcow2
/// cloud image). The first call also starts the NoCloud listener that
/// template's first boot reads its hostname and keys from.
pub fn with_template_image(mut self, prefix: &str, image: PathBuf) -> Result<DraupnirGuest, String> {
if !image.is_file() {
return Err(format!("template-image: {} is not a file", image.display()));
}
if self.templates.is_none() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| format!("nocloud: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let meta = std::sync::Arc::new(Mutex::new(BTreeMap::new()));
let serving = meta.clone();
std::thread::Builder::new()
.name("nocloud".into())
.spawn(move || serve_nocloud(listener, serving))
.map_err(|e| format!("nocloud: {e}"))?;
self.templates = Some(Templates { images: Vec::new(), port, meta });
}
if let Some(t) = self.templates.as_mut() {
t.images.push((prefix.to_string(), image));
}
Ok(self)
}
fn disk_path(&self, storage: &str) -> PathBuf {
self.root.join("disks").join(format!("{storage}.qcow2"))
}
pub fn medium_path(&self, storage: &str) -> PathBuf {
self.root.join("media").join(format!("{storage}.iso"))
}
/// The file behind a disk storage, and its format: the uploaded image if
/// the storage received one, the engine's qcow2 otherwise.
fn disk_file(&self, storage: &str) -> (PathBuf, &'static str) {
let up = self.medium_path(storage);
if up.is_file() {
(up, "raw")
} else {
(self.disk_path(storage), "qcow2")
}
}
fn server_dir(&self, server: &str) -> PathBuf {
self.root.join("servers").join(server)
}
fn fleet_power_on(&self, m: &Machine) -> Result<(), String> {
let mut fleet = self.fleet.lock().unwrap();
let mtls = mtls_role(self.mtls, fleet.mtls_door.as_deref(), &m.server, m.medium.is_some());
// The API assigned this NIC its utility address: the fabric offers exactly that.
let utility = match &self.utility {
Some((fabric, group)) => {
let mac = mock_upcloud::fabric::parse_mac(&m.utility_mac)
.ok_or_else(|| format!("utility-mac: server {} has no usable utility MAC {:?}", m.server, m.utility_mac))?;
let mut f = fabric.lock().unwrap();
f.lease(mac, m.utility_offer.clone());
// The FIRST boot only: a server that has booted before is answered at once.
let first_boot = fleet.machines.get(&m.server).is_none_or(|r| r.boots.is_empty());
if first_boot && m.dhcp_late_first_boot_ms > 0 {
eprintln!("mock-upcloud-kvm utility-dhcp-late-first-boot: server {} ({}) gets no utility DHCP answer for {} s from its first ask", m.server, m.hostname, m.dhcp_late_first_boot_ms / 1000);
f.lease_late(mac, m.dhcp_late_first_boot_ms);
}
Some((*group, m.utility_mac.clone()))
}
None => None,
};
let entry = fleet.machines.entry(m.server.clone()).or_insert_with(|| Running {
guest: None,
open_disks: Vec::new(),
nodes: BTreeMap::new(),
boots: Vec::new(),
refused: None,
ejected: false,
});
let r = self.start_machine(m, entry, mtls, utility.as_ref().map(|(g, mac)| (*g, mac.as_str())));
entry.refused = r.as_ref().err().cloned();
let took_door = r.is_ok()
&& entry.boots.last().is_some_and(|b| b.forwards.iter().any(|(g, h)| *g == MTLS_PORT && h.ends_with(&format!(":{MTLS_PORT}"))));
if took_door && fleet.mtls_door.is_none() {
fleet.mtls_door = Some(m.server.clone());
}
r
}
fn start_machine(&self, m: &Machine, entry: &mut Running, mtls: Mtls, utility: Option<(std::net::SocketAddrV4, &str)>) -> Result<(), String> {
if let Some(g) = entry.guest.as_mut() {
if g.is_running() {
return Err(format!("already-running: server {} has a live machine", m.server));
}
}
entry.guest = None;
let Some(boot_disk) = m.disks.first() else {
return Err(format!("no-disk: server {} has no disk to boot", m.server));
};
// The disks, from the records: created at their size, grown to it if a
// resize happened while the server was up.
std::fs::create_dir_all(self.root.join("disks")).map_err(|e| e.to_string())?;
for d in &m.disks {
// A disk storage that received an UPLOAD is that image, attached raw
// — the ladder uploads the installer medium a second time as a
// virtio disk, because korp-installer probes virtio and a kernel
// without ATAPI never sees the IDE cdrom. No qcow2 is made for it.
if self.medium_path(&d.storage).is_file() {
continue;
}
let p = self.disk_path(&d.storage);
let want = d.size_gib << 30;
if !p.exists() {
match &d.template {
// A template clone IS that template: an overlay of its image.
Some(t) => {
let image = self.templates.as_ref().and_then(|x| x.image_for(t)).ok_or_else(|| {
format!(
"template-image-missing: disk {} is a clone of `{t}` and this engine has no image \
for it — start mock-upcloud-kvm with --template-image '{t}=<qcow2>'",
d.storage
)
})?;
create_overlay(&p, want, image).map_err(|e| format!("disk {}: {e}", d.storage))?;
}
None => qemu::create_qcow2(&p, want).map_err(|e| format!("disk {}: {e}", d.storage))?,
}
} else if qemu::qcow2_virtual_size(&p).map_err(|e| e.to_string())? < want {
qemu::grow_qcow2(&p, want).map_err(|e| format!("disk {}: {e}", d.storage))?;
}
}
let medium = match &m.medium {
None => None,
Some(st) => {
let p = self.medium_path(st);
if !p.is_file() {
return Err(format!(
"medium-missing: storage {st} is in the tray but no bytes were ever uploaded to it"
));
}
Some(p.display().to_string())
}
};
let mut gs = GuestSpec::new(m.server.clone(), self.disk_file(&boot_disk.storage).0.display().to_string());
(gs.cores, gs.mem_mb) = capped(m.cores, m.mem_mb, self.max_cores, self.max_mem_mb);
gs.medium = medium;
gs.cdrom_first = m.cdrom_first;
gs.disk_first = m.disk_first;
gs.rtc_skew_seconds = m.rtc_skew_seconds;
gs.template = boot_disk.template.clone();
// A template boot reads its hostname and keys from the NoCloud listener.
if gs.template.is_some() {
if let Some(t) = &self.templates {
t.meta.lock().unwrap().insert(m.server.clone(), NoCloudMeta { hostname: m.hostname.clone(), keys: m.ssh_keys.clone(), utility_mac: m.utility_mac.clone() });
}
}
// **A port taken between the pick and QEMU's bind is retried, not reported as a boot.**
// Every port below is picked by binding :0 and letting go, and QEMU binds it a moment
// later; anything on this box that connects out in that moment can be handed the same
// port as its ephemeral source. MEASURED 2026-09-22 (growbench TD8R, the restore's
// restart): `-vnc 127.0.0.1:40085: Failed to find an available port: Address already in
// use`, the machine never ran, and the API said `started`. UpCloud does not lose a boot to
// its own console port, so the mock picks fresh ports and boots again (at most 3 times).
let mut attempt = 0u32;
let (guest, forwards, public, vnc, disks, workdir) = loop {
let mut boot = self.spec_for(&gs);
// **A template boot is the front's Ubuntu**: DNS that answers (the
// front's apt works on UpCloud), and its metadata named by SMBIOS.
if gs.template.is_some() {
boot.guest_dns = None;
if let Some(t) = &self.templates {
boot.extra_qemu_args.extend([
"-smbios".to_string(),
format!("type=1,serial=ds=nocloud;s=http://{}:{}/nocloud/{}/", if self.offline.is_some() { OFFLINE_HOST } else { "10.0.2.2" }, t.port, m.server),
]);
}
}
// **The console, for real.** UpCloud's is a VNC the API reports; the
// mock's is this guest's own, on loopback and on a port the kernel
// hands out fresh each boot, so a caller that dials what the API
// reports sees this machine's screen — and a stale report (14) is a
// socket that really went away. QEMU takes a display number, so the
// port must be at least 5900; an ephemeral port always is.
let vnc = free_loopback_port().filter(|p| *p >= 5900).map(|p| ("127.0.0.1".to_string(), p));
if let Some((h, p)) = &vnc {
boot.display = draupnir::Display::Framebuffer { vnc: Some(format!("{h}:{}", p - 5900)) };
}
// **The second NIC.** An UpCloud server has TWO: public (eth0) and
// utility (eth1), and the estate's media bind their control plane to
// eth1 — a guest with one NIC is not the machine, and the medium's
// gunnar refuses to start on it (measured: `--grpc-bind eth1:50051
// names interface "eth1" … No such device`, then a PID-1 panic). Same
// clause as eth0: DNS pointed at nothing, no UDP inbound. The only
// forwards are TCP, on loopback.
let mut forwards = wire_nics(&mut boot, mtls, utility, gs.template.is_some());
// The lean estate (2026-10-23): a server the API gives no public address has no
// public NIC — on the utility fabric, its one NIC is the utility network.
let has_public = !(m.public_ip.is_empty() && utility.is_some());
if !has_public {
forwards = no_public_nic(&mut boot, forwards);
}
// The installer's ports on the address the API reports (`--public-on-loopback`).
let public = if mock_upcloud::kvm::is_loopback_public(&m.public_ip) { serve_public_on(&mut boot, &m.public_ip, &m.public_admitted) } else { Vec::new() };
// A forward another host process holds is refused by name — never a machine
// that exits at once while the API says `started`.
if let Some(why) = public_port_taken(&public) {
return Err(why);
}
// **The metadata service** (no operator machine in a sale, 2026-10-08): a server
// created with `metadata: "yes"` and a `user_data` reads it at 169.254.169.254, as on
// UpCloud — a release image's per-order pairing seed — over the UTILITY network: its
// DHCP routes the /32 via the net's `.1` and the fabric answers the TCP. (A slirp
// guestfwd on eth0 cannot: slirp takes one only inside 10.0.2.0/24 — MEASURED
// NODWARF-1 2026-10-09, every guest refused to start.)
let file = self.server_dir(&m.server).join(USER_DATA_FILE);
match (&m.user_data, &self.utility) {
(Some(user_data), Some((fabric, _))) => {
let mac = mock_upcloud::fabric::parse_mac(&m.utility_mac).ok_or_else(|| format!("utility-mac: server {} has no usable utility MAC {:?}", m.server, m.utility_mac))?;
write_user_data(&file, user_data)?;
fabric.lock().unwrap().serve_metadata(mac, Some(file.clone()));
}
(Some(_), None) => return Err("metadata-needs-utility-fabric: the metadata service is answered on the utility network (--utility-fabric)".to_string()),
(None, _) => {
let _ = std::fs::remove_file(&file);
}
}
// **On-prem** (`--lan-publish`): this server's named ports on the LAN, in the same eth0.
let lan = lan_forwards(&self.lan_publish, &m.hostname);
if !lan.is_empty() && has_public {
let Some(n0) = boot.extra_qemu_args.iter_mut().find(|a| a.starts_with("user,id=n0")) else {
return Err("lan-publish-needs-public-on-loopback: --lan-publish adds to the eth0 this engine renders itself (--public-on-loopback)".to_string());
};
for r in lan {
n0.push_str(&format!(",hostfwd={r}"));
}
}
// **Offline** (`--offline`): no user-mode NIC NATs out; the relays are the way home.
if let Some(o) = self.offline.as_ref().filter(|_| has_public) {
if !boot.extra_qemu_args.iter().any(|a| a.starts_with("user,id=n0")) {
return Err("offline-needs-public-on-loopback: --offline restricts the eth0 this engine renders itself (--public-on-loopback); draupnir's own eth0 would still NAT out".to_string());
}
// Sealed (restrict=on) while the disk is still the TEMPLATE's Ubuntu: a template
// clone that has never booted a medium. From the first medium boot on, the disk is
// the installer's, and the guest needs its UDP replies (WireGuard).
let marker = self.server_dir(&m.server).join(MEDIUM_BOOTED);
if gs.medium.is_some() {
let _ = std::fs::create_dir_all(self.server_dir(&m.server));
let _ = std::fs::write(&marker, b"this server booted a medium; its disk is no longer the template's\n");
}
let seal = template_seal(gs.template.is_some(), gs.medium.is_some(), marker.exists());
let nocloud: Vec<u16> = match (&gs.template, &self.templates) {
(Some(_), Some(t)) => vec![t.port],
_ => Vec::new(),
};
for a in boot.extra_qemu_args.iter_mut() {
*a = offline_netdev(a, o, &nocloud, seal);
}
}
let data: Vec<(String, PathBuf, &'static str)> = m
.disks
.iter()
.skip(1)
.map(|d| {
let (path, fmt) = self.disk_file(&d.storage);
(d.storage.clone(), path, fmt)
})
.collect();
boot.extra_qemu_args.extend(data_disk_args(self.hotplug, self.machine, &data, &mut entry.nodes));
let workdir = self.server_dir(&m.server).join(format!("boot-{}", entry.boots.len()));
if let Some(why) = refuse_long_socket(&qmp_path(&self.root, &m.server, entry.boots.len())) {
return Err(why);
}
let argv = qemu::argv(&boot, None, Some(&workdir.join("qmp.sock"))).map_err(|e| format!("spec: {e}"))?;
if let Some((clause, why)) = self.preflight(&gs, &argv) {
return Err(format!("{clause}: {why}"));
}
let disks: Vec<(String, u64, Option<u64>)> = m
.disks
.iter()
.map(|d| (d.storage.clone(), file_len(&self.disk_file(&d.storage).0), None))
.collect();
let mut guest = QemuGuest::start(&boot, &workdir).map_err(|e| format!("kvm-host: {e}"))?;
let breath = first_breath(&mut guest, &workdir);
if let FirstBreath::Died(why) = breath {
return Err(format!("kvm-host: QEMU exited at once on this host: {why}"));
}
if attempt < 3 && breath == FirstBreath::TakenPort {
attempt += 1;
eprintln!("mock-upcloud server {}: QEMU lost a port to another socket before it bound it; booting again with fresh ports ({attempt}/3)", m.server);
continue;
}
break (guest, forwards, public, vnc, disks, workdir);
};
let _ = std::fs::write(self.server_dir(&m.server).join("qemu.pid"), guest.pid().to_string());
entry.boots.push(Boot {
argv: guest.argv().to_vec(),
workdir,
started: Instant::now(),
ran_ms: None,
frames: Vec::new(),
frame_ms: Vec::new(),
stdout_bytes: 0,
disks,
forwards,
public,
public_ip: mock_upcloud::kvm::is_loopback_public(&m.public_ip).then(|| m.public_ip.clone()),
firewall: Vec::new(),
utility: utility.map(|(_, mac)| (mac.to_string(), m.utility_offer.address.clone())),
vnc,
qmp_failures: 0,
qmp_error: None,
});
entry.open_disks = m.disks.iter().map(|d| d.storage.clone()).collect();
entry.guest = Some(guest);
Ok(())
}
/// Close the books on a machine that is gone: how long it ran, what it put
/// on stdout, and what each disk weighs now.
fn finish(&self, r: &mut Running) {
let Some(g) = r.guest.take() else { return };
let stdout = g.stdout_bytes();
drop(g);
if let Some(b) = r.boots.last_mut() {
b.ran_ms.get_or_insert(b.started.elapsed().as_millis() as u64);
b.stdout_bytes = stdout;
for d in b.disks.iter_mut() {
d.2 = Some(file_len(&self.disk_file(&d.0).0));
}
}
r.open_disks.clear();
r.nodes.clear();
}
fn fleet_power_off(&self, server: &str, hard: bool) {
let mut fleet = self.fleet.lock().unwrap();
let Some(r) = fleet.machines.get_mut(server) else { return };
let Some(g) = r.guest.as_mut() else { return };
if !g.is_running() {
self.finish(r);
return;
}
if hard {
let _ = g.kill();
self.finish(r);
} else {
// The ACPI button, and return: the estate's lock is held.
let _ = g.qmp_execute("system_powerdown", None);
}
}
fn fleet_running(&self, server: &str) -> Option<bool> {
let mut fleet = self.fleet.lock().unwrap();
let r = fleet.machines.get_mut(server)?;
let alive = r.guest.as_mut().is_some_and(|g| g.is_running());
if !alive {
self.finish(r);
}
Some(alive)
}
fn fleet_eject(&self, server: &str) {
let mut fleet = self.fleet.lock().unwrap();
let Some(r) = fleet.machines.get_mut(server) else { return };
r.ejected = true;
let Some(g) = r.guest.as_mut() else { return };
if !g.is_running() {
return;
}
// The tray is whichever block device is removable; `-cdrom` names it
// for us, and asking beats guessing `ide2-cd0`. `qmp_execute` hands
// back the WHOLE reply, so the list is under "return" — reading the
// reply itself as the list made this a silent no-op on a live machine.
if let Ok(blocks) = g.qmp_execute("query-block", None) {
for b in blocks["return"].as_array().into_iter().flatten() {
if b["removable"].as_bool() == Some(true) {
if let Some(dev) = b["device"].as_str().filter(|d| !d.is_empty()) {
let _ = g.qmp_execute("eject", Some(json!({"device": dev, "force": true})));
} else if let Some(qdev) = b["qdev"].as_str() {
let _ = g.qmp_execute("eject", Some(json!({"id": qdev, "force": true})));
}
}
}
}
}
/// `cdrom/load` on a RUNNING machine: the medium goes into the tray now
/// (QMP `blockdev-change-medium`). A stopped server needs nothing here —
/// its next power-on reads the tray from the device row.
fn fleet_load(&self, server: &str, storage: &str) {
let mut fleet = self.fleet.lock().unwrap();
let Some(r) = fleet.machines.get_mut(server) else { return };
r.ejected = false;
let Some(g) = r.guest.as_mut() else { return };
if !g.is_running() {
return;
}
let file = self.medium_path(storage).display().to_string();
if let Ok(blocks) = g.qmp_execute("query-block", None) {
for b in blocks["return"].as_array().into_iter().flatten() {
if b["removable"].as_bool() == Some(true) {
let mut args = json!({"filename": file, "format": "raw"});
match b["device"].as_str().filter(|d| !d.is_empty()) {
Some(dev) => args["device"] = json!(dev),
None => args["id"] = json!(b["qdev"].as_str().unwrap_or("")),
}
let _ = g.qmp_execute("blockdev-change-medium", Some(args));
}
}
}
}
fn fleet_resize(&self, storage: &str, size_gib: u64) -> Result<(), String> {
let mut fleet = self.fleet.lock().unwrap();
// **Under the running machine, on its node** (hot-plug): QEMU extends
// the file and, if the card is plugged, raises the virtio config
// change; unplugged (a detached member, the growth's case) the node
// simply grows and the re-plug shows the guest the new capacity.
let held = fleet.machines.iter_mut().find_map(|(server, r)| {
let node = r.nodes.get(storage)?.clone();
let live = r.guest.as_mut().is_some_and(|g| g.is_running());
live.then(|| (server.clone(), node, r.boots.last().map(|b| b.workdir.join("qmp.sock"))))
});
if let Some((server, node, sock)) = held {
let sock = sock.ok_or_else(|| format!("server {server}: a running machine with no boot record"))?;
let want = size_gib << 30;
let have = qmp_cmd(&sock, json!({"execute": "query-named-block-nodes"}))?
.as_array()
.into_iter()
.flatten()
.find(|n| n["node-name"] == node.id)
.and_then(|n| n["image"]["virtual-size"].as_u64())
.unwrap_or(0);
if have < want {
qmp_cmd(&sock, json!({"execute": "block_resize", "arguments": {"node-name": node.id, "size": want}}))?;
}
return Ok(());
}
if let Some((server, _)) = fleet.machines.iter().find(|(_, r)| r.open_disks.iter().any(|d| d == storage)) {
return Err(format!(
"disk-in-use: {storage} is open by the running machine of server {server}; \
it is grown at that server's next power-on, never under it"
));
}
let p = self.disk_path(storage);
if !p.exists() {
// Created at its recorded size at the first power-on.
return Ok(());
}
let want = size_gib << 30;
if qemu::qcow2_virtual_size(&p).map_err(|e| e.to_string())? < want {
qemu::grow_qcow2(&p, want).map_err(|e| e.to_string())?;
}
Ok(())
}
/// **Re-plug, or plug for the first time** — `storage/attach` on a running
/// server. A node the machine already holds (a member the growth detached)
/// gets its card back over that node; a storage the machine never saw gets
/// its disk created, `blockdev-add`ed and a card. Without `hotplug`, or
/// with no live machine, the row is the record and the disk is seen at the
/// next power-on, as before.
fn fleet_attach(&self, server: &str, storage: &str, address: &str, size_gib: u64) -> Result<(), String> {
if !self.hotplug {
return Ok(());
}
let mut fleet = self.fleet.lock().unwrap();
let Some(r) = fleet.machines.get_mut(server) else { return Ok(()) };
if !r.guest.as_mut().is_some_and(|g| g.is_running()) {
return Ok(());
}
let sock = r.boots.last().map(|b| b.workdir.join("qmp.sock")).ok_or("a running machine with no boot record")?;
let q35 = self.machine == draupnir::MachineType::Q35;
let node = match r.nodes.get(storage).cloned() {
Some(n) if n.plugged => return Ok(()),
Some(n) => n,
None => {
let (path, fmt) = self.disk_file(storage);
if !path.exists() {
std::fs::create_dir_all(self.root.join("disks")).map_err(|e| e.to_string())?;
qemu::create_qcow2(&path, size_gib << 30).map_err(|e| format!("disk {storage}: {e}"))?;
}
let id = node_id(storage);
let bus = q35.then(|| {
let taken: Vec<String> = r.nodes.values().filter_map(|n| n.bus.clone()).collect();
(0..).map(|i| format!("rp{i}")).find(|b| !taken.contains(b)).expect("an unbounded range has a free port")
});
qmp_cmd(&sock, json!({"execute": "blockdev-add", "arguments": {"driver": "file", "filename": path.display().to_string(), "node-name": format!("f{id}")}}))?;
let addr = if q35 {
None
} else {
let taken: Vec<String> = r.nodes.values().filter_map(|n| n.addr.clone()).collect();
Some(pc_slot(&taken).ok_or("i440fx has no free PCI slot left for another data card (0x10..0x1f)")?)
};
qmp_cmd(&sock, json!({"execute": "blockdev-add", "arguments": {"driver": fmt, "node-name": id, "file": format!("f{id}")}}))?;
Node { id, plugged: false, bus, addr }
}
};
qmp_device_add_held(&sock, &node.id, node.bus.as_deref(), node.addr.as_deref(), Duration::from_secs(2), 3).map_err(|e| format!("{address}: {e}"))?;
r.nodes.insert(storage.to_string(), Node { plugged: true, ..node });
if !r.open_disks.iter().any(|d| d == storage) {
r.open_disks.push(storage.to_string());
}
Ok(())
}
/// **The unplug** — `storage/detach` on a running server: `device_del` and
/// the wait for `DEVICE_DELETED`, which only a guest with PCI hot-plug ever
/// sends (tunnr-hotplug-proof). No ack within the wait is the `Err` the
/// estate turns into 511. The node stays, so the member can be resized and
/// re-plugged in place. Without `hotplug`, or with no live machine or no
/// node for the storage (the implicit shape), the record is all there is.
fn fleet_detach(&self, server: &str, storage: &str, address: &str) -> Result<(), String> {
if !self.hotplug {
return Ok(());
}
let mut fleet = self.fleet.lock().unwrap();
let Some(r) = fleet.machines.get_mut(server) else { return Ok(()) };
if !r.guest.as_mut().is_some_and(|g| g.is_running()) {
return Ok(());
}
let Some(node) = r.nodes.get(storage).cloned() else { return Ok(()) };
if !node.plugged {
return Ok(());
}
let sock = r.boots.last().map(|b| b.workdir.join("qmp.sock")).ok_or("a running machine with no boot record")?;
qmp_device_del_wait(&sock, &format!("v{}", node.id), Duration::from_secs(15)).map_err(|e| format!("{address}: {e}"))?;
r.nodes.insert(storage.to_string(), Node { plugged: false, ..node });
Ok(())
}
/// **The firewall, applied to a RUNNING machine** — UpCloud applies a rule
/// set the moment it is written, and slirp's forwards are fixed at QEMU
/// start, so each change is made live over QMP (HMP `hostfwd_remove` for a
/// port no longer admitted, `hostfwd_add` for a newly admitted one, on eth0's
/// `n0`). A stopped machine needs nothing: its next power-on renders
/// [`Machine::public_admitted`]. A machine that does not serve a public
/// address (no `--public-on-loopback`) has nothing to change.
///
/// Removals go first, so a port is never served past the write that closed
/// it. Every step is recorded on the boot (`firewall` in the evidence); the
/// first one QEMU refuses is the `Err`, and what was already applied stays
/// applied and recorded.
pub fn fleet_firewall(&self, server: &str, admitted: &[(&'static str, u16)]) -> Result<(), String> {
let mut fleet = self.fleet.lock().unwrap();
let Some(r) = fleet.machines.get_mut(server) else { return Ok(()) };
let Some(g) = r.guest.as_mut() else { return Ok(()) };
if !g.is_running() {
return Ok(());
}
let Some(b) = r.boots.last_mut() else { return Ok(()) };
let Some(public) = b.public_ip.clone() else { return Ok(()) };
let want = public_ports(admitted);
let have: Vec<(String, u16)> = b.public.iter().map(|(p, n, _)| (p.clone(), *n)).collect();
let mut steps: Vec<(bool, &'static str, u16)> = Vec::new();
for (p, n) in &have {
if !want.iter().any(|(wp, wn)| wp == p && wn == n) {
let proto = mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS.iter().find(|(ip, _)| ip == p).map(|(ip, _)| *ip).unwrap_or("tcp");
steps.push((false, proto, *n));
}
}
for (p, n) in &want {
if !have.iter().any(|(hp, hn)| hp == p && hn == n) {
steps.push((true, p, *n));
}
}
let t = b.started.elapsed().as_millis() as u64;
for (add, proto, port) in steps {
let rule = hostfwd_rule(proto, &public, port);
let line = if add {
format!("hostfwd_add n0 {rule}")
} else {
format!("hostfwd_remove n0 {}", rule.split('-').next().unwrap_or(&rule))
};
let answer = hmp(g, &line);
b.firewall.push(json!({"at_ms": t, "hmp": line, "answer": answer.as_ref().map(|s| s.clone()).unwrap_or_else(|e| e.clone())}));
let refused = match &answer {
Err(e) => Some(e.clone()),
// HMP answers "" on success and prints its complaint otherwise.
Ok(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
Ok(_) => None,
};
if let Some(why) = refused {
return Err(format!("firewall-live: server {server}: `{line}` → {why}"));
}
if add {
b.public.push((proto.to_string(), port, format!("{public}:{port}")));
} else {
b.public.retain(|(p, n, _)| !(p == proto && *n == port));
}
}
// Keep the record in the installer ports' order, as a boot renders it.
let order = |p: &str, n: u16| mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS.iter().position(|x| x.0 == p && x.1 == n).unwrap_or(usize::MAX);
b.public.sort_by_key(|(p, n, _)| order(p, *n));
Ok(())
}
fn fleet_forget_server(&self, server: &str) {
let mut fleet = self.fleet.lock().unwrap();
if let Some(mut r) = fleet.machines.remove(server) {
if let Some(g) = r.guest.as_mut() {
let _ = g.kill();
}
self.finish(&mut r);
}
let _ = std::fs::remove_dir_all(self.server_dir(server));
}
/// **No storage outlives its mock** (round 5.3.1: "old mock disks linger after
/// `mock down`"). The estate is in memory: once the mock exits, no API can
/// name a disk or a medium again, and the next start only reclaimed them
/// then. So on the way out — after [`DraupnirGuest::kill_all`] — every disk
/// and medium file under the root goes, and the count is said. The
/// machines' boot evidence (`servers/`) stays for reading until the next
/// start. Answers how many files and bytes were removed.
pub fn sweep_storage_files(&self) -> (usize, u64) {
let (mut n, mut bytes) = (0usize, 0u64);
for d in ["disks", "media"] {
if let Ok(rd) = std::fs::read_dir(self.root.join(d)) {
for ent in rd.flatten() {
let len = ent.metadata().map(|m| m.len()).unwrap_or(0);
if std::fs::remove_file(ent.path()).is_ok() {
n += 1;
bytes += len;
}
}
}
}
(n, bytes)
}
/// Kill every machine. The mock calls this on its way out.
pub fn kill_all(&self) -> usize {
let mut fleet = self.fleet.lock().unwrap();
let mut n = 0;
let keys: Vec<String> = fleet.machines.keys().cloned().collect();
for k in keys {
let r = fleet.machines.get_mut(&k).expect("key");
if let Some(g) = r.guest.as_mut() {
if g.is_running() {
n += 1;
}
let _ = g.kill();
}
self.finish(r);
let _ = std::fs::remove_file(self.server_dir(&k).join("qemu.pid"));
}
n
}
/// **Photograph every running machine once.** The mock binary calls this
/// every [`frame_gap`](DraupnirGuest::frame_gap) (200 ms): the whole install
/// is 2–3 s under KVM, so anything slower sees nothing. A frame equal to the
/// one before it is not kept.
pub fn sample(&self) {
let mut fleet = self.fleet.lock().unwrap();
for r in fleet.machines.values_mut() {
let Some(g) = r.guest.as_mut() else { continue };
if !g.is_running() {
self.finish(r);
continue;
}
let Some(b) = r.boots.last_mut() else { continue };
// Past the cap, the screen is still dumped — to ONE rolling file, `frame-last.ppm`, never
// counted as a distinct frame — so the newest screen always exists. With the old hard
// stop, a guest that filled 64 frames in its first minutes (a console scrolling log
// lines) showed nothing of what came after: T15's TD8 lost the twin's follower that way.
if b.frames.len() >= MAX_FRAMES {
let _ = g.screendump(&b.workdir.join("frame-last.ppm"));
continue;
}
let to = b.workdir.join(format!("frame-{}.ppm", b.frames.len()));
match g.screendump(&to).map_err(|e| e.to_string()).and_then(|()| std::fs::read(&to).map_err(|e| e.to_string())) {
Ok(bytes) => {
let h = sha256_hex(&bytes);
if !b.frames.contains(&h) {
b.frames.push(h);
b.frame_ms.push(b.started.elapsed().as_millis() as u64);
}
}
// QEMU binds its QMP socket a few ms after it starts; a miss
// in the first second is that, not a fault.
Err(e) if b.started.elapsed() < Duration::from_secs(1) => {
let _ = e;
}
Err(e) => {
b.qmp_failures += 1;
b.qmp_error = Some(e);
}
}
}
}
fn fleet_evidence(&self, server: &str) -> Option<Value> {
let acked: Vec<String> = self.utility.as_ref().map(|(f, _)| f.lock().unwrap().acked()).unwrap_or_default();
let mut fleet = self.fleet.lock().unwrap();
let r = fleet.machines.get_mut(server)?;
let running = r.guest.as_mut().is_some_and(|g| g.is_running());
let nodes: Vec<Value> = r.nodes.iter().map(|(s, n)| json!({"storage": s, "node": n.id, "plugged": n.plugged, "bus": n.bus})).collect();
let live_stdout = r.guest.as_ref().map(|g| g.stdout_bytes());
let boots: Vec<Value> = r
.boots
.iter()
.enumerate()
.map(|(i, b)| {
let last = i + 1 == r.boots.len();
json!({
"argv": b.argv.join(" "),
"workdir": b.workdir.display().to_string(),
"ran_ms": b.ran_ms,
"frames": b.frames,
"frame_ms": b.frame_ms,
"forwards": b.forwards.iter().map(|(g, h)| json!({"guest_port": g, "host": h})).collect::<Vec<_>>(),
"public": b.public.iter().map(|(p, g, h)| json!({"proto": p, "guest_port": g, "at": h})).collect::<Vec<_>>(),
"firewall": b.firewall,
"utility": b.utility.as_ref().map(|(mac, addr)| json!({"mac": mac, "address": addr, "acked": acked.contains(addr)})),
"vnc": b.vnc.as_ref().map(|(h, p)| format!("{h}:{p}")),
"progressed": progressed_verdict(b.frames.len(), b.qmp_failures),
"qmp_failures": b.qmp_failures,
"qmp_error": b.qmp_error,
"stdout_bytes": if last && running { live_stdout.unwrap_or(0) } else { b.stdout_bytes },
"disks": b.disks.iter().map(|(st, before, after)| json!({
"storage": st, "bytes_before": before,
"bytes_after": after.or_else(|| last.then(|| file_len(&self.disk_file(st).0))),
// What the header claims NOW — how a resize shows.
"virtual_bytes": qemu::qcow2_virtual_size(&self.disk_file(st).0).ok(),
})).collect::<Vec<_>>(),
})
})
.collect();
Some(json!({
"running": running,
"ejected": r.ejected,
"refused": r.refused,
"boots": boots,
"nodes": nodes,
}))
}
}
/// Is `pid` a QEMU started under `root`? Read from `/proc`, so a recycled pid
/// that is now some other program is not mistaken for a leftover guest.
fn qemu_alive_under(pid: u32, root: &str) -> bool {
std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|c| {
let c = String::from_utf8_lossy(&c).replace('\0', " ");
c.contains("qemu-system") && c.contains(root)
})
.unwrap_or(false)
}
// ── QMP, event-aware ─────────────────────────────────────────────────────────
//
// draupnir's `qmp_execute` reads a command's `return`; the unplug needs the
// EVENT that follows it (`DEVICE_DELETED`, sent only once the guest has let
// go), so these open their own session. The shape is gunnar-growbench's
// `rig.rs` (`qmp_device_del`, `qmp_device_add_blk_held`), which measured it.
/// **One HMP command through QMP** (`human-monitor-command`), for the two slirp
/// verbs QMP has no command of its own for: `hostfwd_add` / `hostfwd_remove`.
/// The answer is HMP's text — empty on success. QEMU binds its QMP socket a few
/// ms after it starts, and a rule set is often written right after the
/// power-on, so a connect that finds no socket yet is tried again for 3 s.
fn hmp(g: &QemuGuest, line: &str) -> Result<String, String> {
let t0 = Instant::now();
loop {
match g.qmp_execute("human-monitor-command", Some(json!({"command-line": line}))) {
Ok(v) => return Ok(v.as_str().unwrap_or_default().to_string()),
Err(e) => {
let e = e.to_string();
if e.contains("qmp connect") && t0.elapsed() < Duration::from_secs(3) {
std::thread::sleep(Duration::from_millis(50));
continue;
}
return Err(e);
}
}
}
}
/// One QMP session past the greeting and `qmp_capabilities`.
fn qmp_open(sock: &Path, read_timeout: Duration) -> Result<(std::os::unix::net::UnixStream, std::io::BufReader<std::os::unix::net::UnixStream>), String> {
use std::io::{BufRead, Write};
let s = std::os::unix::net::UnixStream::connect(sock).map_err(|e| format!("qmp {}: {e}", sock.display()))?;
s.set_read_timeout(Some(read_timeout)).map_err(|e| e.to_string())?;
let mut w = s.try_clone().map_err(|e| e.to_string())?;
let mut r = std::io::BufReader::new(s);
let mut line = String::new();
r.read_line(&mut line).map_err(|e| format!("qmp greeting: {e}"))?;
w.write_all(b"{\"execute\":\"qmp_capabilities\"}\n").map_err(|e| e.to_string())?;
loop {
line.clear();
r.read_line(&mut line).map_err(|e| format!("qmp capabilities: {e}"))?;
if line.contains("\"return\"") || line.contains("\"error\"") {
break;
}
}
Ok((w, r))
}
/// One command → its `return` value; an `error` reply is the `Err`, with
/// QEMU's own words. Events between are skipped.
fn qmp_cmd(sock: &Path, msg: Value) -> Result<Value, String> {
use std::io::{BufRead, Write};
let (mut w, mut r) = qmp_open(sock, Duration::from_secs(20))?;
w.write_all(format!("{msg}\n").as_bytes()).map_err(|e| e.to_string())?;
let mut line = String::new();
loop {
line.clear();
if r.read_line(&mut line).map_err(|e| format!("qmp answer to {msg}: {e}"))? == 0 {
return Err(format!("qmp: QEMU closed the socket after {msg}"));
}
let v: Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(e) = v.get("error") {
return Err(format!("qmp {} → {}", msg["execute"].as_str().unwrap_or("?"), e["desc"].as_str().unwrap_or(&e.to_string())));
}
if let Some(ret) = v.get("return") {
return Ok(ret.clone());
}
}
}
/// `device_del` of `dev` and the WAIT for the guest's acknowledgement: QEMU
/// emits `DEVICE_DELETED` for the id only after the guest's `_EJ0` / slot
/// power-off. `Err` when QEMU refuses, or no ack arrives within `wait` (a
/// kernel without PCI hot-plug never produces one — that is the 511).
fn qmp_device_del_wait(sock: &Path, dev: &str, wait: Duration) -> Result<Duration, String> {
use std::io::{BufRead, Write};
let (mut w, mut r) = qmp_open(sock, Duration::from_secs(2))?;
w.write_all(format!("{}\n", json!({"execute": "device_del", "arguments": {"id": dev}})).as_bytes()).map_err(|e| e.to_string())?;
let t0 = Instant::now();
let mut accepted = false;
let mut line = String::new();
while t0.elapsed() < wait {
line.clear();
match r.read_line(&mut line) {
Ok(0) => return Err("qmp: QEMU closed the socket during device_del".into()),
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => continue,
Err(e) => return Err(format!("qmp device_del {dev}: {e}")),
}
let Ok(v) = serde_json::from_str::<Value>(line.trim()) else { continue };
if let Some(e) = v.get("error") {
return Err(format!("qmp device_del {dev} → {}", e["desc"].as_str().unwrap_or(&e.to_string())));
}
if v.get("return").is_some() {
accepted = true;
}
if v["event"] == "DEVICE_DELETED" && v["data"]["device"] == dev {
return Ok(t0.elapsed());
}
}
Err(format!(
"device_del {dev}: {} — no DEVICE_DELETED from the guest within {} s (the kernel did not acknowledge the eject)",
if accepted { "accepted by QEMU" } else { "not even accepted" },
wait.as_secs()
))
}
/// Is `v<id>` a card QEMU holds right now? `query-block` names each attached
/// block device's qdev path; a node whose card was unplugged has none.
fn qmp_device_present(sock: &Path, id: &str) -> Result<bool, String> {
let blocks = qmp_cmd(sock, json!({"execute": "query-block"}))?;
Ok(blocks.as_array().into_iter().flatten().any(|b| b["qdev"].as_str().is_some_and(|q| q.contains(&format!("/peripheral/v{id}/")) || q == format!("v{id}"))))
}
/// **Re-plug and make sure it stays plugged.** MEASURED by the rig
/// (2026-09-15, hot-plug kernel): a `device_add` sent right after the guest
/// acked the unplug returned success, yet the card was gone moments later —
/// the slot was still powering off and took the new card with it. Watch the
/// card for `hold`; add again when it vanishes, up to `tries` times.
fn qmp_device_add_held(sock: &Path, id: &str, bus: Option<&str>, addr: Option<&str>, hold: Duration, tries: u32) -> Result<u32, String> {
for attempt in 1..=tries {
let mut args = json!({"driver": "virtio-blk-pci", "drive": id, "id": format!("v{id}")});
if let Some(b) = bus {
args["bus"] = json!(b);
}
if let Some(s) = addr {
args["addr"] = json!(s);
}
qmp_cmd(sock, json!({"execute": "device_add", "arguments": args}))?;
let t0 = Instant::now();
let mut present = true;
while t0.elapsed() < hold {
std::thread::sleep(Duration::from_millis(250));
if !qmp_device_present(sock, id)? {
present = false;
break;
}
}
if present {
return Ok(attempt);
}
std::thread::sleep(Duration::from_secs(2));
}
Err(format!("v{id}: re-plugged {tries} times and never stayed attached"))
}
fn file_len(p: &Path) -> u64 {
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
/// Why a live test cannot boot a QEMU under KVM here (`None`: it can): `dev` must open
/// read-write, as QEMU opens it. A build container has no `/dev/kvm`.
fn kvm_absent(dev: &std::path::Path) -> Option<String> {
std::fs::OpenOptions::new().read(true).write(true).open(dev).err().map(|e| format!("{}: {e}", dev.display()))
}
/// The live tests' guard, instead of `#[ignore]`: `true` boots; `false` printed
/// `SKIP <name>: …` (no `/dev/kvm`), and the test passes having booted nothing.
fn live_or_skip(name: &str) -> bool {
match kvm_absent(std::path::Path::new("/dev/kvm")) {
None => true,
Some(why) => {
eprintln!("SKIP {name}: needs KVM ({why})");
false
}
}
}
/// ★ **No KVM, no boot, and the reason by name** (score bar tests.no-ignore): a device that
/// is not there or does not open read-write is named; one that opens is no reason.
#[test]
fn kvm_is_absent_when_the_device_does_not_open_read_write() {
let dir = std::env::temp_dir().join(format!("kvm-absent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let missing = dir.join("kvm");
let why = kvm_absent(&missing).expect("a missing device is a reason");
assert!(why.contains(&missing.display().to_string()), "{why}");
std::fs::write(&missing, b"").unwrap();
assert_eq!(kvm_absent(&missing), None, "a device that opens read-write");
let mut ro = std::fs::metadata(&missing).unwrap().permissions();
ro.set_readonly(true);
std::fs::set_permissions(&missing, ro).unwrap();
// root opens a read-only file read-write anyway; only an ordinary user sees the refusal.
let root = std::fs::OpenOptions::new().write(true).open(&missing).is_ok();
assert_eq!(kvm_absent(&missing).is_none(), root, "a device that does not open read-write");
std::fs::remove_dir_all(&dir).unwrap();
}
/// ★ **The live tests skip by name, they are not ignored**: every `live_` test asks
/// [`live_or_skip`] first, so `cargo test` runs them wherever KVM is and says SKIP elsewhere.
#[test]
fn every_live_test_skips_by_name_and_none_is_ignored() {
let src = include_str!("lib.rs");
let lines: Vec<&str> = src.lines().collect();
let attr = ["#[", "ignore"].concat();
assert!(!lines.iter().any(|l| l.trim_start().starts_with(&attr)), "an ignored test is left");
let mut live = 0;
for (i, l) in lines.iter().enumerate() {
if let Some(rest) = l.trim_start().strip_prefix("fn live_") {
let name = format!("live_{}", rest.split('(').next().unwrap());
if name == "live_or_skip" {
continue;
}
live += 1;
let first = lines[i + 1..].iter().find(|l| !l.trim().is_empty() && !l.trim_start().starts_with("use ")).unwrap();
assert!(first.contains(&format!("live_or_skip(\"{name}\")")), "{name} boots without asking for KVM: {first}");
}
}
assert_eq!(live, 2, "the two live tests");
}
/// ★ **The host's caps** (round 5.3.1, the UpCloud box: `-smp` could not be
/// lowered, so each lane was pinned to a core set instead): a guest gets at
/// most the cap, never more than its plan, and never no core at all.
#[test]
fn a_guest_gets_its_plan_under_the_hosts_caps() {
assert_eq!(capped(4, 16_384, None, None), (4, 16_384), "no cap: the plan, exactly");
assert_eq!(capped(4, 16_384, Some(2), Some(8_192)), (2, 8_192));
assert_eq!(capped(2, 4_096, Some(8), Some(65_536)), (2, 4_096), "a cap never raises the plan");
assert_eq!(capped(4, 4_096, Some(0), None), (1, 4_096), "never no core");
}
/// ★ **A QEMU that exits at once said why** (round 5.3.1): a taken port is
/// booted again; words on stderr are the HOST's refusal, by name; an exit
/// with nothing said is a guest's own power-off.
/// ★ A host port lost between the pick and QEMU's own bind is a TAKEN port (boot again with
/// fresh ports), whatever words QEMU says it in. Dwarf 2026-10-12: the twin's forwards named
/// 127.0.0.1:35453 twice (`-:80` and `-:2222`), QEMU said only "Could not set up host
/// forwarding rule", the mock called it the host's refusal, never booted the twin again, and
/// the install waited 30 min on "no WireGuard handshake yet".
#[test]
fn a_forwarding_rule_qemu_could_not_set_up_is_a_taken_port() {
assert_eq!(breath_of("qemu-system-x86_64: -netdev user,id=n0,hostfwd=tcp:127.0.0.1:35453-:80,hostfwd=tcp:127.0.0.1:35453-:2222: Could not set up host forwarding rule 'tcp:127.0.0.1:35453-:80'\n"), FirstBreath::TakenPort);
}
/// ★ The host ports of ONE server are distinct: each is held while the next is picked.
#[test]
fn one_servers_host_ports_are_never_the_same_port_twice() {
let g = DraupnirGuest::default();
for _ in 0..200 {
let mut boot = g.spec_for(&spec());
let fw = wire_nics(&mut boot, Mtls::Door, None, false);
let mut ports: Vec<&str> = fw.iter().map(|(_, a)| a.as_str()).collect();
let n = ports.len();
ports.sort();
ports.dedup();
assert_eq!(ports.len(), n, "a host port twice: {fw:?}");
}
}
#[test]
fn a_qemu_that_exits_at_once_is_the_hosts_refusal_by_its_own_words() {
assert_eq!(breath_of("qemu-system-x86_64: -netdev user: Could not set up host forwarding rule: Address already in use\n"), FirstBreath::TakenPort);
assert_eq!(breath_of(""), FirstBreath::Alive);
assert_eq!(breath_of("\n \n"), FirstBreath::Alive);
assert_eq!(
breath_of("qemu-system-x86_64: cannot set up guest memory 'pc.ram': Cannot allocate memory\n"),
FirstBreath::Died("qemu-system-x86_64: cannot set up guest memory 'pc.ram': Cannot allocate memory".into())
);
}
use draupnir::{Display, Firmware, SerialConsole};
fn spec() -> GuestSpec {
let mut s = GuestSpec::new("appliance", "/vm/appliance.qcow2");
s.medium = Some("/vm/gunnar.iso".to_string());
s.cdrom_first = true;
s
}
/// **A public forward another process holds is refused BY NAME before QEMU starts**
/// (stinkies mock-boot-udp-port-collision, loki 2026-09-30: kdeconnectd held
/// 0.0.0.0:51821/udp — inside Linux's ephemeral range — so every guest's
/// `hostfwd=udp:127.86.33.13:51821-:51821` failed, QEMU exited at once on every
/// boot and the API still said `started`). Held here the way it was there: a UDP
/// socket on the WILDCARD address and the port asked on a 127.x public address.
#[test]
fn a_public_forward_a_host_process_holds_is_refused_by_name() {
let held = std::net::UdpSocket::bind("0.0.0.0:0").expect("a wildcard udp socket");
let port = held.local_addr().unwrap().port();
let taken = vec![("udp".to_string(), port, format!("127.86.33.13:{port}"))];
let why = public_port_taken(&taken).expect("a held port is named");
assert!(why.starts_with("mock-public-port-in-use:") && why.contains(&format!("127.86.33.13:{port}")) && why.contains("ss -lunp"), "{why}");
drop(held);
assert_eq!(public_port_taken(&taken), None, "a free port is not refused");
}
/// The machine this engine renders honours every clause — and the check is
/// the engine's OWN preflight, so the test and the shipped path agree by
/// construction rather than by a second reading of the same table.
#[test]
fn the_rendered_machine_passes_its_own_clause_check() {
let g = DraupnirGuest::default();
let s = spec();
let argv = qemu::argv(&g.spec_for(&s), None, None).unwrap();
assert_eq!(g.preflight(&s, &argv), None, "argv: {}", argv.join(" "));
}
/// **The refusals, by name.** A guest handed a machine with a serial console
/// or a UEFI pflash must be refused, not booted: those two are exactly what a
/// tunnr-shaped guest would have produced, and booting them would report
/// behaviour 13 and 15 as tested when they were not.
#[test]
fn a_serial_console_or_a_pflash_is_refused_by_name() {
let g = DraupnirGuest::default();
let s = spec();
let good = qemu::argv(&g.spec_for(&s), None, None).unwrap();
let with_serial: Vec<String> = good
.iter()
.map(|a| if a == "none" { "mon:stdio".into() } else { a.clone() })
.collect();
assert_eq!(
g.preflight(&s, &with_serial).map(|(c, _)| c),
Some("no-serial-console")
);
let mut with_pflash = good.clone();
with_pflash.push("-drive".into());
with_pflash.push("if=pflash,format=raw,readonly=on,file=/fw/OVMF_CODE_4M.fd".into());
assert_eq!(g.preflight(&s, &with_pflash).map(|(c, _)| c), Some("seabios"));
let no_screen: Vec<String> = good
.iter()
.filter(|a| a.as_str() != "std")
.cloned()
.collect();
assert_eq!(
g.preflight(&s, &no_screen).map(|(c, _)| c),
Some("framebuffer")
);
// L46: an argv on q35 while the engine owes i440fx is refused by name.
let on_q35: Vec<String> = good
.iter()
.map(|a| if a.starts_with("pc,") { a.replacen("pc,", "q35,", 1) } else { a.clone() })
.collect();
assert_eq!(g.preflight(&s, &on_q35).map(|(c, _)| c), Some("machine-pc"));
}
/// **Each forward on the NIC it is served on** (T15's TD8): git's 2222 on
/// eth0 (`n0`, the public NIC), gRPC's 50051 on eth1 (`n1`, utility), both
/// bound to 127.0.0.1 and nowhere else, and nothing forwarded twice.
#[test]
fn git_rides_the_public_nic_and_grpc_the_utility_nic() {
let g = DraupnirGuest::default();
let mut boot = g.spec_for(&spec());
let fw = wire_nics(&mut boot, Mtls::Off, None, false);
let j = qemu::argv(&boot, None, None).unwrap().join(" ");
let n0 = j.split(" -netdev ").find(|a| a.starts_with("user,id=n0")).unwrap().split(' ').next().unwrap().to_string();
let n1 = j.split(" -netdev ").find(|a| a.starts_with("user,id=n1")).unwrap().split(' ').next().unwrap().to_string();
assert!(n0.contains("-:2222") && !n0.contains("-:50051"), "eth0: {n0}");
assert!(n1.contains("-:50051") && !n1.contains("-:2222"), "eth1: {n1}");
assert!(n1.contains("net=10.0.3.0/24") && !n0.contains("net=10.0.3"), "eth1 is its own subnet, eth0 is not: {n0} / {n1}");
for nic in [&n0, &n1] {
assert!(nic.contains("hostfwd=tcp:127.0.0.1:") && !nic.contains("hostfwd=tcp::"), "loopback only: {nic}");
}
assert_eq!(fw.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [80, 443, 2222, 50051]);
assert_eq!(g.preflight(&spec(), &j.split(' ').map(String::from).collect::<Vec<_>>()), None);
}
/// **The installer dials the API's public address at the real ports**
/// (`--public-on-loopback`): eth0 carries tcp/8474, udp/51820 and udp/51821
/// from the server's own loopback address to the same port in the guest,
/// beside the loopback TCP forwards it always had and the DNS-to-nowhere;
/// draupnir's own NIC stands down (`-nic none`) so there is ONE eth0.
#[test]
fn the_installer_ports_ride_the_public_nic_on_the_servers_own_address() {
let g = DraupnirGuest::default();
let mut boot = g.spec_for(&spec());
let fw = wire_nics(&mut boot, Mtls::Off, None, false);
let public = serve_public_on(&mut boot, "127.80.99.12", mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS);
let argv = qemu::argv(&boot, None, None).unwrap();
let j = argv.join(" ");
let n0s: Vec<&str> = j.split(" -netdev ").filter(|a| a.starts_with("user,id=n0")).collect();
assert_eq!(n0s.len(), 1, "one eth0: {j}");
let n0 = n0s[0].split(' ').next().unwrap();
for rule in ["hostfwd=tcp:127.80.99.12:8474-:8474", "hostfwd=udp:127.80.99.12:51820-:51820", "hostfwd=udp:127.80.99.12:51821-:51821", "hostfwd=tcp:127.80.99.12:8475-:8475", "hostfwd=udp:127.80.99.12:51822-:51822", "hostfwd=udp:127.80.99.12:51830-:51830"] {
assert!(n0.contains(rule), "{rule} missing: {n0}");
}
let (_, git) = fw.iter().find(|(p, _)| *p == 2222).unwrap();
assert!(n0.contains(&format!("hostfwd=tcp:{git}-:2222")), "the loopback forward stays: {n0}");
assert!(n0.contains("dns="), "behaviour 18 stays: {n0}");
assert!(j.contains("-nic none"), "draupnir's NIC stands down: {j}");
assert_eq!(public.iter().map(|(p, n, a)| format!("{p}/{n}@{a}")).collect::<Vec<_>>(), [
"tcp/8474@127.80.99.12:8474",
"udp/51820@127.80.99.12:51820",
"udp/51821@127.80.99.12:51821",
"tcp/8475@127.80.99.12:8475",
// DEV-NARRATE (2026-09-30): the sealed early-boot narration, when admitted.
"udp/51822@127.80.99.12:51822",
// OX-WGDS3: the front's downstream WireGuard interface.
"udp/51830@127.80.99.12:51830"
]);
assert_eq!(g.preflight(&spec(), &argv), None, "{j}");
}
/// ★ **A server with no public address has no public NIC** (owner 2026-10-23, the lean
/// estate): its ONE NIC is the utility fabric (it is the guest's eth0), draupnir's own NIC
/// stands down, and nothing is forwarded from the host — no user-mode NIC NATs it out.
#[test]
fn a_server_with_no_public_address_has_only_its_utility_nic() {
let g = DraupnirGuest::default();
let mut boot = g.spec_for(&spec());
let group: std::net::SocketAddrV4 = "239.1.2.3:4567".parse().unwrap();
let fw = wire_nics(&mut boot, Mtls::Off, Some((group, "52:54:00:aa:bb:cc")), false);
let fw = no_public_nic(&mut boot, fw);
let j = qemu::argv(&boot, None, None).unwrap().join(" ");
assert!(fw.is_empty(), "nothing forwarded: {fw:?}");
assert!(!j.contains("user,id="), "no user-mode NIC: {j}");
assert!(j.contains("-nic none"), "draupnir's NIC stands down: {j}");
assert_eq!(j.matches("virtio-net-pci").count(), 1, "one NIC: {j}");
assert!(j.contains("dgram,id=n1") && j.contains("mac=52:54:00:aa:bb:cc"), "the utility fabric: {j}");
// The preflight's behaviour 18 (no DNS answer, no inbound UDP) holds: there is no
// user-mode NIC to answer one (the lean gate's main stood at `no-inbound-udp`).
let argv: Vec<String> = j.split(' ').map(String::from).collect();
assert_eq!(g.preflight(&spec(), &argv), None, "{j}");
}
/// **A port the firewall does not admit is not forwarded at boot.** Only
/// the admitted installer ports ride eth0; with nothing admitted eth0 is
/// still `n0` (so a later rule can open a port on it live) and carries no
/// public forward at all; a port that is not an installer port is never
/// rendered, whatever the caller passes.
#[test]
fn only_the_admitted_installer_ports_are_forwarded_at_boot() {
let g = DraupnirGuest::default();
let n0_of = |admitted: &[(&'static str, u16)]| {
let mut boot = g.spec_for(&spec());
wire_nics(&mut boot, Mtls::Off, None, false);
let public = serve_public_on(&mut boot, "127.80.99.12", admitted);
let argv = qemu::argv(&boot, None, None).unwrap();
let j = argv.join(" ");
let n0 = j.split(" -netdev ").find(|a| a.starts_with("user,id=n0")).unwrap().split(' ').next().unwrap().to_string();
assert_eq!(g.preflight(&spec(), &argv), None, "{j}");
(n0, public)
};
let (n0, public) = n0_of(&[("udp", 51821)]);
assert!(n0.contains("hostfwd=udp:127.80.99.12:51821-:51821"), "{n0}");
assert!(!n0.contains(":8474-") && !n0.contains(":51820-"), "not admitted, still forwarded: {n0}");
assert_eq!(public, vec![("udp".to_string(), 51821, "127.80.99.12:51821".to_string())]);
let (n0, public) = n0_of(&[]);
assert!(!n0.contains("127.80.99.12"), "nothing admitted, something forwarded: {n0}");
assert!(public.is_empty());
let (n0, _) = n0_of(&[("udp", 53), ("tcp", 22), ("tcp", 8474)]);
assert!(n0.contains("hostfwd=tcp:127.80.99.12:8474-:8474") && !n0.contains("127.80.99.12:53") && !n0.contains("127.80.99.12:22"), "{n0}");
}
/// **Live, under KVM, through the estate: the public address answers
/// exactly what the server's firewall admits, and follows every write.**
/// A server laid with `firewall: on` and no rules is wide open (all three
/// ports held by its QEMU); the rule set with its catch-all drop is then
/// written and ONLY the admitted port answers — a tcp/8474 without a rule is
/// `connection refused` at the public address, the udp/51821 with one is
/// held; a second write turns it round, live, with no restart; and the next
/// power-on starts with just the admitted set. Fails before the firewall
/// reached the engine: every port stayed forwarded whatever the rules said.
/// Without /dev/kvm it says SKIP by name.
#[test]
fn live_the_public_address_answers_exactly_what_the_firewall_admits() {
if !live_or_skip("live_the_public_address_answers_exactly_what_the_firewall_admits") {
return;
}
use mock_upcloud::estate::Rule;
use mock_upcloud::{Clock, Estate, Faults};
let root = std::env::temp_dir().join(format!("lane-c-fw-{}", std::process::id()));
let (g, _) = DraupnirGuest::for_estate(root.clone()).unwrap();
let g = std::sync::Arc::new(g);
let mut e = Estate::new(Clock::virtual_only(), Faults::none(), 23).with_public_on_loopback(80, 97).with_engine(g.clone());
let uuid = e.create_server("main", "main", "1xCPU-1GB", "se-sto1", vec![], "boot", 1).unwrap();
e.configure_server(&uuid, vec![], None, true, false, "UTC").unwrap();
e.run_to_quiet();
let public = e.server(&uuid).unwrap().public_ip.clone();
assert!(public.starts_with("127.80.97."), "{public}");
std::thread::sleep(Duration::from_millis(1500));
let held = |proto: &str, port: u16| {
let taken = match proto {
"tcp" => std::net::TcpListener::bind((public.as_str(), port)).err().map(|e| e.kind()),
_ => std::net::UdpSocket::bind((public.as_str(), port)).err().map(|e| e.kind()),
};
taken == Some(std::io::ErrorKind::AddrInUse)
};
let dial_8474 = || std::net::TcpStream::connect_timeout(&format!("{public}:8474").parse().unwrap(), Duration::from_secs(2)).map(|_| ()).map_err(|e| e.kind());
let rule = |proto: &str, port: &str| Rule {
direction: "in".into(),
action: "accept".into(),
family: "IPv4".into(),
protocol: proto.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 mut verdicts = Vec::new();
let mut check = |when: &str, want: &[(&str, u16, bool)]| {
for (p, n, w) in want {
let h = held(p, *n);
if h != *w {
verdicts.push(format!("{when}: {p}/{public}:{n} held={h}, want {w}"));
}
}
};
// 1. On, no rules: wide open.
check("no rules", &[("tcp", 8474, true), ("udp", 51820, true), ("udp", 51821, true)]);
assert_eq!(dial_8474(), Ok(()), "open firewall: 8474 must connect");
// 2. The WireGuard link admitted, then the catch-all: tcp/8474 has no rule.
e.set_rules(&uuid, vec![rule("udp", "51821"), drop_all.clone()]).unwrap();
check("udp/51821 + drop", &[("tcp", 8474, false), ("udp", 51820, false), ("udp", 51821, true)]);
assert_eq!(dial_8474(), Err(std::io::ErrorKind::ConnectionRefused), "8474 has no rule and still connects");
// 3. Turned round, live: 8474 with a rule, 51821 without.
e.set_rules(&uuid, vec![rule("tcp", "8474"), drop_all.clone()]).unwrap();
check("tcp/8474 + drop", &[("tcp", 8474, true), ("udp", 51820, false), ("udp", 51821, false)]);
assert_eq!(dial_8474(), Ok(()), "8474 has a rule and does not connect");
// 4. The next power-on starts with just what is admitted.
e.set_rules(&uuid, vec![rule("udp", "51821"), drop_all]).unwrap();
e.stop_server(&uuid, true).unwrap();
e.run_to_quiet();
e.start_server(&uuid).unwrap();
e.run_to_quiet();
std::thread::sleep(Duration::from_millis(1500));
check("after a restart", &[("tcp", 8474, false), ("udp", 51820, false), ("udp", 51821, true)]);
let ev = g.fleet_evidence(&uuid).unwrap();
let last_argv = ev["boots"].as_array().unwrap().last().unwrap()["argv"].as_str().unwrap().to_string();
let changes = ev["boots"][0]["firewall"].clone();
g.kill_all();
let _ = std::fs::remove_dir_all(&root);
assert!(verdicts.is_empty(), "{verdicts:#?}\nlive changes: {changes}");
assert!(last_argv.contains(&format!("hostfwd=udp:{public}:51821-:51821")) && !last_argv.contains(&format!("{public}:8474")), "{last_argv}");
assert_eq!(changes.as_array().map(|a| a.len()), Some(6), "two per write, applied live: {changes}");
}
/// **Inbound UDP is the WireGuard ports on a public address, and nothing
/// else.** A forward of DNS, or of a WireGuard port on 127.0.0.1 (a port the
/// installer cannot tell apart per server), or to a different guest port, is
/// `no-inbound-udp`.
#[test]
fn any_other_inbound_udp_is_refused_by_name() {
let g = DraupnirGuest::default();
for bad in [
"hostfwd=udp:127.80.99.12:53-:53",
"hostfwd=udp:127.80.99.12:51821-:51820",
"hostfwd=udp::51821-:51821",
"hostfwd=udp:0.0.0.0:51821-:51821",
"hostfwd=udp:192.0.2.10:51821-:51821",
] {
let mut argv = qemu::argv(&g.spec_for(&spec()), None, None).unwrap();
let i = argv.iter().position(|a| a.starts_with("user,id=n0")).unwrap();
argv[i].push_str(&format!(",{bad}"));
assert_eq!(g.preflight(&spec(), &argv).map(|(c, _)| c), Some("no-inbound-udp"), "{bad}");
}
}
/// ★ **The front's downstream WireGuard port is inbound UDP the mock admits** (lane
/// OX-WGDS3): a forward of udp/51830 on a public address, to the same guest port, passes the
/// guard; on another guest port it is still `no-inbound-udp`.
#[test]
fn the_downstream_wireguard_port_is_an_admitted_inbound_udp() {
let g = DraupnirGuest::default();
let mut argv = qemu::argv(&g.spec_for(&spec()), None, None).unwrap();
let i = argv.iter().position(|a| a.starts_with("user,id=n0")).unwrap();
let mut bad = argv.clone();
argv[i].push_str(",hostfwd=udp:127.80.99.12:51830-:51830");
assert!(udp_forwards_are_wireguard_only(&argv.join(" ")), "{}", argv.join(" "));
bad[i].push_str(",hostfwd=udp:127.80.99.12:51830-:51831");
assert_eq!(g.preflight(&spec(), &bad).map(|(c, _)| c), Some("no-inbound-udp"));
}
/// **Live, under KVM: each server's public address is HELD, at the
/// installer's ports, by that server's QEMU** — so the installer's dial
/// lands in that guest and nowhere else. Three machines, three addresses,
/// the same three ports on each. Fails on the mock before
/// `--public-on-loopback`: the API's address was TEST-NET and nothing on
/// this host answered at it. Without /dev/kvm it says SKIP by name.
#[test]
fn live_each_servers_public_address_is_held_at_the_installer_ports() {
if !live_or_skip("live_each_servers_public_address_is_held_at_the_installer_ports") {
return;
}
let root = std::env::temp_dir().join(format!("lane-c-public-{}", std::process::id()));
let (g, _) = DraupnirGuest::for_estate(root.clone()).unwrap();
let publics = ["127.80.98.10", "127.80.98.11", "127.80.98.12"];
let servers: Vec<String> = (0..3).map(|i| format!("00000000-0000-4000-8000-00000000c0{i}")).collect();
for (s, p) in servers.iter().zip(publics) {
let m = Machine {
server: s.clone(),
mem_mb: 256,
cores: 1,
disks: vec![mock_upcloud::kvm::MachineDisk { storage: format!("01{}", &s[2..]), size_gib: 1, template: None }],
medium: None,
cdrom_first: false,
disk_first: false,
rtc_skew_seconds: 7200,
utility_mac: String::new(),
utility_offer: mock_upcloud::net::DhcpOffer::for_address("10.13.8.100"),
dhcp_late_first_boot_ms: 0,
hostname: String::new(),
ssh_keys: Vec::new(),
user_data: None,
public_ip: p.to_string(),
public_admitted: mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS.to_vec(),
};
g.fleet_power_on(&m).unwrap();
}
std::thread::sleep(Duration::from_millis(1500));
let mut held = Vec::new();
for p in publics {
for (proto, port) in mock_upcloud::kvm::INSTALLER_PUBLIC_PORTS {
let taken = match *proto {
"tcp" => std::net::TcpListener::bind((p, *port)).err().map(|e| e.kind()),
_ => std::net::UdpSocket::bind((p, *port)).err().map(|e| e.kind()),
};
held.push((format!("{proto}/{p}:{port}"), taken == Some(std::io::ErrorKind::AddrInUse)));
}
// And the dial the installer makes in direct mode connects.
assert!(std::net::TcpStream::connect_timeout(&format!("{p}:8474").parse().unwrap(), Duration::from_secs(2)).is_ok(), "{p}:8474");
}
for s in &servers {
g.fleet_power_off(s, true);
}
let _ = std::fs::remove_dir_all(&root);
let missing: Vec<_> = held.iter().filter(|(_, h)| !h).map(|(w, _)| w.clone()).collect();
assert!(missing.is_empty(), "not held by the server's QEMU: {missing:?}");
}
/// **The door is main's**: never the front's (no medium), the first server booted from the tray
/// takes it, and it stays that server's across its restarts while the twin never gets it.
#[test]
fn the_mtls_door_goes_to_the_first_server_booted_from_the_medium_and_stays_its() {
assert_eq!(mtls_role(false, None, "front", false), Mtls::Off);
assert_eq!(mtls_role(true, None, "front", false), Mtls::Other, "the front boots first, from a template");
assert_eq!(mtls_role(true, None, "main", true), Mtls::Door, "main installs from the tray");
assert_eq!(mtls_role(true, Some("main"), "twin", true), Mtls::Other, "the twin installs from the same medium, later");
assert_eq!(mtls_role(true, Some("main"), "main", false), Mtls::Door, "main restarted with the tray ejected keeps it");
assert_eq!(mtls_role(true, Some("main"), "front", false), Mtls::Other);
}
/// **The mTLS door, only when asked** (`--mtls`): eth1 carries 50051 AND 50052, both on
/// loopback; eth0 is untouched; without the flag the utility NIC is exactly as before.
#[test]
fn the_mtls_door_rides_the_utility_nic_only_when_asked() {
let g = DraupnirGuest::default();
let n1_of = |mtls: Mtls| {
let mut boot = g.spec_for(&spec());
let fw = wire_nics(&mut boot, mtls, None, false);
let j = qemu::argv(&boot, None, None).unwrap().join(" ");
let n0 = j.split(" -netdev ").find(|a| a.starts_with("user,id=n0")).unwrap().split(' ').next().unwrap().to_string();
let n1 = j.split(" -netdev ").find(|a| a.starts_with("user,id=n1")).unwrap().split(' ').next().unwrap().to_string();
(n0, n1, fw)
};
let port_of = |fw: &Vec<(u16, String)>, g: u16| fw.iter().find(|(p, _)| *p == g).and_then(|(_, h)| h.rsplit(':').next()?.parse::<u16>().ok()).unwrap();
let (n0, n1, fw) = n1_of(Mtls::Door);
assert!(n1.contains("-:50051") && n1.contains("-:50052"), "eth1: {n1}");
assert!(!n0.contains("-:50052"), "eth0 must not carry the mTLS door: {n0}");
assert_eq!(n1.matches("hostfwd=tcp:127.0.0.1:").count(), 2, "loopback only: {n1}");
assert_eq!(fw.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [80, 443, 2222, 50051, 50052]);
// The twin dials the port the primary binds: host 50052 itself when it is free.
assert!(port_of(&fw, 50052) == 50052 || port_of(&fw, 50052) == port_of(&fw, 50051) + 1, "mTLS door at {}", port_of(&fw, 50052));
// **Another server never takes the door**, free or not: it gets the adjacent pair, so a
// twin restarted while its primary is between boots cannot end up dialling itself.
let (_, other, fw_other) = n1_of(Mtls::Other);
assert!(other.contains("-:50052"), "the other server still has an mTLS forward: {other}");
assert_ne!(port_of(&fw_other, 50052), 50052, "the door's host port is the holder's alone");
assert_eq!(port_of(&fw_other, 50052), port_of(&fw_other, 50051) + 1, "an adjacent pair: {other}");
let (_, off, fw_off) = n1_of(Mtls::Off);
assert!(!off.contains("-:50052"), "off by default: {off}");
assert_eq!(fw_off.iter().map(|(p, _)| *p).collect::<Vec<_>>(), [80, 443, 2222, 50051]);
}
/// **`pc` by default, q35 only by name.** The default engine renders
/// i440fx; an engine told `Q35` renders q35 and passes its own check, and
/// nothing else in the argv moves.
#[test]
fn the_machine_is_pc_unless_q35_is_asked_for() {
let s = spec();
let pc = DraupnirGuest::default();
let a = qemu::argv(&pc.spec_for(&s), None, None).unwrap();
assert_eq!(&a[..2], ["-machine", "pc,accel=kvm:tcg"]);
assert_eq!(pc.preflight(&s, &a), None);
let q = DraupnirGuest { machine: draupnir::MachineType::Q35, ..DraupnirGuest::default() };
let b = qemu::argv(&q.spec_for(&s), None, None).unwrap();
assert_eq!(b[1], "q35,accel=kvm:tcg");
assert_eq!(q.preflight(&s, &b), None);
assert_eq!(a[2..], b[2..]);
}
/// The engine's spec really is the cloud shape, field by field — the shape
/// the argv check above is checking.
#[test]
fn the_spec_is_seabios_no_serial_a_framebuffer_and_two_hours_ahead() {
let g = DraupnirGuest::default();
let b = g.spec_for(&spec());
assert!(matches!(b.firmware, Firmware::Bios { .. }));
assert_eq!(b.serial, SerialConsole::None);
assert!(matches!(b.display, Display::Framebuffer { .. }));
assert_eq!(b.rtc, Rtc::SkewedBy { seconds: 7200 });
assert_eq!(b.guest_dns.as_deref(), Some("10.0.2.99"));
assert_eq!(b.boot_order, draupnir::BootOrder::MediumThenDisk);
}
/// A host with no QEMU is a REFUSAL naming the host, never a guest that
/// failed to boot: the two are different facts and only one of them is about
/// the software under test.
/// **A root too long for its QMP sockets is refused BY NAME at start**,
/// with the limit and the length — the defect T3 met was a 108-byte path
/// that failed silently and read as a hung guest.
#[test]
fn a_root_too_long_for_its_qmp_sockets_is_refused_by_name() {
let long = PathBuf::from("/home/rickard/scratch/t3-twin-restore/mkvm/root");
assert!(longest_qmp_path(&long) > UNIX_SOCKET_PATH_MAX);
let Err(why) = DraupnirGuest::for_estate(long) else {
panic!("a root whose sockets exceed 107 bytes was accepted")
};
assert!(why.starts_with("qmp-socket-path:"), "{why}");
assert!(why.contains("107") && why.contains(&longest_qmp_path(Path::new("/home/rickard/scratch/t3-twin-restore/mkvm/root")).to_string()), "{why}");
// The longest root that fits is accepted; one byte more is not.
// What a root adds to its own length: "/servers/<uuid>/boot-9999/qmp.sock".
let suffix = longest_qmp_path(Path::new("/tmp/r")) - "/tmp/r".len();
let fits = format!("/tmp/{}", "r".repeat(UNIX_SOCKET_PATH_MAX - suffix - "/tmp/".len()));
assert_eq!(longest_qmp_path(Path::new(&fits)), UNIX_SOCKET_PATH_MAX);
assert!(DraupnirGuest::for_estate(PathBuf::from(&fits)).is_ok());
let _ = std::fs::remove_dir_all(&fits);
assert!(DraupnirGuest::for_estate(PathBuf::from(format!("{fits}x"))).is_err());
// The one-shot run refuses too, rather than booting blind.
let g = DraupnirGuest { root: PathBuf::from(format!("/tmp/{}", "y".repeat(100))), ..Default::default() };
match g.run(&GuestSpec::new("appliance", "/nonexistent.qcow2")).outcome {
GuestOutcome::Refused { clause, .. } => assert_eq!(clause, "qmp-socket-path"),
other => panic!("{other}"),
}
}
// ── the hot-plug shape (T21r) ────────────────────────────────────────────
fn three_disks() -> Vec<(String, PathBuf, &'static str)> {
vec![
("01000009-0000-4000-8000-0000000714a1".into(), PathBuf::from("/d/a.qcow2"), "qcow2"),
("0100000a-0000-4000-8000-0000000714a2".into(), PathBuf::from("/d/b.qcow2"), "qcow2"),
("0100000b-0000-4000-8000-0000000714a3".into(), PathBuf::from("/m/iso.iso"), "raw"),
]
}
/// RED-when-broken: without `--hotplug` a guest keeps EXACTLY the machine it
/// had — the implicit `-drive if=virtio` per data disk, no node, no card.
#[test]
fn without_hotplug_the_data_disks_are_the_implicit_virtio_drives_they_always_were() {
let mut nodes = BTreeMap::new();
let a = data_disk_args(false, draupnir::MachineType::Pc, &three_disks(), &mut nodes);
assert_eq!(
a,
vec![
"-drive", "file=/d/a.qcow2,if=virtio,format=qcow2",
"-drive", "file=/d/b.qcow2,if=virtio,format=qcow2",
"-drive", "file=/m/iso.iso,if=virtio,format=raw",
]
);
assert!(nodes.is_empty(), "no node is remembered for a disk QMP cannot address");
let g = DraupnirGuest::default();
assert!(!g.hotplug, "off by default");
}
/// With hot-plug every data disk is a `-blockdev` file node, a format node
/// over it and a `virtio-blk-pci` card over that, in disk order; on i440fx
/// the card goes on `pci.0` (no bus), and every node is remembered plugged.
#[test]
fn with_hotplug_the_data_disks_are_blockdev_nodes_behind_virtio_blk_pci_cards() {
let mut nodes = BTreeMap::new();
let disks = three_disks();
let a = data_disk_args(true, draupnir::MachineType::Pc, &disks, &mut nodes);
let id = node_id(&disks[0].0);
assert_eq!(&a[..6], &[
"-blockdev".to_string(), format!("driver=file,filename=/d/a.qcow2,node-name=f{id}"),
"-blockdev".to_string(), format!("driver=qcow2,node-name={id},file=f{id}"),
"-device".to_string(), format!("virtio-blk-pci,drive={id},id=v{id},addr=0x10"),
]);
// Pinned HIGH, one slot per card: the boot disk keeps the lowest slot
// (MEASURED 2026-09-26: left to QEMU the cards came first and SeaBIOS
// booted a data member).
assert!(a.iter().any(|x| x.ends_with(",addr=0x12")), "{a:?}");
assert_eq!(nodes[&disks[2].0].addr.as_deref(), Some("0x12"));
assert_eq!(pc_slot(&["0x10".into(), "0x11".into()]).as_deref(), Some("0x12"));
assert_eq!(pc_slot(&(0x10..=0x1f).map(|s| format!("0x{s:x}")).collect::<Vec<_>>()), None);
assert!(a.iter().any(|x| x == "driver=raw,node-name=d0100000b0000000714a3,file=fd0100000b0000000714a3"), "{a:?}");
assert!(!a.iter().any(|x| x.contains("pcie-root-port")), "i440fx: no root ports");
assert_eq!(nodes.len(), 3);
assert!(nodes.values().all(|n| n.plugged && n.bus.is_none()));
assert!(nodes.values().all(|n| n.addr.is_some()));
assert_eq!(nodes[&disks[1].0].id, "d0100000a0000000714a2");
}
/// On q35 `pcie.0` refuses `device_del`, so each card sits behind its own
/// root port and spare ports are rendered for volumes attached later.
#[test]
fn on_q35_each_card_sits_behind_a_root_port_and_spare_ports_wait_for_later_attaches() {
let mut nodes = BTreeMap::new();
let disks = three_disks();
let a = data_disk_args(true, draupnir::MachineType::Q35, &disks, &mut nodes);
let ports: Vec<&String> = a.iter().filter(|x| x.starts_with("pcie-root-port,id=rp")).collect();
assert_eq!(ports.len(), 3 + SPARE_ROOT_PORTS);
assert_eq!(ports[0], "pcie-root-port,id=rp0,chassis=1,slot=1");
assert!(a.iter().any(|x| x.ends_with(",bus=rp0")) && a.iter().any(|x| x.ends_with(",bus=rp2")), "{a:?}");
assert_eq!(nodes[&disks[2].0].bus.as_deref(), Some("rp2"));
assert!(nodes.values().all(|n| n.addr.is_none()) && !a.iter().any(|x| x.contains(",addr=")), "q35: the root port is the slot");
}
/// A node name starts with a letter, is under 32 bytes, and two of the
/// mock's uuids (which differ in their first 8 and last 12 hex digits)
/// never share one.
#[test]
fn node_ids_are_valid_qemu_node_names_and_distinct() {
let a = node_id("01000009-0000-4000-8000-0000000714a1");
let b = node_id("01000009-0000-4000-8000-0000000714a2");
let c = node_id("0100000a-0000-4000-8000-0000000714a1");
for id in [&a, &b, &c] {
assert!(id.starts_with('d') && id.len() < 32 && id.chars().all(|ch| ch.is_ascii_alphanumeric()), "{id}");
}
assert!(a != b && a != c && b != c);
assert_eq!(node_id("short"), "dshort");
}
/// An unreadable screen is UNKNOWN, never "did not progress".
#[test]
fn a_screen_qmp_cannot_read_is_unknown_not_frozen() {
assert_eq!(progressed_verdict(0, 5), None);
assert_eq!(progressed_verdict(0, 0), Some(false));
assert_eq!(progressed_verdict(2, 3), Some(false));
assert_eq!(progressed_verdict(3, 0), Some(true));
}
#[test]
fn a_host_without_qemu_is_refused_as_a_host() {
std::env::set_var("DRAUPNIR_QEMU_BIN", "/nonexistent/qemu-system-x86_64");
let dir = std::env::temp_dir().join(format!("dg-nohost-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let disk = dir.join("d.qcow2");
qemu::create_qcow2(&disk, 1 << 30).unwrap();
let g = DraupnirGuest {
root: dir.clone(),
..Default::default()
};
let mut s = GuestSpec::new("x", disk.display().to_string());
s.medium = None;
s.cdrom_first = false;
match g.run(&s).outcome {
GuestOutcome::Refused { clause, .. } => assert_eq!(clause, "kvm-host"),
other => panic!("{other}"),
}
std::env::remove_var("DRAUPNIR_QEMU_BIN");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod template_tests {
use super::*;
/// ★ The overlay names its backing file and format where QEMU reads them:
/// backing_file_offset/size in the header, the `qcow2` format extension
/// right after the header, and a virtual size no smaller than either the
/// ask or the image.
#[test]
fn an_overlay_points_at_its_image() {
let dir = std::env::temp_dir().join(format!("overlay-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let image = dir.join("image.qcow2");
qemu::create_qcow2(&image, 3 << 30).unwrap();
let disk = dir.join("disk.qcow2");
create_overlay(&disk, 25 << 30, &image).unwrap();
let b = std::fs::read(&disk).unwrap();
let off = u64::from_be_bytes(b[8..16].try_into().unwrap()) as usize;
let len = u32::from_be_bytes(b[16..20].try_into().unwrap()) as usize;
assert_eq!(std::str::from_utf8(&b[off..off + len]).unwrap(), image.canonicalize().unwrap().display().to_string());
assert_eq!(&b[104..108], &0xe279_2acau32.to_be_bytes(), "the backing-format extension");
assert_eq!(&b[112..117], b"qcow2");
assert_eq!(qemu::qcow2_virtual_size(&disk).unwrap(), 25 << 30);
let small = dir.join("small.qcow2");
create_overlay(&small, 1 << 30, &image).unwrap();
assert_eq!(qemu::qcow2_virtual_size(&small).unwrap(), 3 << 30, "never smaller than the image");
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ The NoCloud files say what the create said: the hostname, root let in
/// with exactly the create's keys, every NIC by DHCP; anything else is 404.
#[test]
fn nocloud_serves_the_creates_hostname_and_keys() {
let m = NoCloudMeta { hostname: "gunnar-front".into(), keys: vec!["ssh-ed25519 AAAA one".into()], utility_mac: "0a:00:00:00:c1:02".into() };
assert_eq!(nocloud_file("u1", &m, "meta-data").unwrap(), "instance-id: u1\nlocal-hostname: gunnar-front\n");
let u = nocloud_file("u1", &m, "user-data").unwrap();
assert!(u.starts_with("#cloud-config\n") && u.contains("disable_root: false") && u.contains(" - ssh-ed25519 AAAA one\n"), "{u}");
assert!(u.contains("macaddress: \"0a:00:00:00:c1:02\"") && u.contains("dhcp4: true") && u.contains("[netplan, apply]"), "the utility NIC by its API MAC: {u}");
assert_eq!(nocloud_file("u1", &m, "passwd"), None);
}
}
/// `--offline` (2026-09-26): no user-mode NIC NATs out, and what a guest may still
/// reach is exactly the relayed loopback ports.
#[cfg(test)]
mod offline {
use super::*;
fn o() -> Offline {
Offline { relay: PathBuf::from("/x/mock-upcloud-kvm"), ports: vec![18453] }
}
#[test]
fn every_user_mode_nic_is_restricted_and_only_eth0_carries_the_relays() {
let n0 = offline_netdev("user,id=n0,hostfwd=tcp:127.0.0.1:4000-:2222,dns=10.0.2.99", &o(), &[40123], true);
assert!(n0.starts_with("user,id=n0,hostfwd=tcp:127.0.0.1:4000-:2222,dns=10.0.2.99,restrict=on,"), "{n0}");
assert!(n0.contains(",guestfwd=tcp:10.0.2.100:18453-cmd:/x/mock-upcloud-kvm --relay 127.0.0.1:18453"), "{n0}");
assert!(n0.contains(",guestfwd=tcp:10.0.2.100:40123-cmd:/x/mock-upcloud-kvm --relay 127.0.0.1:40123"), "the template's NoCloud: {n0}");
assert_eq!(n0.matches("guestfwd=").count(), 2);
let n1 = offline_netdev("user,id=n1,net=10.0.3.0/24,dns=10.0.3.99", &o(), &[40123], true);
assert_eq!(n1, "user,id=n1,net=10.0.3.0/24,dns=10.0.3.99,restrict=on", "eth1 is restricted and relays nothing");
let fabric = "dgram,id=n1,remote.type=inet,remote.host=239.1.2.3,remote.port=2000";
assert_eq!(offline_netdev(fabric, &o(), &[], true), fabric, "the utility fabric never NATs; untouched");
// A medium boot: the relays, and NO restrict (UDP hostfwd replies must pass).
let m0 = offline_netdev("user,id=n0,hostfwd=udp:127.80.99.12:51821-:51821", &o(), &[], false);
assert_eq!(m0, "user,id=n0,hostfwd=udp:127.80.99.12:51821-:51821,guestfwd=tcp:10.0.2.100:18453-cmd:/x/mock-upcloud-kvm --relay 127.0.0.1:18453");
assert_eq!(offline_netdev("-device", &o(), &[], true), "-device");
assert!(template_seal(true, false, false), "the Ubuntu template boot is sealed");
assert!(!template_seal(true, true, false), "a medium boot on a template clone is not");
assert!(!template_seal(true, false, true), "nor the installed disk after it");
assert!(!template_seal(false, false, false), "a blank disk laid for a medium is not a template");
}
#[test]
fn the_relay_carries_both_ways_and_refuses_anything_but_loopback() {
use std::io::{Read as _, Write as _};
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let at = l.local_addr().unwrap().to_string();
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut got = [0u8; 4];
s.read_exact(&mut got).unwrap();
assert_eq!(&got, b"ping");
s.write_all(b"pong").unwrap();
});
let mut out = Vec::new();
relay(std::io::Cursor::new(b"ping".to_vec()), &mut out, &at).unwrap();
assert_eq!(out, b"pong");
assert!(relay(std::io::Cursor::new(Vec::new()), Vec::new(), "185.125.188.55:443").unwrap_err().contains("loopback only"));
}
}
/// `--lan-publish` (on-prem, 2026-09-27): the named server's ports on the LAN,
/// nobody else's, never on loopback.
#[cfg(test)]
mod lan_publish {
use super::*;
#[test]
fn only_the_named_server_is_published_on_the_lan() {
let lan = parse_lan_publish("gunnar-front-test3=192.168.1.20:8080-80,192.168.1.20:8443-443,0.0.0.0:2222-2222").unwrap();
assert_eq!(lan.len(), 3);
assert_eq!(
lan_forwards(&lan, "gunnar-front-test3"),
vec!["tcp:192.168.1.20:8080-:80", "tcp:192.168.1.20:8443-:443", "tcp:0.0.0.0:2222-:2222"]
);
assert!(lan_forwards(&lan, "gunnar-appliance-test3").is_empty(), "main is never on the LAN");
assert!(lan_forwards(&[], "gunnar-front-test3").is_empty(), "no flag: nothing beyond loopback");
}
#[test]
fn a_bad_publish_is_refused_by_name() {
for (bad, why) in [
("192.168.1.20:8080-80", "no hostname"),
("=192.168.1.20:8080-80", "empty hostname"),
("front=127.0.0.1:8080-80", "loopback"),
("front=192.168.1.20:8080", "no guest port"),
("front=lan:8080-80", "not an address"),
("front=192.168.1.20:x-80", "not a port"),
("front=", "nothing"),
] {
assert!(parse_lan_publish(bad).is_err(), "{bad} ({why}) was accepted");
}
}
}