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>
165 lines
6.3 KiB
Python
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
|