Cookbook · 1 of 3

Develop and test

Throwaway OpenWrt routers in Docker, so you can see your package running before anyone else does. You need Docker and nothing else.

Start a router

go install owfeed.org/owlab/cmd/owlab@latest

cd my-luci-app
owlab up                  # start it
owlab open owrt2512       # LuCI in your browser

Log in as root with an empty password. The router is disposable — break it, then owlab up --rebuild.

See your changes without rebuilding

owlab sync --watch

Copies your sources into the running router on every save and reloads LuCI. This is the loop you spend the day in; building a package is for later.

owlab copies the same directories luci.mk installs — htdocs, ucode, luasrc, root. If your package lives in a subdirectory, say so:

project:
  install:
    my-package/htdocs: /www
    my-package/ucode:  /usr/share/ucode/luci
    my-package/root:   /

Cover both release lines

version: 1
routers:
  - id: owrt2512
    release: "25.12.5"
  - id: owrt2410
    release: "24.10.8"

25.12 uses apk, 24.10 uses opkg, and they agree on almost nothing. A package that works on one can fail on the other, so both are worth having open.

Pin exact point releases. snapshot images and snapshot feeds are rebuilt daily and independently, so installs start failing within a day. owlab releases tells you when a pin has fallen behind.

Make the router look inhabited

An empty router renders almost nothing — no zone badges, no VLANs, no DHCP leases, no tables with rows in them. That is a poor place to develop a page against:

defaults:
  fixtures: [all]

Individual profiles: networks, clients, wireguard, portforwards, system, wifi.

The whole check in one command

owlab test --release 25.12.5 --release 24.10.8 \
  --install 'dist/*/luci-app-mine-*' \
  --assert 'http 200 /cgi-bin/luci/admin/services/mine' \
  --assert 'service mined'

Starts the routers, installs, asserts, tears down, exits 0 or 1. This is what CI runs, and running it locally first is how you find out before the pull request does.

Two things it does that a hand-written curl does not. It logs into LuCI first, because every admin page answers a redirect otherwise — which is why the obvious check reports 403 on a perfectly healthy page. And it fails a 200 whose body is an error page: LuCI's dispatcher returns 200 with the traceback in it when it catches an exception.

Full list of assertion kinds: Commands and config.

When a kernel module is involved

routers:
  - id: real
    fidelity: vm
    release: "25.12.5"
    packages: ["+kmod-nft-nat"]

A container shares the host kernel, so kmod-* packages install and do nothing. fidelity: vm is QEMU with OpenWrt's own kernel — first start takes about a minute and a half, after that twenty seconds. It also gets simulated radios, so the wireless pages show a router instead of a config file.