utils/vmbox/lib/launch.sh
Krzysztof kuhy Rudnicki 694c013b6b
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
vmbox: UEFI sandboxes and 'vm wake' RTC-wake emulation for poweroff/hibernate; worktree copies skip gitignored files
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:29:54 +02:00

147 lines
6.4 KiB
Bash

#!/bin/bash
# ============================================================================
# vmbox: assemble the qemu command line and start a sandbox.
#
# Every observation channel the verdict logic depends on is wired here:
# - QMP socket + a persistent event recorder (started BEFORE the guest can
# act, so a fast poweroff cannot race it)
# - per-boot serial log (append, never truncate)
# - pvpanic, so a kernel panic is distinguishable from a clean stop
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# shellcheck source=firmware.sh
source "$(dirname "${BASH_SOURCE[0]}")/firmware.sh"
readonly VMBOX_MEM="${VMBOX_MEM:-4096}"
readonly VMBOX_SMP="${VMBOX_SMP:-4}"
# $1 = vm name. Echoes the serial-log index it started.
# VM-to-VM segment, addressed by the VM's own index.
#
# History, so nobody re-tries these: `mcast=` is silently dead on this host
# (`lo` has no MULTICAST flag, no multicast route -- qemu accepts it and drops
# every frame), and a listen=/connect= socket hub connected but never passed
# traffic. `dgram` with explicit UDP endpoints is the third approach: each VM
# binds its own port and points at its peer, so a PAIR of sandboxes can talk.
# This is a pair-wise link, not a switch: VMs 1 and 2 are wired together, 3
# and 4 are wired together, and so on.
_peer_netdev() {
# Pair (1,2), (3,4), (5,6)... An index pairs UP when odd and DOWN when
# even. Getting this backwards points a VM at a port nobody bound, which
# looks exactly like a broken network: both ends up, zero frames, ARP
# fails, and nothing in any log says why.
local index="$1" base="$VMBOX_HUB_PORT" mine theirs
mine=$(( base + index ))
if (( index % 2 == 1 )); then
theirs=$(( base + index + 1 ))
else
theirs=$(( base + index - 1 ))
fi
# Type first, then id: qemu rejects `id=` before the netdev type.
printf 'dgram,id=net1,local.type=inet,local.host=127.0.0.1,local.port=%s,remote.type=inet,remote.host=127.0.0.1,remote.port=%s' \
"$mine" "$theirs"
}
launch_vm() {
local name="$1"
require_vm "$name"
vm_is_running "$name" && { log "sandbox '$name' is already running"; return 0; }
source "$VMBOX_LIB_DIR/overlay.sh"
overlay_verify_base
local overlay port index rtc serial_n serial qmp share
overlay="$(vm_overlay "$name")"
port="$(meta_get "$name" ssh_port)"
index="$(meta_get "$name" index)"
# VMBOX_RTC_ONCE is a one-launch override and is never written to meta:
# `vm wake` powers the guest on at the instant its alarm fired, without
# moving the sandbox's own pin.
rtc="${VMBOX_RTC_ONCE:-$(meta_get "$name" rtc 2>/dev/null || true)}"
serial_n="$(vm_next_serial "$name")"
serial="$(vm_serial "$name" "$serial_n")"
qmp="$(vm_qmp_sock "$name")"
# Repos you want testable go here. Use `vm share <path>` -- it BIND-MOUNTS
# rather than symlinks, because 9p exports a directory tree and will not
# follow a symlink pointing outside it (the guest would see a dangling
# link). Nothing outside this directory is ever exposed.
share="${VMBOX_SHARE:-$VMBOX_HOME/share}"
install -d -m 755 "$share"
# Refuse to boot the base itself -- doing so would corrupt every overlay.
[[ "$(realpath "$overlay")" == "$(realpath "$VMBOX_BASE_IMG")" ]] &&
die "refusing to boot the base image directly"
rm -f "$qmp"
# shellcheck disable=SC2054 # commas are qemu option syntax, not separators
local -a args=(
-name "vmbox-$name"
-machine q35,accel=kvm -cpu host
-smp "$VMBOX_SMP" -m "$VMBOX_MEM"
-drive file="$overlay",if=virtio,format=qcow2
-netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${port}-:22"
-device virtio-net-pci,netdev=net0
# Shared segment so sandboxes can reach each other. No root, no bridge.
# listen= for the first VM up, connect= for the rest (see _peer_netdev).
-netdev "$(_peer_netdev "$index")"
-device "virtio-net-pci,netdev=net1,mac=52:54:00:be:ef:$(printf '%02x' "$index")"
# Repos under test, mounted PHYSICALLY read-only. Scoped to an
# explicit share dir -- NEVER $HOME, which would hand the guest
# ~/.ssh, ~/.claude and ~/.config/crdt-sync. The guest clones out of
# this into its own home before touching anything.
-virtfs "local,path=$share,mount_tag=hostrepo,security_model=mapped-xattr,readonly=on"
-display none
-device virtio-vga
-chardev file,id=ser0,path="$serial",append=on
-serial chardev:ser0
# ttyS1: interactive console on a unix socket. Separate from ttyS0 so
# driving a shell never interleaves with the log the verdict reads.
-chardev "socket,id=ser1,path=$(vm_console "$name"),server=on,wait=off"
-serial chardev:ser1
-qmp "unix:${qmp},server=on,wait=off"
# A QMP socket serves ONE client, and the recorder holds that one for
# the VM's whole life. Screenshots and status queries therefore need
# their own channel or they block until the recorder exits.
-qmp "unix:${qmp}.ctl,server=on,wait=off"
-device pvpanic
# A guest *reset* becomes a SHUTDOWN(guest-reset) event, which is how a
# script that reboots instead of powering off is caught.
-no-reboot
-action panic=pause
-pidfile "$(vm_pidfile "$name")"
)
[[ -n "$rtc" ]] && args+=(-rtc "base=$rtc")
# One-shot, for `vm wake`: see DOCS-rtc-wake.md ("TCO watchdog").
[[ -n "${VMBOX_WATCHDOG_ACTION:-}" ]] && args+=(-action "watchdog=$VMBOX_WATCHDOG_ACTION")
firmware_qemu_args "$name" args
_launch_qemu "$name" "$serial" "$qmp" args
printf '%s' "$serial_n"
}
_launch_qemu() {
local name="$1" serial="$2" qmp="$3"
local -n argref="$4"
qemu-system-x86_64 "${argref[@]}" >/dev/null 2>>"$serial.qemu-stderr" &
disown
# Wait for the QMP socket, then attach the recorder BEFORE anything runs in
# the guest: attaching after the fact loses the SHUTDOWN event entirely
# (qemu exits, the stream closes, and there is no verdict to read).
local waited=0
while [[ ! -S "$qmp" ]] && (( waited < 100 )); do
sleep 0.1; waited=$(( waited + 1 ))
done
[[ -S "$qmp" ]] || die "qemu did not create a QMP socket -- see $serial.qemu-stderr"
nohup python3 "$VMBOX_LIB_DIR/recorder.py" "$qmp" "$(vm_events "$name")" \
>/dev/null 2>&1 &
disown
}