Files

211 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Prepare the one pinned M9 VM for unattended installation; never start it."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import re
import selectors
import stat
import subprocess
import sys
import time
from typing import Callable, Sequence
import guard
TARGET_UUID = "b14c850b-a21e-4a56-acca-785590f0c4ac"
LAB_ROOT = guard.LAB_ROOT
ISO_PATH = LAB_ROOT / guard.EXPECTED_ISO
ISO_SHA256 = guard.EXPECTED_ISO_SHA256
ADDITIONS_ISO = Path("/usr/share/virtualbox/VBoxGuestAdditions.iso")
ADDITIONS_SHA256 = "4f51a073296de31cce53924860549149be5dc339f65dcd1dbf34fd7accefe8fb"
PASSWORD_DIR = Path("/tmp/wga-m9-private")
USER_PASSWORD_FILE = PASSWORD_DIR / "wga-password"
ROOT_PASSWORD_FILE = PASSWORD_DIR / "root-password"
MAX_CAPTURE = 1024 * 1024
TIMEOUT_SECONDS = 120
class PrepareError(Exception):
"""Preflight refused or unattended preparation stopped; output is redacted."""
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
"""Run bounded VBoxManage while retaining its potentially secret output privately."""
process = subprocess.Popen(
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"},
)
assert process.stdout is not None and process.stderr is not None
output = {process.stdout: bytearray(), process.stderr: bytearray()}
selector = selectors.DefaultSelector()
for stream in output:
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ)
deadline = time.monotonic() + TIMEOUT_SECONDS
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(argv, TIMEOUT_SECONDS)
for key, _ in selector.select(remaining):
stream = key.fileobj
chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream])))
if not chunk:
selector.unregister(stream)
continue
output[stream].extend(chunk)
if len(output[stream]) > MAX_CAPTURE:
raise PrepareError("VBoxManage output exceeded the preparation limit")
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
except (OSError, subprocess.TimeoutExpired, PrepareError):
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
raise
finally:
selector.close()
process.stdout.close()
process.stderr.close()
return subprocess.CompletedProcess(
argv, returncode,
bytes(output[process.stdout]).decode("utf-8", errors="replace"),
bytes(output[process.stderr]).decode("utf-8", errors="replace"),
)
def _check_private_file(path: Path, owner_uid: int) -> None:
try:
info = path.lstat()
except OSError as exc:
raise PrepareError("private synthetic password file is unavailable") from exc
if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600
or info.st_size == 0):
raise PrepareError("synthetic password files must be nonempty current-user files with mode 0600")
def _check_private_credentials(owner_uid: int) -> None:
try:
info = PASSWORD_DIR.lstat()
except OSError as exc:
raise PrepareError("private synthetic password directory is unavailable") from exc
if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700):
raise PrepareError("synthetic password directory must be current-user-owned mode 0700")
_check_private_file(USER_PASSWORD_FILE, owner_uid)
_check_private_file(ROOT_PASSWORD_FILE, owner_uid)
def _check_digest(path: Path, expected: str, label: str) -> None:
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise PrepareError(f"{label} must be a regular non-symlink file")
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
except OSError as exc:
raise PrepareError(f"{label} is unavailable") from exc
if digest.hexdigest() != expected:
raise PrepareError(f"{label} SHA-256 does not match the pinned value")
def _validate_preinstall_state(info: dict[str, str]) -> None:
if info.get("VMState") != "poweroff":
raise PrepareError("exact target must be powered off before preparation")
expected_disk = str((LAB_ROOT / guard.TARGET_NAME / f"{guard.TARGET_NAME}.vdi").resolve(strict=True))
expected_iso = str(ISO_PATH.resolve(strict=True))
attachments = {key: value for key, value in info.items()
if re.fullmatch(r"SATA-[0-9]+-[0-9]+", key)}
if (attachments.get("SATA-0-0") != expected_disk
or attachments.get("SATA-1-0") != expected_iso):
raise PrepareError("target media do not match the expected VDI and pinned installer ISO")
extra_attachments = set(attachments) - {"SATA-0-0", "SATA-1-0"}
if any(attachments[key] not in {"none", "emptydrive"} for key in extra_attachments):
raise PrepareError("target media do not match the expected VDI and pinned installer ISO")
def _arguments(*, dry_run: bool) -> tuple[str, ...]:
args = [
"VBoxManage", "unattended", "install", TARGET_UUID,
f"--iso={ISO_PATH}",
"--user=wga", "--full-user-name=WireGuard Admin Lab",
f"--user-password-file={USER_PASSWORD_FILE}",
f"--admin-password-file={ROOT_PASSWORD_FILE}",
"--hostname=wga-m9-debian137.invalid", "--locale=en_US", "--time-zone=UTC",
"--country=US", "--package-selection-adjustment=minimal",
"--install-additions", f"--additions-iso={ADDITIONS_ISO}",
"--start-vm=none",
]
if dry_run:
args.append("--dry-run")
return tuple(args)
def _invoke(runner: Runner, argv: Sequence[str], stage: str) -> None:
try:
result = runner(argv)
except PrepareError as exc:
if str(exc) == "VBoxManage output exceeded the preparation limit":
raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed") from None
raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None
except Exception:
raise PrepareError(f"{stage} failed or timed out; VBoxManage output was suppressed") from None
if result.returncode != 0:
raise PrepareError(f"{stage} failed with status {result.returncode}; VBoxManage output was suppressed")
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
raise PrepareError(f"{stage} output exceeded the preparation limit; output was suppressed")
def prepare_target(*, runner: Runner = run_command, owner_uid: int | None = None) -> str:
owner_uid = os.getuid() if owner_uid is None else owner_uid
_check_private_credentials(owner_uid)
if guard.EXPECTED_ISO_SHA256 != ISO_SHA256:
raise PrepareError("configured installer digest differs from the pinned digest")
_check_digest(ISO_PATH, ISO_SHA256, "installer ISO")
_check_digest(ADDITIONS_ISO, ADDITIONS_SHA256, "Guest Additions ISO")
try:
target_uuid, info = guard.inspect_target(runner=runner)
except (guard.GuardError, OSError, subprocess.TimeoutExpired):
raise PrepareError("read-only exact-target guard failed; refusing preparation") from None
if target_uuid != TARGET_UUID:
raise PrepareError("read-only guard returned an unexpected target UUID")
_validate_preinstall_state(info)
_invoke(runner, _arguments(dry_run=True), "unattended dry run")
try:
_invoke(runner, _arguments(dry_run=False), "unattended preparation")
except PrepareError:
raise PrepareError(
f"unattended preparation stopped or partially completed for {TARGET_UUID}; "
"do not rerun; perform separate read-only inspection before any separately reviewed start"
) from None
return TARGET_UUID
def main() -> int:
try:
target_uuid = prepare_target()
except PrepareError as exc:
print(f"Unattended preparation refused/stopped: {exc}", file=sys.stderr)
return 2
print(
f"Unattended preparation completed for the pinned target ({target_uuid}); "
"VM remains unstarted. Perform separate read-only media inspection before any separately reviewed start."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())