utils/vmbox/lib/overlay.sh
Krzysztof kuhy Rudnicki 694c013b6b
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
vmbox: UEFI sandboxes and 'vm wake' RTC-wake emulation for poweroff/hibernate; worktree copies skip gitignored files
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 12:29:54 +02:00

145 lines
5.4 KiB
Bash

#!/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"
# shellcheck source=firmware.sh
source "$(dirname "${BASH_SOURCE[0]}")/firmware.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="" firmware=bios 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 ;;
--uefi) firmware=uefi; shift ;;
*) 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
# Fail before anything is created, not halfway through.
if [[ "$firmware" == uefi ]]; then _firmware_file executable >/dev/null || exit 1; fi
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"
meta_set "$name" firmware "$firmware"
[[ "$firmware" == uefi ]] && firmware_init_vars "$name"
_overlay_create_disk "$name"
ok "sandbox '$name' ready ($firmware) (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"
# Wait for the qemu process to actually release the overlay. Recreating it
# while the old process still has it open leaves the next boot reading a
# half-detached file, which surfaces much later as "did not become
# reachable" rather than as an error here.
local waited=0 pid
pid="$(cat "$(vm_pidfile "$name")" 2>/dev/null || true)"
while [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && (( waited < 30 )); do
sleep 1; waited=$(( waited + 1 ))
done
# Discard everything the guest did: the overlay holds 100% of its writes.
_overlay_create_disk "$name"
# The varstore is guest-writable state too (boot entries, systemd's
# HibernateLocation), so pristine means a fresh copy of it as well.
if [[ -f "$(vm_efivars "$name")" ]]; then
rm -f "$(vm_efivars "$name")"
firmware_init_vars "$name"
fi
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"
}