Files
linux-image-manager/tests/e2e/qemu/boot_unlock.py
Kevin Veen-Birkenbach 2c3b29ee98 test(e2e): full virtualized build+boot+unlock QEMU harness
Opt-in via LIM_E2E_QEMU=1. Models the whole process on a virtio VM (the
Pi's USB-gadget net can't be emulated, so it models the software stack,
not the board): builds a LUKS image carrying the real lim initcpio Tor
artifacts, boots it in QEMU rootless, lets the real netconf/tor/dropbear/
encryptssh chain publish the onion, delivers the passphrase over Tor, and
asserts the boot-ok marker on the serial console. Supports a private
offline Tor network via chutney.

The pure command builders (config.qemu_argv/kernel_cmdline/ssh_argv,
qemu_binary), the env parser, and drift guards that keep build_image.sh
aligned with the harness run in the normal suite — no QEMU/root/network.

The build stage needs root; harness.py primes sudo up front, keeps the
credential warm, and reclaims work-dir ownership on every exit path so
the pytest tmp cleanup never trips on root-owned files. QEMU stderr is
captured so an early exit is debuggable, and each teardown step is
fault-isolated so none masks the real error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:12:35 +02:00

165 lines
6.3 KiB
Python

"""Boot a built image in QEMU and unlock its LUKS root over Tor.
This is the live half of the harness. QEMU runs rootless (user-mode net);
the passphrase is delivered exactly as an operator would: SSH through Tor to
the initramfs dropbear, piping the passphrase into the encryptssh prompt.
Success is observed on the serial console, not inferred.
"""
import contextlib
import subprocess
import threading
import time
from pathlib import Path
from tests.e2e.qemu import config
from tests.e2e.qemu.config import QemuSpec
# One generous budget covers guest boot + tor bootstrap + onion publish +
# unlock. TCG (no KVM) is slow, so keep it roomy.
UNLOCK_TIMEOUT = 420
DELIVERY_INTERVAL = 8
_SSH_ATTEMPT_TIMEOUT = 45
_POLL_INTERVAL = 2
_PROMPT_WAIT = 4 # let the encryptssh prompt appear before sending
def _serial_contains(spec: QemuSpec, needle: str) -> bool:
log = spec.serial_log
return log.is_file() and needle in log.read_text(errors="replace")
def _stream_new_serial(spec: QemuSpec, offset: int) -> int:
"""Print serial output written since ``offset``; return the new offset.
Streams the guest's boot messages (netconf, the tor hook, dropbear,
encryptssh) live so the operator sees the VM working, not a black box.
"""
log = spec.serial_log
if not log.is_file():
return offset
data = log.read_bytes()
if len(data) > offset:
print(data[offset:].decode(errors="replace"), end="", flush=True)
return len(data)
def _serial_tail(spec: QemuSpec, lines: int = 15) -> str:
if not spec.serial_log.is_file():
return "(no serial output captured)"
tail = spec.serial_log.read_text(errors="replace").splitlines()[-lines:]
return "serial tail:\n " + "\n ".join(tail)
def _qemu_stderr_path(spec: QemuSpec) -> Path:
return spec.work_dir / "qemu.stderr.log"
def _qemu_stderr_tail(spec: QemuSpec, lines: int = 10) -> str:
path = _qemu_stderr_path(spec)
if not path.is_file() or not path.read_text(errors="replace").strip():
return ""
tail = path.read_text(errors="replace").splitlines()[-lines:]
return "\nqemu stderr:\n " + "\n ".join(tail)
def _deliver_worker(spec: QemuSpec) -> None:
"""Feed the passphrase over an SSH-through-Tor session, held open.
Runs in a background thread so the main loop keeps streaming serial. The
channel is deliberately kept OPEN after sending: closing stdin immediately
(as a plain pipe does) tears the session down before cryptsetup finishes
its argon2 KDF, killing the unlock mid-flight. We wait for the remote to
close it once the boot proceeds and dropbear is torn down.
"""
proc = subprocess.Popen(
config.ssh_argv(spec),
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
text=True,
)
try:
time.sleep(_PROMPT_WAIT)
if proc.poll() is None and proc.stdin is not None:
try:
proc.stdin.write(spec.passphrase + "\n")
proc.stdin.flush()
except OSError:
pass
proc.wait(timeout=_SSH_ATTEMPT_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
finally:
if proc.stdin is not None:
with contextlib.suppress(OSError):
proc.stdin.close()
def boot_and_unlock(spec: QemuSpec) -> None:
"""Boot the image, unlock it over Tor, and confirm the real system booted.
Returns normally only when the boot-ok marker appears on the serial
console; the passphrase is (re)delivered on each poll until then. Raises
with the serial tail on QEMU death or timeout.
"""
if spec.serial_log.exists():
spec.serial_log.unlink()
print(f"\n========== booting in QEMU ({spec.arch}, accel={spec.accel}) ==========")
print(f"Target onion: {spec.onion_address}:22 (unlock budget {UNLOCK_TIMEOUT}s)")
print("---- guest serial console ----", flush=True)
# QEMU's own diagnostics (bad -machine/-cpu, /dev/kvm denied, image busy)
# go to stderr; capture them so an early exit is debuggable, not opaque.
qemu_stderr = _qemu_stderr_path(spec).open("wb")
qemu = subprocess.Popen(
config.qemu_argv(spec),
stdout=subprocess.DEVNULL, stderr=qemu_stderr,
)
try:
deadline = time.monotonic() + UNLOCK_TIMEOUT
offset = 0
next_delivery = 0.0
delivery: threading.Thread | None = None
while time.monotonic() < deadline:
offset = _stream_new_serial(spec, offset)
if _serial_contains(spec, config.BOOT_OK_MARKER):
print("\n[harness] boot-ok marker seen — LUKS root unlocked and booted.")
return
if qemu.poll() is not None:
raise RuntimeError(
f"QEMU exited early (code {qemu.returncode}).\n"
f"{_serial_tail(spec)}{_qemu_stderr_tail(spec)}"
)
# One delivery at a time; each holds its SSH session open in the
# background while we keep streaming serial and watching the marker.
idle = delivery is None or not delivery.is_alive()
if idle and time.monotonic() >= next_delivery:
print(f"\n[harness] delivering passphrase over Tor to "
f"{spec.onion_address}:22 (holding session open) ...", flush=True)
delivery = threading.Thread(
target=_deliver_worker, args=(spec,), daemon=True
)
delivery.start()
next_delivery = time.monotonic() + DELIVERY_INTERVAL
time.sleep(_POLL_INTERVAL)
raise RuntimeError(
f"Boot-ok marker never appeared within {UNLOCK_TIMEOUT}s.\n"
f"{_serial_tail(spec)}{_qemu_stderr_tail(spec)}"
)
finally:
qemu.terminate()
try:
qemu.wait(timeout=15)
except subprocess.TimeoutExpired:
qemu.kill()
qemu.wait(timeout=10)
qemu_stderr.close()
def parse_env_file(path: Path) -> dict[str, str]:
"""Read the KEY=VALUE image.env written by build_image.sh into a dict."""
result: dict[str, str] = {}
for line in path.read_text().splitlines():
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
result[key.strip()] = value.strip()
return result