feat: implement ZeroTier exit gateway switcher
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.
This commit is contained in:
106
README.md
106
README.md
@@ -17,21 +17,107 @@ disruption.
|
||||
the new gateway immediately. Auto-falls-back to a force switch after a
|
||||
configurable timeout if drain does not complete.
|
||||
|
||||
## Status
|
||||
## Files
|
||||
|
||||
> **Planning / pre-implementation.**
|
||||
```
|
||||
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 full design — file structure, UCI config schema, rpcd backend (ucode),
|
||||
`zt-gateway-switch` shell script, LuCI frontend, switch logic for both modes,
|
||||
persistence model, health checks, rollback/safety, Docker macvlan test harness,
|
||||
and build/install instructions — is the source of truth in:
|
||||
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:
|
||||
|
||||
[`docs/.omp/plans/zerotier-gateway-switching.md`](.omp/plans/zerotier-gateway-switching.md)
|
||||
| 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 |
|
||||
|
||||
(also tracked at `.omp/plans/zerotier-gateway-switching.md`)
|
||||
## UCI config
|
||||
|
||||
Implementation (Makefile, ucode backend, switch script, `overview.js`, UCI
|
||||
config, ACL, menu entry) lands in subsequent commits against this repository.
|
||||
`/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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user