# 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`](.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 [drain_timeout]`. ## Verified behavior The following scenarios have been exercised against the Docker harness: - Force switch: `ip route show table 100` reflects the new gateway, host route and mwan3 table 1 return route preserved. - Graceful drain: table 100 → new gateway, table 101 → old gateway, fwmark `0x100` rule 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 `force` and `graceful` modes). - 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 | ` dev br-zt` | | Kernel: table 100 default | `default via 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