Files
luci-app-zt-gateway/README.md
Malar Invention eb04a2597d 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.
2026-06-19 02:51:21 +05:30

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 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