diff --git a/vmbox/.gitignore b/vmbox/.gitignore new file mode 100644 index 0000000..9d9ac77 --- /dev/null +++ b/vmbox/.gitignore @@ -0,0 +1,7 @@ +# VM state lives in ~/.local/share/vmbox, never in the repo: these are +# multi-GB disk images and live sockets. Listed here only as a guard against +# an accidental copy landing in the tree. +*.qcow2 +*.iso +*.sock +*.log diff --git a/vmbox/.pre-commit-config.yaml b/vmbox/.pre-commit-config.yaml new file mode 100644 index 0000000..223673a --- /dev/null +++ b/vmbox/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +# vmbox hooks. The `files: ^vmbox/` scoping below is REQUIRED by +# meta/scripts/check_subproject_scope.sh -- without that literal line every +# hook here silently passes instead of running. +repos: + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck + files: ^vmbox/ + args: [--severity=warning, --external-sources] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + files: ^vmbox/ + - id: ruff-format + files: ^vmbox/ + + - repo: local + hooks: + - id: file-length-cap + name: enforce 250-line cap + entry: bash -c 'for f in "$@"; do n=$(wc -l < "$f"); if [ "$n" -gt 250 ]; then echo "$f: $n lines exceeds the 250-line cap"; exit 1; fi; done' -- + language: system + files: ^vmbox/ diff --git a/vmbox/README.md b/vmbox/README.md new file mode 100644 index 0000000..2386401 --- /dev/null +++ b/vmbox/README.md @@ -0,0 +1,103 @@ +# vmbox — a disposable Arch VM sandbox + +Run destructive system scripts — shutdown installers, `chattr +i` hosts guards, +pacman hooks — against a throwaway Arch guest, and **verify the outcome from the +host**. Break it as thoroughly as you like; reset takes under a second. + +```bash +./install.sh # installs deps (libisoburn), links `vm` onto PATH +vm build # builds the golden image, once (~10 min) +vm new demo # instant: a ~200 KB overlay on the sealed base +vm run demo 'sudo systemctl poweroff' +vm reset demo # back to pristine +``` + +## Why this exists + +Much of `testsAndMisc/linux_configuration` cannot be tested on the real machine. +The repo's own CI says so — its Ubuntu job runs only the "side-effect-free" +tests and notes the rest *"drive pacman, /etc, systemd or adb and cannot run on +an Ubuntu runner without root and an Arch container"*. Locally they probe the +**live** system (`test_results.log` asserts `PASS: /etc/hosts is immutable`). + +Three things make testing them on the host genuinely unsafe: + +- **`chattr +i` immutability** — 15 files make `/etc/hosts` and friends + immutable. A bad test is not simply revertable; `rm` fails with EPERM. +- **Auto-poweroff** — the shutdown installers write systemd units that power the + machine off on a schedule. +- **Pacman hooks** — `boot_recovery/install.sh` installs pre/post-transaction + hooks and touches mkinitcpio. Blast radius is "your next update". + +## The hard part: a successful shutdown kills your observer + +Running a shutdown script over SSH returns a broken pipe. That is +**indistinguishable** from a crash, a hang, or a network blip — ssh returns 255 +for all of them. So "did it actually shut down?" is answered from the host, +using two artefacts that outlive the VM: + +| Channel | What it proves | +|---|---| +| QMP event log (`events.jsonl`) | *That* the machine stopped, and who stopped it | +| Serial console log | *How far* the shutdown sequence got | + +A persistent recorder attaches at launch, **before** anything runs in the guest. +This ordering matters: attach afterwards and QEMU has already exited, the stream +is closed, and there is no verdict to read. + +### Five outcomes, all host-observable + +| Observation | Verdict | Exit | +|---|---|---| +| `SHUTDOWN guest:true` + serial reaches `reboot: Power down` | clean poweroff | 0 | +| `SHUTDOWN guest:true`, serial truncated | **dirty** — stopped mid-sequence | 3 | +| `SHUTDOWN reason=guest-reset` | **rebooted**, did not power off | 4 | +| `GUEST_PANICKED` | **crashed** | 5 | +| no event, liveness probe fails | **hung** | 6 | + +`guest:true` alone is not enough for "shuts down *completely*" — `poweroff -f` +and a script that kills systemd mid-sequence both produce it. The serial tail is +the discriminator, and it is asserted by grep, not read by eye. + +> A `POWERDOWN` event is **not** proof of anything: it means the ACPI request was +> delivered. With no OS booted, QEMU emits `POWERDOWN` and then runs forever. + +## Design notes + +- **Reset is a backing-file trick.** The golden image is built once and sealed + (`chmod 444` + a sha256 sidecar verified on every launch). Each sandbox is a + thin qcow2 overlay, so `vm reset` is `rm` + recreate. Booting the base itself + would silently invalidate every overlay, so the launcher refuses to. +- **No root anywhere.** User-mode networking with a per-VM forwarded SSH port; + VM-to-VM traffic rides a socket multicast segment. No bridge, no libvirt, no + daemon. +- **The guest clock is pinned** (`--rtc`) and re-applied on *every* launch. The + shutdown installers gate on a 21:00–05:00 window, so an unpinned clock makes + their tests pass or fail depending on the time of day. NTP is masked in the + image, or it would quietly undo the pin. +- **The guest gets its own clone** of the repos from a read-only mount. Real + credentials are never shared; the guest carries sentinel values instead. + +## Commands + +| Command | Effect | +|---|---| +| `vm build [--force]` | Build/rebuild the golden image | +| `vm new [--rtc ]` | Create a sandbox | +| `vm run ` | Run a command, print the verdict | +| `vm ssh ` | Interactive shell (starts the VM if needed) | +| `vm screenshot [out.png]` | Capture the screen (locker/X11 tests) | +| `vm reset ` | Wipe back to pristine | +| `vm rm ` / `vm list` | Delete / list sandboxes | + +## Out of scope + +A VM cannot cover everything: adb/phone tests (~36 files), GPU monitors, the +i2c RGB in `setup_night_lockdown.sh`, and the Nextcloud/SearXNG suites (whose +fixtures assume a Debian layout) all stay on the host or stay untested. + +## Tests + +`bats tests/test_vmbox.bats` — host-side unit tests for name validation, meta +handling, serial rotation and the full verdict table. They do not boot a VM; +booting is covered by the destructive end-to-end demo above. diff --git a/vmbox/bin/vm b/vmbox/bin/vm new file mode 100755 index 0000000..3e127e9 --- /dev/null +++ b/vmbox/bin/vm @@ -0,0 +1,87 @@ +#!/bin/bash +# ============================================================================ +# vm -- disposable Arch VM sandbox. +# +# Runs destructive system scripts (shutdown installers, chattr +i guards, +# pacman hooks) against a throwaway Arch guest, and verifies the outcome from +# the HOST -- a guest that powers itself off cannot report its own success. +# ============================================================================ + +set -euo pipefail + +VMBOX_BIN_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd -P)" +readonly VMBOX_BIN_DIR +# shellcheck source=../lib/common.sh +source "$VMBOX_BIN_DIR/../lib/common.sh" + +usage() { + cat <<'USAGE' +Usage: vm [args] + + build Build the golden base image (once, ~10 min) + new [--rtc ] Create a sandbox (instant; thin overlay) + run Run a command in the sandbox, report the verdict + ssh [args...] Interactive shell in the sandbox + repo [--worktree] + Copy a host repo in (clone=HEAD, --worktree=uncommitted) + screenshot [out.png] Capture the guest screen (for locker tests) + reset Wipe back to pristine (sub-second) + rm Delete the sandbox entirely + list List sandboxes and their state + + --rtc Pin the guest clock, e.g. 2026-08-22T20:55:00. Stored in the + VM's meta and re-applied on EVERY launch: the shutdown scripts + are gated on a 21:00-05:00 window, so an unpinned clock makes + their tests nondeterministic. + +Examples: + vm new demo --rtc 2026-08-22T20:55:00 + vm run demo 'sudo systemctl poweroff' # -> verdict: clean poweroff + vm reset demo # back to pristine +USAGE +} + +# vm repo [--worktree] +cmd_repo() { + local name="${1:-}" src="${2:-}" mode=clone + [[ -n "$name" && -n "$src" ]] || die "usage: vm repo [--worktree]" + shift 2 + [[ "${1:-}" == "--worktree" ]] && mode=worktree + repo_sync "$name" "$src" "$mode" +} + +cmd_list() { + [[ -d "$VMBOX_VMS_DIR" ]] || { echo "no sandboxes yet (create one: vm new )"; return 0; } + local found=0 d name state rtc + printf '%-20s %-12s %s\n' "NAME" "STATE" "RTC" + for d in "$VMBOX_VMS_DIR"/*/; do + [[ -d "$d" ]] || continue + found=1 + name="$(basename "$d")" + if vm_is_running "$name"; then state="running"; else state="stopped"; fi + rtc="$(meta_get "$name" rtc 2>/dev/null || echo '-')" + printf '%-20s %-12s %s\n' "$name" "$state" "${rtc:--}" + done + (( found )) || echo "(none)" +} + +main() { + local cmd="${1:-}" + [[ $# -gt 0 ]] && shift || true + + case "$cmd" in + build) require_host_deps; source "$VMBOX_LIB_DIR/base.sh"; base_build "$@" ;; + new) require_host_deps; source "$VMBOX_LIB_DIR/overlay.sh"; overlay_new "$@" ;; + reset) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_reset "$@" ;; + rm) source "$VMBOX_LIB_DIR/overlay.sh"; overlay_rm "$@" ;; + run) require_host_deps; source "$VMBOX_LIB_DIR/run.sh"; run_in_vm "$@" ;; + ssh) source "$VMBOX_LIB_DIR/ssh.sh"; vm_ssh_interactive "$@" ;; + screenshot) source "$VMBOX_LIB_DIR/screenshot.sh"; vm_screenshot "$@" ;; + repo) source "$VMBOX_LIB_DIR/repo.sh"; cmd_repo "$@" ;; + list|ls) cmd_list ;; + -h|--help|help|'') usage ;; + *) die "unknown command '$cmd' (try: vm --help)" ;; + esac +} + +main "$@" diff --git a/vmbox/guest/dummy-creds.sh b/vmbox/guest/dummy-creds.sh new file mode 100755 index 0000000..86e5223 --- /dev/null +++ b/vmbox/guest/dummy-creds.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ============================================================================ +# Provision SENTINEL credentials inside a sandbox. +# +# The guest must never hold real secrets: these repos reach live GitHub and +# Firebase, and a "test" run of an installer that syncs or pushes would hit +# production. Values are deliberately greppable, so if one ever shows up in a +# log or a request it is obvious where it came from. +# ============================================================================ + +set -euo pipefail + +readonly SENTINEL="DUMMY-NOT-A-REAL-TOKEN-vmbox" + +install -d -m 700 "$HOME/.config/crdt-sync" +cat > "$HOME/.config/crdt-sync/config.json" </dev/null || true +fi +systemctl enable serial-getty@ttyS0.service + +# --- ssh ------------------------------------------------------------------ +# `systemctl enable sshd` alone is NOT enough on this image: systemd's +# ssh-generator provides a socket-activated AF_UNIX unit, so the enable can +# no-op and leave nothing listening on TCP:22. The guest then boots perfectly +# and every ssh gets "Connection reset by peer". Force the real service on and +# verify the symlink exists, failing the build loudly if it does not. +systemctl enable sshd.service +systemctl disable sshd.socket 2>/dev/null || true +if [[ ! -e /etc/systemd/system/multi-user.target.wants/sshd.service ]]; then + echo "provision: FAILED to enable sshd.service -- guest would be unreachable" >&2 + exit 1 +fi +# Host keys: sshd is "enabled" but FAILS TO START without them, which looks +# identical to a network fault from the host side. Generate now and assert. +ssh-keygen -A +ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1 || { + echo "provision: FAILED to generate sshd host keys" >&2 + exit 1 +} +echo "provision: sshd.service enabled with $(ls /etc/ssh/ssh_host_*_key | wc -l) host keys" + +# --- systemd --user session ---------------------------------------------- +# Several target scripts use `systemctl --user`, which needs a real session +# bus. Lingering gives the user one without an interactive login. +loginctl enable-linger "$GUEST_USER" 2>/dev/null || true + +# --- serial console autologin ------------------------------------------- +# Debuggability: when ssh is broken, the serial console is the ONLY way in. +# Without autologin here an unreachable guest can only be diagnosed by +# inferring from absent log lines, which is exactly how a single sshd fault +# turned into several blind image rebuilds. +install -d -m 755 /etc/systemd/system/serial-getty@ttyS0.service.d +cat > /etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf </dev/null; then + echo 'hostrepo /mnt/hostrepo 9p trans=virtio,version=9p2000.L,ro,nofail 0 0' >> /etc/fstab +fi + +# --- X11 autologin + i3 --------------------------------------------------- +# Locker tests need a real X session on tty1. Autologin then startx. +install -d -m 755 /etc/systemd/system/getty@tty1.service.d +cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf < "/home/$GUEST_USER/.xinitrc" <<'XINIT' +exec i3 +XINIT +chown "$GUEST_USER:$GUEST_USER" "/home/$GUEST_USER/.xinitrc" + +# startx on tty1 login only, so `vm ssh` sessions do not try to start X. +cat > "/home/$GUEST_USER/.bash_profile" <<'PROFILE' +[[ -f ~/.bashrc ]] && . ~/.bashrc +if [[ -z ${DISPLAY:-} && $(tty) == /dev/tty1 ]]; then + exec startx -- -keeptty >~/.xsession.log 2>&1 +fi +PROFILE +chown "$GUEST_USER:$GUEST_USER" "/home/$GUEST_USER/.bash_profile" + +# i3 without a config prompts a wizard that blocks the session; ship a minimal one. +install -d -m 755 "/home/$GUEST_USER/.config/i3" +cat > "/home/$GUEST_USER/.config/i3/config" <<'I3' +set $mod Mod4 +font pango:monospace 10 +bindsym $mod+Return exec xterm +bindsym $mod+d exec dmenu_run +bindsym $mod+Shift+q kill +# Marker window so screenshot tests have something deterministic to match. +exec --no-startup-id xterm -T vmbox-ready -e 'echo VMBOX READY; exec bash' +I3 +chown -R "$GUEST_USER:$GUEST_USER" "/home/$GUEST_USER/.config" + +# --- static IP on the VM-to-VM segment ------------------------------------ +# The multicast segment has no DHCP (the user-mode NIC serves the other +# interface), so without a static address sandboxes cannot reach each other. +# The last octet is injected per-VM at launch via the vmbox.peer kernel arg. +cat > /usr/local/bin/vmbox-peer-ip <<'PEER' +#!/bin/bash +# Assign the peer-network address from the vmbox.peer= kernel argument. +set -euo pipefail +n=$(sed -n 's/.*vmbox\.peer=\([0-9]\+\).*/\1/p' /proc/cmdline) +[[ -n "$n" ]] || exit 0 +subnet=$(sed -n 's/.*vmbox\.subnet=\([0-9.]\+\).*/\1/p' /proc/cmdline) +[[ -n "$subnet" ]] || subnet=10.77.0 +# The peer NIC is the one WITHOUT a default route (user-mode NIC has it). +for i in /sys/class/net/e*; do + dev=$(basename "$i") + # Skip the primary NIC: it carries ssh and already has DHCP + a default + # route. Touching it is how the sandbox becomes unreachable. + ip route show default dev "$dev" | grep -q . && continue + ip -4 addr show dev "$dev" | grep -q 'inet ' && continue + ip addr add "${subnet}.${n}/24" dev "$dev" 2>/dev/null || true + ip link set "$dev" up + break +done +PEER +chmod 755 /usr/local/bin/vmbox-peer-ip + +cat > /etc/systemd/system/vmbox-peer-ip.service <<'PSVC' +[Unit] +Description=vmbox peer network address +After=network.target +[Service] +Type=oneshot +ExecStart=/usr/local/bin/vmbox-peer-ip +RemainAfterExit=yes +[Install] +WantedBy=multi-user.target +PSVC +systemctl enable vmbox-peer-ip.service + +systemctl enable systemd-networkd systemd-resolved 2>/dev/null || true + +# --- keep the pinned clock pinned ----------------------------------------- +# The host passes -rtc base= so time-gated scripts (the shutdown installers +# key off a 21:00-05:00 window) are deterministic. systemd-timesyncd reaches +# the network on boot and silently overwrites that with real time, which +# re-introduces exactly the nondeterminism -rtc exists to remove. +systemctl disable systemd-timesyncd 2>/dev/null || true +systemctl mask systemd-timesyncd 2>/dev/null || true +# Do NOT write a /etc/systemd/network/*.network file here: an en* match +# also catches the primary NIC and outranks the cloud image's own DHCP +# config, which breaks sshd's network and makes the sandbox unreachable. +# (Learned the hard way -- it cost a full rebuild.) Masking the unit above is +# sufficient to keep the pinned clock; UseNTP only matters if timesyncd runs. +# cloud-init also writes an NTP config on some images; neutralise it. +rm -f /etc/systemd/timesyncd.conf.d/*.conf 2>/dev/null || true + +echo "=== vmbox provision: done ===" diff --git a/vmbox/install.sh b/vmbox/install.sh new file mode 100755 index 0000000..4ef759d --- /dev/null +++ b/vmbox/install.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# ============================================================================ +# vmbox installer: installs host dependencies and links `vm` onto PATH. +# +# Deliberately NOT run as root: vmbox is designed to need no root at all +# (user-mode + multicast networking, qcow2 overlays, no bridge, no libvirt). +# Only the pacman step escalates, and only when something is missing. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +readonly SCRIPT_DIR +readonly LINK_DIR="${LINK_DIR:-$HOME/.local/bin}" + +# xorriso ships in libisoburn -- there is no package named "xorriso". +readonly -a PACKAGES=(qemu-base libisoburn openssh python) + +if [[ $EUID -eq 0 ]]; then + echo "Do not run install.sh as root: it links into \$HOME and would" >&2 + echo "create root-owned files there. It escalates on its own if needed." >&2 + exit 1 +fi + +echo "==> Checking host dependencies ..." +missing=() +for pkg in "${PACKAGES[@]}"; do + pacman -Qq "$pkg" >/dev/null 2>&1 || missing+=("$pkg") +done + +if (( ${#missing[@]} )); then + echo "==> Installing: ${missing[*]}" + sudo pacman -S --needed --noconfirm "${missing[@]}" +else + echo " all present: ${PACKAGES[*]}" +fi + +# KVM is what makes this usable rather than glacial; a missing /dev/kvm is a +# hard stop, not a warning, because the fallback is ~20x slower. +[[ -c /dev/kvm ]] || { + echo "error: /dev/kvm missing -- enable virtualization (SVM/VT-x) in firmware" >&2 + exit 1 +} +[[ -r /dev/kvm && -w /dev/kvm ]] || { + echo "error: /dev/kvm not read/writable by $USER -- add yourself to the 'kvm' group" >&2 + exit 1 +} + +echo "==> Linking vm -> $LINK_DIR/vm" +install -d -m 755 "$LINK_DIR" +ln -sfn "$SCRIPT_DIR/bin/vm" "$LINK_DIR/vm" + +case ":$PATH:" in + *":$LINK_DIR:"*) ;; + *) echo " note: $LINK_DIR is not on your PATH" ;; +esac + +echo +echo "Installed. Next: vm build (builds the golden image, once)" diff --git a/vmbox/lib/base.sh b/vmbox/lib/base.sh new file mode 100644 index 0000000..cc8668d --- /dev/null +++ b/vmbox/lib/base.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# ============================================================================ +# vmbox: golden base image build. +# +# Downloads the signed Arch cloud image, provisions it once, then SEALS it: +# chmod 444 + a sha256 sidecar. Every sandbox is a thin qcow2 overlay on this +# file, so booting the base even once would silently corrupt every overlay +# derived from it -- hence the seal and the launch-time verification. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +# shellcheck source=seed.sh +source "$(dirname "${BASH_SOURCE[0]}")/seed.sh" +# shellcheck source=smoke.sh +source "$(dirname "${BASH_SOURCE[0]}")/smoke.sh" + +readonly CLOUD_URL="https://geo.mirror.pkgbuild.com/images/latest" +readonly CLOUD_IMG="Arch-Linux-x86_64-cloudimg.qcow2" +readonly BASE_DISK_SIZE="${BASE_DISK_SIZE:-20G}" +readonly BUILD_SSH_PORT="${BUILD_SSH_PORT:-2222}" + +base_fetch() { + # Separate statements: within a single `local a=.. b="$a.."`, the later + # reference is unbound under `set -u` because the assignment is not done yet. + local dest="$VMBOX_BASE_DIR/cloudimg.qcow2" + local tmp="$dest.tmp" + local sums="$dest.SHA256" + [[ -f "$dest" ]] && { log "Cloud image present, skipping download"; return 0; } + + install -d -m 755 "$VMBOX_BASE_DIR" + log "Downloading Arch cloud image (~531 MB)" + curl -fL --progress-bar -o "$tmp" "$CLOUD_URL/$CLOUD_IMG" + curl -fsSL -o "$sums" "$CLOUD_URL/$CLOUD_IMG.SHA256" + + local expected actual + expected="$(awk '{print $1}' "$sums")" + actual="$(sha256sum "$tmp" | awk '{print $1}')" + [[ "$expected" == "$actual" ]] || + die "checksum mismatch -- refusing to use image (expected $expected, got $actual)" + mv "$tmp" "$dest" + ok "cloud image verified" +} + +# Boot the working copy with the seed ISO and an ssh forward, provision over +# ssh (real exit codes, unlike driving a shell over the serial console), then +# shut down cleanly. +base_provision() { + local disk="$1" work seed serial qpid + work="$(mktemp -d)" + seed="$work/seed.iso" + serial="$VMBOX_BASE_DIR/build.log" + rm -f "$serial" + + seed_ensure_key + seed_build_iso "$seed" "$work" + + log "Booting image for provisioning" + qemu-system-x86_64 \ + -machine q35,accel=kvm -cpu host -smp 4 -m 4096 \ + -drive file="$disk",if=virtio,format=qcow2 \ + -drive file="$seed",if=virtio,format=raw,readonly=on \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${BUILD_SSH_PORT}-:22" \ + -device virtio-net-pci,netdev=net0 \ + -display none \ + -chardev file,id=ser0,path="$serial",append=on -serial chardev:ser0 \ + -no-reboot >/dev/null 2>&1 & + qpid=$! + + # Always reap the build VM, including on failure or Ctrl-C. The pid and + # workdir are baked into the trap body: a RETURN trap fires after the + # function's locals are gone, so referencing $qpid there is unbound. + # shellcheck disable=SC2064 # expanding NOW is intended: see comment above + trap "kill $qpid 2>/dev/null || true; rm -rf '$work'" RETURN + + _base_wait_ssh "$qpid" || die "guest never became reachable -- see $serial" + + log "Provisioning guest (installs Xorg/i3 + dev tools; several minutes)" + _base_scp "$VMBOX_GUEST_DIR/provision.sh" "/tmp/provision.sh" + _base_ssh "chmod +x /tmp/provision.sh && sudo /tmp/provision.sh '$VMBOX_GUEST_USER'" || + die "provisioning failed -- see $serial" + + # Disable cloud-init so per-VM boots skip datasource probing entirely + # (it adds startup delay and would re-run on every overlay). + # + # Do NOT run `cloud-init clean` here: on this image cloud-init is what + # brings sshd up, and cleaning strips that alongside its own state, + # producing a sealed image whose guests boot fine but are unreachable. + # provision.sh enables sshd.service independently, which is what makes + # disabling cloud-init safe at all. + _base_ssh "sudo touch /etc/cloud/cloud-init.disabled" || true + + # Assert the sealed image will actually be reachable. A base that boots but + # refuses ssh is the single most expensive failure here: it looks like a + # network bug and costs a full rebuild to find. + _base_ssh "test -e /etc/systemd/system/multi-user.target.wants/sshd.service" || + die "sshd.service is not enabled in the image -- guests would be unreachable" + ok "verified: sshd.service will start on boot" + + log "Shutting the build VM down cleanly" + _base_ssh "sudo systemctl poweroff" >/dev/null 2>&1 || true + + local waited=0 + while kill -0 "$qpid" 2>/dev/null && (( waited < 120 )); do + sleep 2; waited=$(( waited + 2 )) + done + kill -0 "$qpid" 2>/dev/null && { warn "build VM did not stop; forcing"; kill "$qpid"; } + ok "provisioning complete" +} + +_base_ssh_opts() { + printf '%s' "-i $VMBOX_SSH_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ +-o LogLevel=ERROR -o ConnectTimeout=5 -p $BUILD_SSH_PORT" +} + +_base_ssh() { + # shellcheck disable=SC2046 # word splitting of ssh opts is intended + ssh $(_base_ssh_opts) "${VMBOX_GUEST_USER}@127.0.0.1" "$@" +} + +_base_scp() { + scp -i "$VMBOX_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -P "$BUILD_SSH_PORT" "$1" "${VMBOX_GUEST_USER}@127.0.0.1:$2" +} + +# ssh is its own port prober, so no nc/socat needed (neither is installed). +_base_wait_ssh() { + local qpid="$1" waited=0 + log "Waiting for guest ssh (first boot runs cloud-init)" + while (( waited < 300 )); do + kill -0 "$qpid" 2>/dev/null || return 1 + if _base_ssh -o BatchMode=yes true 2>/dev/null; then + ok "guest reachable after ${waited}s" + return 0 + fi + sleep 5; waited=$(( waited + 5 )) + done + return 1 +} + +# Make the base read-only and record its hash. The hash is the guard that +# actually catches corruption; chmod alone can be bypassed. +base_seal() { + local disk="$1" + log "Sealing base image" + mv "$disk" "$VMBOX_BASE_IMG" + chmod 444 "$VMBOX_BASE_IMG" + sha256sum "$VMBOX_BASE_IMG" | awk '{print $1}' > "$VMBOX_BASE_SHA" + ok "base sealed: $VMBOX_BASE_IMG ($(du -h "$VMBOX_BASE_IMG" | cut -f1))" +} + +base_build() { + require_host_deps + if [[ -f "$VMBOX_BASE_IMG" && "${1:-}" != "--force" ]]; then + die "base image already exists -- rebuild with: vm build --force" + fi + base_fetch + + local disk="$VMBOX_BASE_DIR/building.qcow2" + rm -f "$disk" + cp --reflink=auto "$VMBOX_BASE_DIR/cloudimg.qcow2" "$disk" + qemu-img resize -q "$disk" "$BASE_DISK_SIZE" + + base_provision "$disk" + base_seal "$disk" + base_smoke_test + echo + ok "Done. Create a sandbox with: vm new demo" +} diff --git a/vmbox/lib/common.sh b/vmbox/lib/common.sh new file mode 100644 index 0000000..a59c6b8 --- /dev/null +++ b/vmbox/lib/common.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================================================ +# vmbox: shared paths, logging and validation. +# Sourced by every other lib/ file and by bin/vm. Defines no side effects. +# ============================================================================ + +# Guard against double-sourcing: each lib sources common.sh unconditionally. +# shellcheck disable=SC2034 # every path/const below is consumed by the +# sibling lib/*.sh files that source this one, which shellcheck cannot see. +[[ -n "${VMBOX_COMMON_SOURCED:-}" ]] && return 0 +readonly VMBOX_COMMON_SOURCED=1 + +# Repo root is the parent of lib/, resolved through symlinks so the CLI works +# when bin/vm is symlinked onto PATH by install.sh. +VMBOX_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +readonly VMBOX_LIB_DIR +# Go up one real directory rather than stripping a "/lib" suffix: the suffix +# form silently yields the wrong root when sourced via a relative path. +VMBOX_ROOT="$(cd "$VMBOX_LIB_DIR/.." && pwd -P)" +readonly VMBOX_ROOT +readonly VMBOX_GUEST_DIR="$VMBOX_ROOT/guest" + +# State lives OUTSIDE the repo: these are multi-GB disk images and live +# sockets, and the repo has a pre-commit hook that blocks binaries. +readonly VMBOX_HOME="${VMBOX_HOME:-$HOME/.local/share/vmbox}" +readonly VMBOX_BASE_DIR="$VMBOX_HOME/base" +readonly VMBOX_VMS_DIR="$VMBOX_HOME/vms" +readonly VMBOX_BASE_IMG="$VMBOX_BASE_DIR/base.qcow2" +readonly VMBOX_BASE_SHA="$VMBOX_BASE_DIR/base.qcow2.sha256" +readonly VMBOX_SSH_KEY="$VMBOX_BASE_DIR/id_ed25519" + +# Base SSH port. Each VM gets VMBOX_SSH_PORT_BASE + its index. +readonly VMBOX_SSH_PORT_BASE="${VMBOX_SSH_PORT_BASE:-2300}" +# Shared multicast segment so sandboxes can reach each other (no root, no bridge). +readonly VMBOX_MCAST="${VMBOX_MCAST:-230.0.0.1:12345}" +# Guest static subnet on the second NIC (the mcast segment has no DHCP). +readonly VMBOX_GUEST_SUBNET="${VMBOX_GUEST_SUBNET:-10.77.0}" + +readonly VMBOX_GUEST_USER="${VMBOX_GUEST_USER:-arch}" + +# Colours only when stdout is a terminal, so logs stay clean when piped. +if [[ -t 1 ]]; then + readonly C_RED=$'\033[0;31m' C_GRN=$'\033[0;32m' C_YLW=$'\033[0;33m' + readonly C_BLU=$'\033[0;34m' C_OFF=$'\033[0m' +else + readonly C_RED='' C_GRN='' C_YLW='' C_BLU='' C_OFF='' +fi + +log() { printf '%s==>%s %s\n' "$C_BLU" "$C_OFF" "$*"; } +ok() { printf '%s ok %s %s\n' "$C_GRN" "$C_OFF" "$*"; } +warn() { printf '%s warn%s %s\n' "$C_YLW" "$C_OFF" "$*" >&2; } +die() { printf '%serror%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; } + +# VM names become directory names and qemu -name values; keep them boring. +validate_vm_name() { + local name="${1:-}" + [[ -n "$name" ]] || die "vm name is required" + [[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$ ]] || + die "invalid vm name '$name' (allowed: alphanumeric, _ and -, max 32 chars)" + printf '%s' "$name" +} + +vm_dir() { printf '%s/%s' "$VMBOX_VMS_DIR" "$1"; } +vm_overlay() { printf '%s/%s/overlay.qcow2' "$VMBOX_VMS_DIR" "$1"; } +vm_meta() { printf '%s/%s/meta' "$VMBOX_VMS_DIR" "$1"; } +vm_qmp_sock() { printf '%s/%s/qmp.sock' "$VMBOX_VMS_DIR" "$1"; } +# Separate control channel: the recorder occupies the main QMP socket, and a +# QMP socket accepts a single client at a time. +vm_qmp_ctl() { printf '%s/%s/qmp.sock.ctl' "$VMBOX_VMS_DIR" "$1"; } +vm_events() { printf '%s/%s/events.jsonl' "$VMBOX_VMS_DIR" "$1"; } +vm_pidfile() { printf '%s/%s/vm.pid' "$VMBOX_VMS_DIR" "$1"; } + +# Serial logs are per-boot: a relaunch must never overwrite the log a verdict +# is about to be read from. See vm_next_serial(). +vm_serial() { printf '%s/%s/serial.%s.log' "$VMBOX_VMS_DIR" "$1" "$2"; } + +vm_exists() { [[ -d "$(vm_dir "$1")" ]]; } + +require_vm() { + vm_exists "$1" || die "no such vm: '$1' (create it with: vm new $1)" +} + +# meta is a flat key=value file; values must not contain newlines. +meta_get() { + local name="$1" key="$2" line + line="$(grep -m1 "^${key}=" "$(vm_meta "$name")" 2>/dev/null)" || return 1 + printf '%s' "${line#*=}" +} + +meta_set() { + local name="$1" key="$2" value="$3" meta + meta="$(vm_meta "$name")" + [[ "$value" == *$'\n'* ]] && die "meta value for '$key' must be single-line" + touch "$meta" + # Rewrite in place: delete any existing key, then append the new value. + grep -v "^${key}=" "$meta" > "$meta.tmp" 2>/dev/null || true + printf '%s=%s\n' "$key" "$value" >> "$meta.tmp" + mv "$meta.tmp" "$meta" +} + +# Highest existing serial.N.log + 1, so each boot writes its own file. +vm_next_serial() { + local name="$1" n=0 f + # -e guards the unmatched-glob case; without it an empty dir yields either + # a literal "serial.*.log" (bash) or a hard error (zsh). + for f in "$(vm_dir "$name")"/serial.*.log; do + [[ -e "$f" ]] || continue + local base="${f##*/serial.}" + base="${base%.log}" + [[ "$base" =~ ^[0-9]+$ ]] && (( base >= n )) && n=$(( base + 1 )) + done + printf '%d' "$n" +} + +# A VM is running if its pidfile names a live qemu process. Stale pidfiles are +# normal: a guest that powers itself off leaves one behind. +vm_is_running() { + local pid + pid="$(cat "$(vm_pidfile "$1")" 2>/dev/null)" || return 1 + [[ -n "$pid" ]] || return 1 + [[ -r "/proc/$pid/cmdline" ]] || return 1 + grep -qa vmbox-"$1" "/proc/$pid/cmdline" 2>/dev/null +} + +require_host_deps() { + local missing=() + command -v qemu-system-x86_64 >/dev/null || missing+=(qemu-base) + command -v qemu-img >/dev/null || missing+=(qemu-img) + command -v xorrisofs >/dev/null || missing+=(libisoburn) + command -v python3 >/dev/null || missing+=(python) + (( ${#missing[@]} == 0 )) || + die "missing host dependencies: ${missing[*]} -- run: $VMBOX_ROOT/install.sh" +} diff --git a/vmbox/lib/launch.sh b/vmbox/lib/launch.sh new file mode 100644 index 0000000..303ad62 --- /dev/null +++ b/vmbox/lib/launch.sh @@ -0,0 +1,104 @@ +#!/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" + +readonly VMBOX_MEM="${VMBOX_MEM:-4096}" +readonly VMBOX_SMP="${VMBOX_SMP:-4}" + +# $1 = vm name. Echoes the serial-log index it started. +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)" + rtc="$(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")" + # Symlink the repos you want testable into here; nothing else is 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. + -netdev "socket,id=net1,mcast=${VMBOX_MCAST}" + -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 + -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") + + _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 +} diff --git a/vmbox/lib/overlay.sh b/vmbox/lib/overlay.sh new file mode 100644 index 0000000..b1f7e5f --- /dev/null +++ b/vmbox/lib/overlay.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# ============================================================================ +# vmbox: sandbox lifecycle -- create, reset, delete. +# +# A sandbox is a thin qcow2 overlay on the sealed base image. Creating one is +# instant and costs ~200 KB; resetting is `rm` + recreate, so it is +# sub-second no matter how thoroughly the guest was destroyed. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# The base must never be written to: qcow2 overlays record their backing file's +# state, so booting the base once silently invalidates EVERY overlay on it. +# chmod 444 is the lock; this hash is what actually detects a breach. +overlay_verify_base() { + [[ -f "$VMBOX_BASE_IMG" ]] || + die "no base image -- build it first with: vm build" + [[ -f "$VMBOX_BASE_SHA" ]] || + die "base image has no checksum sidecar -- rebuild with: vm build --force" + + local expected actual + expected="$(cat "$VMBOX_BASE_SHA")" + actual="$(sha256sum "$VMBOX_BASE_IMG" | awk '{print $1}')" + [[ "$expected" == "$actual" ]] || die \ +"BASE IMAGE MODIFIED -- every existing sandbox is now unreliable. + expected $expected + actual $actual +Rebuild with: vm build --force" +} + +# Allocate the lowest free index; it determines the ssh port and peer IP. +_overlay_next_index() { + local used=() d idx i + for d in "$VMBOX_VMS_DIR"/*/; do + [[ -d "$d" ]] || continue + idx="$(meta_get "$(basename "$d")" index 2>/dev/null)" || continue + [[ -n "$idx" ]] && used+=("$idx") + done + for (( i = 1; i < 200; i++ )); do + local taken=0 u + for u in ${used+"${used[@]}"}; do [[ "$u" == "$i" ]] && taken=1 && break; done + (( taken )) || { printf '%d' "$i"; return 0; } + done + die "no free sandbox slots (200 in use?)" +} + +overlay_new() { + local name rtc="" arg + name="$(validate_vm_name "${1:-}")"; shift || true + + while [[ $# -gt 0 ]]; do + arg="$1" + case "$arg" in + --rtc) rtc="${2:-}"; [[ -n "$rtc" ]] || die "--rtc needs a timestamp"; shift 2 ;; + *) die "unknown option for 'vm new': $arg" ;; + esac + done + + vm_exists "$name" && die "sandbox '$name' already exists (reset it with: vm reset $name)" + overlay_verify_base + + local dir index + dir="$(vm_dir "$name")" + install -d -m 755 "$dir" + index="$(_overlay_next_index)" + + meta_set "$name" index "$index" + meta_set "$name" ssh_port "$(( VMBOX_SSH_PORT_BASE + index ))" + meta_set "$name" created "$(date -Is)" + # Stored, not passed once: every `vm run` is a fresh qemu process, so the + # clock must be re-applied on each launch or boot #2 drifts to wall-clock. + [[ -n "$rtc" ]] && meta_set "$name" rtc "$rtc" + + _overlay_create_disk "$name" + ok "sandbox '$name' ready (ssh port $(( VMBOX_SSH_PORT_BASE + index )), peer ${VMBOX_GUEST_SUBNET}.${index})" +} + +_overlay_create_disk() { + local name="$1" overlay + overlay="$(vm_overlay "$name")" + rm -f "$overlay" + qemu-img create -q -f qcow2 -F qcow2 -b "$VMBOX_BASE_IMG" "$overlay" + # Belt and braces: assert the chain really points at the sealed base. + qemu-img info --backing-chain "$overlay" | grep -q "$VMBOX_BASE_IMG" || + die "overlay backing chain does not reference the base image" +} + +overlay_reset() { + local name + name="$(validate_vm_name "${1:-}")" + require_vm "$name" + overlay_verify_base + + vm_is_running "$name" && _overlay_stop "$name" + + # Discard everything the guest did: the overlay holds 100% of its writes. + _overlay_create_disk "$name" + rm -f "$(vm_dir "$name")"/serial.*.log "$(vm_events "$name")" "$(vm_pidfile "$name")" + ok "sandbox '$name' reset to pristine" +} + +_overlay_stop() { + local name="$1" pid waited=0 + pid="$(cat "$(vm_pidfile "$name")" 2>/dev/null)" || return 0 + log "Stopping running sandbox '$name'" + kill "$pid" 2>/dev/null || true + while kill -0 "$pid" 2>/dev/null && (( waited < 20 )); do + sleep 1; waited=$(( waited + 1 )) + done + kill -9 "$pid" 2>/dev/null || true +} + +overlay_rm() { + local name + name="$(validate_vm_name "${1:-}")" + require_vm "$name" + vm_is_running "$name" && _overlay_stop "$name" + rm -rf "$(vm_dir "$name")" + ok "sandbox '$name' deleted" +} diff --git a/vmbox/lib/qmp.py b/vmbox/lib/qmp.py new file mode 100755 index 0000000..060fae2 --- /dev/null +++ b/vmbox/lib/qmp.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""One-shot QMP client for vmbox: issue a command and print the reply. + +Used for actions that need a round trip rather than passive event recording -- +notably `screendump`, which is how locker/X11 tests get a screenshot without +any VNC or SPICE viewer installed on the host. + +Usage: + qmp.py screendump + qmp.py status +""" + +from __future__ import annotations + +import json +import os +import socket +import sys +from typing import Any, TextIO + + +def _open(path: str) -> tuple[socket.socket, TextIO]: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(20) + sock.connect(path) + stream = sock.makefile("rw", encoding="utf-8", newline="\n") + stream.readline() # greeting + _command(stream, "qmp_capabilities") + return sock, stream + + +def _command(stream: TextIO, name: str, **args: Any) -> dict[str, Any]: + """Send one command and return its reply, skipping any events in between.""" + payload: dict[str, Any] = {"execute": name} + if args: + payload["arguments"] = args + stream.write(json.dumps(payload) + "\n") + stream.flush() + while True: + line = stream.readline() + if not line: + raise SystemExit(f"qmp: connection closed during '{name}'") + msg = json.loads(line) + if "return" in msg or "error" in msg: + return msg + + +def main() -> int: + if len(sys.argv) < 3: + print(__doc__, file=sys.stderr) + return 2 + path, action = sys.argv[1], sys.argv[2] + + if not os.path.exists(path): + print(f"qmp: no such socket: {path} (is the sandbox running?)", file=sys.stderr) + return 1 + + sock, stream = _open(path) + try: + if action == "screendump": + if len(sys.argv) < 4: + print("qmp: screendump needs an output path", file=sys.stderr) + return 2 + out = os.path.abspath(sys.argv[3]) + # PNG is native to qemu's screendump; no ImageMagick needed. + reply = _command(stream, "screendump", filename=out, format="png") + elif action == "status": + reply = _command(stream, "query-status") + else: + print(f"qmp: unknown action '{action}'", file=sys.stderr) + return 2 + + if "error" in reply: + print(f"qmp: {reply['error'].get('desc', reply['error'])}", file=sys.stderr) + return 1 + print(json.dumps(reply.get("return", {}))) + return 0 + finally: + stream.close() + sock.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vmbox/lib/recorder.py b/vmbox/lib/recorder.py new file mode 100755 index 0000000..9a32dfb --- /dev/null +++ b/vmbox/lib/recorder.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Persistent QMP event recorder for a vmbox sandbox. + +Started at VM launch, before anything runs inside the guest, and appends every +QMP event to a JSONL file. This ordering is the whole point: a guest that +powers itself off kills QEMU, so a prober attached *after* the fact finds a +closed socket and no event at all. Recording from launch means the verdict is +read from a file that outlives the VM. + +Usage: recorder.py +""" + +from __future__ import annotations + +import json +import socket +import sys +import time +from typing import Any, TextIO + + +def connect(path: str, timeout: float = 30.0) -> socket.socket: + """Connect to the QMP unix socket, retrying until QEMU creates it.""" + deadline = time.time() + timeout + last_err: OSError | None = None + while time.time() < deadline: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(path) + return sock + except OSError as exc: + last_err = exc + sock.close() + time.sleep(0.1) + raise SystemExit(f"recorder: could not connect to {path}: {last_err}") + + +def negotiate(stream: TextIO) -> None: + """Complete the QMP capability handshake; events only flow afterwards.""" + stream.readline() # greeting + stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n") + stream.flush() + for _ in range(20): + line = stream.readline() + if not line: + return + msg = json.loads(line) + if "return" in msg or "error" in msg: + return + + +def record(stream: TextIO, out: TextIO) -> None: + """Append each event as one JSON line, flushing so readers see it at once.""" + while True: + line = stream.readline() + if not line: # QEMU exited: the guest is gone, which is itself a signal. + _emit(out, {"event": "_RECORDER_EOF", "timestamp": _now()}) + return + try: + msg: dict[str, Any] = json.loads(line) + except json.JSONDecodeError: + continue + if "event" in msg: + _emit(out, msg) + + +def _now() -> dict[str, int]: + now = time.time() + return {"seconds": int(now), "microseconds": int((now % 1) * 1_000_000)} + + +def _emit(out: TextIO, msg: dict[str, Any]) -> None: + out.write(json.dumps(msg) + "\n") + out.flush() + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + qmp_path, events_path = sys.argv[1], sys.argv[2] + + sock = connect(qmp_path) + # newline='\n' keeps QMP's line protocol intact regardless of locale. + stream = sock.makefile("rw", encoding="utf-8", newline="\n") + with open(events_path, "a", encoding="utf-8") as out: + negotiate(stream) + _emit(out, {"event": "_RECORDER_READY", "timestamp": _now()}) + try: + record(stream, out) + except OSError as exc: + _emit( + out, + { + "event": "_RECORDER_ERROR", + "data": {"error": str(exc)}, + "timestamp": _now(), + }, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vmbox/lib/repo.sh b/vmbox/lib/repo.sh new file mode 100644 index 0000000..9b89718 --- /dev/null +++ b/vmbox/lib/repo.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# ============================================================================ +# vmbox: get a host repo into a sandbox. +# +# Two modes, because they answer different questions: +# clone (default) -- reproducible: tests exactly what is committed at HEAD +# worktree -- tests what is on disk right now, uncommitted edits and +# all, which is usually what "does this script work?" means +# +# The host side is always mounted READ-ONLY, so nothing the guest does can +# reach the real repo. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh" + +readonly GUEST_MOUNT="/mnt/hostrepo" + +# $1 vm name, $2 host repo path, $3 mode (clone|worktree) +repo_sync() { + local name="$1" src="$2" mode="${3:-clone}" repo_name + require_vm "$name" + [[ -d "$src" ]] || die "no such directory: $src" + repo_name="$(basename "$src")" + + vm_is_running "$name" || die "sandbox '$name' is not running" + + # Warn about a dirty tree: another session's uncommitted work is a real + # hazard, and it silently changes what a `clone` actually tests. + if [[ -d "$src/.git" ]] && ! git -C "$src" diff --quiet 2>/dev/null; then + warn "host repo has uncommitted changes" + [[ "$mode" == clone ]] && + warn " mode=clone tests HEAD, NOT those edits (use --worktree for them)" + fi + + case "$mode" in + clone) _repo_clone "$name" "$src" "$repo_name" ;; + worktree) _repo_rsync "$name" "$src" "$repo_name" ;; + *) die "unknown repo mode '$mode' (use: clone | worktree)" ;; + esac +} + +_repo_clone() { + local name="$1" src="$2" repo_name="$3" + [[ -d "$src/.git" ]] || die "$src is not a git repo (use --worktree instead)" + log "Cloning $repo_name into '$name' (HEAD, reproducible)" + # --no-hardlinks: never link into the read-only host mount. + vm_ssh_exec "$name" \ + "rm -rf ~/$repo_name && git clone --no-hardlinks -q $GUEST_MOUNT/$repo_name ~/$repo_name" || + die "clone failed" + local head + head="$(vm_ssh_exec "$name" "git -C ~/$repo_name rev-parse --short HEAD" 2>/dev/null)" + ok "$repo_name cloned at $head (committed state)" +} + +_repo_rsync() { + local name="$1" src="$2" repo_name="$3" + log "Copying $repo_name into '$name' (working tree, including uncommitted edits)" + vm_ssh_exec "$name" \ + "rsync -a --exclude .git $GUEST_MOUNT/$repo_name/ ~/$repo_name/" || + die "rsync failed" + ok "$repo_name copied (working-tree state)" +} diff --git a/vmbox/lib/run.sh b/vmbox/lib/run.sh new file mode 100644 index 0000000..73398c1 --- /dev/null +++ b/vmbox/lib/run.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ============================================================================ +# vmbox: run a command in a sandbox and report what happened to the machine. +# +# The hard case is a command that powers the guest off (the whole point of the +# tool). ssh cannot report that: the connection dies with the machine and +# returns 255, the same value it returns for a crash or a dropped link. So the +# exit code is written to disk INSIDE the guest, and recovered afterwards from +# the overlay -- which survives the poweroff -- while the verdict comes from +# host-side QMP/serial evidence. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +source "$(dirname "${BASH_SOURCE[0]}")/launch.sh" +source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh" +source "$(dirname "${BASH_SOURCE[0]}")/verdict.sh" + +readonly RC_PATH="/var/tmp/vmbox.rc" +readonly VMBOX_RUN_TIMEOUT="${VMBOX_RUN_TIMEOUT:-600}" + +run_in_vm() { + local name + name="$(validate_vm_name "${1:-}")"; shift || true + require_vm "$name" + [[ $# -gt 0 ]] || die "usage: vm run " + + local serial_n serial events + if vm_is_running "$name"; then + serial_n="$(( $(vm_next_serial "$name") - 1 ))" + (( serial_n < 0 )) && serial_n=0 + else + serial_n="$(launch_vm "$name")" + vm_wait_ssh "$name" 150 || die "sandbox '$name' did not become reachable" + fi + serial="$(vm_serial "$name" "$serial_n")" + events="$(vm_events "$name")" + + # Record where the verdict should start reading, so a previous boot's + # shutdown event is never mistaken for this run's outcome. + local events_before=0 + [[ -f "$events" ]] && events_before=$(wc -l < "$events") + + log "Running in '$name': $*" + _run_exec "$name" "$@" + local ssh_rc=$? + + _run_settle "$name" "$serial" + _run_finish "$name" "$serial" "$events" "$events_before" "$ssh_rc" +} + +# stdin is closed: several target installers prompt, and an inherited terminal +# would make them block forever instead of taking their default. +_run_exec() { + local name="$1"; shift + local cmd="$*" + vm_ssh_exec "$name" \ + "sh -c '{ $cmd; } ; echo \$? | sudo tee $RC_PATH >/dev/null' /dev/null && break + sleep 1; waited=$(( waited + 1 )) + done + # Let qemu actually exit after the marker appears. + waited=0 + while vm_is_running "$name" && (( waited < 15 )); do + grep -qa "$POWEROFF_MARKER" "$serial" 2>/dev/null || break + sleep 1; waited=$(( waited + 1 )) + done +} + +_run_finish() { + local name="$1" serial="$2" events="$3" events_before="$4" ssh_rc="$5" + + # Only consider events produced by THIS run. + local scoped="$events.scoped" + if [[ -f "$events" ]]; then + tail -n +$(( events_before + 1 )) "$events" > "$scoped" 2>/dev/null || : > "$scoped" + else + : > "$scoped" + fi + + echo + local vrc=0 + verdict_report "$name" "$serial" "$scoped" || vrc=$? + rm -f "$scoped" + + # If the guest stopped, recover the real exit code from the overlay. + if ! vm_is_running "$name"; then + local rc + rc="$(_run_recover_rc "$name")" + if [[ -n "$rc" ]]; then + log "command exit code (recovered from the stopped guest): $rc" + return "$rc" + fi + log "no exit code recorded (the command never returned before the machine stopped)" + return "$vrc" + fi + + if (( ssh_rc == 255 )); then + warn "ssh reported 255 but the guest is still running -- connection issue, not a shutdown" + fi + return "$ssh_rc" +} + +# Boot the sandbox again just far enough to read the recorded exit code. +_run_recover_rc() { + local name="$1" rc="" + launch_vm "$name" >/dev/null 2>&1 || return 0 + if vm_wait_ssh "$name" 120; then + rc="$(vm_ssh_exec "$name" "cat $RC_PATH 2>/dev/null" 2>/dev/null | tr -dc '0-9')" + fi + printf '%s' "$rc" +} diff --git a/vmbox/lib/screenshot.sh b/vmbox/lib/screenshot.sh new file mode 100644 index 0000000..9b1f512 --- /dev/null +++ b/vmbox/lib/screenshot.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# ============================================================================ +# vmbox: capture the guest screen. +# +# Screenshots come from qemu's own framebuffer via QMP screendump, so no VNC +# or SPICE viewer is needed. This is how locker/i3 tests are verified -- and +# it is safer than testing on the host, where a fullscreen locker can hijack +# input and steal focus from whatever the user is doing. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +vm_screenshot() { + local name out + name="$(validate_vm_name "${1:-}")" + out="${2:-$PWD/${name}.png}" + require_vm "$name" + + vm_is_running "$name" || + die "sandbox '$name' is not running (start it with: vm ssh $name)" + + python3 "$VMBOX_LIB_DIR/qmp.py" "$(vm_qmp_ctl "$name")" screendump "$out" >/dev/null || + die "screendump failed" + + [[ -s "$out" ]] || die "screendump produced an empty file" + ok "screenshot: $out ($(du -h "$out" | cut -f1))" + printf '%s' "$out" +} diff --git a/vmbox/lib/seed.sh b/vmbox/lib/seed.sh new file mode 100644 index 0000000..b922f0a --- /dev/null +++ b/vmbox/lib/seed.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# ============================================================================ +# vmbox: cloud-init NoCloud seed ISO generation. +# +# The seed ISO is how the golden image gets its user, SSH key and provisioning +# script. It is used ONLY during `vm build`; the sealed base disables +# cloud-init, so per-VM boots never touch it. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# Generate the throwaway keypair the host uses to reach guests. This key is +# sandbox-only and is never the user's real ~/.ssh key. +seed_ensure_key() { + [[ -f "$VMBOX_SSH_KEY" ]] && return 0 + log "Generating sandbox SSH key (throwaway, sandbox-only)" + install -d -m 700 "$VMBOX_BASE_DIR" + ssh-keygen -t ed25519 -N '' -q -C 'vmbox-sandbox-key' -f "$VMBOX_SSH_KEY" + ok "key: $VMBOX_SSH_KEY" +} + +# Write user-data + meta-data and pack them into a CIDATA volume. +# $1 = output ISO path, $2 = directory to build in. +seed_build_iso() { + local iso="$1" workdir="$2" pubkey + pubkey="$(cat "${VMBOX_SSH_KEY}.pub")" + + # NOPASSWD sudo is load-bearing, not a convenience: without it every + # `vm run` that uses sudo blocks forever on an invisible password prompt. + cat > "$workdir/user-data" < "$workdir/meta-data" </dev/null 2>&1 + overlay_new "$smoke" >/dev/null + + local failed=0 + launch_vm "$smoke" >/dev/null + if vm_wait_ssh "$smoke" 180 && vm_ssh_exec "$smoke" true 2>/dev/null; then + ok "smoke test passed: a fresh sandbox boots and accepts ssh" + else + failed=1 + warn "SMOKE TEST FAILED -- the sealed image boots but is unreachable." + warn "Serial log: $(vm_serial "$smoke" 0)" + warn "Debug it with the serial console (root autologin is enabled there)." + fi + + # On failure KEEP the sandbox: deleting it would destroy the serial log + # this very message points at, which is the only evidence available when + # ssh is the thing that is broken. + if (( failed == 0 )); then + overlay_rm "$smoke" >/dev/null 2>&1 || true + else + warn "keeping sandbox '__smoke' so its logs survive; remove it with: vm rm __smoke" + die "refusing to ship an unreachable base image" + fi +} + diff --git a/vmbox/lib/ssh.sh b/vmbox/lib/ssh.sh new file mode 100644 index 0000000..b05089d --- /dev/null +++ b/vmbox/lib/ssh.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# ============================================================================ +# vmbox: ssh access to a sandbox. +# +# Guest host keys are deliberately NOT verified and NOT recorded: a sandbox is +# reset constantly, so a real known_hosts entry would collide on every reset. +# Isolation comes from the VM boundary, not from ssh key continuity. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +vm_ssh_port() { + local port + port="$(meta_get "$1" ssh_port)" || die "sandbox '$1' has no ssh port in meta" + printf '%s' "$port" +} + +_ssh_base_args() { + local port="$1" + printf '%s\0' \ + -i "$VMBOX_SSH_KEY" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o GlobalKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + -o ConnectTimeout=5 \ + -o ServerAliveInterval=5 \ + -p "$port" +} + +# Run a command in the guest. Returns the guest's exit code, EXCEPT when the +# connection dies -- ssh reports 255 for a dropped link, a killed sshd and a +# successful poweroff alike, which is exactly why the verdict logic never +# trusts this return value on its own. +vm_ssh_exec() { + local name="$1"; shift + local port; port="$(vm_ssh_port "$name")" + local -a args=() + mapfile -d '' -t args < <(_ssh_base_args "$port") + ssh "${args[@]}" "${VMBOX_GUEST_USER}@127.0.0.1" "$@" +} + +vm_ssh_interactive() { + local name + name="$(validate_vm_name "${1:-}")"; shift || true + require_vm "$name" + + if ! vm_is_running "$name"; then + log "Sandbox '$name' is not running; starting it" + source "$VMBOX_LIB_DIR/launch.sh" + launch_vm "$name" + vm_wait_ssh "$name" 120 || die "sandbox '$name' did not become reachable" + fi + + local port; port="$(vm_ssh_port "$name")" + local -a args=() + mapfile -d '' -t args < <(_ssh_base_args "$port") + # -t forces a pty so interactive tools (and sudo prompts) behave. + ssh -t "${args[@]}" "${VMBOX_GUEST_USER}@127.0.0.1" "$@" +} + +vm_scp_to() { + local name="$1" src="$2" dest="$3" port + port="$(vm_ssh_port "$name")" + scp -i "$VMBOX_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -P "$port" -r "$src" "${VMBOX_GUEST_USER}@127.0.0.1:$dest" +} + +# Poll until the guest answers. ssh is its own port prober, so this needs no +# nc/socat (neither is installed on this host). +vm_wait_ssh() { + local name="$1" timeout="${2:-120}" waited=0 + while (( waited < timeout )); do + if vm_is_running "$name" && vm_ssh_exec "$name" -o BatchMode=yes true 2>/dev/null; then + return 0 + fi + # A qemu that has exited means the guest will never answer. + vm_is_running "$name" || { (( waited > 3 )) && return 1; } + sleep 2; waited=$(( waited + 2 )) + done + return 1 +} diff --git a/vmbox/lib/verdict.sh b/vmbox/lib/verdict.sh new file mode 100644 index 0000000..17a56ed --- /dev/null +++ b/vmbox/lib/verdict.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# ============================================================================ +# vmbox: classify what happened to a guest, from the HOST. +# +# A guest cannot report its own poweroff: the connection carrying the report +# dies with it, and ssh returns 255 for a clean shutdown, a crash, a killed +# sshd and a network blip alike. So the verdict is read from two host-side +# artefacts that outlive the VM -- the QMP event log and the serial log. +# ============================================================================ + +# shellcheck source=common.sh +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +# The line the kernel prints as the very last thing on a complete poweroff. +readonly POWEROFF_MARKER='reboot: Power down' + +# Read the last SHUTDOWN/panic event out of events.jsonl. +# Echoes one of: guest-shutdown | guest-reset | host-* | panic | none +verdict_last_event() { + local events="$1" + [[ -s "$events" ]] || { printf 'none'; return 0; } + python3 - "$events" <<'PY' +import json, sys + +kind = "none" +with open(sys.argv[1], encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + event = msg.get("event", "") + if event == "GUEST_PANICKED": + kind = "panic" + elif event == "SHUTDOWN": + data = msg.get("data", {}) + reason = data.get("reason", "") + # `guest` distinguishes a guest-initiated stop from one we caused. + kind = reason if reason else ("guest-shutdown" if data.get("guest") else "host") +print(kind) +PY +} + +# Did the guest reach the end of its shutdown sequence, or die partway? +# This is what separates "shut down" from "shut down COMPLETELY". +verdict_serial_complete() { + local serial="$1" + [[ -s "$serial" ]] || return 1 + grep -qa "$POWEROFF_MARKER" "$serial" +} + +# Liveness probe, so a merely SLOW script (an installer running pacman -Syu) +# is not reported as hung. +verdict_is_alive() { + local name="$1" serial="$2" before after + vm_is_running "$name" || return 1 + before=$(stat -c %s "$serial" 2>/dev/null || echo 0) + sleep 3 + after=$(stat -c %s "$serial" 2>/dev/null || echo 0) + [[ "$after" != "$before" ]] && return 0 + source "$VMBOX_LIB_DIR/ssh.sh" + vm_ssh_exec "$name" -o BatchMode=yes -o ConnectTimeout=3 true 2>/dev/null +} + +# Print the human-readable verdict and return a distinct exit code per outcome: +# 0 clean poweroff 3 dirty 4 rebooted 5 panicked 6 hung 7 still running +verdict_report() { + local name="$1" serial="$2" events="$3" kind + kind="$(verdict_last_event "$events")" + + case "$kind" in + guest-shutdown) + if verdict_serial_complete "$serial"; then + ok "VERDICT: clean poweroff -- guest powered itself off and reached '$POWEROFF_MARKER'" + return 0 + fi + warn "VERDICT: DIRTY shutdown -- the VM stopped, but the serial log never reached" + warn " '$POWEROFF_MARKER'; the sequence was cut short (see $serial)" + return 3 + ;; + guest-reset) + warn "VERDICT: REBOOTED, did not power off -- the guest reset instead of halting" + return 4 + ;; + panic) + warn "VERDICT: KERNEL PANIC -- the guest crashed rather than shutting down" + return 5 + ;; + host*|none) + if vm_is_running "$name"; then + ok "VERDICT: still running (no shutdown event)" + return 7 + fi + warn "VERDICT: stopped without a guest shutdown event (host-initiated or killed)" + return 6 + ;; + *) + warn "VERDICT: unrecognised shutdown reason '$kind'" + return 6 + ;; + esac +} diff --git a/vmbox/tests/test_vmbox.bats b/vmbox/tests/test_vmbox.bats new file mode 100644 index 0000000..c426c7e --- /dev/null +++ b/vmbox/tests/test_vmbox.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats +# Host-side unit tests for vmbox's pure functions. These deliberately do NOT +# boot a VM: booting is covered by the destructive end-to-end demo in README. +# Everything here runs against a temporary VMBOX_HOME. + +setup() { + VMBOX_TEST_HOME="$(mktemp -d)" + export VMBOX_HOME="$VMBOX_TEST_HOME" + REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + export REPO + # shellcheck source=../lib/common.sh + source "$REPO/lib/common.sh" +} + +teardown() { + [[ -n "${VMBOX_TEST_HOME:-}" && -d "$VMBOX_TEST_HOME" ]] && rm -rf "$VMBOX_TEST_HOME" +} + +@test "validate_vm_name accepts ordinary names" { + run validate_vm_name "demo" + [ "$status" -eq 0 ] + [ "$output" = "demo" ] + run validate_vm_name "test-1_x" + [ "$status" -eq 0 ] +} + +@test "validate_vm_name rejects path traversal and spaces" { + run validate_vm_name "../escape" + [ "$status" -ne 0 ] + run validate_vm_name "bad name" + [ "$status" -ne 0 ] + run validate_vm_name "" + [ "$status" -ne 0 ] +} + +@test "meta_set overwrites rather than duplicating a key" { + mkdir -p "$(vm_dir demo)" + meta_set demo rtc "2026-01-01T00:00:00" + meta_set demo rtc "2026-08-22T20:55:00" + run bash -c "grep -c '^rtc=' '$(vm_meta demo)'" + [ "$output" = "1" ] + run meta_get demo rtc + [ "$output" = "2026-08-22T20:55:00" ] +} + +@test "vm_next_serial starts at 0 and continues past the highest index" { + mkdir -p "$(vm_dir demo)" + run vm_next_serial demo + [ "$output" = "0" ] + touch "$(vm_dir demo)/serial.0.log" "$(vm_dir demo)/serial.7.log" + run vm_next_serial demo + [ "$output" = "8" ] +} + +@test "vm_is_running is false for a stale pidfile" { + mkdir -p "$(vm_dir demo)" + echo "999999" > "$(vm_pidfile demo)" + run vm_is_running demo + [ "$status" -ne 0 ] +} + +@test "verdict classifies a guest poweroff as clean only with the serial marker" { + source "$REPO/lib/verdict.sh" + mkdir -p "$(vm_dir demo)" + local ev="$(vm_dir demo)/events.jsonl" + printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' > "$ev" + + run verdict_last_event "$ev" + [ "$output" = "guest-shutdown" ] + + # Truncated serial log -> the machine stopped, but not cleanly. + local ser="$(vm_dir demo)/serial.0.log" + printf 'Stopping some unit...\n' > "$ser" + run verdict_serial_complete "$ser" + [ "$status" -ne 0 ] + + printf 'reboot: Power down\n' >> "$ser" + run verdict_serial_complete "$ser" + [ "$status" -eq 0 ] +} + +@test "verdict distinguishes reset, panic and host-initiated stops" { + source "$REPO/lib/verdict.sh" + mkdir -p "$(vm_dir demo)" + local ev="$(vm_dir demo)/events.jsonl" + + printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-reset"}}' > "$ev" + run verdict_last_event "$ev" + [ "$output" = "guest-reset" ] + + printf '%s\n' '{"event":"GUEST_PANICKED","data":{}}' > "$ev" + run verdict_last_event "$ev" + [ "$output" = "panic" ] + + printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":false,"reason":"host-qmp-quit"}}' > "$ev" + run verdict_last_event "$ev" + [ "$output" = "host-qmp-quit" ] + + : > "$ev" + run verdict_last_event "$ev" + [ "$output" = "none" ] +} + +@test "verdict reads the LAST event, so an earlier boot does not win" { + source "$REPO/lib/verdict.sh" + mkdir -p "$(vm_dir demo)" + local ev="$(vm_dir demo)/events.jsonl" + { + printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-reset"}}' + printf '%s\n' '{"event":"SHUTDOWN","data":{"guest":true,"reason":"guest-shutdown"}}' + } > "$ev" + run verdict_last_event "$ev" + [ "$output" = "guest-shutdown" ] +}