Wren Linux

wren-linux / vendor / mockvps-kvm / mockvps-kvm.sh

#!/usr/bin/env bash
#
# mockvps-kvm.sh -- spin up a disposable Debian "VPS" locally with QEMU/KVM
#
# The KVM sibling of mockvps-vbox.sh: same promise, same security posture,
# different hypervisor -- no VirtualBox, no libvirt daemon, just one
# qemu-system-x86_64 process and plain files.
#
#   * Boots an unattended, headless Debian 13 (or current stable) install.
#     The debian-installer is driven by a generated preseed. QEMU boots
#     Debian's official netboot kernel + initrd directly (downloaded over
#     HTTPS and checked against Debian's SHA256SUMS); that initrd carries the
#     NIC and disk drivers, which the smaller netinst-ISO initrd does not.
#     The preseed is appended to a private copy of the initrd as
#     /preseed.cfg (the way virt-install's --initrd-inject works).
#   * virtio disk (guest sees /dev/vda) + virtio NIC of user-mode networking.
#
# Security model (identical to mockvps-vbox):
#   * SSH is reachable from this host only: QEMU forwards a loopback port.
#     (You can bind elsewhere with -k, but you are warned.)
#   * The preseed never travels over a network: it lives only in 0600 files
#     on this host (including the private initrd copy) and is read by the
#     installer as /preseed.cfg.
#   * The guest password is random, stored 0600 in
#     "$WORKDIR/credentials/<name>.env", remembered as root/sudo in the VM.
#     SSH itself is key-only: a dedicated Ed25519 key, password auth off.
#
# Also:
#   * Provisioning: --user-data FILE (cloud-init NoCloud seed, carried in the
#     same private initrd), -P/--package NAME (installed by the installer) and
#     --provision FILE (a bash script streamed over SSH and run as root once
#     the VM is up; re-runnable with --provision-only).
#   * Snapshots: --snapshot / --restore / --snapshots / --delete-snapshot
#     (qcow2 internal snapshots; the VM is powered off for the operation).
#   * The netboot checksum file is authenticated through Debian's GPG-signed
#     InRelease when gpgv + debian-archive-keyring are present.
#
# Usage:  ./mockvps-kvm.sh            # install (5-15 min) and wait for SSH
#         ./mockvps-kvm.sh --help     # all flags
#
# Exit codes: 0 ok; 1 error; 3 VM up but key-only login refused.

set -euo pipefail

# Where the netboot installer (kernel + initrd) comes from. The suite is a
# directory name under dists/ ("stable", "trixie", ...).
DEBIAN_MIRROR_URL="https://deb.debian.org/debian"

QEMU_BIN_DEFAULT="qemu-system-x86_64"

# Characters allowed in the guest password. A strict whitelist lets the
# credentials file be parsed as plain data (never sourced) and keeps the
# preseed / late_command free of anything the installer could misread.
PW_RE='^[A-Za-z0-9._~@+=:,-]+$'

### Helpers ###########################################################

log()  { printf '\n==> %s\n' "$*"; }
warn() { printf 'WARNING: %s\n' "$*" >&2; }
die()  { printf 'ERROR: %s\n' "$*" >&2; exit 1; }

# is_uint VALUE MIN MAX -- decimal integer in range (leading zeros handled)
is_uint() { [[ "$1" =~ ^[0-9]{1,9}$ ]] && (( 10#$1 >= $2 && 10#$1 <= $3 )); }

as_root() {
  if [[ $EUID -eq 0 ]]; then "$@"; else sudo "$@"; fi
}

confirm() {
  if [[ $ASSUME_YES -eq 1 ]]; then return 0; fi
  [[ -t 0 ]] || die "Confirmation needed ('$1') but stdin is not a terminal; re-run with -y."
  local reply
  read -r -p "$1 [y/N] " reply
  [[ "$reply" =~ ^[yY]([eE][sS])?$ ]]
}

usage() {
  cat <<'EOF'
usage: mockvps-kvm.sh [options]

Set up a disposable Debian server "VPS" locally with QEMU/KVM: unattended
install (typically 5-15 minutes), key-only SSH reachable from this host
only, no GUI. Afterwards:  ssh <name>

Requires an x86_64 Linux host with KVM support (VT-x/AMD-V and /dev/kvm),
plus wget and cpio. gpgv and debian-archive-keyring (present on Debian/Ubuntu
hosts) let the script verify Debian's signature on the installer checksums.
If qemu-system-x86 is missing, the script offers to install it with apt
(Debian/Ubuntu/derivatives); on other hosts install it yourself first.

options:
  -h, --help            show this help and exit
  -y, --yes             non-interactive; confirms prompts (deleting an
                        existing VM to reinstall, installing QEMU, ...)
  -n, --name NAME       VM name (default: mockvps-kvm)
  -u, --user USER       guest user (default: vps)
  -p, --port PORT       host port for the SSH forward (default: 2232)
  -c, --cpus N          guest vCPUs (default: 2)
  -m, --memory MIB      guest RAM in MiB (default: 2048)
  -d, --disk MB         guest disk size in MB (default: 20000)
  -w, --workdir DIR     state directory (default: ~/vpskvm)
  -s, --suite NAME      Debian release to install (default: stable)
  -f, --forward L:G     also tunnel host port L to guest port G over SSH
                        (repeatable; L must be 1024-65535), e.g. -f 8443:443
  -k, --ip IP           bind the SSH forward to this IPv4 address (default
                        127.0.0.1; only change this if you know why)
  --no-web-tunnel       do not add the default 8080 -> guest:80 tunnel
  --user-data FILE      cloud-init user-data (first line '#cloud-config', or a
                        #! script / MIME multipart; max 256 KiB). Fresh installs
                        only: seeded as the NoCloud datasource, cloud-init is
                        installed, and the script waits for it to finish.
                        Define 'users:' yourself if you do not want cloud-init's
                        default 'debian' user. SSH stays key-only whatever the
                        user-data says (a 00- sshd drop-in wins).
  -P, --package NAME    extra Debian package for the installer to install
                        (repeatable). Fresh installs only.
  --provision FILE      run FILE with bash as root in the guest once SSH is up
                        (repeatable; run in order; stops at the first failure).
                        Runs non-interactively with stdin closed. It runs on
                        every start/install it is passed to, so keep it
                        idempotent.
  --https-mirror        make the installer fetch packages over https (default
                        http; apt verifies package signatures either way)
  --require-signature   refuse to continue unless the netboot checksums can be
                        authenticated through Debian's signed InRelease

management:
  --start               boot the installed VM headless (SSH + tunnels)
  --stop                power the running VM off gracefully (ACPI)
  --status              is the VM running?
  --provision-only      run the --provision files against the already running VM
  --snapshot NAME       save the disk state as snapshot NAME (VM is powered off
                        for this and booted again if it was running)
  --restore NAME        roll the disk back to snapshot NAME (asks first)
  --snapshots           list snapshots
  --delete-snapshot NAME
                        remove snapshot NAME
  --uninstall           remove every trace of the VM: disk, keys,
                        credentials, logs, the ssh config block, host-key
                        entries and the workdir files. The repo itself is
                        kept. (Alias: --remove.)
  --dry-run             only with --uninstall: list what would be removed
  --purge-software      only with --uninstall: ALSO purge the QEMU packages
                        and drop your kvm group membership (uses sudo).
                        QEMU keeps no registry of its other users, so you
                        are the safety rail here: only use this if no other
                        QEMU VMs/scripts use this host.

Every option has an environment-variable equivalent (VM_NAME, VM_USER,
SSH_HOST_PORT, VM_VCPUS, VM_MEMORY, VM_DISK_SIZE_MB, WORKDIR, DEBIAN_SUITE,
SSH_HOST_IP, WEB_TUNNEL=0|1, UNINSTALL=1, DRY_RUN=1,
PURGE_SOFTWARE=1, START=1, STOP=1, STATUS=1,
FORWARDS="8443:443,3000:3000", USER_DATA=FILE, NET_RESTRICT=0|1,
HOST_FORWARDS="127.0.0.1:18080:80", GUEST_SOCKS="10.0.2.100:9050", PACKAGES="git,curl",
PROVISION_FILES="a.sh:b.sh", PROVISION_ONLY=1, DEBIAN_MIRROR_PROTO=https,
REQUIRE_SIGNATURE=1); flags win.

The guest password is stored as a plain VM_PASSWORD=value line and must use
only A-Z a-z 0-9 . _ ~ @ + = : , - (VM_PASSWORD from the environment too).

Stall detection: if the installer's console log stops changing, the script
warns after INSTALL_STALL_WARN seconds (default 180) and shows what the
installer is asking; after INSTALL_STALL_ABORT seconds (default 600) it
gives up, stops the VM and removes the half-installed disk.
EOF
}

### Running it from a Wren Linux checkout ################################

# Inside Wren Linux this script is meant to be run as the "mockvps-kvm"
# command, which extras/mockvps-kvm.sh sets up (it also installs QEMU and
# socat, which a clean power-off needs). Run straight from its folder before
# that setup, it stops and says what to do; MOCKVPS_FORCE=1 runs it anyway.
# After the setup, running it directly works and gets a one-line reminder.
# A standalone copy has no ../../extras/mockvps-kvm.sh, so none of this
# applies to it. It looks at where the script file is, not at the current
# folder, so it holds wherever you run it from.
wren_check() {
  local self setup cmd="$HOME/.local/bin/mockvps-kvm"
  self="$(readlink -f -- "${BASH_SOURCE[0]}" 2>/dev/null)" || return 0
  setup="$(readlink -f -- "$(dirname -- "$self")/../../extras/mockvps-kvm.sh" 2>/dev/null)" || return 0
  [[ -f "$setup" ]] || return 0
  # Started through the command: nothing to say
  [[ "$0" != */.local/bin/mockvps-kvm ]] || return 0
  if [[ "$(readlink -f -- "$cmd" 2>/dev/null)" == "$self" ]]; then
    warn "You are running the script inside Wren Linux directly; the command 'mockvps-kvm' runs the same script."
    return 0
  fi
  if [[ "${MOCKVPS_FORCE:-0}" == 1 ]]; then
    warn "Running the Wren Linux copy directly (MOCKVPS_FORCE=1), without the setup."
    return 0
  fi
  die "Don't run this file directly: Wren Linux sets it up first.

  1. Run this once (it installs QEMU and socat, and adds the 'mockvps-kvm' command):
         bash $setup
  2. Then use the command:
         mockvps-kvm ARGS        (for example: mockvps-kvm --status)
     If the shell says 'command not found', log out and back in, or run
     ~/.local/bin/mockvps-kvm ARGS.

  To run this file anyway: MOCKVPS_FORCE=1 $0 ARGS"
}

### Configuration #####################################################

init_defaults() {
  VM_NAME="${VM_NAME:-mockvps-kvm}"
  VM_VCPUS="${VM_VCPUS:-2}"
  VM_MEMORY="${VM_MEMORY:-2048}"            # MiB
  VM_DISK_SIZE_MB="${VM_DISK_SIZE_MB:-20000}"
  VM_USER="${VM_USER:-vps}"
  WORKDIR="${WORKDIR:-$HOME/vpskvm}"
  SSH_HOST_PORT="${SSH_HOST_PORT:-2232}"
  SSH_HOST_IP="${SSH_HOST_IP:-127.0.0.1}"
  DEBIAN_SUITE="${DEBIAN_SUITE:-stable}"
  WEB_TUNNEL="${WEB_TUNNEL:-1}"
  # (Wren Linux) For a VM that must not reach any network: NET_RESTRICT=1 runs it
  # with QEMU's restrict=on (the guest can't send a packet out; only the
  # forwards below get through), HOST_FORWARDS="127.0.0.1:18080:80 ..." adds
  # host-loopback ports that lead into the guest, and GUEST_SOCKS="10.0.2.100:9050"
  # gives the guest that one address, relayed per connection to the host's
  # 127.0.0.1:9050 (Tor's SOCKS port) with socat. Run phase only.
  NET_RESTRICT="${NET_RESTRICT:-0}"
  HOST_FORWARDS="${HOST_FORWARDS:-}"
  GUEST_SOCKS="${GUEST_SOCKS:-}"
  UNINSTALL="${UNINSTALL:-0}"
  DRY_RUN="${DRY_RUN:-0}"
  PURGE_SOFTWARE="${PURGE_SOFTWARE:-0}"
  START="${START:-0}"
  STALL_WARN_SECS="${INSTALL_STALL_WARN:-180}"
  STALL_ABORT_SECS="${INSTALL_STALL_ABORT:-600}"
  STOP="${STOP:-0}"
  STATUS="${STATUS:-0}"
  VWANT_BIN="${MOCKVPS_QEMU:-$QEMU_BIN_DEFAULT}"
  REAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
  ASSUME_YES=0
  EXTRA_FORWARDS=()
  FORWARD_LIST=()
  REINSTALL=1
  PRESEED_FILE=""
  USER_DATA_FILE="${USER_DATA:-}"
  DEBIAN_MIRROR_PROTO="${DEBIAN_MIRROR_PROTO:-http}"
  REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-0}"
  PROVISION_ONLY="${PROVISION_ONLY:-0}"
  SNAP_ACTION=""
  SNAP_NAME=""
  EXTRA_PACKAGES=()
  PROVISION_LIST=()
  PROV_FILES=()
  PKG_LINE="openssh-server"
  HAVE_PACKAGES=0
  SUMS_EXPECTED=""
}

need_arg() { [[ $2 -ge 2 ]] || die "option $1 requires an argument"; }

set_snap_action() {
  [[ -z "$SNAP_ACTION" ]] || die "only one snapshot action per run (second one: $1)"
  SNAP_ACTION="$2"; SNAP_NAME="${3:-}"
}

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -h|--help) usage; exit 0 ;;
      -y|--yes) ASSUME_YES=1; shift ;;
      -n|--name) need_arg "$1" $#; VM_NAME="$2"; shift 2 ;;
      -u|--user) need_arg "$1" $#; VM_USER="$2"; shift 2 ;;
      -p|--port) need_arg "$1" $#; SSH_HOST_PORT="$2"; shift 2 ;;
      -c|--cpus) need_arg "$1" $#; VM_VCPUS="$2"; shift 2 ;;
      -m|--memory) need_arg "$1" $#; VM_MEMORY="$2"; shift 2 ;;
      -d|--disk) need_arg "$1" $#; VM_DISK_SIZE_MB="$2"; shift 2 ;;
      -w|--workdir) need_arg "$1" $#; WORKDIR="$2"; shift 2 ;;
      -s|--suite) need_arg "$1" $#; DEBIAN_SUITE="$2"; shift 2 ;;
      -f|--forward) need_arg "$1" $#; EXTRA_FORWARDS+=("$2"); shift 2 ;;
      -k|--ip) need_arg "$1" $#; SSH_HOST_IP="$2"; shift 2 ;;
      --user-data) need_arg "$1" $#; USER_DATA_FILE="$2"; shift 2 ;;
      -P|--package) need_arg "$1" $#; EXTRA_PACKAGES+=("$2"); shift 2 ;;
      --provision) need_arg "$1" $#; PROVISION_LIST+=("$2"); shift 2 ;;
      --provision-only) PROVISION_ONLY=1; shift ;;
      --snapshot) need_arg "$1" $#; set_snap_action "$1" create "$2"; shift 2 ;;
      --restore) need_arg "$1" $#; set_snap_action "$1" restore "$2"; shift 2 ;;
      --delete-snapshot) need_arg "$1" $#; set_snap_action "$1" delete "$2"; shift 2 ;;
      --snapshots) set_snap_action "$1" list; shift ;;
      --https-mirror) DEBIAN_MIRROR_PROTO=https; shift ;;
      --require-signature) REQUIRE_SIGNATURE=1; shift ;;
      --manual) MANUAL=1; shift ;;   # accepted for input compat; see validate
      --no-web-tunnel) WEB_TUNNEL=0; shift ;;
      --uninstall|--remove) UNINSTALL=1; shift ;;
      --dry-run) DRY_RUN=1; shift ;;
      --purge-software) PURGE_SOFTWARE=1; shift ;;
      --start) START=1; shift ;;
      --stop) STOP=1; shift ;;
      --status) STATUS=1; shift ;;
      --) shift; break ;;
      -*) die "unknown option: $1 (try --help)" ;;
      *) warn "ignoring unexpected argument: $1"; shift ;;
    esac
  done
}

