A UniFi-grade management interface for the OpenWrt you already run.
Not a fork. Not a firmware. Not a distribution. A front end that connects to stock OpenWrt over its existing API and makes it manageable the way UniFi is.
Status: design phase — no product code yet. These documents are the plan.
What does exist: tools/probe.py, which validates the design's
assumptions against a real device, and deploy/acl/oonfeewrt.json,
the rpcd ACL that is the project's entire device-side footprint. The design has
been validated against a WRT3200ACM on OpenWrt 25.12.5 — including the
apply/confirm/rollback safety mechanism everything else depends on. See
docs/IMPLEMENTATION.md §14 for what that run settled.
GL.iNet ships a friendly UI on top of OpenWrt — but it's their firmware, their fork, their maintenance burden, and it manages exactly one router. LuCI is stock and manages one device, exposing OpenWrt's full complexity with none of its ergonomics. UniFi has the ergonomics and the fleet view, but it's a closed appliance ecosystem.
oonfeeWRT takes the third position:
Stock OpenWrt firmware. Multiple devices. UniFi's ergonomics.
It deploys the way the Omada software controller and self-hosted UniFi Network do: a Docker container you run yourself — on a NAS, mini-PC, Pi, or home server — that connects out to your OpenWrt devices. One image, one volume, compose file included. The routers run nothing of ours; the controller has all the room it needs.
You keep running whatever OpenWrt you already have, from wherever you already get it, upgraded on whatever schedule you like. oonfeeWRT connects to it, reads its state, and writes its config — the same config LuCI writes, through the same API LuCI uses.
We do not maintain OpenWrt. Not a fork, not a firmware image, not a build system, not a patch set, not a kernel module, not a device-side daemon we wrote.
Everything oonfeeWRT touches on a device is either already in stock OpenWrt or already in the official package feeds. Our entire device-side footprint is one JSON file — an rpcd ACL granting a dedicated user scoped permissions. That's it. Nothing to maintain, nothing to rebuild when OpenWrt releases, nothing that breaks when the user upgrades their router.
If a feature would require shipping code that runs on the router, the feature is cut. That constraint is the entire reason this project can survive with a small team.
No invented capabilities. If OpenWrt can't do it, oonfeeWRT doesn't pretend to. The value is presentation and orchestration of existing functionality, not new functionality.
- ❌ Building, patching, or distributing OpenWrt firmware
- ❌ A device-side agent or daemon of our own authorship
- ❌ Adopting or managing UniFi hardware, or any non-OpenWrt device
- ❌ Reimplementing Ubiquiti's inform protocol
- ❌ Features OpenWrt doesn't already support
- ❌ Cloud services, SSO, remote-access brokering
- ❌ Replacing LuCI — oonfeeWRT coexists with it, permanently and safely
One screen where you define a site — networks, VLANs, WiFi, firewall zones — and it reconciles onto every OpenWrt device you've pointed it at. Plus the live view UniFi is loved for: topology, clients, radios, traffic, logs.
Change an SSID password once. It lands on three APs across two bands each, correctly, with automatic rollback if anything goes wrong. That's the product.
Two sides, and only one of them needs anything installed. Every step below is what the code actually does today, verified against a Linksys WRT3200ACM on OpenWrt 25.12.5.
There is no package to build, no opkg feed, no init script. A stock OpenWrt
device already has everything: rpcd, and uhttpd with the ubus handler
enabled. What adoption needs from you is SSH access, once.
Prerequisites on the device
1. OpenWrt 21.02 or newer, reachable on the network
2. SSH enabled (dropbear is on by default)
3. A root password set <-- see the warning below
Set a root password before adopting. A stock OpenWrt with no root password
authenticates anything — we measured it accepting an empty password, the
correct one, and a deliberately wrong one over ubus, plus the SSH none method.
Adoption probes for this and shows a warning, and it deliberately does not
refuse, because you may be knowingly running that way on a trusted lab network.
But it means the credential you type proves nothing about who you are:
ssh root@192.168.1.1 passwd # do this firstAdoption then uses that credential exactly once, to write one file and create one login, and never stores it. Removing the device asks for it again.
npm --prefix ui install && npm --prefix ui run build # builds the embedded UI
go build -o oonfeewrtd ./cmd/oonfeewrtd
./oonfeewrtd -data-dir "$PWD/.run" -listen 127.0.0.1:8080On first start it asks for an operator passphrase, twice. That passphrase encrypts every device credential at rest; there is no recovery if it is lost. Then open the address and create the administrator account the UI asks for.
The adopt screen has a Scan button that sweeps the networks this host is attached to and lists what answers as OpenWrt. It tells you how many addresses it will probe before it probes them, and what it is not covering and why — because a controller that quietly skipped your subnet would report "no devices found", which reads as a fact about your network rather than about itself.
The probe sends no password and creates no session. It is one unauthenticated request asking the device to list what it can do, which stock OpenWrt answers to anyone who can reach the port. Scanning is on demand only: there is no periodic rescan, because sweeping your subnet on a timer forever is not something a controller should do unasked.
It will not tell you the model. That needs a credential — stock OpenWrt refuses
system.board to an unauthenticated caller — so the list shows the address and
the shape of the device (radios up, gateway, DHCP server) and says the model is
unknown until you sign in. Better a blank than a guess.
Add-by-address stays first-class, and you will need it if the controller runs in a container on a bridge network or on Docker Desktop: there is no LAN layer 2 to sweep from there, so the scan will come up empty while adoption by address works perfectly. Discovery is a convenience; adoption never depends on it.
For an unattended host (a container, a systemd unit) supply the passphrase from a file instead:
OONFEE_DATA_DIR=/data OONFEE_LISTEN=:8080 OONFEE_PASSPHRASE_FILE=/run/secrets/oonfee-passphrase ./oonfeewrtdThe file must be mode 600 or it is refused. There is deliberately no
OONFEE_PASSPHRASE environment variable — env is readable from /proc,
inherited by child processes, and printed by docker inspect — and setting one
is an error rather than being ignored, so the mistake is loud.
Low friction and secure pull in opposite directions in exactly three places. Here is where each line was drawn, so you can move it knowingly:
| Choice | Friction | What it buys |
|---|---|---|
| No default credentials, anywhere. First run creates the admin account interactively | one extra screen | A shipped default nobody rotates is the most common way a self-hosted controller ends up on the internet with a known password |
| The passphrase is not in the environment | you must create a file for unattended boot | /proc, child processes and docker inspect never see it |
| A device with no root password is warned about, not refused | none | You keep control of a real tradeoff; the controller's own login is password-protected regardless |
And the parts that are simply free, because they cost you nothing to have:
- The controller does not run as root on your device. Adoption creates a
dedicated
oonfeewrtlogin scoped to one ACL file, and that file is the entire device-side footprint. Review it like code — it is the blast radius. - The operator credential is never stored. It is used for one transaction and requested again at removal, because a controller that could delete its own permissions could also widen them.
- Certificates and host keys are pinned on first use. A device whose TLS certificate or SSH host key changes is refused, not clicked through.
- Removal is complete and tested. Adopt, use, remove, and the device is byte-for-byte as it was — there is a test that asserts exactly that against real hardware.
Over plain HTTP the session cookie cannot carry the Secure attribute, because
a browser silently drops a Secure cookie on an insecure origin and you would
be unable to sign in at all. On a trusted LAN that is a reasonable place to
start. If the controller is reachable from anywhere you do not fully trust, put
it behind TLS — the cookie attributes upgrade themselves automatically once the
request arrives over HTTPS or through a proxy that sets
X-Forwarded-Proto: https.
| File | What's in it |
|---|---|
docs/ARCHITECTURE.md |
Components, transport, data model, provisioning + rollback, telemetry, capability probing |
docs/DEVICE-BUDGET.md |
Target hardware classes, hard resource budgets, where the cost actually is |
docs/PARITY-MATRIX.md |
Every UniFi screen → OpenWrt source → verdict, with dependency tier |
docs/UI-SPEC.md |
Navigation map, layout system, validated design tokens, screen specs |
docs/ROADMAP.md |
Phases with acceptance criteria |
docs/RISKS.md |
What kills this project |
Every config apply goes out as:
uci.set (batched, stages only) → uci.apply {rollback: true, timeout: 90}
→ poll uci.confirm until it succeeds
→ timer cancelled, change is permanent
(if confirm never lands, the device reverts itself)
Note: no uci.commit before apply — apply is what commits the staged
delta with the rollback snapshot. Committing first silently disarms the
protection. See ARCHITECTURE §4.
This mechanism already exists in OpenWrt — it is what LuCI's apply countdown uses. Build it in Phase 0 and test it by deliberately breaking a device. Without it, one bad VLAN push means a car trip, and the project dies in its first week of real use.
Three classes, weakest one sets the rules: A WRT3200ACM (roomy — 512 MB RAM, 256 MB NAND), B MT7981/Filogic AX3000 units (the sweet spot), C MT7621 AX3000 units (880 MHz MIPS, often 16 MB flash — this class sets the budget).
| Condition | CPU on class C | Flash writes |
|---|---|---|
| Idle, no UI open | < 0.5% | zero |
| A UI screen showing this device is open | < 3% | zero |
Zero new daemons by default. Everything beyond stock is opt-in per device with
its cost stated. Collection is demand-driven: baseline ~60s always, focused
5–10s only while someone is looking. See DEVICE-BUDGET.md.
The one tradeoff you can't engineer away — now narrower than we thought. Per-client bandwidth accounting needs connection accounting, which hardware flow offloading bypasses on the MT7621-class parts that need it to route at gigabit. Software offloading does not: measured on kernel 6.12 with an nftables flowtable and a flow confirmed in the fast path, conntrack byte counters stayed complete. So the conflict is real only where hardware offload is, and remains untested there. Either way we never change offload settings silently — we state the tradeoff and let the user choose. Default: leave it alone, accounting off.
Ownership tagging. oonfeeWRT only ever writes UCI sections it created, marked
with option oonfeewrt '1'. Anything a human wrote in LuCI or over SSH is read
for display and never touched. Conflicts are surfaced loudly, never resolved
silently.
You are a guest on someone else's router. Act like one.
Apache License 2.0. See LICENSE and NOTICE.
That choice has a practical consequence worth stating, because it decides what this project may borrow from:
| Source | License | Usable here |
|---|---|---|
| LuCI — drives the same rpcd/ubus API we do | Apache-2.0 | ✅ Compatible. Attribute in NOTICE |
rpcd, uhttpd interfaces |
ISC | ✅ Permissive |
| GL.iNet firmware and packages | GPL-2.0 | ❌ Incompatible with Apache-2.0 |
This lands the right way round. LuCI is both the legally compatible option and
the technically relevant one — it is the only widely-deployed client that talks
to rpcd over HTTP the way a controller must, so its handling of sessions,
batching and ACLs is grounded in the same constraints we measured.
Vendor firmware, GL.iNet's included, is the opposite on both counts: licensed incompatibly, and architecturally inverted — it runs on the router, as root, over the local ubus socket, managing one device. Almost none of the behaviour this project had to discover (session-bound confirm, the two denial channels, ACL scoping, the armed-window token) is visible from that position, so there is little there to learn from even setting the license aside.