Files
owui-tools/tests/test_vm_launch_argv.sh
T
2026-09-15 15:24:03 -04:00

248 lines
10 KiB
Bash
Executable File

#!/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/<session id> - 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 <ubuntu|windows>
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"