Malar Invention c6c5a04aca chore: clean up repo structure
- Add node_modules/, test-results/, e2e-report/, .omp/ to .gitignore
- Remove throwaway debug scripts (debug-pw*.js, debug-login-dom.js)
- Remove empty mock-server/ directory
- Untrack harness artifact (.omp/plans/)
- Add missing project files to git (e2e tests, Dockerfiles, tooling configs)
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30
2026-07-12 23:42:54 +05:30

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

Description
LuCI app for OpenWRT that switches ZeroTier gateways
Readme 59 KiB
Languages
Shell 48.9%
JavaScript 27.2%
UnrealScript 22.6%
Makefile 1.3%