Skip to content

Commit a580933

Browse files
josephnefclaude
andauthored
tests: VM mode for kernel cells (aircrack-ng/rtl8812au on pinned kernel) (#33)
## What this is Adds a libvirt-VM execution mode to `tests/regress.py` so the kernel-side cells of the regression matrix can run against the `aircrack-ng/rtl8812au` out-of-tree driver on a **pinned kernel**, instead of fighting the host kernel. ## Why a VM The OOT `aircrack-ng/rtl8812au` driver lags kernel API changes by 6-12 months (timer_*, cfg80211 callback signatures with MLO link_id, etc.). On kernel 6.15+ it needs hand-patching to build. morrownr's README flags that mainline `rtw88_*` is now the recommended path from kernel 6.14 onwards — but **mainline `rtw88_8814au` currently fails to probe** RTL8814AU on this lab's adapter (`failed to download firmware`, `error -22`). So for 8814 specifically, OOT aircrack-ng is the only working kernel-side path. Pinning a VM to Ubuntu 22.04 LTS (kernel 5.15) gives a stable platform where aircrack-ng's driver builds and loads cleanly. The host can upgrade freely without breaking the test rig. ## Pieces **`tests/setup_vm.sh`** — one-shot VM provisioner. Clones an Ubuntu 22.04 cloud image (`jammy-base.qcow2`), generates a cloud-init seed (creates `dima` user with caller's SSH key, NOPASSWD sudo, installs build-essential / dkms / linux-headers / iw / tcpdump / python3-scapy / aircrack-ng), `virt-install`s with `qemu-xhci` USB controller for hot-plug, runs `make dkms_install` of `aircrack-ng/rtl8812au` inside via `runcmd`. ~5-10 min end to end. `--teardown` and `--status` subcommands included. **`tests/regress.py` refactor** — introduces a `KernelHost` abstraction owning every kernel-side operation (`modprobe`, sysfs reads, `iw`, `tcpdump`, scapy). Local mode = `subprocess.run`. VM mode = `ssh ... sudo` + `virsh attach-device`/`detach-device` for per-cell USB passthrough. New CLI flags `--vm-name` / `--vm-ssh` (env: `DEVOURER_VM_NAME`, `DEVOURER_VM_SSH`). When invoked under `sudo`, picks up `SUDO_USER`'s SSH key — root usually doesn't have keys provisioned on the VM. **Per-cell DUT routing** — each cell calls `_ensure_dut_location` for each DUT, which (in VM mode) moves the DUT between host and VM via virsh as needed. State always restored to \"both DUTs on host\" between cells via try/finally so a crashed cell doesn't poison the next one. Script start has a `release_all_known_duts` pass for leftover-attached DUTs from previous aborted runs. ## Validation on trainer-arch Arch Linux host kernel 6.18, VM Ubuntu 22.04 LTS kernel 5.15, two USB DUTs in a hub (0bda:8812 RTL8812AU + 0bda:8813 RTL8814AU): ``` ## Regression matrix — channel 100, 2026-05-23 13:22:14 - TX adapter: 0bda:8812 (RTL8812AU) - RX adapter: 0bda:8813 (RTL8814AU) - Kernel host: VM devourer-testrig via dima@10.216.129.126 - Cell duration: 10s - Pass threshold: ≥ 3 hits | | TX = devourer | TX = kernel | |---|---|---| | RX = devourer | 0 hits / 4500 TX ✗ | 0 hits / 258 TX ✗ | | RX = kernel | 4172 hits / 4500 TX ✓ | 229 hits / 259 TX ✓ | ``` - **Baseline ✓** kernel-TX 8812 → kernel-RX 8814 inside VM, **~88% delivery** - **devourer-TX validation ✓** devourer-TX 8812 on host → kernel-RX 8814 in VM, **~93% delivery** — confirms devourer's RTL8812AU TX really emits valid frames at the wire level - The two failing cells are the pre-existing devourer 8814 RX TODO, not regressions; cell 3's new \"0 hits / 258 TX\" output correctly fingers the RX side (TX side really did emit 258 frames; devourer-RX 8814 silent) For comparison: the same hardware in local mode from #32's first run got **1 hit** on the devourer-TX→kernel-RX cell because mainline `rtw88_8814au` couldn't probe the chip. The VM with aircrack-ng gives **~4000× the signal**. ## Smaller fixes folded in - TX-count parser surfaces \"Failed to send packet\" failure count separately from the rate-limited `<devourer-tx>` print count (previously misleadingly low when sends were failing) - `--no-baseline-abort` flag for partial-rig diagnostics - `wait_for_wlan_iface` timeout bumped to 20s (kernel rebinds + VM passthrough enumeration take 10s+) - Kernel-TX cells `wait()` for `inject_beacon` to self-terminate instead of killing the ssh wrapper — captures the final \"sent N frames\" line (previously TX count showed 0 even though RX side received frames) ## Usage ```bash sudo tests/setup_vm.sh # ~5-10 min, one-time sudo tests/setup_vm.sh --status sudo python3 tests/regress.py --channel 100 \ --vm-name devourer-testrig \ --vm-ssh dima@<VM-IP> ``` See [`tests/README.md`](tests/README.md) for full options, prereqs, architecture notes. ## Known limitations (documented in README) - VM mode assumes a single libvirt host running both `virsh` (locally) and the VM. Pulling the VM onto a different host needs your own `virsh` wrapper. - Per matrix run: ~3-4 min in VM mode (USB hot-plug adds ~5s per cell transition vs ~100s for local mode). - Two-adapter scope today. >2 needs a pairing loop in `main()`. - Cell 4 (`devourer-TX → devourer-RX`) needs both DUTs devourer-claimable simultaneously — if one chipset has broken devourer RX (current RTL8814AU TODO), that cell shows 0 regardless of TX. ## Test plan - [x] VM provisioning succeeds end-to-end (`setup_vm.sh` clean run on trainer-arch) - [x] aircrack-ng/rtl8812au DKMS install works inside VM (kernel 5.15) - [x] USB hot-plug of 8814AU into VM works (mainline rtw88 couldn't probe; aircrack-ng claims cleanly) - [x] Full 4-cell matrix runs end-to-end in VM mode - [x] Baseline cell passes (rig sanity) - [x] devourer-TX → kernel-RX cell passes (cross-driver validation) - [x] Failing cells produce diagnostic output (TX count vs RX hits) - [ ] Validate on a different distro / different VM base image - [ ] Validate with a 2× same-chip DUT setup (both cells with both-devourer pass) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 63e737d commit a580933

3 files changed

Lines changed: 820 additions & 349 deletions

File tree

tests/README.md

Lines changed: 123 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# devourer regression test rig
22

33
Cross-driver matrix test that compares this project's userspace stack
4-
against the kernel-driver baseline (aircrack-ng / mainline `rtw88`) for
5-
both TX and RX on plugged-in USB Wi-Fi adapters.
4+
against the kernel-driver baseline for both TX and RX on plugged-in USB
5+
Wi-Fi adapters.
66

77
```
88
TX = devourer TX = kernel
@@ -13,95 +13,154 @@ RX = kernel does dvr emit valid baseline / rig sanity check
1313

1414
Each cell injects/receives the canonical beacon (SA `57:42:75:05:d6:00`,
1515
matching `txdemo/main.cpp`) for `--duration` seconds and counts hits.
16-
The baseline cell runs first — if it fails the rig itself is broken
17-
(channel busy, antennas, kernel driver mismatch) and the remaining cells
18-
are skipped.
16+
The baseline cell runs first — if it fails the rig itself is broken and
17+
the remaining cells are skipped (override with `--no-baseline-abort`).
1918

20-
## Prerequisites
19+
## Two run modes
2120

22-
- 2 supported USB Wi-Fi adapters plugged into the same host
23-
- devourer built (`build/WiFiDriverDemo`, `build/WiFiDriverTxDemo`)
24-
- Kernel driver(s) for the adapter(s) installed and `modprobe`-able
25-
(rtl8812au/rtl8814au from aircrack-ng or your distro's `rtw88` for
26-
mainline). The script doesn't care which — it queries sysfs for whatever
27-
is bound.
28-
- Python 3.9+ with `scapy` available (`pip install scapy` or your distro's
29-
`python3-scapy`)
30-
- `iw`, `tcpdump`, `ip` on PATH
31-
- Passwordless `sudo`, or run the script directly as root
32-
- NetworkManager users: stop NM for the duration of the test, or
33-
`nmcli device set <iface> managed no` on the test interfaces before
34-
running. (NM will fight you for the monitor-mode wlan iface otherwise.)
21+
### Local mode
3522

36-
The script does a preflight check and prints distro-agnostic install
37-
hints for anything missing.
38-
39-
## Usage
23+
The kernel-side cells run against whatever driver is bound to the DUTs on
24+
the **host** (mainline `rtw88_*` or whatever's loaded). Cheap to set up
25+
but limited to drivers that build cleanly against the host kernel — that's
26+
a moving target as kernels evolve, especially for the out-of-tree
27+
`aircrack-ng/rtl8812au` driver.
4028

4129
```bash
4230
sudo python3 tests/regress.py --channel 100
4331
```
4432

45-
Auto-detects the first two supported adapters via sysfs. To pick
46-
specific ones:
33+
### VM mode (recommended)
34+
35+
The kernel-side cells run inside a **pinned-kernel libvirt VM** that has
36+
the OOT `aircrack-ng/rtl8812au` driver built and loaded. DUTs are
37+
transferred between host and VM per cell via `virsh attach-device` /
38+
`detach-device`. The VM's kernel never moves so the driver never breaks.
39+
40+
Provision the VM once with the included script (Ubuntu 22.04 LTS,
41+
kernel 5.15 — where aircrack-ng's driver builds without patches):
4742

4843
```bash
49-
sudo python3 tests/regress.py \
50-
--tx-pid 0x8812 --rx-pid 0x8813 --channel 100 --duration 20
44+
sudo tests/setup_vm.sh # provision; ~5-10 min
45+
sudo tests/setup_vm.sh --status # show VM IP, ssh hint
5146
```
5247

53-
Output is a markdown table printed to stdout — paste into PR comments
54-
or save with `tee`:
48+
Then run the matrix in VM mode:
49+
50+
```bash
51+
sudo python3 tests/regress.py --channel 100 \
52+
--vm-name devourer-testrig \
53+
--vm-ssh dima@<VM-IP-from-status>
54+
```
55+
56+
VM mode is what unblocks chipsets where the host kernel driver doesn't
57+
work — e.g. RTL8814AU, where mainline `rtw88_8814au` currently fails to
58+
probe on kernels 6.15+ (`failed to download firmware`, `error -22`), but
59+
`aircrack-ng/rtl8812au` claims it cleanly on the pinned kernel 5.15.
60+
61+
## Prerequisites
62+
63+
### On the host (both modes)
64+
65+
- 2 supported USB Wi-Fi adapters plugged in
66+
- devourer built (`build/WiFiDriverDemo`, `build/WiFiDriverTxDemo`)
67+
- Python 3.9+ with `scapy` (`pip install scapy` or `python3-scapy`)
68+
- `iw`, `tcpdump`, `ip` on PATH
69+
- Passwordless `sudo`, or run directly as root
70+
71+
### For local mode
72+
73+
- Kernel driver(s) installed and `modprobe`-able for your DUTs (rtw88 or
74+
aircrack-ng — script auto-detects whatever's bound via sysfs)
75+
- NetworkManager users: stop NM, or `nmcli device set <iface> managed no`
76+
on the test interfaces
77+
78+
### For VM mode (in addition)
79+
80+
- `libvirtd` + `virsh` + `virt-install` on the host
81+
- `xorriso` (for the cloud-init seed ISO that `setup_vm.sh` generates)
82+
- An Ubuntu 22.04 cloud image at `/var/lib/libvirt/images/jammy-base.qcow2`
83+
(download from <https://cloud-images.ubuntu.com/jammy/current/>)
84+
- Working USB hot-plug on libvirt (`xhci` controller; `setup_vm.sh` adds it)
85+
- The host user's SSH key in `~/.ssh/id_rsa.pub` (or set `SSH_PUBKEY=...`
86+
before `setup_vm.sh`) — gets baked into the VM's `dima` user
87+
88+
The script does a preflight check and prints distro-agnostic install
89+
hints for anything missing.
90+
91+
## Output
92+
93+
Markdown table to stdout, ready to paste into PR comments:
5594

5695
```
57-
## Regression matrix — channel 100, 2026-05-23 12:34:56
96+
## Regression matrix — channel 100, 2026-05-23 13:22:14
5897
5998
- TX adapter: `0bda:8812` (RTL8812AU)
6099
- RX adapter: `0bda:8813` (RTL8814AU)
61-
- Cell duration: 15s
62-
- Pass threshold: ≥ 5 hits
100+
- Kernel host: VM devourer-testrig via dima@10.216.129.126
101+
- Cell duration: 10s
102+
- Pass threshold: ≥ 3 hits
63103
64104
| | TX = devourer | TX = kernel |
65105
|---|---|---|
66-
| RX = devourer | 42 hits / 7500 TX / 15s ✓ | 35 hits / 7500 TX / 15s ✓ |
67-
| RX = kernel | 31 hits / 7500 TX / 15s ✓ | 47 hits / 7500 TX / 15s ✓ |
106+
| RX = devourer | 0 hits / 4500 TX ✗ | 0 hits / 258 TX |
107+
| RX = kernel | 4172 hits / 4500 TX ✓ | 229 hits / 259 TX ✓ |
68108
```
69109

70-
For debugging a specific cell that failed, re-run with `--keep-logs`
71-
per-cell stdout/stderr logs are symlinked at
72-
`/tmp/devourer-regress-last/`.
73-
74-
## Supported adapters
110+
Pass/fail per cell on hit-count threshold (default ≥ 1 — generous because
111+
air interference makes absolute counts unreliable). Bump for higher-
112+
confidence runs on a quieter channel.
75113

76-
Listed in `SUPPORTED_DUTS` at the top of `regress.py`. Extend the dict
77-
to add new chipsets — the rest of the script is chipset-agnostic.
114+
For debugging a specific cell that failed, re-run with `--keep-logs`
115+
per-cell stdout/stderr logs end up at `/tmp/devourer-regress-last/`.
78116

79-
## Channel selection
117+
## CLI knobs
80118

81-
The default `--channel 36` is a 5GHz channel that's typically quiet,
82-
which means hit counts will be low but stable. For high-confidence
83-
runs, pick a channel where your nearest AP is actively transmitting
84-
(check via `iw dev wlan0 scan | grep -E "freq|SSID"` on a separate
85-
device).
119+
- `--channel N` — Wi-Fi channel for both adapters (default 36; pick the
120+
channel your nearest AP is on for guaranteed traffic)
121+
- `--duration SECONDS` — per-cell injection/measurement window (default 15)
122+
- `--pass-threshold N` — min hits to pass (default 1)
123+
- `--tx-pid 0xNNNN` / `--rx-pid 0xNNNN` — pick specific DUTs (defaults to
124+
the first two auto-detected)
125+
- `--no-baseline-abort` — run all 4 cells even if kernel-kernel fails
126+
(useful when one chipset has no working kernel driver on this rig)
127+
- `--vm-name NAME` / `--vm-ssh USER@HOST` — enter VM mode
128+
- `--keep-logs` — symlink the temp log dir at `/tmp/devourer-regress-last`
86129

87-
## VM-readiness
130+
Environment variable equivalents: `DEVOURER_VM_NAME`, `DEVOURER_VM_SSH`.
88131

89-
The kernel-cell shell-outs go through `run_kernel_cmd()` in `regress.py`.
90-
Today it's `subprocess.run` (local). To migrate the kernel side into a
91-
pinned-kernel VM — recommended once host-kernel upgrades start breaking
92-
the out-of-tree aircrack-ng driver — replace `run_kernel_cmd` with an
93-
`ssh user@trainer-vm sudo` wrapper and arrange USB hot-plug passthrough
94-
into the VM via libvirt (`virsh attach-device` with a `<hostdev>` USB
95-
spec). The matrix orchestrator doesn't need to change.
132+
## Supported DUTs
96133

97-
## Known gaps
134+
Listed in `SUPPORTED_DUTS` at the top of `regress.py`. Extend the dict
135+
to add new chipsets — the rest of the script is chipset-agnostic.
98136

99-
- Tests "signal of life", not throughput. Hit counts vary 5-20× run-over-
100-
run depending on ambient RF — thresholds are deliberately generous.
101-
- Per-cell startup time is ~10s (devourer fwdl + warmup). 4 cells × ~25s
102-
≈ 100s per matrix run. Fine for manual runs, would be annoying for CI.
103-
- No support yet for >2 adapters. To extend, add a pairing loop in
137+
## Architecture notes
138+
139+
- All kernel-side operations (modprobe / sysfs reads / `iw` / `tcpdump` /
140+
scapy) go through one abstraction (`KernelHost`). Local mode runs them
141+
via `subprocess.run`; VM mode wraps them in `ssh ... sudo`. Adding a
142+
third backend (e.g. remote bare-metal box) is a new class.
143+
- DUT routing in VM mode uses `virsh attach-device` (USB hot-plug). The
144+
matrix moves DUTs between host and VM per cell as needed, restoring all
145+
DUTs to the host on exit so the next cell starts from a clean baseline.
146+
- `inject_beacon.py` is shipped to the VM via `scp` each run (small file)
147+
and exits when its `--duration` elapses — orchestrator waits rather
148+
than killing, so the final "sent N frames" line is captured.
149+
150+
## Known limitations
151+
152+
- Tests "signal of life", not throughput — air noise makes absolute
153+
counts unreliable; pass-threshold is deliberately generous.
154+
- Per matrix run: ~100s in local mode, ~3-4 min in VM mode (USB hot-plug
155+
adds ~5s per cell transition).
156+
- Two-adapter scope today. To extend to >2, add a pairing loop in
104157
`main()` that runs the 4-cell matrix per chipset pair.
105-
- Kernel TX side uses scapy at 500 fps. If your kernel driver's
106-
injection rate is the bottleneck on a given chip, lower
107-
`--interval` in `inject_beacon.py`.
158+
- VM mode assumes a single libvirt host running both `virsh` (locally)
159+
and the VM. Pulling the VM onto a different host is a `--vm-ssh
160+
user@vmhost` away on the kernel cell side, but `virsh attach-device`
161+
still runs locally; if the VM is on a different host, run virsh there
162+
(via your own wrapper).
163+
- Cell 4 (`devourer-TX → devourer-RX`) requires both DUTs to be on the
164+
host and devourer-claimable simultaneously. Works fine, but means both
165+
chipsets need working devourer RX — if one is RX-broken (e.g. current
166+
RTL8814AU TODO), that cell will always show 0 hits regardless of TX.

0 commit comments

Comments
 (0)