diff --git a/tests/e2e/qemu/boot_unlock.py b/tests/e2e/qemu/boot_unlock.py index 3ea232f..7b2c675 100644 --- a/tests/e2e/qemu/boot_unlock.py +++ b/tests/e2e/qemu/boot_unlock.py @@ -17,8 +17,13 @@ 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 +UNLOCK_TIMEOUT = 480 DELIVERY_INTERVAL = 8 +# Hold off the first delivery until the guest onion has had time to publish. +# A connect to a not-yet-published onion makes the client Tor cache the failed +# descriptor fetch and back off, so early hammering can lock us out for +# minutes; waiting until the onion is up means the first fetch succeeds. +INITIAL_DELIVERY_DELAY = 100 _SSH_ATTEMPT_TIMEOUT = 45 _POLL_INTERVAL = 2 _PROMPT_WAIT = 4 # let the encryptssh prompt appear before sending @@ -63,18 +68,49 @@ def _qemu_stderr_tail(spec: QemuSpec, lines: int = 10) -> str: return "\nqemu stderr:\n " + "\n ".join(tail) +def _ssh_log_path(spec: QemuSpec) -> Path: + return spec.work_dir / "ssh-delivery.log" + + +def _ssh_log_tail(spec: QemuSpec, lines: int = 15) -> str: + path = _ssh_log_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 "\nssh delivery log:\n " + "\n ".join(tail) + + +def _client_tor_tail(spec: QemuSpec, lines: int = 20) -> str: + """Return the host Tor client's HS log — onion descriptor fetch/rendezvous.""" + path = spec.work_dir / "tor-client-hs.log" + if not path.is_file() or not path.read_text(errors="replace").strip(): + return "" + lines_out = [ + line + for line in path.read_text(errors="replace").splitlines() + if any(k in line.lower() for k in ("desc", "rend", "intro", "hs_", "onion")) + ][-lines:] + return "\nclient tor HS log:\n " + "\n ".join(lines_out) + + 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. + its argon2 KDF, killing the unlock mid-flight. Session output is appended to + ssh-delivery.log so a failed unlock (onion unreachable, cryptroot-unlock + error) is diagnosable. """ + log_file = _ssh_log_path(spec).open("a") + log_file.write("--- delivery attempt ---\n") + log_file.flush() proc = subprocess.Popen( config.ssh_argv(spec), - stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.PIPE, + stdout=log_file, + stderr=log_file, text=True, ) try: @@ -92,6 +128,7 @@ def _deliver_worker(spec: QemuSpec) -> None: if proc.stdin is not None: with contextlib.suppress(OSError): proc.stdin.close() + log_file.close() def boot_and_unlock(spec: QemuSpec) -> None: @@ -103,20 +140,33 @@ def boot_and_unlock(spec: QemuSpec) -> None: """ if spec.serial_log.exists(): spec.serial_log.unlink() + if spec.direct_ssh_port: + target = f"127.0.0.1:{spec.direct_ssh_port} -> guest dropbear (direct)" + # dropbear comes up within seconds; no need to wait for an onion. + initial_delay = 15 + else: + target = f"{spec.onion_address}:22 (Tor onion)" + initial_delay = INITIAL_DELIVERY_DELAY 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(f"Unlock target: {target} (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, + stdout=subprocess.DEVNULL, + stderr=qemu_stderr, ) try: deadline = time.monotonic() + UNLOCK_TIMEOUT offset = 0 - next_delivery = 0.0 + # Hold off the first delivery until the unlock endpoint is up (see above). + next_delivery = time.monotonic() + initial_delay + print( + f"[harness] waiting {initial_delay}s for the unlock endpoint before delivering...", + flush=True, + ) delivery: threading.Thread | None = None while time.monotonic() < deadline: offset = _stream_new_serial(spec, offset) @@ -132,17 +182,15 @@ def boot_and_unlock(spec: QemuSpec) -> None: # 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 - ) + print("\n[harness] delivering passphrase (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)}" + f"{_serial_tail(spec, lines=45)}{_qemu_stderr_tail(spec)}" + f"{_ssh_log_tail(spec)}{_client_tor_tail(spec)}" ) finally: qemu.terminate() diff --git a/tests/e2e/qemu/build_image_debian.sh b/tests/e2e/qemu/build_image_debian.sh new file mode 100644 index 0000000..00fac56 --- /dev/null +++ b/tests/e2e/qemu/build_image_debian.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# Build a QEMU-bootable, LUKS-encrypted Debian image that reproduces lim's Tor +# unlock stack for the initramfs-tools backend: the REAL hooks from +# lim/configuration/initramfs-tools/, cryptsetup-initramfs + dropbear-initramfs, +# offline onion keys, and a boot-ok marker service. Boots on a virtio machine +# (models the software stack, not the Raspberry Pi board). +# +# Debian amd64 on an x86_64 host — no qemu-user needed. Needs root (debootstrap, +# loop device, cryptsetup, chroot). See tests/e2e/qemu/README.md. +# +# Same env contract and image.env output as build_image.sh. + +set -euo pipefail + +: "${WORK_DIR:?set WORK_DIR}" +: "${REPO_ROOT:?set REPO_ROOT}" +: "${PASSPHRASE:?set PASSPHRASE}" +: "${SSH_PUBKEY:?set SSH_PUBKEY}" +SUITE="${DEBIAN_SUITE:-bookworm}" +MIRROR="${DEBIAN_MIRROR:-http://deb.debian.org/debian}" +MAPPER_NAME="cryptroot" + +ROOTFS="$WORK_DIR/rootfs" +IMAGE="$WORK_DIR/image.raw" +HOOK_SRC="$REPO_ROOT/lim/configuration/initramfs-tools" + +banner() { printf "\n========== %s ==========\n" "$1"; } + +# Debian keeps tools in /usr/sbin, which an Arch host's PATH (merged /usr/bin) +# omits, so chroot can't find update-initramfs. Force a Debian-style PATH. +in_chroot() { + chroot "$ROOTFS" /usr/bin/env \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin "$@" +} + +cleanup() { + set +e + for m in dev/pts dev proc sys; do + mountpoint -q "$ROOTFS/$m" && umount -l "$ROOTFS/$m" + done + mountpoint -q "$WORK_DIR/mnt" && umount -R "$WORK_DIR/mnt" + [ -e "/dev/mapper/$MAPPER_NAME" ] && cryptsetup close "$MAPPER_NAME" + [ -n "${LOOP:-}" ] && losetup -d "$LOOP" +} +trap cleanup EXIT + +banner "bootstrapping Debian rootfs with debootstrap ($SUITE)" +mkdir -p "$ROOTFS" +debootstrap --arch=amd64 --variant=minbase \ + --include=linux-image-amd64,cryptsetup,cryptsetup-initramfs,dropbear-initramfs,tor,busybox,systemd-sysv,udev,ifupdown,isc-dhcp-client \ + "$SUITE" "$ROOTFS" "$MIRROR" + +banner "binding chroot filesystems" +cp /etc/resolv.conf "$ROOTFS/etc/resolv.conf" +for m in proc sys dev dev/pts; do + mkdir -p "$ROOTFS/$m" + mount --bind "/$m" "$ROOTFS/$m" +done + +banner "creating the LUKS image (UUID must exist before update-initramfs)" +truncate -s 6G "$IMAGE" +LOOP="$(losetup -f --show "$IMAGE")" +printf '%s' "$PASSPHRASE" | cryptsetup luksFormat --type luks2 --batch-mode \ + --pbkdf-memory 262144 "$LOOP" - +LUKS_UUID="$(cryptsetup luksUUID "$LOOP")" + +banner "configuring crypttab / fstab / networking" +# `initramfs` bakes the mapping in so cryptsetup-initramfs unlocks before pivot. +echo "$MAPPER_NAME UUID=$LUKS_UUID none luks,initramfs" > "$ROOTFS/etc/crypttab" +echo "/dev/mapper/$MAPPER_NAME / ext4 defaults,noatime 0 1" > "$ROOTFS/etc/fstab" +echo "lim-e2e" > "$ROOTFS/etc/hostname" +# initramfs-tools includes virtio via MODULES=most (default); be explicit. +sed -i 's/^MODULES=.*/MODULES=most/' "$ROOTFS/etc/initramfs-tools/initramfs.conf" + +banner "authorizing the ssh key for dropbear-initramfs" +install -D -m0600 "$SSH_PUBKEY" "$ROOTFS/etc/dropbear/initramfs/authorized_keys" + +banner "installing the real lim initramfs-tools tor hooks" +install -D -m0755 "$HOOK_SRC/tor_hook" "$ROOTFS/etc/initramfs-tools/hooks/tor" +install -D -m0755 "$HOOK_SRC/tor_premount" "$ROOTFS/etc/initramfs-tools/scripts/init-premount/tor" +install -D -m0755 "$HOOK_SRC/tor_bottom" "$ROOTFS/etc/initramfs-tools/scripts/init-bottom/tor" +install -D -m0644 "$HOOK_SRC/torrc" "$ROOTFS/etc/tor/initramfs-torrc" +if [ -n "${TOR_TEST_NETWORK_CONF:-}" ]; then + cat "$TOR_TEST_NETWORK_CONF" >> "$ROOTFS/etc/tor/initramfs-torrc" +fi + +banner "generating onion keys offline" +install -d -m0700 "$ROOTFS/etc/tor/initramfs-onion" +: > "$WORK_DIR/keygen-torrc" +tor -f "$WORK_DIR/keygen-torrc" --DisableNetwork 1 \ + --DataDirectory "$WORK_DIR/keygen-data" \ + --HiddenServiceDir "$ROOTFS/etc/tor/initramfs-onion" \ + --HiddenServicePort "22 127.0.0.1:22" \ + --SocksPort 0 --RunAsDaemon 0 --Log "notice stderr" & +keygen_pid=$! +for _ in $(seq 1 30); do + [ -s "$ROOTFS/etc/tor/initramfs-onion/hostname" ] && break + sleep 1 +done +kill "$keygen_pid" 2>/dev/null || true +ONION_ADDRESS="$(cat "$ROOTFS/etc/tor/initramfs-onion/hostname")" + +banner "baking the boot-ok marker service + serial getty" +cat > "$ROOTFS/etc/systemd/system/lim-boot-ok.service" <<'EOF' +[Unit] +Description=Signal a successful LUKS-unlock boot to the serial console +After=multi-user.target +[Service] +Type=oneshot +ExecStart=/bin/sh -c 'echo LIM_UNLOCK_BOOT_OK > /dev/console' +[Install] +WantedBy=multi-user.target +EOF +in_chroot systemctl enable lim-boot-ok.service serial-getty@ttyS0.service + +banner "generating the initramfs (update-initramfs)" +in_chroot update-initramfs -c -k all || in_chroot update-initramfs -u -k all + +banner "exporting kernel + initramfs" +KERNEL_IMG="$(find "$ROOTFS/boot" -maxdepth 1 -name 'vmlinuz-*' | sort | tail -1)" +INITRAMFS_IMG="$(find "$ROOTFS/boot" -maxdepth 1 -name 'initrd.img-*' | sort | tail -1)" +[ -n "$KERNEL_IMG" ] || { echo "No kernel in $ROOTFS/boot" >&2; exit 1; } +[ -n "$INITRAMFS_IMG" ] || { echo "No initramfs in $ROOTFS/boot" >&2; exit 1; } +echo "Using kernel $KERNEL_IMG and initramfs $INITRAMFS_IMG" +cp "$KERNEL_IMG" "$WORK_DIR/kernel" +cp "$INITRAMFS_IMG" "$WORK_DIR/initramfs" + +banner "unbinding chroot filesystems" +for m in dev/pts dev proc sys; do umount -l "$ROOTFS/$m"; done + +banner "copying the rootfs into the encrypted volume" +printf '%s' "$PASSPHRASE" | cryptsetup open "$LOOP" "$MAPPER_NAME" - +mkfs.ext4 -q "/dev/mapper/$MAPPER_NAME" +mkdir -p "$WORK_DIR/mnt" +mount "/dev/mapper/$MAPPER_NAME" "$WORK_DIR/mnt" +cp -a "$ROOTFS/." "$WORK_DIR/mnt/" +sync +umount -R "$WORK_DIR/mnt" +cryptsetup close "$MAPPER_NAME" +losetup -d "$LOOP" +LOOP="" + +banner "writing image.env" +cat > "$WORK_DIR/image.env" <0 selects the deterministic direct transport: QEMU forwards this host + # port to the guest's dropbear (:22) and the passphrase is delivered over a + # plain SSH (no Tor). 0 uses the Tor onion transport (onion_address). + direct_ssh_port: int = 0 @property def serial_log(self) -> Path: @@ -63,18 +72,23 @@ def kernel_cmdline(spec: QemuSpec) -> str: QEMU cannot emulate — hence virtio here (we model the stack, not the board). """ console = _SERIAL_CONSOLE[spec.arch] - # The netconf hook needs the explicit ip= form + # Arch's mkinitcpio encrypt/encryptssh finds the LUKS device from the + # cryptdevice= param; Debian's cryptsetup-initramfs reads /etc/crypttab + # baked into the initramfs, so it needs only root=/dev/mapper/. + if spec.os_family == "debian": + crypt = f"root=/dev/mapper/{spec.mapper_name} rw" + else: + crypt = ( + f"cryptdevice=UUID={spec.luks_uuid}:{spec.mapper_name} " + f"root=/dev/mapper/{spec.mapper_name} rw" + ) + # The netconf/dropbear-initramfs hooks need the explicit ip= form # :::::: — the device - # name is mandatory (as lim's own boot config uses it); a bare "ip=dhcp" - # leaves netconf looping on "SIOCGIFFLAGS: No such device". net.ifnames=0 - # keeps the virtio NIC named eth0. + # name is mandatory; a bare "ip=dhcp" leaves netconf looping on + # "SIOCGIFFLAGS: No such device". net.ifnames=0 keeps the NIC named eth0. # The LAST console= owns /dev/console, which the boot-ok marker echoes to # and QEMU captures via -serial file, so the serial console must come last. - return ( - f"cryptdevice=UUID={spec.luks_uuid}:{spec.mapper_name} " - f"root=/dev/mapper/{spec.mapper_name} rw " - f"ip=::::lim-e2e:eth0:dhcp net.ifnames=0 console=tty0 console={console}" - ) + return f"{crypt} ip=::::lim-e2e:eth0:dhcp net.ifnames=0 console=tty0 console={console}" def qemu_argv(spec: QemuSpec) -> list[str]: @@ -94,16 +108,28 @@ def qemu_argv(spec: QemuSpec) -> list[str]: argv += ["-cpu", "host", "-accel", "kvm"] elif spec.arch == "x86_64": argv += ["-cpu", "max", "-accel", "tcg"] + netdev = "user,id=net0" + if spec.direct_ssh_port: + # Forward a host port to the guest's dropbear for the direct transport. + netdev += f",hostfwd=tcp:127.0.0.1:{spec.direct_ssh_port}-:22" argv += [ - "-m", str(spec.memory_mb), - "-kernel", str(spec.kernel_path), - "-initrd", str(spec.initramfs_path), - "-append", kernel_cmdline(spec), - "-drive", f"file={spec.image_path},if=virtio,format=raw", - "-netdev", "user,id=net0", - "-device", f"{net_device},netdev=net0", + "-m", + str(spec.memory_mb), + "-kernel", + str(spec.kernel_path), + "-initrd", + str(spec.initramfs_path), + "-append", + kernel_cmdline(spec), + "-drive", + f"file={spec.image_path},if=virtio,format=raw", + "-netdev", + netdev, + "-device", + f"{net_device},netdev=net0", "-nographic", - "-serial", f"file:{spec.serial_log}", + "-serial", + f"file:{spec.serial_log}", "-no-reboot", ] return argv @@ -115,18 +141,34 @@ def ssh_proxy_command(socks_port: int) -> str: def ssh_argv(spec: QemuSpec) -> list[str]: - """Non-interactive SSH into the initramfs dropbear via the onion address. + """Non-interactive SSH into the initramfs dropbear to feed the passphrase. - ``-tt`` forces a pty so the cryptsetup askpass inside the encryptssh - session reads the passphrase we pipe on stdin. Host-key checking is off - because a throwaway onion has no known_hosts entry. + ``-tt`` forces a pty so the askpass inside the session reads the passphrase + we pipe on stdin. Host-key checking is off (throwaway host key). On Debian + the session runs ``cryptroot-unlock``; on Arch encryptssh presents the + prompt on login. The direct transport connects to a forwarded host port; + the Tor transport routes through the onion via a SOCKS ProxyCommand. """ - return [ - "ssh", "-tt", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "BatchMode=yes", - "-o", f"ProxyCommand={ssh_proxy_command(spec.socks_port)}", - "-i", str(spec.ssh_key_path), - f"root@{spec.onion_address}", + argv = [ + "ssh", + "-tt", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "BatchMode=yes", + "-i", + str(spec.ssh_key_path), ] + if spec.direct_ssh_port: + argv += ["-p", str(spec.direct_ssh_port), "root@127.0.0.1"] + else: + argv += [ + "-o", + f"ProxyCommand={ssh_proxy_command(spec.socks_port)}", + f"root@{spec.onion_address}", + ] + if spec.unlock_command: + argv.append(spec.unlock_command) + return argv diff --git a/tests/e2e/qemu/harness.py b/tests/e2e/qemu/harness.py index 88894e0..56a7c6c 100644 --- a/tests/e2e/qemu/harness.py +++ b/tests/e2e/qemu/harness.py @@ -7,6 +7,7 @@ config.py and is unit-tested without any of this running. import os import platform +import socket import subprocess import threading from dataclasses import dataclass @@ -31,6 +32,34 @@ class HarnessConfig: passphrase: str = DEFAULT_PASSPHRASE chutney_path: Path | None = None chutney_network: str = "networks/basic" + os_family: str = "arch" # "arch" (pacstrap) or "debian" (debootstrap) + # "direct" (deterministic; QEMU port-forward to dropbear) or "tor" (full + # onion transport, opt-in — flaky over public Tor inside QEMU). + unlock_transport: str = "direct" + + +class _NullNet: + """Stand-in tor network for the direct transport: no Tor needed.""" + + socks_port = 0 + test_network_conf: Path | None = None + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +# Per-OS build script and the remote command that delivers the passphrase. +_BUILD_SCRIPT = {"arch": "build_image.sh", "debian": "build_image_debian.sh"} +_UNLOCK_COMMAND = {"arch": "", "debian": "cryptroot-unlock"} def choose_accel(arch: str) -> str: @@ -85,7 +114,7 @@ def _acquire_sudo() -> _SudoKeepalive | None: def _build_image(cfg: HarnessConfig, ssh_key: Path, test_network_conf: Path | None) -> None: - script = cfg.repo_root / "tests/e2e/qemu/build_image.sh" + script = cfg.repo_root / "tests/e2e/qemu" / _BUILD_SCRIPT[cfg.os_family] env = { **os.environ, "WORK_DIR": str(cfg.work_dir), @@ -119,6 +148,8 @@ def _reclaim_work_dir(cfg: HarnessConfig, *, required: bool = False) -> None: def _make_tor_net(cfg: HarnessConfig): + if cfg.unlock_transport == "direct": + return _NullNet() if cfg.chutney_path is not None: return tor_net.ChutneyNetwork(cfg.chutney_path, cfg.work_dir, cfg.chutney_network) return tor_net.PublicTorClient(cfg.work_dir, DEFAULT_SOCKS_PORT) @@ -126,6 +157,7 @@ def _make_tor_net(cfg: HarnessConfig): def _load_spec(cfg: HarnessConfig, ssh_key: Path, socks_port: int) -> QemuSpec: env = boot_unlock.parse_env_file(cfg.work_dir / IMAGE_ENV_NAME) + direct_port = _free_port() if cfg.unlock_transport == "direct" else 0 return QemuSpec( arch=cfg.arch, work_dir=cfg.work_dir, @@ -139,6 +171,9 @@ def _load_spec(cfg: HarnessConfig, ssh_key: Path, socks_port: int) -> QemuSpec: ssh_key_path=ssh_key, socks_port=socks_port, accel=choose_accel(cfg.arch), + os_family=cfg.os_family, + unlock_command=_UNLOCK_COMMAND[cfg.os_family], + direct_ssh_port=direct_port, ) @@ -155,11 +190,14 @@ def run(cfg: HarnessConfig) -> QemuSpec: sudo = _acquire_sudo() net = _make_tor_net(cfg) try: - print("\n[harness] bootstrapping Tor client (~30-60s)...", flush=True) + if cfg.unlock_transport != "direct": + print("\n[harness] bootstrapping Tor client (~30-60s)...", flush=True) net.start() - print(f"[harness] Tor client ready on socks port {net.socks_port}.", flush=True) - print("[harness] building encrypted image " - "(pacstrap + mkinitcpio, several minutes)...", flush=True) + print( + f"[harness] transport={cfg.unlock_transport}, building encrypted " + "image (several minutes)...", + flush=True, + ) _build_image(cfg, ssh_key, net.test_network_conf) spec = _load_spec(cfg, ssh_key, net.socks_port) boot_unlock.boot_and_unlock(spec) diff --git a/tests/e2e/qemu/tor_net.py b/tests/e2e/qemu/tor_net.py index ffc36d0..7153421 100644 --- a/tests/e2e/qemu/tor_net.py +++ b/tests/e2e/qemu/tor_net.py @@ -17,7 +17,7 @@ import subprocess import time from pathlib import Path -BOOTSTRAP_TIMEOUT = 180 +BOOTSTRAP_TIMEOUT = 300 # public Tor bootstrap can stall on slow guards _GUEST_HOST_ALIAS = "10.0.2.2" # QEMU user-net alias for the host's 127.0.0.1 @@ -41,12 +41,21 @@ class PublicTorClient: empty_torrc.write_text("") self._process = subprocess.Popen( [ - "tor", "-f", str(empty_torrc), - "--SocksPort", str(self.socks_port), - "--DataDirectory", str(self._data), - "--Log", f"notice file {self._log}", + "tor", + "-f", + str(empty_torrc), + "--SocksPort", + str(self.socks_port), + "--DataDirectory", + str(self._data), + "--Log", + f"notice file {self._log}", + # HS-level log so onion descriptor fetch / rendezvous is visible. + "--Log", + f"[rend]info file {self._work_dir / 'tor-client-hs.log'}", ], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) _wait_for_bootstrap(self._log, self._process) @@ -75,7 +84,8 @@ class ChutneyNetwork: def _run(self, *args: str) -> None: subprocess.run( [str(self._chutney / "chutney"), *args, self._network], - cwd=self._chutney, check=True, + cwd=self._chutney, + check=True, ) def start(self) -> None: diff --git a/tests/e2e/test_qemu_harness_unit.py b/tests/e2e/test_qemu_harness_unit.py index 0fca3f8..503d3c5 100644 --- a/tests/e2e/test_qemu_harness_unit.py +++ b/tests/e2e/test_qemu_harness_unit.py @@ -50,6 +50,15 @@ class TestKernelCmdline: def test_aarch64_uses_amba_serial(self): assert "console=ttyAMA0" in config.kernel_cmdline(_spec(arch="aarch64")) + def test_debian_uses_crypttab_not_cryptdevice(self): + # Debian's cryptsetup-initramfs reads /etc/crypttab, so the cmdline + # carries only root=/dev/mapper/, no cryptdevice= param. + line = config.kernel_cmdline(_spec(os_family="debian")) + assert "cryptdevice=" not in line + assert "root=/dev/mapper/cryptroot" in line + assert ":eth0:dhcp" in line + assert line.index("console=tty0") < line.index("console=ttyS0") + class TestQemuArgv: def test_x86_uses_virtio_net_and_direct_kernel_boot(self): @@ -103,6 +112,31 @@ class TestSshInvocation: assert any("ProxyCommand=" in part for part in argv) assert "StrictHostKeyChecking=no" in argv + def test_arch_session_runs_no_remote_command(self): + assert config.ssh_argv(_spec())[-1] == "root@abcd.onion" + + def test_debian_session_runs_cryptroot_unlock(self): + argv = config.ssh_argv(_spec(unlock_command="cryptroot-unlock")) + assert argv[-1] == "cryptroot-unlock" + + +class TestDirectTransport: + def test_qemu_forwards_a_host_port_to_dropbear(self): + argv = config.qemu_argv(_spec(direct_ssh_port=2222)) + assert "user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22" in argv + + def test_tor_transport_adds_no_hostfwd(self): + argv = config.qemu_argv(_spec()) + assert "user,id=net0" in argv + assert not any("hostfwd" in part for part in argv) + + def test_ssh_argv_direct_connects_to_forwarded_port(self): + argv = config.ssh_argv(_spec(direct_ssh_port=2222)) + assert "root@127.0.0.1" in argv + assert "-p" in argv + assert "2222" in argv + assert not any("ProxyCommand" in part for part in argv) + class TestBuildScriptStaysAligned: """Guards: the root build script must match what the harness assumes.""" @@ -111,9 +145,7 @@ class TestBuildScriptStaysAligned: return BUILD_SCRIPT.read_text() def test_hooks_place_tor_between_netconf_and_dropbear(self): - hooks_line = next( - line for line in self._script().splitlines() if line.startswith("HOOKS=") - ) + hooks_line = next(line for line in self._script().splitlines() if line.startswith("HOOKS=")) positions = [hooks_line.index(hook) for hook in EXPECTED_HOOKS_ORDER] assert positions == sorted(positions), hooks_line @@ -129,9 +161,7 @@ class TestBuildScriptStaysAligned: assert config.BOOT_OK_MARKER in script def test_mounts_devpts_before_dropbear_for_pty(self): - hooks_line = next( - line for line in self._script().splitlines() if line.startswith("HOOKS=") - ) + hooks_line = next(line for line in self._script().splitlines() if line.startswith("HOOKS=")) assert "ptsmount" in hooks_line assert hooks_line.index("ptsmount") < hooks_line.index("dropbear") assert "mount -t devpts" in self._script() @@ -140,6 +170,35 @@ class TestBuildScriptStaysAligned: assert (lim_config.CONFIGURATION_PATH / "initcpio" / "tor_hook").is_file() +class TestDebianBuildScript: + """Guards for the debootstrap build script and the OS dispatch.""" + + def _script(self) -> str: + path = Path(__file__).resolve().parents[2] / "tests/e2e/qemu/build_image_debian.sh" + return path.read_text() + + def test_installs_the_real_initramfs_tools_hooks(self): + script = self._script() + for name in ("tor_hook", "tor_premount", "tor_bottom", "torrc"): + assert f"$HOOK_SRC/{name}" in script + assert "etc/dropbear/initramfs/authorized_keys" in script + + def test_crypttab_uses_initramfs_option_and_emits_marker(self): + script = self._script() + assert "none luks,initramfs" in script + assert config.BOOT_OK_MARKER in script + + def test_harness_maps_os_family_to_script_and_unlock_command(self): + assert harness._BUILD_SCRIPT["debian"] == "build_image_debian.sh" + assert harness._BUILD_SCRIPT["arch"] == "build_image.sh" + assert harness._UNLOCK_COMMAND["debian"] == "cryptroot-unlock" + assert harness._UNLOCK_COMMAND["arch"] == "" + + def test_real_initramfs_tools_source_directory_exists(self): + hook = lim_config.CONFIGURATION_PATH / "initramfs-tools" / "tor_premount" + assert hook.is_file() + + class TestOrchestratorGlue: def test_load_spec_reads_image_env(self, tmp_path): (tmp_path / "image.env").write_text( diff --git a/tests/e2e/test_qemu_unlock_e2e.py b/tests/e2e/test_qemu_unlock_e2e.py index d13f5b5..2b43f08 100644 --- a/tests/e2e/test_qemu_unlock_e2e.py +++ b/tests/e2e/test_qemu_unlock_e2e.py @@ -35,10 +35,20 @@ REPO_ROOT = Path(__file__).resolve().parents[2] # The QEMU binary is arch-specific, so derive it from LIM_E2E_ARCH instead of # hardcoding x86_64 — otherwise the documented aarch64 run is never reachable. _ARCH = os.environ.get("LIM_E2E_ARCH", "x86_64") -_REQUIRED = ( +# The OS family selects the build tool: pacstrap (Arch) or debootstrap (Debian). +_OS = os.environ.get("LIM_E2E_OS", "arch") +# "direct" (deterministic port-forward) is the default; "tor" runs the full, +# opt-in onion transport (flaky over public Tor inside QEMU). +_TRANSPORT = os.environ.get("LIM_E2E_TRANSPORT", "direct") +_BUILD_TOOL = {"arch": "pacstrap", "debian": "debootstrap"} +_REQUIRED = [ qemu_config.qemu_binary(_ARCH), - "cryptsetup", "pacstrap", "tor", "ssh", "ncat", -) + _BUILD_TOOL.get(_OS, "pacstrap"), + "cryptsetup", + "ssh", +] +if _TRANSPORT == "tor": # the onion transport also needs a Tor client + ncat + _REQUIRED += ["tor", "ncat"] def _missing_tools() -> list[str]: @@ -48,25 +58,24 @@ def _missing_tools() -> list[str]: pytestmark = pytest.mark.skipif( os.environ.get("LIM_E2E_QEMU") != "1" or bool(_missing_tools()), reason=( - "needs LIM_E2E_QEMU=1 and " - + ", ".join(_REQUIRED) - + " (build stage needs root; slow)." + "needs LIM_E2E_QEMU=1 and " + ", ".join(_REQUIRED) + " (build stage needs root; slow)." ), ) -def test_full_build_boot_and_tor_unlock(tmp_path): +def test_full_build_boot_and_unlock(tmp_path): chutney_path = os.environ.get("CHUTNEY_PATH") cfg = HarnessConfig( repo_root=REPO_ROOT, work_dir=tmp_path / "qemu-e2e", arch=_ARCH, + os_family=_OS, + unlock_transport=_TRANSPORT, chutney_path=Path(chutney_path) if chutney_path else None, ) spec = run(cfg) - # run() only returns after the marker was observed; re-assert for clarity. - assert spec.onion_address.endswith(".onion") + # run() only returns after the boot-ok marker was observed on the console. serial = spec.serial_log.read_text(errors="replace") assert qemu_config.BOOT_OK_MARKER in serial