check_forward() {
  local lp gp
  [[ "$1" =~ ^([0-9]{1,5}):([0-9]{1,5})$ ]] \
    || die "invalid forward '$1' (expected LOCALPORT:GUESTPORT, e.g. 8443:443)"
  lp="${BASH_REMATCH[1]}"; gp="${BASH_REMATCH[2]}"   # is_uint clobbers BASH_REMATCH
  is_uint "$lp" 1024 65535 || die "forward '$1': local port must be 1024-65535"
  is_uint "$gp" 1 65535    || die "forward '$1': guest port must be 1-65535"
}

build_forwards() {
  local f lp gp seen=" " envf=()
  FORWARD_LIST=()
  if [[ "$WEB_TUNNEL" != "0" ]]; then FORWARD_LIST+=("8080:80"); fi
  if [[ -n "${FORWARDS:-}" ]]; then
    IFS=',' read -r -a envf <<<"$FORWARDS"
    for f in "${envf[@]}"; do check_forward "$f"; FORWARD_LIST+=("$f"); done
  fi
  for f in ${EXTRA_FORWARDS[@]+"${EXTRA_FORWARDS[@]}"}; do
    check_forward "$f"; FORWARD_LIST+=("$f")
  done
  for f in ${FORWARD_LIST[@]+"${FORWARD_LIST[@]}"}; do
    lp=$((10#${f%%:*})); gp=$((10#${f##*:}))
    [[ "$seen" != *" $lp "* ]] || die "local port $lp is used by more than one forward"
    seen+="$lp "
  done
}

# Extra packages for the installer (d-i pkgsel/include). openssh-server is
# always present; cloud-init is added when --user-data is used.
build_packages() {
  local p envp=() seen=" openssh-server "
  PKG_LINE="openssh-server"
  HAVE_PACKAGES=0
  if [[ -n "${PACKAGES:-}" ]]; then IFS=',' read -r -a envp <<<"$PACKAGES"; fi
  for p in ${envp[@]+"${envp[@]}"} ${EXTRA_PACKAGES[@]+"${EXTRA_PACKAGES[@]}"}; do
    if [[ -z "$p" ]]; then continue; fi
    [[ "$p" =~ ^[a-z0-9][a-z0-9+.-]{1,63}$ ]] \
      || die "invalid package name '$p' (Debian names: lowercase letters, digits, + - .)"
    HAVE_PACKAGES=1
    if [[ "$seen" == *" $p "* ]]; then continue; fi
    seen+="$p "
    PKG_LINE+=" $p"
  done
  if [[ -n "$USER_DATA_FILE" && "$seen" != *" cloud-init "* ]]; then
    PKG_LINE+=" cloud-init"
  fi
}

# cloud-init user-data travels inside the installer initrd (never over a
# network), so keep it small and make sure it is something cloud-init knows.
check_user_data() {
  local first size
  if [[ -z "$USER_DATA_FILE" ]]; then return 0; fi
  [[ -f "$USER_DATA_FILE" && -r "$USER_DATA_FILE" ]] \
    || die "--user-data: '$USER_DATA_FILE' is not a readable file"
  size="$(wc -c < "$USER_DATA_FILE")"; size="${size//[[:space:]]/}"
  (( size > 0 && size <= 262144 )) \
    || die "--user-data: file must be between 1 byte and 256 KiB (it is embedded in the installer initrd)"
  first="$(head -n1 "$USER_DATA_FILE" | tr -d '\r')"
  case "$first" in
    '#cloud-config'*|'#!'*|'Content-Type: multipart/'*|'MIME-Version:'*) ;;
    *) die "--user-data: first line must be '#cloud-config' (or a #! script / MIME multipart); got '${first:0:40}'" ;;
  esac
}

# Provisioning scripts: PROVISION_FILES="a.sh:b.sh" (env) then --provision flags.
build_provision_list() {
  local f envp=() size
  PROV_FILES=()
  if [[ -n "${PROVISION_FILES:-}" ]]; then IFS=':' read -r -a envp <<<"$PROVISION_FILES"; fi
  for f in ${envp[@]+"${envp[@]}"} ${PROVISION_LIST[@]+"${PROVISION_LIST[@]}"}; do
    if [[ -z "$f" ]]; then continue; fi
    [[ -f "$f" && -r "$f" ]] || die "--provision: '$f' is not a readable file"
    size="$(wc -c < "$f")"; size="${size//[[:space:]]/}"
    (( size <= 1048576 )) || die "--provision: '$f' is larger than 1 MiB"
    PROV_FILES+=("$f")
  done
}

