Files
luci-app-zt-gateway/README.md

125 lines
5.7 KiB
Markdown
Raw Normal View History

# 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 <ip> <mode> [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 | `<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