- test.yml: add a qemu-e2e-debian job (build -> boot -> LUKS-unlock in QEMU via the deterministic direct transport) running on push/PR, plus a workflow_dispatch trigger for manual runs. - Makefile: test-qemu-debian target (LIM_E2E_OS=debian); document the direct vs tor transport on test-qemu. - README: document the two build scripts and the direct transport. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.2 KiB
Virtualized end-to-end Tor unlock harness
This harness models the entire encrypted-image process in a virtual
machine: it builds a LUKS image carrying the real lim Tor-in-initramfs
artifacts, boots it in QEMU, lets the real netconf → tor → dropbear → encryptssh chain run in early userspace, then unlocks the root over Tor and
proves the real system booted.
It complements the lightweight, always-rootless
test_tor_unlock_e2e.py: that one round-trips a
passphrase through Tor against a dropbear stand-in; this one runs the real
dropbear, real cryptsetup, real mkinitcpio initramfs, and a real tor daemon in
a booted machine.
Why a virtio machine and not an emulated Raspberry Pi
QEMU's raspi3b/raspi4b machines do not emulate the Pi's USB-gadget
Ethernet — exactly the network path the Pi images use in the initramfs
(g_ether/smsc95xx/lan78xx). Without early networking there is no Tor and
no unlock, so emulating the literal board is pointless here. Instead we
reproduce the same software stack (same HOOKS chain, same tor hook, same
torrc and onion keys) on a QEMU-friendly virt/q35 machine with
virtio-net. The only deltas from a real Pi image are the NIC driver and the
CPU architecture.
Stages
| Stage | File | Privilege |
|---|---|---|
| Build encrypted image (Arch) | build_image.sh |
root (loop, cryptsetup, chroot) |
| Build encrypted image (Debian) | build_image_debian.sh |
root (debootstrap, loop, cryptsetup, chroot) |
| Tor network | tor_net.py |
rootless |
| Boot + unlock | boot_unlock.py |
rootless (QEMU user-mode net) |
| Orchestration | harness.py |
mixed (uses sudo for the build only) |
| Pure command builders | config.py |
none — unit-tested offline |
Keeping the host rootless
Only build_image.sh needs root (there is no rootless dm-crypt). To keep your
host unprivileged, run the whole harness inside a throwaway VM that has
/dev/kvm, and drive it from there. QEMU itself, the tor client and the unlock
are rootless: user-mode networking (-netdev user) needs no tap device, and a
Tor onion service needs no inbound port forward (its rendezvous is outbound
from both ends).
When run directly on the host, harness.py invokes the build via sudo -E
and then chowns the artifacts back so rootless QEMU can read them.
Requirements
qemu-system-x86_64(or-aarch64forLIM_E2E_ARCH=aarch64)arch-install-scripts(pacstrap,arch-chroot)cryptsetup,mkinitcpio,tor,openssh,ncat(SOCKS5 ProxyCommand)- Network access to the public Tor network, or
chutneyfor a private one /dev/kvmrecommended (TCG works but is slow; aarch64-on-x86 is always TCG)
For the Debian build (LIM_E2E_OS=debian), additionally: debootstrap and
network access to a Debian mirror. The guest uses the initramfs-tools backend
(cryptsetup-initramfs + dropbear-initramfs), so the unlock SSH session runs
cryptroot-unlock instead of Arch's login-time encryptssh prompt.
Unlock transport: direct vs Tor
LIM_E2E_TRANSPORT (default direct) selects how the passphrase reaches the
guest's dropbear:
- direct — QEMU forwards a host port to the guest's dropbear (
:22) and the passphrase is delivered over a plain SSH. Deterministic; it verifies the whole LUKS-unlock stack (cryptsetup-initramfs + dropbear-initramfs + cryptroot-unlock- LUKS + boot). This is what CI/
makeruns.
- LUKS + boot). This is what CI/
- tor — the full onion path: the guest publishes its onion and the host
reaches it through Tor. Representative of production but flaky over public
Tor inside QEMU (fresh guest Tor + user-mode net make the onion rendezvous
unreliable), so it is opt-in. The onion transport itself is covered
deterministically by the rootless
test_tor_unlock_e2e.py.
Running
# Arch (mkinitcpio), direct transport (deterministic, default):
LIM_E2E_QEMU=1 pytest tests/e2e/test_qemu_unlock_e2e.py -v -s
# Debian (initramfs-tools) via debootstrap, direct transport:
LIM_E2E_QEMU=1 LIM_E2E_OS=debian pytest tests/e2e/test_qemu_unlock_e2e.py -v -s
# Full onion transport (opt-in, flaky over public Tor):
LIM_E2E_QEMU=1 LIM_E2E_TRANSPORT=tor pytest tests/e2e/test_qemu_unlock_e2e.py -v -s
make test-qemu / make test-qemu-debian wrap the direct-transport runs.
Environment knobs: LIM_E2E_ARCH (x86_64 default, or aarch64),
CHUTNEY_PATH (enables the private network), CHUTNEY_PATH unset → public.
Determinism note (chutney)
For a fully private loop the guest image must trust the same authorities as
the host client. ChutneyNetwork.test_network_conf() distils the
TestingTorNetwork/DirAuthority lines (rewriting 127.0.0.1 to the QEMU
user-net host alias 10.0.2.2) into a file, which build_image.sh appends to
the baked-in torrc via TOR_TEST_NETWORK_CONF. The offline onion key
generation is unaffected — it never touches the network either way.
What runs in CI without any of this
The pure logic (config.py, the env parser, the chutney torrc parsing) and the
drift guards that keep build_image.sh aligned with what the harness expects
are covered by test_qemu_harness_unit.py,
which runs in the normal pytest suite — no QEMU, root, or network.