check_snapshot_name() {
  if [[ -n "$SNAP_ACTION" && "$SNAP_ACTION" != list ]]; then
    [[ "$SNAP_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ ]] \
      || die "snapshot name must be 1-64 chars [A-Za-z0-9_.-], not starting with - or . (got '$SNAP_NAME')"
  fi
}

validate_config() {
  local hf
  [[ "$NET_RESTRICT" == 0 || "$NET_RESTRICT" == 1 ]] || die "NET_RESTRICT must be 0 or 1 (got '$NET_RESTRICT')"
  for hf in $HOST_FORWARDS; do
    [[ "$hf" =~ ^127\.0\.0\.1:([0-9]{4,5}):([0-9]{1,5})$ ]] && is_uint "${BASH_REMATCH[1]}" 1024 65535 &&
      is_uint "${hf##*:}" 1 65535 ||
      die "HOST_FORWARDS entries must look like 127.0.0.1:18080:80 (got '$hf')"
  done
  if [[ -n "$GUEST_SOCKS" ]]; then
    [[ "$GUEST_SOCKS" =~ ^10\.0\.2\.([0-9]{1,3}):([0-9]{1,5})$ ]] ||
      die "GUEST_SOCKS must look like 10.0.2.100:9050 (got '$GUEST_SOCKS')"
    command -v socat >/dev/null 2>&1 || die "GUEST_SOCKS needs socat: sudo apt-get install socat"
  fi
  [[ "$VM_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ ]] \
    || die "VM_NAME must be 1-64 chars [A-Za-z0-9_.-], not starting with - or . (got '$VM_NAME')"
  [[ "$VM_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
    || die "VM_USER must be a lowercase POSIX user name (got '$VM_USER')"
  is_uint "$SSH_HOST_PORT" 1 65535 || die "SSH_HOST_PORT must be 1-65535 (got '$SSH_HOST_PORT')"
  is_uint "$VM_VCPUS" 1 32         || die "VM_VCPUS must be 1-32 (got '$VM_VCPUS')"
  is_uint "$VM_MEMORY" 512 131072  || die "VM_MEMORY (MiB) must be 512-131072 (got '$VM_MEMORY')"
  is_uint "$VM_DISK_SIZE_MB" 4000 2097152 \
    || die "VM_DISK_SIZE_MB must be 4000-2097152 MiB (got '$VM_DISK_SIZE_MB')"
  [[ "$SSH_HOST_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] \
    || die "SSH_HOST_IP must be an IPv4 address (got '$SSH_HOST_IP')"
  [[ "$DEBIAN_SUITE" =~ ^[a-z][a-z0-9-]{1,30}$ ]] \
    || die "DEBIAN_SUITE must look like 'stable' or 'trixie' (got '$DEBIAN_SUITE')"
  is_uint "$STALL_WARN_SECS" 30 3600 || die "INSTALL_STALL_WARN must be 30-3600 seconds (got '$STALL_WARN_SECS')"
  is_uint "$STALL_ABORT_SECS" 60 7200 || die "INSTALL_STALL_ABORT must be 60-7200 seconds (got '$STALL_ABORT_SECS')"
  (( 10#$STALL_ABORT_SECS > 10#$STALL_WARN_SECS )) || die "INSTALL_STALL_ABORT must be larger than INSTALL_STALL_WARN"
  [[ "$WORKDIR" != *[[:space:]]* ]] || die "WORKDIR must not contain spaces (got '$WORKDIR')"
  if [[ "${MANUAL:-0}" -eq 1 ]]; then
    die "--manual is not supported by mockvps-kvm: the whole point is the unattended install."
  fi
  [[ "$DEBIAN_MIRROR_PROTO" == http || "$DEBIAN_MIRROR_PROTO" == https ]] \
    || die "DEBIAN_MIRROR_PROTO must be http or https (got '$DEBIAN_MIRROR_PROTO')"
  is_uint "$REQUIRE_SIGNATURE" 0 1 || die "REQUIRE_SIGNATURE must be 0 or 1 (got '$REQUIRE_SIGNATURE')"
  is_uint "$PROVISION_ONLY" 0 1    || die "PROVISION_ONLY must be 0 or 1 (got '$PROVISION_ONLY')"
  REQUIRE_SIGNATURE=$((10#$REQUIRE_SIGNATURE)); PROVISION_ONLY=$((10#$PROVISION_ONLY))
  SSH_HOST_PORT=$((10#$SSH_HOST_PORT))
  build_forwards
  build_packages
  check_user_data
  build_provision_list
  check_snapshot_name
  for f in ${FORWARD_LIST[@]+"${FORWARD_LIST[@]}"}; do
    [[ $((10#${f%%:*})) -ne "$SSH_HOST_PORT" ]] \
      || die "SSH port $SSH_HOST_PORT collides with the forward ${f%%:*}:${f##*:}: pick another -p"
  done
  if [[ "$SSH_HOST_IP" != 127.* ]]; then
    warn "SSH forward bound to $SSH_HOST_IP -- SSH will be reachable from OTHER MACHINES. Use a host firewall."
    confirm "Proceed with a non-loopback bind?" || die "aborted by user"
  fi
}

derive_paths() {
  DISK="$WORKDIR/$VM_NAME.qcow2"
  BOOT_DIR="$WORKDIR/boot"
  RUN_DIR="$WORKDIR/run"
  CRED_DIR="$WORKDIR/credentials"
  CRED_FILE="$CRED_DIR/$VM_NAME.env"
  KEY_FILE="$HOME/.ssh/${VM_NAME}_id"
  INSTALL_LOG="$WORKDIR/${VM_NAME}-install.log"
  RUN_LOG="$WORKDIR/${VM_NAME}-run.log"
  PIDFILE="$RUN_DIR/$VM_NAME.pid"
  MONITOR_SOCK="$RUN_DIR/$VM_NAME.mon.sock"
  PRESEED_FILE="$CRED_DIR/$VM_NAME.preseed"
  SSH_BEGIN="# BEGIN mockvps-kvm:$VM_NAME (managed by mockvps-kvm.sh)"
  SSH_END="# END mockvps-kvm:$VM_NAME"
  GUEST_HOST="$(printf '%s' "$VM_NAME" | LC_ALL=C tr 'A-Z_.' 'a-z--')"
}

### Host system: QEMU + KVM access ####################################

have_apt()  { command -v apt-get >/dev/null 2>&1 && [[ -r /etc/os-release ]]; }
have_qemu() { command -v "$VWANT_BIN" >/dev/null 2>&1 && command -v qemu-img >/dev/null 2>&1; }

install_qemu_apt() {
  as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y \
    qemu-system-x86 qemu-utils \
    || die "QEMU install failed. Install it yourself:
  sudo apt-get install -y qemu-system-x86 qemu-utils"
}

ensure_qemu() {
  if have_qemu; then
    log "Using $("$VWANT_BIN" --version 2>/dev/null | head -n1 || printf 'unknown version')"
    return 0
  fi
  if ! have_apt; then
    die "qemu-system-x86 / qemu-utils are not installed, and automatic installation
only supports apt-based hosts. Install QEMU with your package manager and re-run."
  fi
  confirm "QEMU is missing. Install qemu-system-x86 + qemu-utils with apt (uses sudo)?" \
    || die "QEMU is required; aborted."
  log "Installing qemu-system-x86 and qemu-utils with apt"
  install_qemu_apt
  have_qemu || die "QEMU was installed but is still not found on PATH; check your PATH and re-run."
  log "Using $("$VWANT_BIN" --version 2>/dev/null | head -n1 || printf 'unknown version')"
}

# /dev/kvm access needs kvm group membership; sg bridges the "just added to
# the group, not yet re-logged-in" window (same pattern as mockvps-vbox).
run_kvm() {
  if [[ $EUID -eq 0 ]] || id -nG | grep -qw kvm; then
    "$@"
  else
    sg kvm -c "$(printf '%q ' "$@")"
  fi
}

ensure_kvm_host() {
  if [[ ! -c /dev/kvm ]]; then
    die "/dev/kvm does not exist. KVM is disabled or its module did not load:
check that VT-x/AMD-V is enabled in the firmware
('grep -E -q \"vmx|svm\" /proc/cpuinfo' should match), then run
'sudo modprobe kvm_intel'  (Intel CPU) or 'sudo modprobe kvm_amd'  (AMD CPU)."
  fi
  if ! id -nG "$REAL_USER" 2>/dev/null | grep -qw kvm; then
    log "Adding $REAL_USER to the kvm group (uses sudo)"
    as_root usermod -aG kvm "$REAL_USER" \
      || die "Could not add $REAL_USER to the kvm group. Do it yourself:
  sudo usermod -aG kvm $REAL_USER   (then log out and back in)"
  fi
  # Verify access through the wrapper (works without re-login thanks to sg).
  run_kvm bash -c 'test -w /dev/kvm' 2>/dev/null \
    || die "/dev/kvm is not writable even through the kvm group. Compare permissions:
  ls -l /dev/kvm; id -nG | tr ' ' '\n' | grep -x kvm"
  log "KVM acceleration available (/dev/kvm writable)"
}

### Credentials, keys, netboot installer ##############################

gen_password() {
  local pw
  while :; do
    pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 30)" || true
    if [[ ${#pw} -eq 30 ]]; then printf '%s' "$pw"; return 0; fi
  done
}

# Password precedence: env VM_PASSWORD > stored file > new random.
# Stored as a plain VM_PASSWORD=value line, read back without sourcing it.
setup_credentials() {
  mkdir -p "$WORKDIR" "$CRED_DIR"
  chmod 700 "$CRED_DIR"
  if [[ -n "${VM_PASSWORD:-}" ]]; then
    log "Using VM_PASSWORD from the environment"
  elif [[ -f "$CRED_FILE" ]]; then
    if VM_PASSWORD="$(read_cred_password "$CRED_FILE")"; then
      log "Reusing the stored password in $CRED_FILE"
    else
      warn "Stored password file is empty or not plain VM_PASSWORD=value data; generating a new password"
      VM_PASSWORD="$(gen_password)"
    fi
  else
    log "Generating a random guest password (stored in $CRED_FILE)"
    VM_PASSWORD="$(gen_password)"
  fi
  [[ "$VM_PASSWORD" =~ $PW_RE ]] \
    || die "VM_PASSWORD may only contain A-Z a-z 0-9 and . _ ~ @ + = : , - (no spaces or shell/preseed metacharacters)"
  ( umask 077; printf 'VM_PASSWORD=%s\n' "$VM_PASSWORD" > "$CRED_FILE" )
  chmod 600 "$CRED_FILE"
}

# Read VM_PASSWORD out of a credentials file WITHOUT sourcing it (sourcing
# would execute whatever the file contains). Accepts only a plain value that
# passes the same whitelist as setup_credentials.
read_cred_password() {
  local line val
  [[ -f "$1" ]] || return 1
  line="$(grep -m1 '^VM_PASSWORD=' "$1" 2>/dev/null)" || return 1
  val="${line#VM_PASSWORD=}"
  [[ "$val" =~ $PW_RE ]] || return 1
  printf '%s' "$val"
}

ensure_host_key() {
  mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
  if [[ ! -f "$KEY_FILE" ]]; then
    log "Generating dedicated SSH key for $VM_NAME: $KEY_FILE"
    ssh-keygen -q -t ed25519 -N "" -f "$KEY_FILE" -C "$VM_USER@$VM_NAME"
  fi
  chmod 600 "$KEY_FILE"; chmod 644 "$KEY_FILE.pub"
}

sig_unavailable() {
  if [[ $REQUIRE_SIGNATURE -eq 1 ]]; then
    die "A GPG-verified checksum chain is required (--require-signature), but $1."
  fi
  warn "Cannot authenticate the netboot files through Debian's signature: $1.
         Falling back to HTTPS-only trust for kernel + initrd (use --require-signature to refuse this)."
}

# Authenticate the checksum file through Debian's signed InRelease:
#   gpgv(InRelease, debian-archive-keyring) -> SHA256 of SHA256SUMS -> kernel/initrd.
# Sets SUMS_EXPECTED to the signed SHA256 of installer-amd64/current/images/SHA256SUMS,
# or "" when the chain could not be established (see sig_unavailable).
# MOCKVPS_KEYRINGS (space-separated paths) overrides the keyring search; it exists for tests.
verify_release_signature() {
  local dst="$1" inrel rel gh k errf entry="main/installer-amd64/current/images/SHA256SUMS"
  local keyargs=() klist=()
  SUMS_EXPECTED=""
  inrel="$dst/InRelease"; rel="$dst/Release.verified"; errf="$dst/gpgv.err"
  if ! command -v gpgv >/dev/null 2>&1; then
    sig_unavailable "gpgv is not installed (sudo apt-get install gpgv)"; return 0
  fi
  if [[ -n "${MOCKVPS_KEYRINGS:-}" ]]; then
    read -r -a klist <<<"$MOCKVPS_KEYRINGS"
  else
    klist=(/usr/share/keyrings/debian-archive-*.gpg /usr/share/keyrings/debian-archive-*.pgp
           /etc/apt/trusted.gpg.d/debian-archive-*.gpg)
  fi
  for k in ${klist[@]+"${klist[@]}"}; do
    if [[ -r "$k" && "$k" != *removed* ]]; then keyargs+=(--keyring "$k"); fi
  done
  if [[ ${#keyargs[@]} -eq 0 ]]; then
    sig_unavailable "no Debian archive keyring found (sudo apt-get install debian-archive-keyring)"; return 0
  fi
  wget -q -O "$inrel" --timeout=60 "$DEBIAN_MIRROR_URL/dists/$DEBIAN_SUITE/InRelease" \
    || die "Could not download $DEBIAN_MIRROR_URL/dists/$DEBIAN_SUITE/InRelease."
  gh="$(mktemp -d)"; chmod 700 "$gh"
  rm -f -- "$rel"
  if ! gpgv --quiet --homedir "$gh" "${keyargs[@]}" --output "$rel" "$inrel" 2>"$errf"; then
    cat "$errf" >&2; rm -rf "$gh"
    die "The GPG signature on $DEBIAN_SUITE/InRelease did NOT verify. Refusing to continue (tampered mirror or stale keyring?)."
  fi
  rm -rf "$gh"
  SUMS_EXPECTED="$(awk -v p="$entry" '$3 == p && length($1) == 64 { print $1; exit }' "$rel")"
  if [[ -z "$SUMS_EXPECTED" ]]; then
    sig_unavailable "the signed Release file has no SHA256 entry for $entry"
    return 0
  fi
  log "InRelease signature verified (Debian archive keyring)"
}

# Download Debian's netboot installer kernel + initrd for the chosen suite and
# check them against the SHA256SUMS published beside them. When gpgv and the
# Debian archive keyring are available, that SHA256SUMS file is itself
# authenticated against the GPG-signed InRelease; otherwise the protection is
# HTTPS to the mirror (with a warning, or a hard stop under --require-signature).
# Every package the installer then installs is verified by apt as usual.
ensure_netboot() {
  local dst="$BOOT_DIR/$VM_NAME" base sums rel f expected actual
  base="$DEBIAN_MIRROR_URL/dists/$DEBIAN_SUITE/main/installer-amd64/current/images"
  rel="netboot/debian-installer/amd64"
  command -v wget >/dev/null 2>&1 || die "wget is required: sudo apt-get install -y wget"
  command -v sha256sum >/dev/null 2>&1 || die "sha256sum is required (coreutils)."
  mkdir -p "$dst"; chmod 700 "$BOOT_DIR" "$dst"
  sums="$dst/SHA256SUMS"
  log "Fetching the $DEBIAN_SUITE netboot installer from $base"
  verify_release_signature "$dst"
  wget -q -O "$sums" --timeout=60 "$base/SHA256SUMS" \
    || die "Could not download $base/SHA256SUMS.
Check your network and the suite name ('$DEBIAN_SUITE'), then re-run."
  if [[ -n "$SUMS_EXPECTED" ]]; then
    actual="$(sha256sum "$sums" | awk '{ print $1 }')"
    [[ "$actual" == "$SUMS_EXPECTED" ]] \
      || die "SHA256SUMS does not match the hash in the GPG-signed Release file (tampered or mid-update mirror); re-run to try again."
    log "SHA256SUMS authenticated through the signed Release file"
  fi
  for f in linux initrd.gz; do
    expected="$(awk -v p="./$rel/$f" '$2 == p { print $1 }' "$sums")"
    [[ -n "$expected" ]] || die "SHA256SUMS has no entry for $rel/$f (suite '$DEBIAN_SUITE')."
    rm -f "$dst/$f.part"
    wget -q -O "$dst/$f.part" --timeout=120 "$base/$rel/$f" \
      || { rm -f "$dst/$f.part"; die "Download of $rel/$f failed."; }
    actual="$(sha256sum "$dst/$f.part" | awk '{ print $1 }')"
    if [[ "$actual" != "$expected" ]]; then
      rm -f "$dst/$f.part"
      die "SHA256 mismatch for $rel/$f (corrupt or tampered download); re-run to try again."
    fi
  done
  mv "$dst/linux.part" "$dst/vmlinuz"
  mv "$dst/initrd.gz.part" "$dst/initrd.gz"
  chmod 600 "$dst/vmlinuz" "$dst/initrd.gz"
  rm -f "$sums" "$dst/InRelease" "$dst/Release.verified" "$dst/gpgv.err"
  log "Netboot kernel + initrd verified (SHA256) in $dst"
}

### Preseed ###########################################################

# Fully self-generated d-i preseed (VirtualBox's template does not exist
# here). The generator enforces the same hard rules mockvps-vbox learned
# the hard way:
#   * late_command is ONE line with NO backslashes (d-i's parser mangles them),
#     and it is syntax-checked with sh -n before embedding,
#   * the authorized_keys chown is numeric (uid 1000), because the installer
#     user does not resolve to a name inside /target,
#   * pkgsel entries sit at top level, never inside an if-block.
# Placeholders are quoted in a 'PSEED' heredoc (nothing expands there) and
# substituted with sed, which is safe because every substituted value is
# validated against a character whitelist (no | % & \ spaces).
build_preseed() {
  local out="$CRED_DIR/$VM_NAME.preseed" pubkey latecmd tmp cicmd value1
  mkdir -p "$CRED_DIR"
  pubkey="$(tr -d '\n' < "$KEY_FILE.pub")"
  case "$pubkey" in (*[![:print:]]*) die "public key contains non-printable characters" ;; esac
  case "$pubkey" in (*[\|%\&\\]*) die "public key contains | % & or backslash" ;; esac

  # cloud-init (only with --user-data): seed the NoCloud datasource from the
  # files carried in the initrd, pin the datasource list, keep the SSH host keys
  # the installer made (so known_hosts stays valid) and leave networking to
  # the installer's ifupdown config instead of letting cloud-init re-render it.
  cicmd=""
  if [[ -n "$USER_DATA_FILE" ]]; then
    cicmd="mkdir -p /target/var/lib/cloud/seed/nocloud /target/etc/cloud/cloud.cfg.d; cp /ci-user-data /target/var/lib/cloud/seed/nocloud/user-data; cp /ci-meta-data /target/var/lib/cloud/seed/nocloud/meta-data; chmod 600 /target/var/lib/cloud/seed/nocloud/user-data; echo 'datasource_list: [ NoCloud, None ]' > /target/etc/cloud/cloud.cfg.d/90-mockvps.cfg; echo 'ssh_deletekeys: false' >> /target/etc/cloud/cloud.cfg.d/90-mockvps.cfg; echo 'network: {config: disabled}' >> /target/etc/cloud/cloud.cfg.d/90-mockvps.cfg; "
  fi

  latecmd="mkdir -p /target/home/${VM_USER}/.ssh; echo '${pubkey}' > /target/home/${VM_USER}/.ssh/authorized_keys; chown -R 1000:1000 /target/home/${VM_USER}/.ssh; chmod 700 /target/home/${VM_USER}/.ssh; chmod 600 /target/home/${VM_USER}/.ssh/authorized_keys; mkdir -p /target/etc/ssh/sshd_config.d; echo 'PasswordAuthentication no' > /target/etc/ssh/sshd_config.d/00-mockvps-hardening.conf; echo 'PubkeyAuthentication yes' >> /target/etc/ssh/sshd_config.d/00-mockvps-hardening.conf; grep -q '^Include' /target/etc/ssh/sshd_config || echo 'PasswordAuthentication no' >> /target/etc/ssh/sshd_config; ${cicmd}sed -i 's/^GRUB_CMDLINE_LINUX=.*/GRUB_CMDLINE_LINUX=\"console=tty0 console=ttyS0,115200n8\"/' /target/etc/default/grub; ln -sf /lib/systemd/system/serial-getty@.service /target/etc/systemd/system/getty.target.wants/serial-getty@ttyS0.service; chroot /target update-grub || true; true"

  [[ "$latecmd" != *\\* ]] || die "late_command contains backslashes"
  # '|', '&' and '%' are legitimate shell syntax in late_command (e.g. '||').
  # Placeholders are filled by a pure-bash loop below (no sed), so none of
  # them needs to be excluded; only backslashes and newlines stay forbidden.
  printf '%s' "$latecmd" | sh -n || die "late_command fails sh -n parse check"
  [[ "$latecmd" == *$'\n'* ]] && die "late_command must be a single line"

  tmp="$CRED_DIR/.$VM_NAME.preseed.part"
  {
    cat <<'PSEED'
d-i debian-installer/locale string en_US.UTF-8
d-i keyboard-configuration/xkb-keymap select us
d-i console-setup/ask_detect boolean false
d-i netcfg/enable boolean true
d-i netcfg/disable_autoconfig boolean false
d-i netcfg/choose_interface select auto
d-i mirror/country string manual
d-i mirror/protocol string %MIRROR_PROTO%
d-i mirror/http/hostname string deb.debian.org
d-i mirror/http/directory string /debian
d-i mirror/http/proxy string
d-i mirror/https/hostname string deb.debian.org
d-i mirror/https/directory string /debian
d-i mirror/https/proxy string
d-i apt-setup/cdrom/set-first boolean false
d-i apt-setup/cdrom/set-next boolean false
d-i apt-setup/cdrom/set-failed boolean false
d-i popularity-contest/participate boolean false
d-i netcfg/get_hostname string %VM_HOST%
d-i netcfg/get_domain string localdomain
d-i hw-detect/load_firmware boolean false
d-i clock-setup/utc boolean true
d-i time/zone string UTC
d-i pkgsel/include string %PKGS%
d-i pkgsel/upgrade select none
d-i pkgsel/install-language-support boolean false
d-i pkgsel/update-policy select none
tasksel tasksel/first multiselect standard
d-i partman-auto/disk string /dev/vda
d-i partman-auto/method string lvm
d-i partman-lvm/device_remove_lvm boolean true
d-i partman-lvm/device_remove_lvm_span boolean true
d-i partman-md/device_remove_md boolean true
d-i partman-lvm/confirm boolean true
d-i partman-lvm/confirm_nooverwrite boolean true
d-i partman-auto/choose_recipe select atomic
d-i partman/confirm_write_new_label boolean true
d-i partman/confirm boolean true
d-i partman/confirm_nooverwrite boolean true
d-i partman/choose_partition select finish
d-i partman-auto-lvm/guided_size string max
d-i partman-partitioning/confirm_write_new_label boolean true
d-i passwd/root-login boolean false
d-i passwd/make-user boolean true
d-i passwd/user-fullname string %VM_USER%
d-i passwd/username string %VM_USER%
d-i passwd/user-password password %VM_PASSWORD%
d-i passwd/user-password-again password %VM_PASSWORD%
d-i user-setup/encrypt-password boolean false
d-i user-setup/allow-password-weak boolean true
d-i passwd/user-default-groups string sudo
d-i grub-installer/only_debian boolean true
d-i grub-installer/bootdev string /dev/vda
d-i grub-installer/with_other_os boolean false
d-i finish-install/reboot_in_progress note
d-i preseed/late_command string %LATECMD%
PSEED
  } | {
    # Literal placeholder substitution in pure bash: sed would treat '&' and
    # the delimiter in the replacement specially. bash 5.2's patsub_replacement
    # makes '&' special too, so switch it off where it exists.
    shopt -u patsub_replacement 2>/dev/null || true
    while IFS= read -r line || [[ -n "$line" ]]; do
      line="${line//%VM_HOST%/$GUEST_HOST}"
      line="${line//%VM_USER%/$VM_USER}"
      line="${line//%VM_PASSWORD%/$VM_PASSWORD}"
      line="${line//%PKGS%/$PKG_LINE}"
      line="${line//%MIRROR_PROTO%/$DEBIAN_MIRROR_PROTO}"
      line="${line//%LATECMD%/$latecmd}"
      printf '%s\n' "$line"
    done
  } > "$tmp"
  mv "$tmp" "$out"; chmod 600 "$out"

  # --- post-generation guards (the vbox lessons, asserted) ---
  [[ "$(grep -c '^d-i preseed/late_command string ' "$out")" -eq 1 ]] \
    || die "expected exactly one preseed/late_command line"
  grep -qxF "d-i pkgsel/include string $PKG_LINE" "$out" \
    || die "pkgsel/include line missing from preseed"
  if [[ -n "$USER_DATA_FILE" ]]; then
    grep -qF 'cp /ci-user-data' "$out" || die "cloud-init seed step missing from latecmd"
  fi
  grep -qF 'chown -R 1000:1000' "$out" || die "numeric chown missing from latecmd"
  grep -qF 'sshd_config.d/00-mockvps-hardening.conf' "$out" \
    || die "sshd hardening drop-in missing from latecmd"
  grep -qF "$pubkey" "$out" || die "public key not embedded in preseed"
  grep -qF 'console=ttyS0,115200n8' "$out" || die "serial console setup missing from latecmd"
  # No d-i question ever named /target/home/... with a slash in the query part:
  awk '/^d-i preseed\/late_command/ && found++ { exit 1 }' "$out" \
    || die "multiple late_command entries (post-embed check)"
  value1="$(grep '^d-i preseed/late_command string ' "$out")"
  value1="${value1#d-i preseed/late_command string }"
  [[ "$value1" == "$latecmd" ]] || die "late_command changed during placeholder substitution"
  PRESEED_FILE="$out"
  log "Preseed written: $PRESEED_FILE (0600; contains the guest password)"
}

# Append the preseed to a private copy of the installer initrd. The kernel
# unpacks concatenated gzip'd cpio archives in order, so the result carries
# /preseed.cfg and d-i finds it with file=/preseed.cfg on the command line.
# This keeps the preseed off every network and available from the very first
# installer step.
build_initrd() {
  local dst="$BOOT_DIR/$VM_NAME" stage files=(preseed.cfg)
  command -v cpio >/dev/null 2>&1 \
    || die "cpio is required to inject the preseed into the installer initrd:
  sudo apt-get install -y cpio"
  command -v gzip >/dev/null 2>&1 || die "gzip is required."
  stage="$(mktemp -d)"; chmod 700 "$stage"
  cp "$PRESEED_FILE" "$stage/preseed.cfg"
  if [[ -n "$USER_DATA_FILE" ]]; then
    # NoCloud seed files; the preseed's late_command copies them into the
    # new system as /var/lib/cloud/seed/nocloud/{user-data,meta-data}.
    cp -- "$USER_DATA_FILE" "$stage/ci-user-data"
    printf 'instance-id: mockvps-%s-%s\nlocal-hostname: %s\n' \
      "$GUEST_HOST" "$(date +%s)" "$GUEST_HOST" > "$stage/ci-meta-data"
    files+=(ci-user-data ci-meta-data)
  fi
  chmod 600 "$stage"/*
  if ! ( umask 077; cd -- "$stage" \
         && printf '%s\n' "${files[@]}" | cpio --quiet -o -H newc | gzip -9 > "$stage/preseed.cpio.gz" ); then
    rm -rf "$stage"; die "Could not build the preseed cpio archive."
  fi
  ( umask 077; cat "$dst/initrd.gz" "$stage/preseed.cpio.gz" > "$dst/initrd-preseed.gz" ) \
    || { rm -rf "$stage"; die "Could not write $dst/initrd-preseed.gz"; }
  rm -rf "$stage"
  chmod 600 "$dst/initrd-preseed.gz"
  log "Preseed${USER_DATA_FILE:+ and cloud-init user-data} injected into the installer initrd: $dst/initrd-preseed.gz"
}

### QEMU plumbing #####################################################

# Compose the QEMU command line as an array QQA. Takes the serial-log
# destination as an argument; does not run anything, so die() still aborts.
define_cmd() {
  local phase="$1" log="$2" f iface
  QQA=("$VWANT_BIN")
  QQA+=(-enable-kvm -cpu host)
  QQA+=(-smp "$VM_VCPUS" -m "$VM_MEMORY")
  QQA+=(-name "mockvps-$VM_NAME")
  QQA+=(-drive "file=$DISK,format=qcow2,if=virtio")
  if [[ $phase == install ]]; then
    # Install phase: no host-forward (nothing should reach in while the
    # installer runs); the guest only needs DHCP and internet for packages.
    iface="user,model=virtio-net-pci"
    QQA+=(-nic "$iface")
    QQA+=(-kernel "$BOOT_DIR/$VM_NAME/vmlinuz")
    QQA+=(-initrd "$BOOT_DIR/$VM_NAME/initrd-preseed.gz")
    QQA+=(-append "auto=true priority=critical console=ttyS0,115200n8 file=/preseed.cfg")
    QQA+=(-no-reboot)      # d-i's final reboot exits QEMU -> run phase follows
  else
    iface="user,model=virtio-net-pci"
    # Only SSH is exposed by QEMU. The web/other tunnels (-f, 8080->80) are
    # SSH LocalForwards written to the ssh config block, so they exist only
    # while an 'ssh <name>' session is open (and never clash with QEMU).
    iface+=",hostfwd=tcp:$SSH_HOST_IP:$SSH_HOST_PORT-:22"
    # (Wren Linux) see NET_RESTRICT, HOST_FORWARDS and GUEST_SOCKS in init_defaults
    if [[ "$NET_RESTRICT" == 1 ]]; then iface+=",restrict=on"; fi
    for f in $HOST_FORWARDS; do
      iface+=",hostfwd=tcp:127.0.0.1:$(cut -d: -f2 <<<"$f")-:${f##*:}"
    done
    if [[ -n "$GUEST_SOCKS" ]]; then
      iface+=",guestfwd=tcp:$GUEST_SOCKS-cmd:socat - TCP:127.0.0.1:9050"
    fi
    QQA+=(-nic "$iface")
  fi
  QQA+=(-boot order=c)
  # shellcheck disable=SC2054  # commas belong to QEMU's option syntax
  QQA+=(-object rng-random,id=rng0)
  # shellcheck disable=SC2054
  QQA+=(-device virtio-rng-pci,rng=rng0)
  QQA+=(-display none)
  # (appended to, so that cap_serial_log can empty the file while QEMU writes to it)
  # shellcheck disable=SC2054  # commas belong to QEMU's option syntax
  QQA+=(-chardev "file,id=serial0,path=${log//,/,,},append=on" -serial chardev:serial0)
  QQA+=(-monitor "unix:$MONITOR_SOCK,server,nowait")
  QQA+=(-pidfile "$PIDFILE")
  # Wren Linux: QEMU's own syscall filter, when the caller asks for it (claude-vm)
  [[ -z "${QEMU_SANDBOX:-}" ]] || QQA+=(-sandbox "$QEMU_SANDBOX")
}

port_in_use() {
  if command -v ss >/dev/null 2>&1; then
    ss -ltnH "sport = :$1" 2>/dev/null | grep -q .
  elif command -v netstat >/dev/null 2>&1; then
    netstat -ltn 2>/dev/null | awk '{ print $4 }' | grep -qE "[.:]$1\$"
  else
    warn "Neither ss nor netstat is available; cannot check whether port $1 is free."
    return 1
  fi
}

check_port_free() {
  local hf
  if port_in_use "$SSH_HOST_PORT"; then
    die "Host port $SSH_HOST_PORT is already in use (checked with a plain TCP bind probe). Pick another with -p/--port."
  fi
  for hf in $HOST_FORWARDS; do
    if port_in_use "$(cut -d: -f2 <<<"$hf")"; then
      die "Host port $(cut -d: -f2 <<<"$hf") (HOST_FORWARDS) is already in use."
    fi
  done
}

# monitor_answers: QEMU's monitor socket is there and something listens on it
monitor_answers() {
  [[ -S "$MONITOR_SOCK" ]] || return 1
  if command -v socat >/dev/null 2>&1; then
    socat -u /dev/null "unix-connect:$MONITOR_SOCK" 2>/dev/null
  elif command -v python3 >/dev/null 2>&1; then
    python3 -I -c 'import socket, sys; socket.socket(socket.AF_UNIX).connect(sys.argv[1])' "$MONITOR_SOCK" 2>/dev/null
  else
    return 1
  fi
}

# PROC_DIR: where /proc is (a test points it elsewhere)
process_is_qemu() {
  local pid="$1" c
  [[ "$pid" =~ ^[0-9]+$ ]] || return 1
  if [[ -r "${PROC_DIR:-/proc}/$pid/comm" ]]; then
    c="$(cat "${PROC_DIR:-/proc}/$pid/comm" 2>/dev/null)"
    [[ "$c" == qemu-system-x86* ]] || [[ "$c" == qemu-system-x86_64 ]] && return 0
    # Fallback: argv0 (containers firejail namespacing may rename comm)
    c="$(head -c 200 "${PROC_DIR:-/proc}/$pid/cmdline" 2>/dev/null | tr '\0' ' ' | awk '{ print $1 }')" || c=""
    [[ "${c##*/}" == qemu-system-x86* ]]
    return
  fi
  # /proc doesn't show it. Under hidepid (parts/hidepid.sh in Wren Linux) a process that isn't
  # dumpable, as QEMU isn't, is hidden even from its own user, and a running VM then looked off:
  # the next start stopped at "Host port ... is already in use". So: the process is there and may
  # be signalled, and the monitor socket QEMU made answers (a stale pid file, even one whose number
  # another program has now, has no socket that answers).
  kill -0 "$pid" 2>/dev/null && monitor_answers
}

# drop_stale_files: the pid file and monitor socket of a VM that is not there. A process that is
# there but isn't seen as QEMU (hidden by hidepid, its monitor socket already gone) keeps them:
# they are all that says where it is, and deleting them made a running VM look off for good.
drop_stale_files() {
  local pid
  pid="$(head -n1 "$PIDFILE" 2>/dev/null || true)"
  if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then return 0; fi
  rm -f -- "$PIDFILE" "$MONITOR_SOCK"
}

qemu_running() {
  local pid
  [[ -s "$PIDFILE" ]] || return 1
  pid="$(head -n1 "$PIDFILE" 2>/dev/null)"
  process_is_qemu "$pid"
}

clear_stale_pidfile() {
  if [[ -s "$PIDFILE" ]] && ! qemu_running; then
    drop_stale_files
  fi
}

# (Wren Linux) What a guest writes to its serial port lands in a file here, and a
# guest with root can write without end: without this, until this computer's disk
# is full. The file is emptied each time it passes SERIAL_LOG_MAX bytes (64 MiB), so
# what is lost is old log. It is watched from outside, since a limit on QEMU's file
# size would hit the VM's disk image too. It ends with the VM.
cap_serial_log() {
  local pid max=${SERIAL_LOG_MAX:-67108864}
  pid="$(head -n1 "$PIDFILE" 2>/dev/null)" || return 0
  [[ "$pid" =~ ^[0-9]+$ ]] || return 0
  # shellcheck disable=SC2016  # expanded by the inner bash, on purpose
  setsid -f bash -c 'while kill -0 "$1" 2>/dev/null; do
      if [ "$(stat -c %s -- "$2" 2>/dev/null || echo 0)" -gt "$3" ]; then truncate -s 0 -- "$2"; fi
      sleep "$4"
    done' _ "$pid" "$1" "$max" "${SERIAL_LOG_EVERY:-2}" </dev/null >/dev/null 2>&1 || true
}

# Start the VM (phase 'install' or 'run'). QEMU writes its own pidfile
# (-pidfile), which is what --stop/--status management uses.
start_phase() {
  local phase="$1" log="$2" attempt startmsg
  clear_stale_pidfile
  rm -f -- "$MONITOR_SOCK"
  define_cmd "$phase" "$log"
  mkdir -p "$RUN_DIR"
  if [[ $phase == install ]]; then
    startmsg="Starting the unattended installer console (serial log: $log)"
  else
    startmsg="Booting the VM headless (serial log: $log)"
  fi
  log "$startmsg"
  # Note: with 'sg kvm -c' the QEMU command line is visible in 'ps'. It holds
  # no secrets (password and keys live only in the initrd / 0600 files); keep
  # it that way when adding options.
  run_kvm "${QQA[@]}" >>"$log" 2>&1 &
  for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
    if [[ -s "$PIDFILE" ]] && qemu_running; then
      log "QEMU is up (pid $(cat "$PIDFILE"))"
      if [[ $phase == run ]]; then cap_serial_log "$log"; fi
      return 0
    fi
    sleep 0.3
  done
  die "QEMU exited immediately during the '$phase' phase. See $log.
If the disk is locked by another QEMU, stop that one first ('--stop')."
}

# Turn the installer's text-mode screen (serial log, full of cursor-movement
# escape codes) into a few readable lines: the last dialog it drew.
installer_screen_text() {
  [[ -s "$1" ]] || return 0
  # POSIX tools only: split the stream at every ESC byte, strip the CSI/charset
  # sequence left at the start of each piece, then drop all non-ASCII (box
  # drawing characters) so no locale or GNU sed extension is needed.
  tail -c 12000 "$1" 2>/dev/null \
    | tr -d '\r' \
    | tr '\033' '\n' \
    | sed -E 's#^(\[[0-9;?]*[ -/]*[@-~]|[()][A-Z0-9]|[=>])##' \
    | LC_ALL=C tr -cd '\n\040-\176' \
    | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \
    | awk 'NF' \
    | tail -n 18
}

report_stall() {
  local idle="$1" screen title
  screen="$(installer_screen_text "$INSTALL_LOG")" || screen=""
  title="$(grep -E '\[!+\]' <<<"$screen" | tail -n1 || true)"
  warn "No installer output for $((idle / 60)) min $((idle % 60)) s."
  printf '  The installer is probably waiting on a question the preseed does not answer.\n' >&2
  if [[ -n "$title" ]]; then printf '  Last dialog: %s\n' "$title" >&2; fi
  if [[ -n "$screen" ]]; then
    printf '  ---- last text on the installer screen ----\n' >&2
    sed 's/^/  | /' <<<"$screen" >&2
    printf '  --------------------------------------------\n' >&2
  fi
}

wait_qemu_exit() {
  local i=0 size last_size=-1 idle=0 warned=0 pid
  log "Waiting for the installer to finish (serial log: $INSTALL_LOG; up to 30 min)"
  while (( i < 300 )); do
    if ! qemu_running; then
      rm -f -- "$PIDFILE" "$MONITOR_SOCK"
      return 0
    fi
    size="$(stat -c %s "$INSTALL_LOG" 2>/dev/null || echo 0)"
    if [[ "$size" == "$last_size" ]]; then
      idle=$((idle + 10))
    else
      idle=0; warned=0; last_size="$size"
    fi
    if (( idle >= 10#$STALL_ABORT_SECS )); then
      report_stall "$idle"
      pid="$(head -n1 "$PIDFILE" 2>/dev/null || true)"
      [[ "$pid" =~ ^[0-9]+$ ]] && kill "$pid" 2>/dev/null || true
      sleep 2
      die "The installer made no progress for $((idle / 60)) minutes and was stopped.
Add the missing answer to the preseed in build_preseed() and re-run.
Full console log: $INSTALL_LOG"
    elif (( idle >= 10#$STALL_WARN_SECS && warned == 0 )); then
      report_stall "$idle"
      printf '  Still waiting; the VM will be stopped after %s s of silence in total.\n' "$STALL_ABORT_SECS" >&2
      warned=1
    fi
    i=$((i + 1))
    if (( i % 6 == 0 )); then
      log "Still installing (elapsed ~$((i * 10 / 60)) min)"
    fi
    sleep 10
  done
  die "The installer did not return within 30 minutes. Inspect $INSTALL_LOG;
kill the VM with '--stop' and re-run."
}

stop_vm() {
  local pid i
  if ! qemu_running; then
    log "VM '$VM_NAME' is not running."
    drop_stale_files
    return 0
  fi
  pid="$(head -n1 "$PIDFILE")"
  log "Powering off '$VM_NAME' gracefully (ACPI)"
  mkdir -p "$RUN_DIR"
  local sent=0
  if command -v socat >/dev/null 2>&1; then
    printf 'system_powerdown\n' | socat - "unix-connect:$MONITOR_SOCK" 2>/dev/null && sent=1
  elif command -v nc >/dev/null 2>&1; then
    printf 'system_powerdown\n' | nc -U "$MONITOR_SOCK" 2>/dev/null && sent=1
  fi
  if [[ $sent -eq 0 ]]; then
    warn "Neither socat nor nc could reach the monitor socket; will use SIGTERM if ACPI times out.
         Install socat for a clean power-off: sudo apt-get install socat
         (the nc that comes as netcat-traditional can't talk to unix sockets)"
  fi
  for i in $(seq 1 15); do
    if ! qemu_running; then break; fi
    sleep 2
  done
  if qemu_running; then
    warn "ACPI powerdown did not complete within 30s; falling back to SIGTERM (potentially slower qcow2 cleanup on next boot, but safe)."
    kill "$pid" 2>/dev/null || true
    for i in $(seq 1 10); do
      if ! qemu_running; then break; fi
      sleep 1
    done
  fi
  if qemu_running; then
    die "QEMU for '$VM_NAME' is still alive. Investigate manually:
  ps aux | grep -F '$VWANT_BIN' ; kill -9 <pid>"
  fi
  rm -f -- "$PIDFILE" "$MONITOR_SOCK"
  log "Stopped."
}

status_vm() {
  if qemu_running; then
    log "Running (pid $(head -n1 "$PIDFILE")) -- ssh $SSH_HOST_IP:$SSH_HOST_PORT"
  else
    log "Not running."
    drop_stale_files
  fi
}

is_vm_installed() { [[ -f "$DISK" && -s "$DISK" ]]; }

# The guest disk must exist before QEMU starts (QEMU does not create it).
create_disk() {
  rm -f -- "$DISK"
  log "Creating a ${VM_DISK_SIZE_MB} MiB qcow2 disk: $DISK"
  qemu-img create -q -f qcow2 "$DISK" "${VM_DISK_SIZE_MB}M" \
    || die "qemu-img could not create $DISK"
  chmod 600 "$DISK"
}

# EXIT trap during install: drop the disk if the
# install did not complete so a failed run is not mistaken for an installed VM.
cleanup_install() {
  if [[ "${INSTALL_DONE:-0}" -ne 1 ]]; then
    rm -f -- "$DISK"
  fi
}

### SSH config ########################################################

write_ssh_block() {
  local cfg="$HOME/.ssh/config" blockfile out f
  mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
  touch "$cfg"; chmod 600 "$cfg"
  if grep -qxF "$SSH_BEGIN" "$cfg" && ! grep -qxF "$SSH_END" "$cfg"; then
    die "$cfg has a '$SSH_BEGIN' line but no matching END line; fix it by hand and re-run."
  fi
  cp -p "$cfg" "$cfg.mockvps-kvm.bak"

  blockfile="$(mktemp)"; out="$(mktemp)"
  chmod 600 "$blockfile" "$out"
  {
    echo "$SSH_BEGIN"
    echo "Host $VM_NAME"
    echo "  HostName $SSH_HOST_IP"
    echo "  Port $SSH_HOST_PORT"
    echo "  User $VM_USER"
    echo "  IdentityFile $KEY_FILE"
    echo "  IdentitiesOnly yes"
    echo "  HostKeyAlias $VM_NAME"
    echo "  StrictHostKeyChecking accept-new"
    for f in ${FORWARD_LIST[@]+"${FORWARD_LIST[@]}"}; do
      echo "  LocalForward ${f%%:*} localhost:${f##*:}"
    done
    echo "$SSH_END"
  } > "$blockfile"

  awk -v bf="$blockfile" -v begin="$SSH_BEGIN" -v end="$SSH_END" '
    $0 == begin  { while ((getline l < bf) > 0) print l; close(bf); placed = 1; skip = 1; next }
    skip         { if ($0 == end) skip = 0; next }
    skip == 1    { next }
    { print }
    END { if (!placed) { if (NR > 0) print ""; while ((getline l < bf) > 0) print l } }
  ' "$cfg" > "$out" || { rm -f "$blockfile" "$out"; die "ssh config block replacement failed"; }
  cat "$out" > "$cfg"     # keep the inode, so a symlinked config still works
  rm -f "$blockfile" "$out"
  log "ssh config block for '$VM_NAME' written to $cfg (backup: $cfg.mockvps-kvm.bak)"
}

remove_ssh_block() {
  local cfg="$HOME/.ssh/config" out
  [[ -f "$cfg" ]] || return 0
  cp -p "$cfg" "$cfg.mockvps-kvm.bak"
  out="$(mktemp)"; chmod 600 "$out"
  awk -v begin="$SSH_BEGIN" -v end="$SSH_END" '
    $0 == begin   { skip = 1; next }
    skip == 1     { if ($0 == end) skip = 0; next }
    { print }
  ' "$cfg" > "$out"
  cat "$out" > "$cfg"
  rm -f "$out"; chmod 600 "$cfg"
}

forget_old_host_keys() {
  local kh="$HOME/.ssh/known_hosts"
  [[ -f "$kh" ]] || return 0
  ssh-keygen -R "$VM_NAME" -f "$kh" >/dev/null 2>&1 || true
  ssh-keygen -R "[$SSH_HOST_IP]:$SSH_HOST_PORT" -f "$kh" >/dev/null 2>&1 || true
}

ssh_banner_ready() {
  # shellcheck disable=SC2016  # expanded by the inner bash, on purpose
  timeout 5 bash -c 'exec 3<>"$1" 2>/dev/null; read -t 3 -r line <&3; [[ $line == SSH-* ]]' _ "/dev/tcp/$SSH_HOST_IP/$SSH_HOST_PORT" 2>/dev/null
}

probe_key_login() {
  ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5 \
      -o StrictHostKeyChecking=accept-new -o HostKeyAlias="$VM_NAME" \
      -o IdentitiesOnly=yes -i "$KEY_FILE" \
      -p "$SSH_HOST_PORT" "$VM_USER@$SSH_HOST_IP" true 2>/dev/null
}

# Checks every 2 s (a check is one local connection), so a booted guest is
# noticed at once rather than up to 10 s later; gives up after 30 min.
wait_for_ssh_key_login() {
  local i end=$((SECONDS + 1800))
  log "Waiting for SSH on $SSH_HOST_IP:$SSH_HOST_PORT"
  until ssh_banner_ready; do
    (( SECONDS < end )) || die "SSH never came up on $SSH_HOST_IP:$SSH_HOST_PORT (30 min). Inspect $RUN_LOG;"
    sleep 2
  done
  log "SSH is up; checking the key-only login"
  for i in $(seq 1 15); do
    if probe_key_login; then
      log "Key-based SSH works; password authentication is disabled in the guest."
      return 0
    fi
    sleep 2
  done
  printf 'ERROR: SSH answers, but the key-only login was refused.\n' >&2
  printf '  Log in once with the stored password (see %s) and add\n' "$CRED_FILE" >&2
  printf '  %s.pub to the guest'"'"'s ~/.ssh/authorized_keys.\n' "$KEY_FILE" >&2
  exit 3
}

### Guest access: cloud-init wait + SSH provisioner ##################

# Non-interactive key-only ssh into the guest (independent of ~/.ssh/config).
guest_ssh() {
  ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=10 \
      -o StrictHostKeyChecking=accept-new -o HostKeyAlias="$VM_NAME" \
      -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o LogLevel=ERROR \
      -i "$KEY_FILE" -p "$SSH_HOST_PORT" "$VM_USER@$SSH_HOST_IP" "$@"
}

# Only after a fresh install with --user-data: cloud-init runs on that first
# boot, and SSH comes up before its runcmd/packages are done.
wait_cloud_init() {
  if [[ -z "$USER_DATA_FILE" ]]; then return 0; fi
  log "Waiting for cloud-init to finish in the guest (up to 15 min)"
  if guest_ssh 'timeout 900 cloud-init status --wait --long'; then
    log "cloud-init finished"
  else
    warn "cloud-init did not finish cleanly. Inspect with:
  ssh $VM_NAME 'cloud-init status --long; sudo tail -n 60 /var/log/cloud-init-output.log'"
  fi
}

# Run every --provision file in the guest, as root, in order. The script is
# streamed over SSH after the sudo password (read by 'sudo -S' from stdin, so
# it never appears in any process list), stored in a 0600 temp file, and run
# non-interactively with stdin closed. A failing script stops the run.
run_provisioners() {
  local f n=0 total=${#PROV_FILES[@]} remote
  if (( total == 0 )); then return 0; fi
  if [[ -z "${VM_PASSWORD:-}" ]]; then
    VM_PASSWORD="$(read_cred_password "$CRED_FILE")" \
      || die "Cannot read the guest sudo password from $CRED_FILE (needed to run provisioning scripts as root).
Set VM_PASSWORD in the environment, or reinstall."
  fi
  remote="$(cat <<'EOS'
sudo -S -p '' bash -c 'f=$(mktemp /tmp/mockvps-prov.XXXXXX) || exit 1; cat > "$f"; DEBIAN_FRONTEND=noninteractive bash "$f" </dev/null; rc=$?; rm -f "$f"; exit $rc'
EOS
)"
  for f in "${PROV_FILES[@]}"; do
    n=$((n + 1))
    log "Provisioning ($n/$total): running $f in the guest as root"
    if ! { printf '%s\n' "$VM_PASSWORD"; cat -- "$f"; } | guest_ssh "$remote"; then
      die "Provisioning script '$f' failed (output above). The VM is left running;
fix the script and re-run with:  --provision-only --provision '$f'"
    fi
  done
  log "Provisioning finished ($total script(s))"
}

provision_only_run() {
  (( ${#PROV_FILES[@]} > 0 )) \
    || die "--provision-only needs at least one --provision FILE (or PROVISION_FILES=a.sh:b.sh)"
  qemu_running || die "'$VM_NAME' is not running; boot it with --start first."
  probe_key_login || die "Key-only SSH login to '$VM_NAME' failed; see 'ssh $VM_NAME'."
  run_provisioners
}

### Snapshots (qcow2 internal snapshots; VM must be off) ##############

snapshot_exists() {
  qemu-img snapshot -U -l "$DISK" 2>/dev/null \
    | awk -v n="$1" '$1 ~ /^[0-9]+$/ && $2 == n { f = 1 } END { exit !f }'
}

# Run "$@" with the VM powered off (qemu-img needs the disk lock); boot it
# again afterwards if it was running before.
with_vm_stopped() {
  local was_running=0 rc=0
  if qemu_running; then
    confirm "'$VM_NAME' is running. Power it off for this operation and boot it again afterwards?" \
      || die "Aborted; nothing changed."
    stop_vm
    was_running=1
  fi
  "$@" || rc=$?
  if [[ $was_running -eq 1 ]]; then
    log "Booting '$VM_NAME' again"
    check_port_free
    start_phase run "$RUN_LOG"
    wait_for_ssh_key_login
  fi
  return "$rc"
}

do_snapshot_create() {
  log "Creating snapshot '$SNAP_NAME' of $DISK"
  qemu-img snapshot -c "$SNAP_NAME" "$DISK" || die "qemu-img could not create the snapshot."
  log "Snapshot '$SNAP_NAME' created. Roll back with: ${0##*/} --restore $SNAP_NAME"
}

do_snapshot_restore() {
  log "Rolling '$VM_NAME' back to snapshot '$SNAP_NAME'"
  qemu-img snapshot -a "$SNAP_NAME" "$DISK" || die "qemu-img could not apply the snapshot."
  log "Restored. The snapshot is kept; it can be restored again."
}

do_snapshot_delete() {
  log "Deleting snapshot '$SNAP_NAME'"
  qemu-img snapshot -d "$SNAP_NAME" "$DISK" || die "qemu-img could not delete the snapshot."
  log "Snapshot '$SNAP_NAME' deleted (the disk file may stay large; qcow2 reuses the space)."
}

snapshot_run() {
  local out
  command -v qemu-img >/dev/null 2>&1 || die "qemu-img is not installed (sudo apt-get install -y qemu-utils)."
  is_vm_installed || die "No installed VM disk at $DISK."
  case "$SNAP_ACTION" in
    list)
      out="$(qemu-img snapshot -U -l "$DISK" 2>&1)" || die "qemu-img could not list snapshots: $out"
      log "Snapshots of '$VM_NAME'"
      if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo "  (none)"; fi
      ;;
    create)
      if snapshot_exists "$SNAP_NAME"; then
        die "A snapshot named '$SNAP_NAME' already exists (remove it with --delete-snapshot $SNAP_NAME)."
      fi
      with_vm_stopped do_snapshot_create
      ;;
    restore)
      snapshot_exists "$SNAP_NAME" || die "No snapshot named '$SNAP_NAME' (see --snapshots)."
      confirm "Roll '$VM_NAME' back to '$SNAP_NAME'? Everything written to its disk since then is lost." \
        || die "Aborted; nothing changed."
      with_vm_stopped do_snapshot_restore
      ;;
    delete)
      snapshot_exists "$SNAP_NAME" || die "No snapshot named '$SNAP_NAME' (see --snapshots)."
      with_vm_stopped do_snapshot_delete
      ;;
    *) die "internal error: unknown snapshot action '$SNAP_ACTION'" ;;
  esac
}

### Uninstall #########################################################

# Lists only the artifacts that currently exist (or are explicitly kept).
uninstall_items() {
  if [[ $PURGE_SOFTWARE -eq 1 ]]; then
    local vp
    vp="$(dpkg -l 2>/dev/null | awk '$1 == "ii" && $2 ~ /^qemu-(system|utils)/ { print $2 }')"
    if [[ -n "$vp" ]]; then echo "software   QEMU packages: ${vp//$'\n'/, }"; fi
    if id -nG "$REAL_USER" 2>/dev/null | grep -qw kvm; then
      echo "software   kvm group membership for $REAL_USER"
    fi
  else
    echo "keep       QEMU software and your kvm group membership (add --purge-software to remove)"
  fi
  if [[ -e "$DISK" ]]; then echo "file       $DISK (guest disk)"; fi
  if [[ -d "$BOOT_DIR/$VM_NAME" ]]; then echo "dir        $BOOT_DIR/$VM_NAME (extracted installer kernel/initrd)"; fi
  if [[ -f "$CRED_FILE" ]]; then echo "file       $CRED_FILE (guest sudo password)"; fi
  if [[ -f "$PRESEED_FILE" ]]; then echo "file       $PRESEED_FILE (preseed copy)"; fi
  local f
  for f in "$WORKDIR"/debian-*-amd64-netinst.iso "$WORKDIR"/debian-*-amd64-netinst.iso.part; do
    if [[ -e "$f" ]]; then echo "file       $f (install ISO left by an older version of this script)"; fi
  done
  if [[ -d "$WORKDIR/verify" ]]; then echo "dir        $WORKDIR/verify (checksum files left by an older version)"; fi
  if [[ -f "$INSTALL_LOG" ]]; then echo "file       $INSTALL_LOG (install serial console)"; fi
  if [[ -f "$RUN_LOG" ]]; then echo "file       $RUN_LOG (run serial console)"; fi
  if [[ -e "$PIDFILE" ]]; then echo "file       $PIDFILE (+ monitor socket $MONITOR_SOCK)"; fi
  if [[ -f "$KEY_FILE" ]]; then echo "file       $KEY_FILE (dedicated ssh key)"; fi
  if [[ -f "$KEY_FILE.pub" ]]; then echo "file       $KEY_FILE.pub"; fi
  local cfg="$HOME/.ssh/config"
  if [[ -f "$cfg" ]]; then
    if grep -qxF "$SSH_BEGIN" "$cfg" && ! grep -qxF "$SSH_END" "$cfg"; then
      die "$cfg has a '$SSH_BEGIN' line but no matching END line; fix it by hand and re-run."
    fi
    if grep -qxF "$SSH_BEGIN" "$cfg"; then
      echo "ssh-block  $cfg (managed block for '$VM_NAME')"
    fi
  fi
  local kh="$HOME/.ssh/known_hosts"
  if [[ -f "$kh" ]] && grep -qE "(^|[[:space:]])$VM_NAME([[:space:]]|\$)|\[$SSH_HOST_IP\]:$SSH_HOST_PORT" "$kh"; then
    echo "known-hosts  entries for '$VM_NAME' and/or [$SSH_HOST_IP]:$SSH_HOST_PORT in $kh"
  fi
  return 0
}

# Opt-in host software purge (--purge-software). QEMU has no VM registry,
# so unlike VirtualBox there is no reliable way to detect other users of
# the packages -- the confirm prompt IS the safety rail, plus:
# the kvm system group itself is NEVER deleted (libvirt, other users and
# udev rules may rely on it; it came with the system, not with our VM).
purge_host_software() {
  confirm "Purge the QEMU packages and drop the kvm group membership? (uses sudo)" \
    || die "Software purge skipped; everything else was removed."
  local pkgs
  pkgs="$(dpkg -l 2>/dev/null | awk '$1 == "ii" && $2 ~ /^qemu-(system|utils)/ { print $2 }')"
  if [[ -n "$pkgs" ]]; then
    log "Purging: $(printf '%s' "$pkgs" | tr '\n' ' ')"
    # shellcheck disable=SC2086  # $pkgs is a list of package names
    as_root env DEBIAN_FRONTEND=noninteractive apt-get purge -y $pkgs \
      || warn "apt-get purge failed; clean it up manually if wanted."
    as_root env DEBIAN_FRONTEND=noninteractive apt-get autoremove --purge -y \
      || warn "apt-get autoremove failed; run it manually if wanted."
  else
    log "No QEMU packages are installed via dpkg; nothing to purge."
  fi
  if getent group kvm >/dev/null 2>&1 && id -nG "$REAL_USER" 2>/dev/null | grep -qw kvm; then
    as_root deluser "$REAL_USER" kvm || true
    log "Revoked kvm group membership for $REAL_USER"
  fi
  log "Note: the kvm system group itself is never removed (other users/libvirt may rely on it)."
}

uninstall_run() {
  log "Uninstall plan for VM '$VM_NAME' (workdir: $WORKDIR)"
  uninstall_items
  if [[ $DRY_RUN -eq 1 ]]; then
    log "dry-run: nothing was removed. Re-run without --dry-run to delete for real."
    return 0
  fi
  confirm "Permanently delete everything listed above?" || die "Aborted; nothing was removed."

  if qemu_running; then
    log "Stopping the running VM first"
    stop_vm
  fi

  local f
  rm -f -- "$DISK" "$CRED_FILE" "$PRESEED_FILE" "$INSTALL_LOG" "$RUN_LOG" \
           "$PIDFILE" "$MONITOR_SOCK" "$KEY_FILE" "$KEY_FILE.pub"
  for f in "$WORKDIR"/debian-*-amd64-netinst.iso "$WORKDIR"/debian-*-amd64-netinst.iso.part; do
    if [[ -e "$f" ]]; then rm -f -- "$f"; fi
  done
  if [[ -d "$WORKDIR/verify" ]]; then rm -rf "$WORKDIR/verify"; fi
  rm -rf "${BOOT_DIR:?}/${VM_NAME:?}"
  rmdir "$BOOT_DIR" 2>/dev/null || true
  rmdir "$RUN_DIR" 2>/dev/null || true
  rmdir "$CRED_DIR" 2>/dev/null || true
  if [[ -d "$WORKDIR" ]] && [[ -z "$(find "$WORKDIR" -mindepth 1 -print -quit 2>/dev/null)" ]]; then
    rmdir "$WORKDIR" || true
  fi

  remove_ssh_block
  if [[ -f "$HOME/.ssh/known_hosts" ]]; then
    forget_old_host_keys
  fi

  if [[ $PURGE_SOFTWARE -eq 1 ]]; then
    purge_host_software
  fi

  log "Removal of '$VM_NAME' is complete."
  local script_path repo_dir remove_hint
  script_path="$(readlink -f -- "${BASH_SOURCE[0]:-$0}" 2>/dev/null)" || script_path="${BASH_SOURCE[0]:-$0}"
  repo_dir="$(dirname -- "$script_path")"
  # Inside Wren Linux the installer is extras/mockvps-kvm.sh; on its own it is
  # the script's neighbour
  remove_hint="$repo_dir/../mockvps-kvm.sh"
  if [[ -f "$repo_dir/../../extras/mockvps-kvm.sh" ]]; then
    remove_hint="$repo_dir/../../extras/mockvps-kvm.sh"
  fi
  cat <<EOF

Deliberately NOT touched (do these yourself if you truly want a clean slate):
  * the mockvps-kvm command and its tools:  bash "$remove_hint" --remove
  * ~/.ssh/known_hosts.old and ~/.ssh/config.mockvps-kvm.bak, if kept or useful
EOF
}

### Summary ###########################################################

print_summary() {
  local f
  cat <<EOF

VM is ready.

Connect:
  ssh $VM_NAME                    # dedicated key; key-only login
  (or) ssh -p $SSH_HOST_PORT $VM_USER@$SSH_HOST_IP

Install something in the guest (sudo asks for the password in):
  ssh $VM_NAME
  cat '$CRED_FILE'                # VM_PASSWORD (root/sudo in the guest)

Forgot the password? It is already in the guest as sudo; the file is 0600.

SSH tunnels (live while an 'ssh $VM_NAME' session is open):
EOF
  if [[ ${#FORWARD_LIST[@]} -eq 0 ]]; then
    echo "  (none)"
  else
    for f in "${FORWARD_LIST[@]}"; do
      echo "  http://localhost:${f%%:*}  ->  guest:${f##*:}"
    done
  fi
  cat <<EOF

Management:
  --start / --stop              boot / gracefully power off
  --status                      is it running?
  --snapshot|--restore NAME     --snapshots  --delete-snapshot NAME
  --provision FILE --provision-only   re-run a script in the running guest
  --uninstall [--dry-run]       remove every trace (preview with --dry-run)
  --uninstall --purge-software  also purge QEMU + kvm membership
Serial consoles: $INSTALL_LOG (install) / $RUN_LOG (run)
EOF
}

### Main ##############################################################

main() {
  init_defaults
  parse_args "$@"
  validate_config
  derive_paths
  wren_check

  [[ "$(uname -s)" == "Linux" ]] || die "This script supports Linux hosts only."
  [[ "$(uname -m)" == "x86_64" ]] || die "This script supports x86_64 hosts only (ARM: look at Lima/multipass)."

  local snap_flag=0 install_opts=0
  if [[ -n "$SNAP_ACTION" ]]; then snap_flag=1; fi
  if [[ -n "$USER_DATA_FILE" || $HAVE_PACKAGES -eq 1 ]]; then install_opts=1; fi

  if (( UNINSTALL + START + STOP + STATUS + PROVISION_ONLY + snap_flag > 1 )); then
    die "--uninstall, --start, --stop, --status, --provision-only and the snapshot actions are mutually exclusive; pick exactly one."
  fi
  if (( UNINSTALL + STOP + STATUS + snap_flag > 0 )) && (( install_opts + ${#PROV_FILES[@]} > 0 )); then
    die "--user-data, --package and --provision cannot be combined with --uninstall, --stop, --status or the snapshot actions."
  fi
  if (( START + PROVISION_ONLY > 0 )) && (( install_opts == 1 )); then
    die "--user-data and --package only apply to a fresh install (the installer bakes them in)."
  fi

  if [[ $UNINSTALL -eq 1 ]]; then
    uninstall_run
    return 0
  fi
  if [[ $DRY_RUN -ne 0 || $PURGE_SOFTWARE -ne 0 ]]; then
    die "--dry-run and --purge-software are only meaningful with --uninstall"
  fi

  if [[ $STOP -eq 1 ]]; then stop_vm; return 0; fi
  if [[ $STATUS -eq 1 ]]; then status_vm; return 0; fi
  if [[ $snap_flag -eq 1 ]]; then snapshot_run; return 0; fi
  if [[ $PROVISION_ONLY -eq 1 ]]; then provision_only_run; return 0; fi

  # From here it is install or --start; both need QEMU + KVM access.
  ensure_qemu
  ensure_kvm_host
  log "Preparing VM '$VM_NAME'"

  if [[ $START -eq 1 ]]; then
    is_vm_installed || die "No installed VM disk at $DISK. Run a plain install first."
    if qemu_running; then
      log "Already running (pid $(head -n1 "$PIDFILE")); ssh $SSH_HOST_IP:$SSH_HOST_PORT"
      run_provisioners
      return 0
    fi
    check_port_free
    start_phase run "$RUN_LOG"
    write_ssh_block
    wait_for_ssh_key_login
    run_provisioners
    print_summary
    return 0
  fi

  if is_vm_installed; then
    log "A VM already exists (disk: $DISK)"
    if qemu_running; then
      die "'$VM_NAME' is running; power it off with '--stop' before reinstalling."
    fi
    if confirm "Delete the existing VM and reinstall from scratch?"; then
      REINSTALL=1
      rm -f -- "$DISK" "$CRED_FILE" "$PRESEED_FILE" "$INSTALL_LOG" "$RUN_LOG"
    else
      log "Reusing the existing VM"
      REINSTALL=0
      if [[ $install_opts -eq 1 ]]; then
        warn "--user-data / --package are ignored: they only apply to a fresh install."
        USER_DATA_FILE=""
      fi
    fi
  fi

  if [[ $REINSTALL -eq 1 ]]; then
    setup_credentials
    ensure_host_key
    ensure_netboot
    build_preseed
    forget_old_host_keys
    INSTALL_DONE=0
    trap cleanup_install EXIT
    create_disk
    build_initrd
    start_phase install "$INSTALL_LOG"
    wait_qemu_exit
    INSTALL_DONE=1
    trap - EXIT
    # The injected initrd holds the guest password (and any user-data): once
    # the install is over it has no further use.
    rm -f -- "$BOOT_DIR/$VM_NAME/initrd-preseed.gz"
    is_vm_installed || die "The installer finished but $DISK is missing or empty; see $INSTALL_LOG."
    log "Install finished. Booting the installed system."
  fi

  check_port_free
  start_phase run "$RUN_LOG"
  write_ssh_block
  wait_for_ssh_key_login
  if [[ $REINSTALL -eq 1 ]]; then wait_cloud_init; fi
  run_provisioners
  print_summary
}

# Allow `source mockvps-kvm.sh` (tests) without running anything.
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  main "$@"
fi