test(e2e): Debian QEMU build+boot+unlock harness and deterministic direct transport
Extend the QEMU e2e to cover the initramfs-tools backend and add a deterministic unlock transport that avoids the flaky public-Tor onion round-trip inside QEMU. - build_image_debian.sh: debootstrap Bookworm, install the real lim/configuration/initramfs-tools hooks, LUKS + cryptsetup-initramfs + dropbear-initramfs, offline onion keys, boot-ok marker; same image.env contract as build_image.sh. - config.py: QemuSpec gains os_family / unlock_command / direct_ssh_port; Debian cmdline uses root=/dev/mapper (crypttab-baked, no cryptdevice=); direct_ssh_port adds hostfwd to guest dropbear and a plain-SSH target. - harness.py: unlock_transport="direct" default, _NullNet, per-OS build script + unlock command, up-front sudo priming with keepalive. - boot_unlock.py: background delivery worker holds the SSH session open; direct vs tor target and initial delay. - test_qemu_harness_unit.py: always-on guards for the Debian/direct branches; test_qemu_unlock_e2e.py parameterized by ARCH/OS/TRANSPORT env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
152
tests/e2e/qemu/build_image_debian.sh
Normal file
152
tests/e2e/qemu/build_image_debian.sh
Normal file
@@ -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" <<EOF
|
||||
LUKS_UUID=$LUKS_UUID
|
||||
MAPPER_NAME=$MAPPER_NAME
|
||||
ONION_ADDRESS=$ONION_ADDRESS
|
||||
IMAGE=$IMAGE
|
||||
KERNEL=$WORK_DIR/kernel
|
||||
INITRAMFS=$WORK_DIR/initramfs
|
||||
EOF
|
||||
echo "Build complete. Onion address: $ONION_ADDRESS"
|
||||
@@ -42,6 +42,15 @@ class QemuSpec:
|
||||
# (build_image.sh also caps --pbkdf-memory to keep this headroom).
|
||||
memory_mb: int = 2048
|
||||
accel: str = "tcg" # "kvm" when the host arch matches and /dev/kvm exists
|
||||
# "arch" (mkinitcpio/encryptssh) or "debian" (initramfs-tools/cryptroot-unlock).
|
||||
os_family: str = "arch"
|
||||
# Remote command the unlock SSH session runs; empty means the session
|
||||
# presents the passphrase prompt itself (Arch encryptssh).
|
||||
unlock_command: str = ""
|
||||
# >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
|
||||
# <client>:<server>:<gw>:<netmask>:<host>:<device>:<proto> — 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.
|
||||
# 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 (
|
||||
# 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/<name>.
|
||||
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"
|
||||
f"ip=::::lim-e2e:eth0:dhcp net.ifnames=0 console=tty0 console={console}"
|
||||
)
|
||||
# The netconf/dropbear-initramfs hooks need the explicit ip= form
|
||||
# <client>:<server>:<gw>:<netmask>:<host>:<device>:<proto> — the device
|
||||
# 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"{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),
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/<name>, 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user