280 lines
12 KiB
Python
280 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Resume configuration of the one reviewed, powered-off partial M9 VM."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
from typing import Sequence
|
|
import uuid
|
|
|
|
import guard
|
|
import create
|
|
|
|
|
|
LAB_ROOT = guard.LAB_ROOT
|
|
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
|
|
VM_NAME = "wga-m9-debian137-20261025"
|
|
TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac"
|
|
PINNED_ISO_SHA256 = "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355"
|
|
|
|
|
|
class ResumeError(Exception):
|
|
"""The partial VM could not safely be resumed or configured."""
|
|
|
|
|
|
Runner = create.Runner
|
|
|
|
|
|
def _require_absent(path: Path, description: str) -> None:
|
|
try:
|
|
path.lstat()
|
|
except FileNotFoundError:
|
|
return
|
|
except OSError as exc:
|
|
raise ResumeError(f"could not inspect {description}; refusing resume") from exc
|
|
raise ResumeError(f"{description} already exists; refusing resume")
|
|
|
|
|
|
def _check_iso(iso_path: Path) -> None:
|
|
if guard.EXPECTED_ISO_SHA256 != PINNED_ISO_SHA256:
|
|
raise ResumeError("configured ISO digest differs from the pinned digest")
|
|
if iso_path != ISO_PATH:
|
|
raise ResumeError("installer ISO path is not the pinned lab path")
|
|
try:
|
|
if iso_path.parent.resolve(strict=True) != LAB_ROOT.resolve(strict=True):
|
|
raise ResumeError("installer ISO must be directly inside the exact lab root")
|
|
info = iso_path.lstat()
|
|
except OSError as exc:
|
|
raise ResumeError("pinned installer ISO is unavailable") from exc
|
|
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
|
|
raise ResumeError("installer ISO must be a regular non-symlink file")
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with iso_path.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
except OSError as exc:
|
|
raise ResumeError("pinned installer ISO cannot be read") from exc
|
|
if digest.hexdigest() != PINNED_ISO_SHA256:
|
|
raise ResumeError("installer ISO SHA-256 does not match the pinned digest")
|
|
|
|
|
|
def _inventory(runner: Runner) -> None:
|
|
output = guard._invoke(runner, ("VBoxManage", "list", "vms"))
|
|
names: set[str] = set()
|
|
uuids: set[str] = set()
|
|
matches: list[tuple[str, str]] = []
|
|
for line in output.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
|
|
if not match:
|
|
raise ResumeError("malformed VBoxManage inventory; refusing resume")
|
|
name, raw_uuid = match.groups()
|
|
try:
|
|
vm_uuid = str(uuid.UUID(raw_uuid))
|
|
except ValueError as exc:
|
|
raise ResumeError("malformed VBoxManage inventory UUID; refusing resume") from exc
|
|
if name in names or vm_uuid in uuids:
|
|
raise ResumeError("duplicate VBoxManage inventory identity; refusing resume")
|
|
names.add(name)
|
|
uuids.add(vm_uuid)
|
|
if name == VM_NAME or vm_uuid == TARGET_UUID:
|
|
matches.append((name, vm_uuid))
|
|
if matches != [(VM_NAME, TARGET_UUID)]:
|
|
raise ResumeError("registered VM inventory does not match the pinned partial target")
|
|
|
|
|
|
def _preflight_info(info: dict[str, str], lab_root: Path, owner_uid: int) -> None:
|
|
if info.get("name") != VM_NAME or info.get("UUID", "").lower() != TARGET_UUID:
|
|
raise ResumeError("showvminfo identity does not match the pinned partial target")
|
|
if info.get("VMState") != "poweroff":
|
|
raise ResumeError("partial target must be powered off")
|
|
if info.get("description", ""):
|
|
raise ResumeError("partial target already has a host marker; refusing resume")
|
|
if info.get("memory") != "1024" or info.get("cpus") != "1":
|
|
raise ResumeError("partial target default memory or CPU state is unexpected")
|
|
|
|
cfg_file = Path(info.get("CfgFile", ""))
|
|
if cfg_file != lab_root / VM_NAME / f"{VM_NAME}.vbox" or cfg_file.is_symlink():
|
|
raise ResumeError("partial target configuration path is not the exact lab path")
|
|
try:
|
|
folder_info = (lab_root / VM_NAME).lstat()
|
|
config_info = cfg_file.lstat()
|
|
except OSError as exc:
|
|
raise ResumeError("partial target configuration file is unavailable") from exc
|
|
if (not stat.S_ISDIR(folder_info.st_mode) or stat.S_ISLNK(folder_info.st_mode)
|
|
or folder_info.st_uid != owner_uid
|
|
or (lab_root / VM_NAME).resolve(strict=True) != lab_root / VM_NAME):
|
|
raise ResumeError("partial target folder must be a real directory at the exact lab path")
|
|
if (not stat.S_ISREG(config_info.st_mode) or stat.S_ISLNK(config_info.st_mode)
|
|
or config_info.st_uid != owner_uid):
|
|
raise ResumeError("partial target configuration must be a current-user-owned regular file")
|
|
|
|
nics = {int(match.group(1)): value for key, value in info.items()
|
|
if (match := re.fullmatch(r"nic([1-8])", key))}
|
|
if (len(nics) != 8 or nics.get(1) != "nat"
|
|
or any(nics.get(slot) not in {"none", "null"} for slot in range(2, 9))):
|
|
raise ResumeError("partial target NIC state must be NIC 1 in NAT with NICs 2-8 disabled")
|
|
if any((key.lower().startswith("natpf") or key.lower().startswith("forwarding(")) and value
|
|
for key, value in info.items()):
|
|
raise ResumeError("partial target has NAT forwarding; refusing resume")
|
|
if any(key.startswith("SharedFolder") and value for key, value in info.items()):
|
|
raise ResumeError("partial target has shared folders; refusing resume")
|
|
storage_rows = [
|
|
(key, value) for key, value in info.items()
|
|
if re.fullmatch(r"[A-Za-z0-9_-]+-[0-9]+-[0-9]+", key)
|
|
]
|
|
if any(value not in {"", "emptydrive", "none"} for _, value in storage_rows):
|
|
raise ResumeError("partial target has attached storage; refusing resume")
|
|
if any(key.startswith("storagecontroller") for key in info):
|
|
raise ResumeError("partial target has a storage controller; refusing resume")
|
|
|
|
|
|
def _preflight(
|
|
*, lab_root: Path, iso_path: Path, owner_uid: int, runner: Runner,
|
|
) -> None:
|
|
if lab_root != LAB_ROOT:
|
|
raise ResumeError("resume is restricted to the pinned lab root")
|
|
try:
|
|
root_info = lab_root.lstat()
|
|
except OSError as exc:
|
|
raise ResumeError("private lab root is unavailable") from exc
|
|
if (not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode)
|
|
or root_info.st_uid != owner_uid or stat.S_IMODE(root_info.st_mode) != 0o700):
|
|
raise ResumeError("lab root must be a real current-user directory with mode 0700")
|
|
_check_iso(iso_path)
|
|
_require_absent(lab_root / guard.IDENTITY_FILE, "target identity manifest")
|
|
_require_absent(lab_root / VM_NAME / f"{VM_NAME}.vdi", "target VDI")
|
|
_inventory(runner)
|
|
info_text = guard._invoke(runner, (
|
|
"VBoxManage", "showvminfo", TARGET_UUID, "--machinereadable",
|
|
))
|
|
try:
|
|
info = guard._machine_values(info_text)
|
|
except guard.GuardError as exc:
|
|
raise ResumeError("partial target machine-readable state is invalid") from exc
|
|
_preflight_info(info, lab_root, owner_uid)
|
|
|
|
|
|
def _write_manifest(lab_root: Path, owner_uid: int) -> None:
|
|
marker = f"{VM_NAME}:{TARGET_UUID}"
|
|
data = {
|
|
"schema": 1,
|
|
"name": VM_NAME,
|
|
"owner_uid": owner_uid,
|
|
"uuid": TARGET_UUID,
|
|
"host_marker": marker,
|
|
}
|
|
path = lab_root / guard.IDENTITY_FILE
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
try:
|
|
fd = os.open(path, flags, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as destination:
|
|
os.fchmod(destination.fileno(), 0o600)
|
|
json.dump(data, destination, sort_keys=True)
|
|
destination.write("\n")
|
|
destination.flush()
|
|
os.fsync(destination.fileno())
|
|
except OSError as exc:
|
|
raise ResumeError(f"could not write identity manifest; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc
|
|
|
|
|
|
def _call(runner: Runner, argv: Sequence[str]) -> str:
|
|
try:
|
|
result = runner(tuple(argv))
|
|
except (OSError, subprocess.TimeoutExpired, create.CreateError, guard.GuardError) as exc:
|
|
raise ResumeError(f"bounded VBoxManage call failed: {argv[1]}") from exc
|
|
if len(result.stdout) > create.MAX_CAPTURE or len(result.stderr) > create.MAX_CAPTURE:
|
|
raise ResumeError("VBoxManage output exceeded the resume limit")
|
|
if result.returncode != 0:
|
|
raise ResumeError(f"VBoxManage {argv[1]} failed with status {result.returncode}")
|
|
return result.stdout
|
|
|
|
|
|
def resume_target(
|
|
*, lab_root: Path = LAB_ROOT,
|
|
iso_path: Path = ISO_PATH,
|
|
owner_uid: int | None = None,
|
|
runner: Runner = create.run_command,
|
|
) -> str:
|
|
"""Preflight the exact partial VM, finish setup, and run the read-only guard."""
|
|
owner_uid = os.getuid() if owner_uid is None else owner_uid
|
|
try:
|
|
_preflight(lab_root=lab_root, iso_path=iso_path, owner_uid=owner_uid, runner=runner)
|
|
except (OSError, guard.GuardError) as exc:
|
|
raise ResumeError("resume preflight failed closed") from exc
|
|
|
|
disk_path = lab_root / VM_NAME / f"{VM_NAME}.vdi"
|
|
try:
|
|
_call(runner, (
|
|
"VBoxManage", "modifyvm", TARGET_UUID,
|
|
"--memory", str(create.MEMORY_MIB), "--cpus", str(create.CPUS),
|
|
"--nic1", "nat", "--nic2", "none", "--nic3", "none", "--nic4", "none",
|
|
"--nic5", "none", "--nic6", "none", "--nic7", "none", "--nic8", "none",
|
|
"--audio", "none", "--usb", "off",
|
|
"--boot1", "dvd", "--boot2", "disk", "--boot3", "none", "--boot4", "none",
|
|
))
|
|
_call(runner, (
|
|
"VBoxManage", "createmedium", "disk", "--filename", str(disk_path),
|
|
"--size", str(create.DISK_SIZE_MIB), "--format", "VDI", "--variant", "Standard",
|
|
))
|
|
_call(runner, (
|
|
"VBoxManage", "storagectl", TARGET_UUID, "--name", "SATA",
|
|
"--add", "sata", "--controller", "IntelAhci",
|
|
))
|
|
_call(runner, (
|
|
"VBoxManage", "storageattach", TARGET_UUID, "--storagectl", "SATA",
|
|
"--port", "0", "--device", "0", "--type", "hdd", "--medium", str(disk_path),
|
|
))
|
|
_call(runner, (
|
|
"VBoxManage", "storageattach", TARGET_UUID, "--storagectl", "SATA",
|
|
"--port", "1", "--device", "0", "--type", "dvddrive", "--medium", str(iso_path),
|
|
))
|
|
_write_manifest(lab_root, owner_uid)
|
|
_call(runner, (
|
|
"VBoxManage", "modifyvm", TARGET_UUID,
|
|
"--description", f"{VM_NAME}:{TARGET_UUID}",
|
|
))
|
|
try:
|
|
selected_uuid, _ = guard.inspect_target(
|
|
lab_root=lab_root, owner_uid=owner_uid, runner=runner,
|
|
)
|
|
except (guard.GuardError, OSError) as exc:
|
|
raise ResumeError("final read-only guard failed") from exc
|
|
if selected_uuid != TARGET_UUID:
|
|
raise ResumeError("final guard returned an unexpected UUID")
|
|
return TARGET_UUID
|
|
except ResumeError as exc:
|
|
if "retained partial VM" in str(exc):
|
|
raise
|
|
raise ResumeError(f"{exc}; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc
|
|
except (OSError, ValueError, KeyError, create.CreateError, guard.GuardError) as exc:
|
|
raise ResumeError(f"resume configuration failed; retained partial VM {VM_NAME} ({TARGET_UUID})") from exc
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.parse_args(argv)
|
|
try:
|
|
target_uuid = resume_target()
|
|
except ResumeError as exc:
|
|
print(f"VM resume refused/stopped: {exc}", file=sys.stderr)
|
|
return 2
|
|
print(f"Resumed and guarded {VM_NAME} ({target_uuid}); VM remains powered off")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|