Adds the luci-app-zt-gateway package: a LuCI app + rpcd/ucode backend +
shell switch script that reconfigures which remote ZeroTier node acts as
the internet exit gateway for WIBLAN clients (10.11.13.0/24).
- Makefile (luci.mk, arch-independent)
- UCI config skeleton with three sample gateways
- rpcd ACL + menu entry
- zt-gateway.uc rpcd backend exposing status / switch / health /
drain_status / cancel_drain ubus methods
- zt-gateway-switch shell script implementing force + graceful modes:
* force: pre-flight ping, atomic route replace, conntrack flush,
UCI/hotplug/rc.local persistence
* graceful: dual-table drain using CONNMARK fwmark 0x100 at
priority 99, background drain monitor with two-consecutive-zero
completion and timeout-forced fallback to force
- LuCI overview.js: gateway radio list, mode select, drain progress
panel, cancel-drain button, health polling
- Docker test harness (docker-compose + Dockerfile.router +
router/gw entrypoints) exercising the switch script against real
iproute2/iptables/conntrack on two simulated exit nodes
Verified against the harness: force switch, graceful drain to natural
completion, pre-flight blocking of unreachable gateways (force + graceful),
and drain-timeout forced fallback.
5.7 KiB
luci-app-zt-gateway
A LuCI application for OpenWRT that lets users switch which remote ZeroTier node
acts as the internet exit gateway for WIBLAN clients (10.11.13.0/24).
Users select a region/gateway, choose a switch mode, and the app reconfigures kernel routing, conntrack, hotplug scripts, and UCI network routes with minimal disruption.
Switch modes
- Force — instant cutover (~1-2s disruption); existing connections break and clients auto-reconnect through the new gateway. Flushes conntrack for the WIBLAN subnet.
- Graceful drain — zero disruption; existing connections keep using the old gateway via fwmark + a drain routing table, while new connections go through the new gateway immediately. Auto-falls-back to a force switch after a configurable timeout if drain does not complete.
Files
luci-app-zt-gateway/
├── Makefile # OpenWRT build (luci.mk)
├── htdocs/luci-static/resources/view/zt-gateway/overview.js # LuCI UI
├── root/etc/config/zt-gateway # UCI config: gateway registry + state
├── root/usr/sbin/zt-gateway-switch # shell: actual switch logic
├── root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json # ACL
├── root/usr/share/rpcd/ucode/zt-gateway.uc # rpcd backend (ubus)
└── root/usr/share/luci/menu.d/luci-app-zt-gateway.json # menu entry
└── docker-compose.yml # local Docker test harness
└── Dockerfile.router # test image (archlinux + tools)
└── docker/router-entrypoint.sh # brings up br-zt + baseline routing
└── docker/gw-entrypoint.sh # simulated ZT exit node startup
The zt-gateway-switch script is callable directly from SSH for testing; the
rpcd/ucode backend wraps it for the LuCI UI and exposes these ubus methods
under the zt-gateway object:
| Method | Args | Description |
|---|---|---|
status |
— | active gateway, settings, gateway registry |
switch |
{ region, mode } |
execute a force or graceful switch |
health |
{ region } |
ping a gateway over br-zt, return latency |
drain_status |
— | graceful drain progress + remaining flows |
cancel_drain |
— | cancel drain and force-switch to the new gw |
UCI config
/etc/config/zt-gateway holds the gateway registry and active state:
config global 'global'
option active_gateway 'amsterdam' # region key of active gateway
option switch_mode 'force' # 'force' | 'graceful'
option drain_timeout '600' # graceful drain timeout (seconds)
option health_interval '60'
option auto_failback '0'
config gateway
option region 'amsterdam'
option label 'Amsterdam (ocirosea641)'
option ip '10.11.12.3'
option default '1'
option health_check 'ping'
Build & install
Build against the OpenWRT SDK with luci.mk (architecture-independent —
LUCI_PKGARCH:=all). Full instructions are in
.omp/plans/zerotier-gateway-switching.md.
A manual opkg-build cheat sheet, the apk install path for OpenWRT 25.12+,
and post-install cache clearing are all documented there too.
Local test harness (Docker)
End-to-end verification without a physical router is possible with the bundled
docker-compose.yml + Dockerfile.router + docker/*-entrypoint.sh. The
harness emulates two bridge networks (WIBLAN clients at 10.99.13.0/24 and
ZeroTier exit gateways at 10.99.12.0/24) plus two simulated exit nodes, and
exercises zt-gateway-switch against real iproute2, iptables, and the
host kernel's conntrack table.
Run it with docker compose up -d --build, then docker exec openwrt-router zt-gateway-switch <ip> <mode> [drain_timeout].
Verified behavior
The following scenarios have been exercised against the Docker harness:
- Force switch:
ip route show table 100reflects the new gateway, host route and mwan3 table 1 return route preserved. - Graceful drain: table 100 → new gateway, table 101 → old gateway, fwmark
0x100rule at priority 99 inserted above the priority-100 src rule, ESTABLISHED WIBLAN connections keep flowing through the old gateway until they naturally age out, after which the mangle rules, drain table, and pidfiles are cleaned up automatically. - Pre-flight: switching to an unreachable gateway aborts with exit code 2 and
leaves routes untouched (verified for both
forceandgracefulmodes). - Drain timeout: a forced-fallback fires after the configured timeout, preserving the new gateway in table 100 and tearing down drain state.
Persistence model
The gateway IP is recorded in five places on the router; the switch script updates them all atomically:
| Location | What |
|---|---|
| Kernel: host route | <gw_ip> dev br-zt |
| Kernel: table 100 default | default via <gw_ip> dev br-zt table 100 |
UCI: /etc/config/network |
zt_gateway_host + zt_gateway_default routes |
Hotplug: 99-zerotier-bridge |
host route + table 100 route + mwan3 table 1 return |
Boot: /etc/rc.local |
all three routes |
The hotplug and rc.local scripts are rewritten via a dot-escaped sed replace
so a reboot boots against the new gateway.
License
Apache-2.0