→ Device Support Matrix — supported devices, flash methods, and tested hardware. → Use Case Test Results — live test evidence from Nostr/Blossom (select "conwrt" tab).
A framework for flashing routers with OpenWrt, with a 2-stage workflow:
- AI-assisted discovery (
prompts/): Encounter an unknown router, identify it, fingerprint its attack surface, plan a capture strategy, and analyze pcap artifacts to determine flash methods and boot signatures. This is how new router models get onboarded into conwrt. - Automated flashing (
scripts/+models/): Once a model is defined with its flash method and boot signatures, subsequent flashes of that model are fully automated -- detect recovery mode, upload firmware, verify, and collect inventory.
DISCLAIMER: conwrt flashes firmware onto real hardware. You must have legal authority to modify any device you use it on. Verify model definitions and firmware images before every flash. The authors accept no liability for bricked devices or data loss.
Plug the router into this machine's ethernet port and run:
python3 scripts/conwrt.py watchconwrt scans for routers in recovery mode (and running OpenWrt), identifies the model by probe evidence, and asks before it flashes. Confirm, and it builds a fresh image with your SSH keys and config baked in (from config.toml), flashes, and verifies over SSH:
$ conwrt watch
Watching enp5s0 — probing 4 addresses every 5.0s (Ctrl-C to stop)
· 192.168.0.1 — uboot
=======================================================
Device found: 192.168.0.1 (uboot)
MAC: AA:BB:CC:12:34:56
Model: COVR-X1860 A1 mesh WiFi
Model ID: dlink-covr-x1860-a1
=======================================================
→ flash this device with a fresh ASU image (latest OpenWrt, your keys baked in)? [y/N]: y
A router already running OpenWrt is detected via SSH board fingerprint and offered an in-place upgrade instead.
Other entry points:
| Command | What it does |
|---|---|
conwrt watch |
Scan loop: detect → prompt → flash (the just-works path) |
conwrt probe |
One-shot table of what's on the wire (IP, state, model candidates) |
conwrt auto |
Deep 5-phase detection (passive listen, LLDP, DHCP, ARP, probing) with menu |
python3 scripts/conwrt_tui.py |
Interactive discovery TUI (preview/dry-run by design) |
conwrt flash --model-id ID --request-image |
Direct flash of a known model |
Device in a weird state (wrong IP, no recovery mode)? Put it in recovery first — for most models hold reset while powering on. conwrt watch will find it once the U-Boot recovery server answers.
- Linux or macOS (Ubuntu 22.04+/Debian and macOS tested; passwordless
sudo -nis used for interface aliases and packet capture — without it, scanning and flashing still work, with fewer live events) curlandsshon PATH- Python 3.10+
- An ethernet cable and a router to flash
Hardware-safe development and CI do not require a router:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e '.[dev]'
make ciUseful targets:
| Target | What it does | Hardware safe |
|---|---|---|
make lint |
Ruff plus shell syntax checks | Yes |
make typecheck |
Pyright static checks | Yes |
make validate-models |
Validate every models/*.json against schemas/model.schema.json |
Yes |
make test |
Python unit tests plus the existing smoke test | Yes |
make ci |
lint + typecheck + schemas + models + tests | Yes |
make rust-ci |
Rust workspace: fmt + clippy + tests + no_std cross-check (crates/, apps/) |
Yes |
make ipk |
Build conwrt as an arch-independent OpenWrt ipk package | Yes |
make integration |
Full QEMU OpenWrt VM suite incl. a real conwrt flash sysupgrade lifecycle |
Yes (local VM) |
make bench |
Bufferbloat benchmark: latency under load before/after SQM | Yes (local VM) |
make e2e |
Tests against a physical router (needs CONWRT_DEVICE_IP) |
No — real device |
make test-live |
Live VPN provider API contract tests (no router; credential-gated subset needs env secrets) | Yes (network) |
Device flashing targets (these mutate real hardware):
| Target | What it does |
|---|---|
make detect |
Auto-detect the connected router (non-interactive) |
make flash |
Source the right vanilla OpenWrt image for the connected router and flash it |
make flash-uboot |
Force U-Boot recovery flash (enter U-Boot, then run; pass MODEL= for non-SSH recovery) |
make source-image |
Download + verify the vanilla OpenWrt image only (MODEL=, optional RELEASE=, ARGS=--image-suffix ...) |
make recovery |
Generic recovery flash for any model: probe the recovery web server, then conwrt flash (MODEL=, optional IFACE=) |
make source-image MODEL=glinet-mt3000 RELEASE=24.10.2 # cache + verify only
make recovery MODEL=glinet-mt3000 IFACE=enp0s31f6 # any model, recovery modeThe recovery helper (scripts/flash-recovery.sh) is model-agnostic: it reads
the model's recovery IP from models/, briefly configures the wired NIC to
probe the bootloader web server (aborting if it isn't there), then runs
conwrt flash. Models without an HTTP recovery address skip the probe.
Device configuration targets (these mutate real hardware):
| Target | What it does |
|---|---|
make sta |
Connect the router to an upstream WiFi AP as a client (SSID=, KEY=, optional BAND=, ENCRYPTION=, IP=) |
make sta SSID=StarGate KEY=passphrase BAND=2.4ghz # WiFi WAN backhaulmake integration / make bench / make e2e wrap scripts/local-tests.sh
and run the layers GitHub CI cannot (they need KVM or real hardware). They run
on demand — no scheduling, no publishing. All results land in the gitignored
test-results/ folder: junit reports, per-run index.md, captured evidence
(serial log, uci show, firmware release), a notes/ journal per run, and
history.md across runs.
Requirements for the VM layers: Linux with KVM (/dev/kvm),
qemu-system-x86_64 + qemu-img, and (first run only) sudo for image
preparation. The VM fixture boots a per-session qcow2 overlay over a pristine
base, so every run starts from clean state. A full integration run including
the flash lifecycle takes ~7 minutes.
A portable Rust implementation is growing beside the Python code
(strangler-fig migration — no 1.x disruption). The M1 slice lives in
crates/ + apps/: shared domain types that parse and validate the same
models/*.json, capability traits with a mock provider, a read-only
boot-state diagnostic workflow, and a conwrt CLI (models, identify,
probe --scenario). Design: docs/architecture/conwrt-2.0-overview.md,
decisions in docs/adr/, plan in docs/roadmap/roadmap.md.
Run make rust-ci (hardware-safe).
Related knobs:
CONWRT_CONFIG=/path/to/config.toml— override the repo-root config for any conwrt invocation (the tests use this to avoid touching your config).lan_ip_mode = "keep"(in[network]or[device]) — leave the LAN IP untouched; useful when the address is managed externally.conwrt flash --keep-config— sysupgrade without-n: settings and SSH keys survive the upgrade.conwrt flash --ip <addr>targets a device at a non-default address (e.g. after a LAN move). Without--keep-configthe override applies to the pre-flash connection only:sysupgrade -nresets the device to the model's default address, which is where conwrt polls, verifies, and records inventory after the flash. non-default address (e.g. after a LAN move).CONWRT_BENCH=direct|labgrid— one-shot bench-backend override for thescripts/bench_*.pytools (direct is the default; see Optional labgrid integration).
Do not run real flashing, sysupgrade, SSH, SCP, TFTP, tcpdump, serial, ASU, or other network-mutating commands from tests. Use mocks/stubs only. Commands such as python3 scripts/conwrt.py ..., scripts/tftp-server.py, and router SSH/SCP helpers can mutate real devices and should only be run intentionally against hardware you control.
| Method | How it works | Requires | Speed |
|---|---|---|---|
| sysupgrade | SSH + SCP, runs sysupgrade -n | Running OpenWrt + SSH | ~3 min |
| recovery-http | Reset button, uboot HTTP server | Physical access + reset pin | ~2 min |
| dlink-hnap | HNAP SOAP API upload via stock firmware web UI | Stock D-Link firmware, network access | ❌ validation blocks flash |
| tftp | TFTP server for uboot network boot | Serial or uboot access | varies |
| extreme-rdwr-tftp | SSH to stock → rdwr_boot_cfg → TFTP boot initramfs → sysupgrade | Stock Extreme firmware, SSH access | ~5 min |
| zycast | Multicast to many devices simultaneously | Network broadcast domain | varies |
| serial-base64 | Transfer firmware over serial via base64 encoding | Serial console + scripts/serial-flash.py |
~22 min |
| serial-xmodem | XMODEM transfer from bootloader prompt | Serial console + bootloader XMODEM support | ~22 min |
See device support matrix — auto-generated from models/*.json (the authoritative single source of truth).
Tested devices are tracked in each model's tested_hardware field. To mark a device as tested, add a tested_hardware entry to its JSON:
"tested_hardware": {
"recovery-http": { "tested": true, "date": "2026-05-16", "notes": "..." },
"wifi_sta_ap": { "tested": true, "date": "2026-05-16", "notes": "..." }
}conwrt/
├── models/ # Device model definitions (species-level, static, in git)
│ └── *.json # See device matrix for full list
├── scripts/ # Automated flashing and management scripts
│ ├── conwrt.py # Main flasher (auto-detect, sysupgrade, U-Boot recovery)
│ ├── router-fingerprint.py # SSH-based device fingerprinting and inventory
│ ├── firmware-manager.py # ASU firmware build/download/cache management
│ ├── model_loader.py # Shared model registry reader
│ ├── router-probe.py # Device boot state detection (off/uboot/openwrt)
│ ├── inventory.py # Inventory utilities
│ ├── serial-boot-capture.py # Serial boot capture with UART break recovery
│ ├── serial-configure.py # Configure devices via serial (IP, SSH key, auth)
│ ├── serial-flash.py # Transfer firmware over serial via base64
│ ├── serial-backup.py # Dump partitions via serial when SSH unavailable
│ ├── serial-console.py # Interactive serial monitor with command FIFO
│ ├── extreme_ap391x_analyze.py # Extreme AP391x firmware image analysis
│ ├── bench_discover.py # Hypothesis-driven dark-device discovery ladder (EUI-64 v6, subnet archaeology, cred ladder)
│ ├── bench_inventory.py # Read-only port scan + registry reconciliation (moved routers, exporter.yaml regen)
│ ├── bench_switch.py # Bench/PoE switch lifecycle: backup/deploy/install-poe/verify/reset (see docs/BENCH-SWITCH-PATTERN.md)
│ ├── bench_session.py # BenchSession: one bench, two backends (direct SSH/ubus default, optional labgrid) — powers the bench_* tools
│ ├── bench_doctor.py # Read-only health check of the optional labgrid bench stack (offline mode: make labgrid-check)
│ ├── gs1900-bench-arm.sh # Re-arm bench failsafes on the switch (TFTP lifelines, fw4 accept, watchers; deployed as /etc/bench-arm.sh)
│ ├── conwrt_serial_bridge.py # Serve a listener router's UART as tcp://HOST:PORT (labgrid NetworkSerialPort / flash --serial)
│ ├── serial_transport.py # Serial console driver: linted line-chunked delivery, FT232R break recovery
│ └── use_cases/ # Use case presets (auto-discovered plugins)
├── labgrid/ # Bench rig: PoE power backend (conwrt_poe), exporter config, tests
├── data/ # Runtime data (gitignored)
│ ├── inventory.jsonl # Append-only device inventory (specimen-level)
│ └── *.bin # Cached firmware images
├── captures/ # Pcap captures (gitignored)
├── images/ # ASU firmware cache (gitignored)
├── recipes/ # Device-specific procedures and notes
├── prompts/ # AI-assisted discovery and serial troubleshooting templates
│ ├── step-01-identify-device.md
│ ├── step-02-fingerprint-surface.md
│ ├── step-03-plan-capture.md
│ ├── step-04-analyze-artifacts.md
│ ├── serial-01-connect-and-identify.md
│ ├── serial-02-bootloader-explore.md
│ ├── serial-03-backup.md
│ ├── serial-04-flash.md
│ ├── serial-05-configure.md
│ ├── serial-06-flash.md
│ ├── bench-forensics-01-dark-device.md
│ └── bench-forensics-02-session-archaeology.md
├── docs/ # Process and documentation
├── examples/ # Example artifacts (redacted)
└── README.md
conwrt has two stages. Stage 1 is for new devices not yet in models/. Stage 2 is for known devices.
When you encounter a router that isn't in the models directory, walk through the prompt templates in prompts/ with an LLM:
- Identify device (
step-01-identify-device.md) -- determine make, model, hardware revision - Fingerprint surface (
step-02-fingerprint-surface.md) -- map exposed services, boot behavior, recovery interfaces - Plan capture (
step-03-plan-capture.md) -- design a pcap capture strategy to observe the boot sequence - Analyze artifacts (
step-04-analyze-artifacts.md) -- extract boot signatures, recovery mode patterns, and flash method from captures
The output of this process is a model JSON for models/ and recipe notes for recipes/. Once committed, that device model moves to Stage 2.
When a device is inaccessible via network (broken firmware, wrong IP, no SSH), the serial console is the primary recovery path. Serial prompts (prompts/serial-*.md) guide the full workflow:
- Connect & identify (
serial-01) — capture boot sequence, identify bootloader/hardware/firmware - Explore bootloader (
serial-02) — enter bootloader via ESC, enumerate commands, inspect env - Backup (
serial-03) — dump Factory/calibration partitions (IRREPLACEABLE — always do this first) - Flash via bootloader (
serial-04) — flash via zycast, TFTP, or XMODEM from the bootloader - Configure (
serial-05) — change IP, install SSH key, enable auth via serial when network is down - Flash via serial (
serial-06) — transfer firmware over the serial line itself (no network needed)
Serial tools:
# Capture boot sequence (auto-recovers from UART break during power cycle)
python3 scripts/serial-boot-capture.py /dev/cu.usbserial-XXXX 57600 --session <model>
# Configure device via serial (change IP, SSH key, auth)
python3 scripts/serial-configure.py /dev/cu.usbserial-XXXX 57600 --set-ip 192.168.5.1
# Backup partitions via serial (when SSH unavailable)
python3 scripts/serial-backup.py /dev/cu.usbserial-XXXX 57600 --partition Factory
# Transfer firmware over serial (no network needed, ~22 min for 7.3MB)
python3 scripts/serial-flash.py /dev/cu.usbserial-XXXX 57600 --base64 firmware.bin --verify --sysupgradeSerial is the MOST RELIABLE recovery method. Try it FIRST, not last. See docs/process.md section 12 (Recovery Decision Tree) for guidance.
For any device already defined in models/, the scripts handle everything end-to-end:
# Auto-detect device and flash with custom ASU image
python3 scripts/conwrt.py --request-image --wan-sshpython3 scripts/conwrt.py --model-id dlink-covr-x1860-a1 --image firmware.binpython3 scripts/conwrt.py --model-id dlink-covr-x1860-a1 \
--request-image --ssh-key ~/.ssh/id_ed25519.pub --no-passwordpython3 scripts/conwrt.py --model-id dlink-covr-x1860-a1 \
--request-image --no-password --wan-sshpython3 scripts/conwrt.py --model-id dlink-covr-x1860-a1 \
--image fw.bin --no-uploadPreview operator profile from config.toml without changing a router:
python3 scripts/conwrt.py profile plan --model-id dlink-covr-x1860-a1
python3 scripts/conwrt.py configure --dry-run --ip 192.168.1.1
python3 scripts/firmware-manager.py request --profile dlink_covr-x1860-a1 --dry-runUse cases and packages are defined once in scripts/use_cases/ and applied via both ASU builds and post-install SSH.
cargo run -q -p conwrt-cli -- models(1.x conwrt.py list deprecated 2026-10-23 — parity-matrix §7, the
first per-command CLI retirement. conwrt models is the successor,
byte-parity pinned both sides by tests/test_parity_rust_models.py.
The Python command stays functional — scripts/conwrt-openwrt-install.sh
runs it as the on-router install smoke check, where no Rust binary
exists — and prints the migration notice until the flash engine
retires.)
Download + SHA-256 verify the stock OpenWrt image (into the gitignored
images/ cache) without flashing. This is what conwrt flash uses
automatically when neither --image nor --request-image is given.
cargo run -q -p conwrt-cli -- source-image --model-id glinet-mt3000
cargo run -q -p conwrt-cli -- source-image --model-id glinet-mt3000 --release 25.12.5
cargo run -q -p conwrt-cli -- source-image --model-id dlink-covr-x1860-a1 # model's image_suffix (factory)
cargo run -q -p conwrt-cli -- source-image --model-id glinet-mt3000 --image-suffix squashfs-factory.bin
(1.x `conwrt.py image` retired 2026-10-22 — parity-matrix §3, retirement
queue #3; `conwrt source-image` is the cache-compatible successor.)The release comes from openwrt.release in the model (falling back to the
repo default); override with --release / CONWRT_OPENWRT_RELEASE.
The image flavour comes from openwrt.image_suffix in the model (e.g.
squashfs-factory.bin for OEM web recovery, initramfs-kernel.bin,
squashfs-sysupgrade.bin), defaulting to squashfs-sysupgrade.bin; override
with --image-suffix / CONWRT_IMAGE_SUFFIX. This is what lets one code path
source the correct artefact for GL.iNet, D-Link, and other devices alike.
# List all cached builds
cargo run -q -p conwrt-cli -- cache list
# Preview a clean (dry run), then remove old builds keeping latest per model
cargo run -q -p conwrt-cli -- cache clean --keep-latest
cargo run -q -p conwrt-cli -- cache clean --keep-latest --yes
# Remove all builds for a specific model (any separator spelling matches)
cargo run -q -p conwrt-cli -- cache clean --model-id dlink-covr-x1860-a1 --yes(1.x conwrt.py cache retired 2026-10-22 — parity-matrix §3, retirement
queue #3; conwrt cache is the successor and clean without --yes is a
dry-run preview. The --model-id match is separator-normalized: the old
first-separator-only swap never matched multi-hyphen ids — fixed Rust-side.)
Default for OpenWrt targets (the Python router-fingerprint.py is
deprecated for this flow — parity-matrix §2):
# Rust CLI: TOFU host keys persist in ~/.config/conwrt/known_hosts
conwrt fingerprint --ip 192.168.1.1
conwrt fingerprint --ip 192.168.1.1 --key ~/.ssh/id_ed25519Stock-vendor fingerprinting (pre-OpenWrt identification) remains on the Python script:
# Auto-detect router at default gateway
python3 scripts/router-fingerprint.py
# Target a specific IP
python3 scripts/router-fingerprint.py --ip 192.168.1.1
# Save to file
python3 scripts/router-fingerprint.py --ip 192.168.1.1 --output fingerprint.json| Option | Description |
|---|---|
--model-id ID |
Model ID from models/ (auto-detected if device is running OpenWrt) |
--image PATH |
Path to firmware image |
--request-image |
Request custom image from ASU with baked-in settings |
--source-image / --no-source-image |
Download the vanilla OpenWrt image for the model when neither --image nor --request-image is given (default: on) |
--release VERSION |
OpenWrt release for --source-image (e.g. 24.10.2); defaults to openwrt.release in the model |
--image-suffix SUFFIX |
Vanilla image flavour for --source-image (e.g. squashfs-factory.bin); defaults to openwrt.image_suffix, then squashfs-sysupgrade.bin |
--suspend-wifi |
Temporarily disable host WiFi during U-Boot recovery to avoid subnet collisions (restored afterwards) |
--ssh-key PATH |
SSH public key to embed (auto-detected: id_ed25519.pub or id_rsa.pub) |
--password PASS |
Set root password (default: random, printed once, saved in inventory) |
--no-password |
Skip password, key-only auth |
--wan-ssh |
Open SSH on WAN interface (disables password login on WAN) |
--force-uboot |
Force U-Boot recovery even if OpenWrt is running |
--no-voice |
Disable voice guidance |
--no-upload |
Dry run, detect only |
--interface IFACE |
Ethernet interface (auto-detected) |
--capture PATH |
Save pcap capture |
--serial SPEC |
Optional serial boot-milestone monitor: tcp://HOST:PORT[,baud] (serial bridge, baud ignored) or /dev/path[,baud] — milestones detected from the serial stream alongside pcap; serial wins conflicts |
--transport ssh|ubus |
Transport for configure command: SSH shell or ubus HTTP |
--version |
Print version and exit |
models/ holds species-level data: what a COVR-X1860 is, how to flash it, what its boot signatures look like. Static, checked into git.
data/inventory.jsonl holds specimen-level data: this specific unit's MAC address, serial number, SSH key fingerprint, and full flash timeline. Gitignored, append-only, stays local.
Each model JSON contains vendor info, OpenWrt target/device/arch, hardware specs (SoC, flash, RAM, ports, Wi-Fi), MAC OUI prefixes for device identification, flash method definitions, and boot milestone patterns from pcap analysis.
- Event-driven state machine with real-time pcap monitoring
- Auto-detects device state (OpenWrt, U-Boot, offline) and picks sysupgrade or U-Boot recovery
- Optional model ID — auto-detected from SSH fingerprint when device is running
- Voice guidance via macOS
say(user actions and milestones only) - Full timeline tracking: power off through SSH verification
- SHA-256 firmware verification
- SSH verification and inventory collection after flash
- ASU integration for custom firmware builds with baked-in SSH keys, passwords, WAN SSH
- Use case presets — flash OpenWrt pre-configured for tethering, SQM, VPN, guest WiFi, etc.
- Transport-agnostic ops pipeline — each use case generates typed operations that render to shell or ubus HTTP
- Firmware cache management (
conwrt cache list/clean— Rust surface; 1.x command retired) - Automatic ethernet interface detection
- Link monitoring survives pcap writer death during reboot
- conwrt ipk package — install on OpenWrt routers for router-to-router flashing
conwrt doesn't just install OpenWrt — it installs OpenWrt pre-configured for a specific use case. Instead of flashing a stock image and then reading wiki docs to manually configure your router, you declare what you want in config.toml and the firmware arrives ready to go.
Status: 14/15 use cases tested on OpenWrt 24.10.2 (QEMU/SHC). See test results (conwrt tab). Use cases requiring WiFi (guest-wifi, travelmate, ap-nostr-id) need physical hardware. MPTCP bonding requires OpenWrt 25.12+ or SNAPSHOT.
Test status (generated — do not edit; sources: tests/integration/_pins.py, docs/roadmap/parity-matrix.md, the use-case registry; refresh: make gen-status):
- QEMU/oracle image: OpenWrt 25.12.5 (single pin:
tests/integration/_pins.py) - §4 typed-ops: ? of 34 presets ported with golden parity
- 1.x registry: 43 presets, 13 still 1.x-untested (ap-nostr-id, fleet_identity, maas-portable-lab, openperf_client, openperf_server, rescue-watchdog, vpn-ivpn, vpn-mullvad, vpn-node, vpn-nordvpn-ikev2, vpn-openvpn, vpn-pia-openvpn, vpn-surfshark)
- Policy: 24.x models plan with a deprecation warning (CVE-2026-98432 / EoS — #607); mptcp-bonding requires ≥ 25.12 and a kernel ≥ 6.12.113
- CVE-2026-98432 (CVSS 9.8): a use-after-free in the kernel's IPv4 TCP handshake path — remotely reachable by any unauthenticated peer, with a publicly demonstrated zero-click root exploit chain against hosts running MPTCP listeners (Anthropic, Oct 27; ~53 min to RCE). Fixed upstream at kernel 6.12.113 (and 6.6.159 / 6.1.190 / 5.15.223 on the older LTS series). Because Go ≥ 1.24 enables MPTCP by default for TCP listeners, any Go service on an unpatched kernel — including tollgate-wrt and BSBF's xray — may expose an MPTCP listener without anyone asking for one.
- conwrt 2.x enforces this at three layers: profiles declare hard
OpenWrt floors (
mptcp-bondingrefuses to plan below 25.12), every apply against a 24.x model carries a deprecation warning (24.10's 6.6 kernels are past end-of-support with unpatched CVEs — warn and plan the upgrade, not silent refusal), and the mptcp-bonding verification refuses to report a healthy apply on a kernel below 6.12.113. - Server side (your VPS): patch the kernel (≥ 6.12.113 / distro equivalent) — disabling MPTCP alone is NOT sufficient (the UAF fires through ordinary TCP; it merely removes the proven root path).
Each preset is a single Python file in scripts/use_cases/ that declares:
- Packages to include in the ASU firmware build
- A shell script of uci commands that runs on first boot
- Required hardware capabilities (auto-skipped if your device lacks USB, WiFi, etc.)
Enable them in config.toml:
[use_cases]
enabled = ["tether-android", "sqm"]
[use_cases.sqm]
download_kbps = 340000
upload_kbps = 19000Or discover what's available:
python3 scripts/conwrt.py list-use-cases
python3 scripts/conwrt.py list-use-cases --model-id glinet-mt3000These use cases are the most immediately useful because they require almost no user configuration:
| Preset | What it does | User provides |
|---|---|---|
| tether | USB WAN from Android or iPhone (auto-detects) | Plug in USB cable |
| sqm | Smart Queue Management with CAKE — eliminates bufferbloat | Download/upload speeds in Kbit/s |
| travelmate | Auto-connect to hotel/airport WiFi with captive portal detection | Nothing (auto-scans) |
These are the "flash and forget" cases — no wiki reading, no manual uci editing. Flash the image, plug in your phone or connect to upstream WiFi, and it works.
The travelmate preset can seed the router's uplink candidates from the machine you deploy with — the same networks your computer already knows. Both modes are optional and off by default; nothing leaves the deploying host unless you ask for it:
[use_cases]
enabled = ["travelmate"]
# Mode 1 — hardcoded list:
[use_cases.travelmate]
uplinks = [
{ ssid = "Home", psk = "password", encryption = "psk2", radio = "radio0" },
{ ssid = "Cabin-5G", psk = "password2", encryption = "sae", radio = "radio1" },
]
# Mode 2 — everything this host has saved (NetworkManager keyfiles,
# read once at build time via passwordless sudo):
[use_cases.travelmate]
seed_from_host = trueEach seeded network becomes a travelmate uplink pair: a STA
wifi-iface plus the uplink section travelmate 2.4.x requires for
recognition (trm_wwan/trm_wwan6 uplink network, wan-zone
membership; PSK2, SAE, and open — enterprise networks are skipped).
Credentials end up in the device config exactly like any PSK you would
type manually; they are never logged and never appear in git.
Status: hardware-proven end-to-end (2026-10-22, COVR-X1860 A1 @
25.12.6, clean-slate run): seeded script applies, travelmate
auto-joins a network seeded from the deploying host's saved passwords,
and its captive probe reports working internet. The unattended
regression e2e is checked in
(providers/conwrt-local-std/tests/hw_travelmate.rs) and runs against
any specimen via env pins (CONWRT_HW_TARGET/KEY/EXPECT_MAC).
| Preset | Description | Post-flash? |
|---|---|---|
tether |
Auto-detect Android or iPhone USB WAN. Android gets ADB auto-enable. | No |
tether-android |
USB WAN from Android. Enable tethering manually on the phone. | No |
tether-android-adb |
USB WAN from Android + ADB auto-enable. Confirm on phone, auto-activates. | No |
tether-ios |
USB WAN from iPhone. Enable Personal Hotspot manually on the phone. | No |
sqm |
Bufferbloat fix via CAKE/fq_codel (manual speeds) | No |
auto-sqm |
Auto-measure WAN speed + configure SQM (experimental) | No |
guest-wifi |
Isolated guest WiFi with separate subnet, DHCP, and firewall zone | No |
doh |
DNS-over-HTTPS via https-dns-proxy for encrypted DNS | No |
mwan3 |
Multi-WAN failover or load balancing | No |
net-roles |
Interface role assignment for constrained devices (NR7101 class): ordered sim/wifi/ethernet uplinks as failover metrics, tagged-VLAN uplink/mgmt doors, optional tollgate-mode engine (uplink-aware pricing + WPS-button override) | No |
travelmate |
Auto-connect to captive portal WiFi | No |
tollgate |
Bitcoin/Lightning payment gateway (ipk from GitHub CI) | Yes (ipk deploy) |
wireguard-client |
VPN tunnel (auto-generates keys per device) | Auto (registration) |
wireguard-server |
VPN server for remote access | Yes (QR codes, peers) |
adguard |
Network-wide ad blocking | Yes (web setup wizard) |
openclash |
Transparent proxy for censorship bypass | Yes (subscription import) |
nodns |
Local DNS cache of nodns records via dnsmasq | No (auto-starts) |
Presets requiring post-flash setup need SSH access after first boot to complete configuration (importing VPN configs, running setup wizards, etc.).
Status: Tested on hardware (D-Link COVR-X1860 A1). WireGuard client, key generation, and post-flash registration all verified.
conwrt handles WireGuard in two stages: firmware build (use case preset) and post-flash registration (automatic). Each device auto-generates its own Curve25519 keypair on first boot — no private keys ever exist in the firmware image.
Enable the wireguard-client use case in config.toml. This bakes wireguard-tools and a first-boot uci script into the firmware image. On first boot, OpenWrt generates a unique private key and configures the wg0 interface with your server's endpoint.
The firmware includes:
wg0WireGuard interface withprivate_key='generate'(unique per device)- Peer config (server public key, endpoint, allowed IPs)
- Firewall
vpnzone + LAN→VPN forwarding - Optional kill switch (block all traffic if VPN drops)
After the router boots, conwrt automatically registers the device with your VPN server. This requires a [wireguard] section in config.toml with an SSH alias to your VPN server:
[wireguard]
registration_server = "my-vpn-server" # SSH alias from ~/.ssh/config
wg_interface = "wg0" # WireGuard interface on the serverThe registration flow:
- SSH to router → read the auto-generated public key via
wg show wg0 public-key - SSH to VPN server → run
wg set wg0 peer <pubkey> allowed-ips <address>(live registration) - Append
[Peer]block to/etc/wireguard/wg0.confon the server (persistence) - Save public key to device inventory
The router's SSH key must be in the VPN server's authorized_keys. conwrt uses BatchMode=yes (key-only auth) for the server connection.
[use_cases]
enabled = ["wireguard-client"]
[use_cases.wireguard-client]
peer_public_key = "SERVER_PUBLIC_KEY"
endpoint_host = "vpn.example.com"
endpoint_port = 51820
address = "10.0.0.2/32"
allowed_ips = "10.0.0.0/24" # only management subnet through tunnel
kill_switch = false
[wireguard]
registration_server = "my-vpn-server"
wg_interface = "wg0"[use_cases]
enabled = ["wireguard-client"]
[use_cases.wireguard-client]
peer_public_key = "SERVER_PUBLIC_KEY"
endpoint_host = "vpn.example.com"
address = "10.0.0.2/32"
kill_switch = true # block all traffic if VPN drops
[wireguard]
registration_server = "my-vpn-server"
wg_interface = "wg0"- Same firmware image works for all devices — each generates unique keys on first boot
- Private key never leaves the router (generated on-device, stored in UCI overlay)
- Keys survive
sysupgrade(UCI overlay preserved by default) - Public key recorded in inventory for auditing and re-registration
- No secrets committed to git —
config.tomlis gitignored
wg-setup.py can apply WireGuard config post-flash from pre-generated server peer configs, without going through the full conwrt flash flow:
python3 scripts/wg-setup.py --peer 3 --server my-vpn-hostStatus: Tested on hardware (D-Link COVR-X1860 A1, OpenWrt 25.12.5, 2026-10-08): unattended ASU flash → first boot brings up the tunnel, the nord 2.4GHz VPN-exit AP, DHCP/DNS for its clients, and management access from the VPN zone. Verified end-to-end: wired client and WiFi client exit via NordVPN DE, kill switch blocks all traffic when the tunnel dies, exit IP ≠ upstream IP. E2E user stories live in tests/e2e/test_nordvpn_e2e.py.
Recommended: access token mode. Everything resolves at BUILD time on the host (NordLynx key via /v1/users/services/credentials with HTTP Basic token:<token>, server via /v1/servers/recommendations with the wireguard_udp filter) and is baked as a static first-boot script — no curl, no jq, no API dependency on the device:
[use_cases]
enabled = ["vpn-nordvpn"]
[use_cases.vpn-nordvpn]
access_token = "nord-account-access-token" # Nord Account → NordVPN → Manual setup
server = "DE" # country code/name/id, hostname, or IP (default: DE)
ap_ssid = "nord" # optional: 2.4GHz AP whose clients exit via the VPN
ap_key = "nordnord" # AP password
kill_switch = true # reject LAN→WAN if the tunnel drops
upstream_iface = "wwan" # wwan for WiFi STA uplink, wan for ethernetServer selection. server accepts DE/germany/81, a concrete hostname (de1507.nordvpn.com), or an IP. Pick one with the companion CLI:
python3 scripts/nordvpn.py servers --country DE --limit 5 # hostname / station / load / pubkey
python3 scripts/nordvpn.py config --token TOKEN --server de1507.nordvpn.com --out de1507.confThe legacy service-username/password token exchange was removed by NordVPN in April 2023 and is not supported. Tunnel address is static 10.5.0.2/32; per-server WireGuard public keys come from the API metadata (they differ per server — never hardcode).
Alternative: static config file. A dashboard-exported WireGuard .conf (config_file = "/path/to/nordvpn.conf") is parsed at build time and overrides the token. Useful for pre-generated keys.
VPN-exit AP (ap_ssid). A second SSID on the 2.4GHz radio whose clients get a DHCP lease on 172.16.31.0/24 and exit exclusively through the tunnel — no path to LAN. Hardware-verified details baked into the generated config: the AP is bridged (br-vpnlan — standalone AP interfaces never delivered DHCP), lives in a dedicated vpnap firewall zone with vpnap→vpn forwarding (joining the vpn zone directly drops DHCP/DNS and client forwarding), raises the shared radio to 20 dBm (txpower is radio-wide), and re-arms dnsmasq once the bridge is up.
Management over the VPN (allow_management, default true). TCP 22/80/443 from the vpn zone to the router are accepted, so you can reconfigure the router through the tunnel (or from the AP) instead of locking yourself out. Set allow_management = false only if you have another access path. DNS from the AP is scoped to 172.16.31.0/24 so the router is not an open resolver at its VPN exit IP.
Routing: the LAN is forwarded into the vpn firewall zone; netifd's wireguard proto installs the endpoint /32 route via the upstream gateway when the uplink comes up (a short fallback discovery covers paths where it doesn't). Optional upstream_gateway pins it statically.
Status: Tested on hardware (COVR-X1860 A1, OpenWrt 25.12.5, 2026-10-08): token-driven service credentials + server resolution at build time, template-faithful config, exit verified (NordVPN UK). Perf: 134–168 Mbps with 65–78% CPU on the MT7621 (see the matrix below).
The same access token drives it — one token, every technology:
[use_cases]
enabled = ["vpn-nordvpn-openvpn"]
[use_cases.vpn-nordvpn-openvpn]
access_token = "nord-account-access-token"
technology = "openvpn-udp" # or openvpn-tcp
server = "DE" # country/hostname/IP, same semantics as nordlynx
openvpn_cipher = "AES-256-CBC" # template default; AES-256-GCM also negotiable
ap_ssid = "nord" # the VPN-exit AP works identically over OpenVPN
kill_switch = trueThe generated config mirrors NordVPN's official per-server .ovpn templates: multi-port remotes (1231–1234), the NordVPN-wide CA and tls-auth key (embedded as constants, verified identical across all servers), verify-x509-name CN pinning, remote-cert-tls server, AES-256-CBC/SHA512 with GCM in data-ciphers for NCP. Service username/password are fetched from the token at build time and land in /etc/openvpn/nordvpn.auth (chmod 600) — nothing sensitive is committed, and the device makes no API calls.
Findings from a hardware bring-up session, recorded so the next attempt starts informed:
- IKEv2 to NordVPN works: strongSwan 6.0.3 with EAP-MSCHAPv2 using the token-derived service credentials, IKE suite AES-CBC-256/HMAC-SHA2-256/MODP-2048, virtual IP 10.6.0.22. The server certificate chains to the same NordVPN Root CA used for OpenVPN.
- OpenWrt 25.12 mt7621 ships without kernel XFRM —
kmod-ipsec4 kmod-xfrm-interface kmod-nft-xfrmmust be installed (image packages, not runtime downloads, for a baked build). - strongSwan needs these packages beyond the base:
strongswan-charon strongswan-swanctl strongswan-mod-{aes,sha1,sha2,gmp,hmac,pubkey,x509,pem,random,nonce,eap-identity,eap-mschapv2,md4,kernel-netlink,updown,attr,openssl}andstrongswan-mod-socket-default— with onlysocket-dynamic, charon logs "sending packet" while nothing ever leaves the interface (it never binds :500). - ESP CHILD_SA install fails with EINVAL (
unable to add SAD entry) while the EIP-93 crypto driver is loaded — IKE succeeds, TS narrows to 0/0 ↔ vip/32, then the kernel rejects the SAD entry. Unloadingcrypto_hw_eip93was attempted to confirm the driver interaction and wedged the kernel (network stack dead, wifi still beaconing; power cycle required). Treat the eip93 driver on kernel 6.12.94 as untested with xfrm — blacklist it before attempting IPsec, and never rmmod it with live crypto references. - Hardware acceleration reality on the MT7621 (EIP-93): accelerates
authenc(hmac(sha1/sha256),cbc(aes))andrfc3686(ctr(aes))— classic IPsec ciphers, exactly what IKEv2 negotiated. It does not cover AES-GCM or ChaCha20-Poly1305: WireGuard and AES-GCM run on the MIPS cores (see/proc/crypto:chacha20-mips,poly1305-mips, generic GCM). - Userspace OpenSSL on this SoC: ChaCha20-Poly1305 ≈ 23 MB/s, AES-256-CBC ≈ 11 MB/s, AES-256-GCM ≈ 6 MB/s — OpenVPN is CPU-bound well below the WireGuard ceiling.
iperf3 download (10s, best of 2) from a controlled server, router CPU sampled at 1 Hz. Server path caps the absolute numbers (~170 Mbps); CPU% at that load still separates the protocols. Harness: prta-scenario-poc/tests/test_conwrt_vpn_matrix.py (results in its gitignored results/).
| Technology | Throughput | Router CPU | Note |
|---|---|---|---|
| NordLynx (WireGuard, ChaCha20) | 172 Mbit/s | 77% | fastest, kernel path |
| OpenVPN UDP AES-256-GCM | 156 Mbit/s | 75% | userspace OpenSSL |
| OpenVPN TCP | 168 Mbit/s | 78% | ≈ GCM (path-capped) |
| OpenVPN UDP AES-256-CBC | 134 Mbit/s | 65% | CBC+HMAC slower per byte |
| IKEv2/IPsec | not measured | — | blocked by the SAD/eip93 issue above |
conwrt watch is the first interactive layer: detect what's plugged in, ask, flash. The long-term vision builds on that — interview the user before flashing ("Do you want USB tethering? SQM? A VPN?") and build the image with everything pre-configured. The config.toml use-case system is the declarative foundation for that interview.
conwrt automatically configures WiFi after flashing based on [network.sta] and [network.ap] in config.toml. No manual uci editing needed.
Status: Tested on hardware (D-Link COVR-X1860 A1). Radio auto-detection verified: radio0=2.4GHz, radio1=5GHz.
- Post-flash SSH (default): After flashing and SSH verification, conwrt detects the correct radio for each band via
uci get wireless.radioN.bandand applies STA/AP uci commands over SSH. - ASU first-boot (via
--request-image): The same uci commands are baked into the firmware's first-boot script, so WiFi is configured before first SSH.
Both flows use the same radio detection logic — iterate radio0..radio3, check band option, match to the configured band.
Point a running router at an upstream AP directly from the CLI or make:
make sta SSID=StarGate KEY=passphrase BAND=2.4ghz IP=192.168.1.1
# equivalently:
python3 scripts/conwrt.py configure --ip 192.168.1.1 \
--sta-ssid StarGate --sta-key passphrase --sta-band 2.4ghzThis creates the wwan interface (DHCP, added to the wan firewall zone),
switches the chosen band's radio to client mode, and connects. --sta-band
defaults to [network.sta].band or 2.4ghz; --sta-encryption defaults to
psk2 (use sae for WPA3, none for open). The other radio keeps serving the
local AP.
[network.sta]
band = "5ghz"
ssid = "UpstreamNetwork"
encryption = "psk2"
key = "passphrase"The router connects to the upstream network on the 5GHz radio (auto-detected) and uses it as WAN. Firewall zone is wan.
[network.sta]
band = "5ghz"
ssid = "UpstreamNetwork"
encryption = "psk2"
key = "passphrase"
[network.ap]
band = "2.4ghz"
ssid = "MyNetwork"
encryption = "psk2"
key = "network-password"STA on 5GHz for upstream WAN, AP on 2.4GHz for local clients. Both radios configured independently.
| Config value | OpenWrt band | Notes |
|---|---|---|
2.4ghz |
2g |
802.11b/g/n/ax |
5ghz |
5g |
802.11a/n/ac/ax |
5ghz-low |
5g |
Lower 5GHz channels (UNII-1) |
5ghz-high |
5g |
Upper 5GHz channels (UNII-3) |
6ghz |
6g |
WiFi 6E (if hardware supports) |
conwrt can flash D-Link routers running stock firmware without entering recovery mode, directly through the manufacturer's web UI API.
How it works: The HNAP (Home Network Administration Protocol) SOAP API accepts firmware uploads when properly authenticated. conwrt performs a challenge-response login (HMAC-MD5 + custom AES), uploads the OpenWrt factory image via multipart POST, and triggers the flash via GetFirmwareValidation.
GetFirmwareValidation returns IsValid: false for OpenWrt images. The firmware upload API accepts the binary (returns OK), and the device reboots, but the bootloader-level validation rejects non-D-Link firmware and boots back to stock. The GPL RSA signing key (password: 12345678) is a test key — production devices use different keys. No router has ever been successfully flashed via HNAP. Use recovery-http (U-Boot) for reliable flashing.
Advantages over recovery-http (when it works):
- No physical reset button press needed
- Works remotely over the network
- Faster setup (no recovery mode dance)
Requirements:
- Router running D-Link stock firmware with known admin password
- Network access to the router's web UI (HTTP)
- OpenWrt factory image (not sysupgrade)
Usage:
python3 scripts/conwrt.py --model-id dlink-covr-x1860-a1 \
--image firmware.bin --flash-method dlink-hnapThe default password ("password") and API endpoints are defined in the model JSON.
conwrt can run FROM an OpenWrt router to flash another router, or from macOS/Linux, with multiple flash methods. Router-to-router provisioning and stock-firmware flashing are both supported.
Status: Tested. x1860 to x1860 recovery-http verified, WiFi STA/AP post-flash config verified. HNAP auth + upload API verified working but does NOT produce a successful flash — stock firmware validation blocks OpenWrt images.
-
Flash the host router with OpenWrt (via conwrt from macOS/Linux). Configure
[network.sta]in config.toml so it gets WiFi WAN after flashing. -
Install dependencies on the host OpenWrt router:
opkg update opkg install python3-base python3-light python3-urllib python3-json \ python3-codecs python3-ctypes python3-email python3-logging \ python3-openssl python3-struct python3-fcntl curl
See
scripts/openwrt-requirements.txtfor the full list. -
Copy conwrt to the router:
scp -O -r scripts/ root@<host-ip>:/tmp/conwrt/ scp -O models/<model>.json root@<host-ip>:/tmp/conwrt/models/ scp -O firmware.bin root@<host-ip>:/tmp/conwrt/
-
Run conwrt from the router:
ssh root@<host-ip> cd /tmp/conwrt/scripts python3 conwrt.py --model-id dlink-covr-x1860-a1 \ --image /tmp/conwrt/firmware.bin --no-pcap --no-voice
The interface is auto-detected as
br-lanon OpenWrt.
The host router's LAN interface gets a temporary IP alias on the recovery subnet (e.g. 192.168.0.10/24). The target router's uboot recovery HTTP server is detected via curl, firmware is uploaded via HTTP POST, and boot completion is verified via SSH polling.
Important: The SSH session to the host router will drop when the interface is reconfigured. Run in background:
python3 conwrt.py --model-id dlink-covr-x1860-a1 \
--image /tmp/conwrt/firmware.bin --no-pcap --no-voice \
> /tmp/conwrt/flash.log 2>&1 &
disown %1| Method | Status | Notes |
|---|---|---|
| sysupgrade | Supported | SSH/SCP via Dropbear, works with any sysupgrade-capable model |
| recovery-http | Tested | Tested x1860 to x1860, polling-only mode |
| dlink-hnap | ❌ upload OK, flash fails | HNAP auth + upload works, but GetFirmwareValidation rejects OpenWrt images — no successful flash ever recorded |
| tftp | Untested | Uses bundled scripts/tftp-server.py (no dnsmasq dependency) |
| extreme-rdwr-tftp | Untested | SSH to stock → rdwr_boot_cfg writes U-Boot vars → TFTP boot initramfs → backup → sysupgrade |
| zycast (multicast) | Untested | Pure Python fallback when C binary unavailable (OpenWrt/MIPS) |
| serial | Tested (base64) | scripts/serial-flash.py + serial console |
| Mode | Requires | Events Detected |
|---|---|---|
| scapy (full) | python3-scapy | All: ARP, HTTP, ICMPv6, UDP |
| tcpdump (events) | tcpdump only | All: parsed from tcpdump output |
| polling-only | curl + ssh | Limited: link state + SSH availability |
On OpenWrt, tcpdump event monitoring is recommended — install via opkg install tcpdump.
Use --no-pcap for polling-only mode (no tcpdump needed).
conwrt is fully standalone by default — flashing, serial recovery, and the bench scripts need nothing but SSH and a cable. For racked benches there is an optional labgrid integration; nothing changes unless you enable it:
- Power, console, and SSH via labgrid places — bench DUTs on a PoE switch become labgrid places:
NetworkPowerPort(PoE),NetworkSerialPort(console over a serial bridge),NetworkService(SSH). - Serial-watched flashing —
conwrt flash --serial tcp://HOST:PORTstreams boot milestones (U-Boot banner, kernel start, procd init, login prompt) from a serial bridge into the flash timeline; when serial and the pcap monitor disagree, serial wins (ground truth). - Boot tripwires — a watched boot shows the dirty-overlay signature (
jffs2 ... unchecked/orphan), wedged overlay replays, and failsafe prompts in-band instead of inferring them from network silence.
Enable it per run or persistently — the direct backend (plain SSH + ubus PoE) stays the default:
CONWRT_BENCH=labgrid python3 scripts/bench_inventory.py scan --host <switch> # one-shot override[labgrid] # config.toml — see config.example.toml
enabled = true # false/absent = direct backend (default)
coordinator = "host:port" # labgrid coordinator addressHealth checks — both read-only, never a power action:
python3 scripts/conwrt.py bench-doctor # L1 coordinator / L2 exporter / L3 serial bridges / L4 registry
make labgrid-check # offline exporter-vs-registry cross-check (CI-safe)Everything stays standalone: without the env var and without a [labgrid] section, behavior is identical to a labgrid-free install — labgrid is never required, and bench-doctor reporting ABSENT levels is a healthy standalone checkout. Bench wiring, serial-bridge instances, and the place registry live in labgrid/README.md; the AP3915i-to-AP3915i serial bridge that feeds NetworkSerialPort and --serial tcp:// is documented in recipes/extreme-networks/ws-ap3915i/SERIAL-VIA-AP3915I.md.
All captures, images, and data directories are gitignored. SSH key user@host comments are stripped before embedding in firmware. Inventory stays local. No personal data in model definitions.
Standard PR-based workflow. Device contributions should include:
- Model JSON in
models/ - Recipe notes in
recipes/ - Boot signatures from pcap analysis
- Tested on real hardware
MIT. See LICENSE.