Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# devourer regression test rig

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

```
TX = devourer TX = kernel
RX = devourer end-to-end devourer does dvr RX a kernel-TX frame?
RX = kernel does dvr emit valid baseline / rig sanity check
frames?
```

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

## Prerequisites

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

The script does a preflight check and prints distro-agnostic install
hints for anything missing.

## Usage

```bash
sudo python3 tests/regress.py --channel 100
```

Auto-detects the first two supported adapters via sysfs. To pick
specific ones:

```bash
sudo python3 tests/regress.py \
--tx-pid 0x8812 --rx-pid 0x8813 --channel 100 --duration 20
```

Output is a markdown table printed to stdout — paste into PR comments
or save with `tee`:

```
## Regression matrix — channel 100, 2026-05-23 12:34:56

- TX adapter: `0bda:8812` (RTL8812AU)
- RX adapter: `0bda:8813` (RTL8814AU)
- Cell duration: 15s
- Pass threshold: ≥ 5 hits

| | TX = devourer | TX = kernel |
|---|---|---|
| RX = devourer | 42 hits / 7500 TX / 15s ✓ | 35 hits / 7500 TX / 15s ✓ |
| RX = kernel | 31 hits / 7500 TX / 15s ✓ | 47 hits / 7500 TX / 15s ✓ |
```

For debugging a specific cell that failed, re-run with `--keep-logs` —
per-cell stdout/stderr logs are symlinked at
`/tmp/devourer-regress-last/`.

## Supported adapters

Listed in `SUPPORTED_DUTS` at the top of `regress.py`. Extend the dict
to add new chipsets — the rest of the script is chipset-agnostic.

## Channel selection

The default `--channel 36` is a 5GHz channel that's typically quiet,
which means hit counts will be low but stable. For high-confidence
runs, pick a channel where your nearest AP is actively transmitting
(check via `iw dev wlan0 scan | grep -E "freq|SSID"` on a separate
device).

## VM-readiness

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

## Known gaps

- Tests "signal of life", not throughput. Hit counts vary 5-20× run-over-
run depending on ambient RF — thresholds are deliberately generous.
- Per-cell startup time is ~10s (devourer fwdl + warmup). 4 cells × ~25s
≈ 100s per matrix run. Fine for manual runs, would be annoying for CI.
- No support yet for >2 adapters. To extend, add a pairing loop in
`main()` that runs the 4-cell matrix per chipset pair.
- Kernel TX side uses scapy at 500 fps. If your kernel driver's
injection rate is the bottleneck on a given chip, lower
`--interval` in `inject_beacon.py`.
78 changes: 78 additions & 0 deletions tests/inject_beacon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Inject the canonical devourer TX-validation beacon on a kernel-driver
monitor interface.

The frame mirrors txdemo/main.cpp's hardcoded beacon: a probe request with
SA = 57:42:75:05:d6:00. WiFiDriverDemo and WiFiDriverTxDemo both grep for
this SA on RX (the `<devourer-tx-hit>` matcher) so the same beacon works
as the TX source whether the RX side is devourer or tcpdump.

Run from tests/regress.py's kernel-TX cell:

sudo python3 tests/inject_beacon.py --iface wlpXX --count 500 --interval 0.002

Requires the iface to already be in monitor mode on the chosen channel
(regress.py sets that up). Idempotent: run multiple times safely.
"""

import argparse
import time

from scapy.all import RadioTap, Dot11, sendp

# Source MAC matches the canonical beacon SA in txdemo/main.cpp and the
# `<devourer-tx-hit>` matcher in demo/main.cpp. Don't change without
# updating both sides.
CANONICAL_SA = "57:42:75:05:d6:00"


def build_beacon():
"""Mgmt / probe-request frame matching txdemo's beacon_frame[]. The body
payload doesn't matter for hit-count testing — only SA is matched."""
return (
RadioTap()
/ Dot11(
type=0, # mgmt
subtype=4, # probe request
addr1="ff:ff:ff:ff:ff:ff", # DA broadcast
addr2=CANONICAL_SA, # SA — matched by RX side
addr3=CANONICAL_SA, # BSSID
)
/ b"\x00\x00\x00\x00\x00\x00\x00\x00" # ssid IE (empty)
)


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--iface", required=True, help="monitor-mode wlan iface")
ap.add_argument(
"--duration",
type=float,
default=30.0,
help="seconds to inject (default 30)",
)
ap.add_argument(
"--interval",
type=float,
default=0.002,
help="inter-frame gap seconds (default 0.002 = 500 fps, matches txdemo)",
)
args = ap.parse_args()

pkt = build_beacon()
end = time.monotonic() + args.duration
sent = 0
while time.monotonic() < end:
try:
sendp(pkt, iface=args.iface, verbose=False)
sent += 1
except OSError as e:
# iface went down mid-test — bail rather than spin.
print(f"inject_beacon: sendp failed after {sent} frames: {e}")
break
time.sleep(args.interval)
print(f"inject_beacon: sent {sent} frames on {args.iface}")


if __name__ == "__main__":
main()
Loading
Loading