#!/bin/bash # ===================================================================== # Asserts the qemu command line, without starting a machine. # # sudo ./tests/test_vm_launch_argv.sh # # WHY THE ARGV IS WORTH A TEST OF ITS OWN # # Some of the isolation cannot be observed from outside the process. # `-netdev user` accepts guestfwd=, smb=, tftp= and bootfile=, and every # one of those is handled INSIDE qemu - the traffic never becomes a host # socket, so neither the cgroup filter nor the nft table can see it. No # filter can catch what no filter can observe; the only defence is that # the string is fixed, and the only way to know it is still fixed is to # look at it. # # The script is run with its final `exec qemu-system-x86_64` replaced by # a printf, so every branch runs for real - the path checks, the # firmware choice, the TPM setup - and nothing boots. # ===================================================================== set -uo pipefail LAUNCH=/usr/local/libexec/owuivm-launch BASE="" # set to the scratch tree once $work exists fails=0 ok() { printf ' ok %s\n' "$*"; } bad() { printf ' FAIL %s\n' "$*"; fails=$((fails+1)); } [ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; } work=$(mktemp -d); trap 'rm -rf "$work"' EXIT mkdir -p "$work/base" "$work/run" "$work/vmrun" BASE="$work/base" # THIS TEST IS HERMETIC, AND IT LEARNED TO BE THE HARD WAY. # # It used to drive the real launcher against the REAL directories: the # real base images, and the real /run/owuivm/ - which is a # CONSTANT for maintenance boots. While an operator was installing # Windows, a run of this file overwrote that machine's OVMF variables, # deleted its swtpm state and pkill'd its swtpm (matching on a session id # it shared), which killed qemu with "tpm-emulator: Could not stop TPM"; # and its mode-flipping loop left the real base image frozen, so the # operator's next boot silently ran on a throwaway overlay and put a # 16 GB Windows install somewhere it was going to be deleted from. # # So the copy of the launcher gets its paths rewritten as well as its # exec line. The path RULES are still exercised - they are rewritten # consistently - but every path they resolve to is inside $work. sed -e 's|^exec /usr/bin/qemu-system-x86_64 .*|printf "%s\\n" "${args[@]}"|' \ -e "s|/var/lib/owuivm/base|$work/base|g" \ -e "s|/Pool/vmrun|$work/vmrun|g" \ -e "s|/run/owuivm|$work/run|g" \ "$LAUNCH" > "$work/launch" chmod +x "$work/launch" # And it refuses outright while a machine is up. Hermetic or not, this # starts a real swtpm and nothing good comes of doing that beside a # running VM. for unit in owuivm.service owuivm-maint@ubuntu.service owuivm-maint@windows.service; do if systemctl is-active --quiet "$unit" 2>/dev/null; then echo "REFUSING: $unit is running. This test builds command lines and starts" >&2 echo "a TPM emulator; run it when no machine is up." >&2 exit 2 fi done argv_for() { # argv_for local name="$1" session="ffffffffffffffffffffffffffffffff" # Inside $work, like everything else: the copy of the launcher has had # /run/owuivm rewritten, so a real path here fails its own vars check # and the whole file reports "produced no command line at all". local run="$work/run/$session" mkdir -p "$run" cp -f /usr/share/OVMF/OVMF_VARS_4M.fd "$run/OVMF_VARS.fd" # A disk only has to exist and be in the right place for the checks. local made="" if [ ! -f "$BASE/$name.qcow2" ]; then qemu-img create -f qcow2 "$BASE/$name.qcow2" 1G >/dev/null 2>&1 made=yes fi OWUIVM_MODE=maint OWUIVM_SESSION="$session" \ OWUIVM_DISK="$BASE/$name.qcow2" OWUIVM_VARS="$run/OVMF_VARS.fd" \ OWUIVM_SHARE="" OWUIVM_MEM_MB=8192 OWUIVM_CPUS=8 \ "$work/launch" 2>/dev/null [ -n "$made" ] && rm -f "$BASE/$name.qcow2" # Matched on the scratch directory, not on a session id that a real # maintenance boot also uses. pkill -f "swtpm socket .*$work" 2>/dev/null rm -rf "$run/tpm" "$run/swtpm.sock" return 0 } for name in ubuntu windows; do echo "== $name" argv=$(argv_for "$name") [ -n "$argv" ] || { bad "$name: produced no command line at all"; continue; } netdev=$(printf '%s\n' "$argv" | grep -A0 '^user,' || true) [ -n "$netdev" ] || netdev=$(printf '%s\n' "$argv" | grep '^user,id=net0' || true) if [ -n "$netdev" ]; then ok "$name: netdev is '$netdev'" for forbidden in guestfwd smb= tftp= bootfile restrict=off; do case "$netdev" in *"$forbidden"*) bad "$name: netdev contains $forbidden - qemu handles that internally, where no filter can see it" ;; *) ok "$name: netdev has no $forbidden" ;; esac done case "$netdev" in *ipv6=off*) ok "$name: ipv6 is off on the netdev" ;; *) bad "$name: ipv6 is not disabled on the netdev" ;; esac else bad "$name: no user-mode netdev found - a tap or bridge would bypass the whole model" fi # No inbound path into the guest, ever. case "$argv" in *hostfwd*) bad "$name: a hostfwd is present - that is a listening socket on this host" ;; *) ok "$name: no hostfwd" ;; esac # Nothing of the host's should be reachable as a device. for forbidden in "-virtfs" "vfio" "usb-host" "hostdev"; do case "$argv" in *"$forbidden"*) bad "$name: $forbidden is present" ;; *) ok "$name: no $forbidden" ;; esac done done echo echo "== what only Windows gets" win=$(argv_for windows); ubu=$(argv_for ubuntu) case "$win" in *secboot*) ok "windows uses the secure-boot firmware" ;; *) bad "windows is not using secure-boot firmware - its installer will refuse the machine" ;; esac case "$win" in *tpm-tis*) ok "windows has a TPM" ;; *) bad "windows has no TPM - its installer will refuse the machine" ;; esac case "$win" in *"property=secure,value=on"*) ok "windows has a protected variable store" ;; *) bad "windows secure boot is present but its variable store is writable from the guest" ;; esac case "$ubu" in *tpm-tis*) bad "ubuntu has a TPM it does not need" ;; *) ok "ubuntu has no TPM" ;; esac case "$ubu" in *secboot*) bad "ubuntu is using secure-boot firmware it does not need" ;; *) ok "ubuntu uses the plain firmware" ;; esac echo echo "== the two machines are told what they are" # Windows without the Hyper-V interfaces spins: it takes a VM exit per # timer tick and per IPI, and burns CPU on locks a hypervisor could have # yielded. Measured on this host as 347% CPU on an idle installer, in a # cgroup that was neither short of memory nor throttled. Linux needs none # of it - it has virtio - so this is the one place the two machines # deliberately differ in their CPU model, and it is asserted both ways so # neither drifts into the other's shape. for name in ubuntu windows; do argv=$(argv_for "$name") cpu=$(printf '%s\n' "$argv" | grep '^host' | head -1) case "$name:$cpu" in windows:*hv-stimer-direct*) ok "windows: Hyper-V enlightenments are on" for flag in hv-relaxed hv-vapic hv-time hv-spinlocks hv-synic hv-tlbflush hv-ipi; do case "$cpu" in *"$flag"*) : ;; *) bad "windows: $flag is missing from the cpu model" ;; esac done case "$cpu" in *hv-passthrough*) bad "windows: hv-passthrough hands over whatever the host has today - a frozen image must not change under a host upgrade" ;; *) ok "windows: the enlightenments are listed, not passed through" ;; esac ;; windows:*) bad "windows: no Hyper-V enlightenments - it will spin. cpu='$cpu'" ;; ubuntu:host) ok "ubuntu: plain -cpu host, which is all Linux wants" ;; ubuntu:*) bad "ubuntu: cpu model is '$cpu', not plain host" ;; esac # Windows reads the RTC as local time. On a host at UTC that leaves the # guest hours wrong, which breaks TLS and reads as a network fault. rtc=$(printf '%s\n' "$argv" | grep '^base=' | head -1) case "$name:$rtc" in windows:base=localtime*) ok "windows: the clock is one it understands" ;; windows:*) bad "windows: rtc is '$rtc' - its clock will be hours out" ;; ubuntu:base=utc*) ok "ubuntu: the clock is UTC" ;; ubuntu:*) bad "ubuntu: rtc is '$rtc'" ;; esac done echo echo "== which disk a maintenance boot opens" # THE RULE: the disk's own mode decides. Frozen means the operator is # looking at the image as the agent will get it, so qemu is given a # throwaway overlay and the base is opened only as a read-only backing # file. Unfrozen means they deliberately made it writable, so qemu gets # the image itself. # # Asserted against whatever is really on this host rather than against a # fixed expectation, plus a scratch image in the other slot so both # branches run wherever this is executed. disk0_of() { printf '%s\n' "$1" | grep '^file=.*id=disk0' | head -1; } for name in ubuntu windows; do disk="$BASE/$name.qcow2" created="" if [ ! -f "$disk" ]; then qemu-img create -f qcow2 "$disk" 1G >/dev/null 2>&1 created=yes fi for mode in 0444 0664; do chmod "$mode" "$disk" line=$(disk0_of "$(argv_for "$name")") case "$mode:$line" in 0444:file=$work/vmrun/maint-$name.qcow2,*) ok "$name frozen: boots a throwaway disk, not the base" ;; 0444:*) bad "$name frozen: a read-only base was opened directly - $line" ;; 0664:file=$disk,*) ok "$name unfrozen: boots the base image itself" ;; 0664:*) bad "$name unfrozen: did not open the base - $line" ;; esac # The overlay must be a fresh copy of the base every time, or a # "discarded" change comes back on the next boot. if [ "$mode" = 0444 ]; then backing=$(qemu-img info --output=json "$work/vmrun/maint-$name.qcow2" 2>/dev/null \ | sed -n 's/.*"backing-filename": "\([^"]*\)".*/\1/p' | head -1) [ "$backing" = "$disk" ] \ && ok "$name frozen: the throwaway disk is backed by the base" \ || bad "$name frozen: backing file is '$backing', not $disk" fi rm -f "$work/vmrun/maint-$name.qcow2" done rm -f "$disk" done echo if [ "$fails" -gt 0 ]; then echo "FAILED $fails"; exit 1; fi echo "the qemu command line is what it should be"