Initial Commit
This commit is contained in:
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user