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:
2026-06-19 02:51:21 +05:30
parent f6bf6e7cd4
commit eb04a2597d
12 changed files with 1376 additions and 10 deletions

View File

@@ -0,0 +1,27 @@
config global 'global'
option active_gateway 'amsterdam'
option switch_mode 'force'
option drain_timeout '600'
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'
config gateway
option region 'tirunelveli'
option label 'Tirunelveli (rpi1000)'
option ip '10.11.12.5'
option default '0'
option health_check 'ping'
config gateway
option region 'bangalore'
option label 'Bangalore (sensecap-m4)'
option ip '10.11.12.4'
option default '0'
option health_check 'ping'

420
root/usr/sbin/zt-gateway-switch Executable file
View File

@@ -0,0 +1,420 @@
#!/bin/sh
# shellcheck shell=sh
#
# zt-gateway-switch — reconfigure which remote ZeroTier node acts as the
# internet exit gateway for WIBLAN clients (10.11.13.0/24).
#
# Usage:
# zt-gateway-switch <new_ip> <mode> [drain_timeout]
#
# Modes:
# force - Instant cutover + conntrack flush
# graceful - fwmark drain; falls back to force on timeout
#
# This script is callable directly from SSH for testing; the LuCI rpcd
# backend (/usr/share/rpcd/ucode/zt-gateway.uc) wraps it for the UI.
#
# Exit codes:
# 0 success
# 1 usage error
# 2 pre-flight failed (gateway unreachable)
# 3 runtime failure (route/iptables/uci update failed)
#
# Environment overrides (used by the Docker test harness as much as by
# production):
# ZTG_BRIDGE bridge device (default: br-zt)
# ZTG_WIBLAN_CIDR WIBLAN source subnet (default: 10.11.13.0/24)
# ZTG_TABLE_MAIN main policy table (default: 100)
# ZTG_TABLE_DRAIN drain policy table (default: 101)
# ZTG_TABLE_MWAN mwan3 return-traffic table (default: 1)
# ZTG_FWMARK conntrack mark used for drain (default: 0x100)
# ZTG_DRAIN_PRIORITY priority of the fwmark drain rule (default: 99)
# ZTG_HOTPLUG hotplug script path
# ZTG_RCLOCAL rc.local path
# ZTG_DRAIN_PIDFILE pidfile for the drain monitor
# ZTG_DRAIN_NEWFILE file recording the new gateway IP during drain
# ZTG_DRAIN_OLDFILE file recording the old gateway IP during drain
# ZTG_SKIP_PERSIST if set to 1, skip persistence (testing)
# ZTG_PING_IFACE interface for the pre-flight ping (default: br-zt)
#
# This script intentionally uses only POSIX sh + busybox-compatible
# utilities so the same code path runs on the router and in the Docker
# openwrt/rootfs test image.
set -eu
# ----------------------------------------------------------------------------
# Config
# ----------------------------------------------------------------------------
BRIDGE="${ZTG_BRIDGE:-br-zt}"
WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}"
TABLE_MAIN="${ZTG_TABLE_MAIN:-100}"
TABLE_DRAIN="${ZTG_TABLE_DRAIN:-101}"
TABLE_MWAN="${ZTG_TABLE_MWAN:-1}"
DRAIN_PRIORITY="${ZTG_DRAIN_PRIORITY:-99}"
FWMARK="${ZTG_FWMARK:-0x100}"
HOTPLUG="${ZTG_HOTPLUG:-/etc/hotplug.d/net/99-zerotier-bridge}"
RCLOCAL="${ZTG_RCLOCAL:-/etc/rc.local}"
DRAIN_PIDFILE="${ZTG_DRAIN_PIDFILE:-/var/run/zt-gateway-drain.pid}"
DRAIN_NEWFILE="${ZTG_DRAIN_NEWFILE:-/var/run/zt-gateway-drain.new}"
DRAIN_OLDFILE="${ZTG_DRAIN_OLDFILE:-/var/run/zt-gateway-drain.old}"
PING_IFACE="${ZTG_PING_IFACE:-$BRIDGE}"
SKIP_PERSIST="${ZTG_SKIP_PERSIST:-0}"
# ----------------------------------------------------------------------------
# Logging
# ----------------------------------------------------------------------------
log() { printf '[zt-gateway-switch] %s\n' "$*" >&2; }
die() { rc=$1; shift; log "ERROR: $*"; exit "$rc"; }
# ----------------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------------
valid_ip() {
ip=$1
# POSIX-only IPv4 validator (no awk regex extensions required).
rest=$ip
i=0
while [ $i -lt 4 ]; do
oct=${rest%%.*}
if [ "$oct" = "$rest" ]; then
# last octet
rest=
else
rest=${rest#*.}
fi
case "$oct" in
''|*[!0-9]*) return 1 ;;
esac
[ "$oct" -ge 0 ] 2>/dev/null || return 1
[ "$oct" -le 255 ] || return 1
i=$((i+1))
done
[ $i -eq 4 ] || return 1
[ -z "$rest" ] || return 1
[ "$ip" != "$rest" ] || return 1
# Reject leading/trailing dots already handled above; accept.
return 0
}
preflight_ping() {
gw_ip=$1
log "pre-flight: ping ${PING_IFACE}->${gw_ip}"
if ping -c 2 -W 3 -I "$PING_IFACE" "$gw_ip" >/dev/null 2>&1; then
return 0
fi
# Retry without -I in case the harness lacks the iface binding.
if ping -c 2 -W 3 "$gw_ip" >/dev/null 2>&1; then
return 0
fi
return 1
}
# Replace OLD_IP with NEW_IP in a file. IPv4 only; dots escaped for sed.
_replace_ip_in_file() {
file=$1
old=$2
new=$3
[ -f "$file" ] || { log "warning: $file not found; skipping"; return 0; }
old_re=$(printf '%s\n' "$old" | sed 's/[.]/\\./g')
sed -i "s/${old_re}/${new}/g" "$file"
}
set_host_route() {
gw_ip=$1
ip route replace "$gw_ip" dev "$BRIDGE"
}
set_table_default() {
gw_ip=$1
table=$2
ip route replace default via "$gw_ip" dev "$BRIDGE" table "$table"
}
del_table_default() {
table=$1
ip route del default dev "$BRIDGE" table "$table" 2>/dev/null || true
}
ensure_mwan_return() {
ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MWAN"
}
# Extract the gateway IP from the table N default route, or echo empty.
current_gateway_of() {
table=$1
ip route show table "$table" 2>/dev/null \
| awk '/^[[:space:]]*default/ {
for (i = 1; i <= NF; i++)
if ($i == "via") { print $(i+1); exit }
}'
}
# ----------------------------------------------------------------------------
# Persistence
# ----------------------------------------------------------------------------
persist_all() {
new_ip=$1
old_ip=${2:-}
if [ "$SKIP_PERSIST" = "1" ]; then
log "ZTG_SKIP_PERSIST=1; skipping persistence"
return 0
fi
if [ -n "$old_ip" ]; then
_replace_ip_in_file "$HOTPLUG" "$old_ip" "$new_ip"
_replace_ip_in_file "$RCLOCAL" "$old_ip" "$new_ip"
fi
if command -v uci >/dev/null 2>&1; then
if uci -q get network.zt_gateway_host >/dev/null 2>&1; then
uci set network.zt_gateway_host.target="$new_ip"
else
uci -q set network.zt_gateway_host=config route
uci -q set network.zt_gateway_host.target="$new_ip"
uci -q set network.zt_gateway_host.interface='br-zt'
fi
if uci -q get network.zt_gateway_default >/dev/null 2>&1; then
uci set network.zt_gateway_default.gateway="$new_ip"
else
uci -q set network.zt_gateway_default=config route
uci -q set network.zt_gateway_default.target='0.0.0.0'
uci -q set network.zt_gateway_default.netmask='0.0.0.0'
uci -q set network.zt_gateway_default.gateway="$new_ip"
uci -q set network.zt_gateway_default.table="$TABLE_MAIN"
fi
uci -q commit network || true
fi
}
# ----------------------------------------------------------------------------
# Force switch
# ----------------------------------------------------------------------------
do_force() {
new_ip=$1
old_ip=${2:-}
preflight_ping "$new_ip" || die 2 "gateway ${new_ip} is unreachable over ${PING_IFACE}"
set_host_route "$new_ip"
set_table_default "$new_ip" "$TABLE_MAIN"
ensure_mwan_return
if command -v conntrack >/dev/null 2>&1; then
conntrack -D -s "$WIBLAN_CIDR" 2>/dev/null || true
else
log "warning: conntrack not present; skipping flush"
fi
persist_all "$new_ip" "$old_ip"
if ip route show table "$TABLE_MAIN" 2>/dev/null | grep -q "default via ${new_ip}"; then
log "force: table ${TABLE_MAIN} default via ${new_ip} confirmed"
else
die 3 "verification failed: default via ${new_ip} not in table ${TABLE_MAIN}"
fi
printf 'force switch to %s complete\n' "$new_ip"
}
# ----------------------------------------------------------------------------
# Graceful drain switch
# ----------------------------------------------------------------------------
mangle_rules_install() {
# Source-only match: WIBLAN clients reach the router on whatever LAN
# interface they live on; binding to -i br-zt would miss the actual
# inbound path. We scope by source subnet so the rule fires regardless
# of ingress interface, then CONNMARK restores/marks/saves per-flow.
iptables -t mangle -A PREROUTING -s "$WIBLAN_CIDR" \
-m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark
iptables -t mangle -A PREROUTING -s "$WIBLAN_CIDR" \
-m conntrack --ctstate ESTABLISHED,RELATED -m mark --mark 0 -j MARK --set-mark "$FWMARK"
iptables -t mangle -A PREROUTING -s "$WIBLAN_CIDR" \
-j CONNMARK --save-mark
}
mangle_rules_remove() {
iptables -t mangle -D PREROUTING -s "$WIBLAN_CIDR" \
-j CONNMARK --save-mark 2>/dev/null || true
iptables -t mangle -D PREROUTING -s "$WIBLAN_CIDR" \
-m conntrack --ctstate ESTABLISHED,RELATED -m mark --mark 0 -j MARK --set-mark "$FWMARK" 2>/dev/null || true
iptables -t mangle -D PREROUTING -s "$WIBLAN_CIDR" \
-m conntrack --ctstate ESTABLISHED,RELATED -j CONNMARK --restore-mark 2>/dev/null || true
}
drain_install_rules() {
old_ip=$1
ip route replace default via "$old_ip" dev "$BRIDGE" table "$TABLE_DRAIN"
mangle_rules_install
ip rule add fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" 2>/dev/null || \
ip rule replace fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY"
}
drain_cleanup() {
log "drain: cleaning up mangle rules, drain table, and pidfiles"
mangle_rules_remove
ip rule del fwmark "$FWMARK" table "$TABLE_DRAIN" priority "$DRAIN_PRIORITY" 2>/dev/null || true
del_table_default "$TABLE_DRAIN"
rm -f "$DRAIN_PIDFILE" "$DRAIN_NEWFILE" "$DRAIN_OLDFILE" 2>/dev/null || true
}
drain_count_marked() {
# Count conntrack entries carrying the drain mark. /proc/net/nf_conntrack
# reports marks in decimal (e.g. mark=256 for 0x100), so convert FWMARK.
mark_dec=$(( FWMARK + 0 ))
mark_hex="0x$(printf '%x' "$mark_dec")"
# Preferred: conntrack CLI with -m filter (conntrack-tools >= 1.4.4).
if command -v conntrack >/dev/null 2>&1; then
count=$(conntrack -L -m "$mark_hex" 2>/dev/null | grep -c . || true)
if [ -n "$count" ] && [ "$count" -gt 0 ]; then
echo "$count"
return
fi
fi
# Fallback: parse /proc/net/nf_conntrack. Works without the CLI and
# also catches entries the CLI filter might miss on older builds.
awk -v mdec="$mark_dec" -v mhex="$mark_hex" '
BEGIN { n = 0 }
{
if (index($0, "mark=" mdec) || index($0, "mark=" mhex)) n++
}
END { print n }
' /proc/net/nf_conntrack 2>/dev/null || echo 0
}
drain_monitor() {
new_ip=$1
old_ip=$2
timeout=$3
# Grace period before the first count: packets already in flight need
# at least one PREROUTING pass through the mangle rules before their
# conntrack entry picks up mark=0x100. Without this cushion the very
# first count races the mangle hook and reports zero prematurely.
DRAIN_INITIAL_GRACE="${ZTG_DRAIN_INITIAL_GRACE:-3}"
# Number of consecutive zero-count polls required before we declare
# the drain complete. Guards against transient empty reads that race
# the conntrack update.
DRAIN_MIN_ZERO_POLLS="${ZTG_DRAIN_MIN_ZERO_POLLS:-2}"
DRAIN_POLL_INTERVAL="${ZTG_DRAIN_POLL_INTERVAL:-5}"
sleep "$DRAIN_INITIAL_GRACE"
end=$(( $(date +%s) + timeout ))
consecutive_zeros=0
while :; do
remaining=$(drain_count_marked)
log "drain: ${remaining} marked connection(s) remaining (consecutive_zeros=${consecutive_zeros})"
if [ "$remaining" -eq 0 ]; then
consecutive_zeros=$(( consecutive_zeros + 1 ))
if [ "$consecutive_zeros" -ge "$DRAIN_MIN_ZERO_POLLS" ]; then
log "drain: complete (${DRAIN_MIN_ZERO_POLLS} consecutive zero polls)"
drain_cleanup
return 0
fi
else
consecutive_zeros=0
fi
if [ "$(date +%s)" -ge "$end" ]; then
log "drain: timeout (${timeout}s) reached; falling back to force"
set_host_route "$new_ip"
set_table_default "$new_ip" "$TABLE_MAIN"
ensure_mwan_return
if command -v conntrack >/dev/null 2>&1; then
conntrack -D -s "$WIBLAN_CIDR" 2>/dev/null || true
fi
drain_cleanup
persist_all "$new_ip" "$old_ip"
return 0
fi
sleep "$DRAIN_POLL_INTERVAL"
done
}
do_graceful() {
new_ip=$1
timeout=${2:-600}
old_ip=$(current_gateway_of "$TABLE_MAIN")
if [ -z "$old_ip" ]; then
log "graceful: no current gateway in table ${TABLE_MAIN}; falling back to force"
do_force "$new_ip" ""
return $?
fi
if [ "$old_ip" = "$new_ip" ]; then
log "graceful: new gateway equals current gateway (${new_ip}); nothing to do"
return 0
fi
preflight_ping "$new_ip" || die 2 "gateway ${new_ip} is unreachable over ${PING_IFACE}"
printf '%s\n' "$new_ip" >"$DRAIN_NEWFILE"
printf '%s\n' "$old_ip" >"$DRAIN_OLDFILE"
drain_install_rules "$old_ip"
set_host_route "$new_ip"
set_table_default "$new_ip" "$TABLE_MAIN"
ensure_mwan_return
persist_all "$new_ip" "$old_ip"
: >"$DRAIN_PIDFILE"
(
trap 'drain_cleanup; exit 0' TERM INT
echo $$ >"$DRAIN_PIDFILE"
drain_monitor "$new_ip" "$old_ip" "$timeout"
rm -f "$DRAIN_PIDFILE" "$DRAIN_NEWFILE" "$DRAIN_OLDFILE" 2>/dev/null || true
) >/tmp/zt-gateway-drain.log 2>&1 &
printf 'graceful switch to %s started (draining from %s, timeout %ss)\n' \
"$new_ip" "$old_ip" "$timeout"
}
# ----------------------------------------------------------------------------
# Entry point
# ----------------------------------------------------------------------------
main() {
if [ $# -lt 2 ]; then
cat >&2 <<'USAGE'
Usage: zt-gateway-switch <new_ip> <mode> [drain_timeout]
Modes:
force Instant cutover + conntrack flush
graceful fwmark drain; falls back to force on timeout
Options:
drain_timeout graceful-mode drain timeout in seconds (default: 600)
USAGE
die 1 "missing arguments"
fi
new_ip=$1
mode=$2
timeout=${3:-600}
if ! valid_ip "$new_ip"; then
die 1 "invalid IPv4 address: ${new_ip}"
fi
case "$mode" in
force)
old_ip=$(current_gateway_of "$TABLE_MAIN")
do_force "$new_ip" "${old_ip:-}"
;;
graceful)
do_graceful "$new_ip" "$timeout"
;;
*)
die 1 "unknown mode: ${mode} (use force|graceful)"
;;
esac
}
main "$@"

View File

@@ -0,0 +1,14 @@
{
"admin/services/zt-gateway": {
"title": "ZeroTier Gateway",
"order": 90,
"action": {
"type": "view",
"path": "zt-gateway/overview"
},
"depends": {
"acl": ["luci-app-zt-gateway"],
"uci": { "zt-gateway": true }
}
}
}

View File

@@ -0,0 +1,25 @@
{
"luci-app-zt-gateway": {
"description": "ZeroTier Exit Gateway switching",
"read": {
"uci": ["zt-gateway", "network"],
"ubus": {
"zt-gateway": ["status", "health", "drain_status"]
},
"file": {
"/etc/hotplug.d/net/99-zerotier-bridge": ["read"],
"/etc/rc.local": ["read"]
}
},
"write": {
"uci": ["zt-gateway", "network"],
"ubus": {
"zt-gateway": ["switch", "cancel_drain"]
},
"file": {
"/etc/hotplug.d/net/99-zerotier-bridge": ["write"],
"/etc/rc.local": ["write"]
}
}
}
}

View File

@@ -0,0 +1,301 @@
'use strict';
import { ubus } from 'ubus';
import { uci } from 'uci';
import { fs } from 'fs';
const SWITCH_SCRIPT = '/usr/sbin/zt-gateway-switch';
const DRAIN_PIDFILE = '/var/run/zt-gateway-drain.pid';
const DRAIN_NEWFILE = '/var/run/zt-gateway-drain.new';
const DRAIN_OLDFILE = '/var/run/zt-gateway-drain.old';
function load_gateways() {
const cursor = uci.cursor();
const result = [];
const sections = uci.sections(cursor, 'zt-gateway', 'gateway');
for (let i = 0; i < length(sections); i++) {
const s = sections[i];
push(result, {
region: s.region ?? '',
label: s.label ?? s.region ?? '',
ip: s.ip ?? '',
default: s.default === '1',
health_check: s.health_check ?? 'ping'
});
}
return result;
}
function gateway_by_region(region) {
const gws = load_gateways();
for (let i = 0; i < length(gws); i++) {
if (gws[i].region === region) {
return gws[i];
}
}
return null;
}
function read_active_region() {
const cursor = uci.cursor();
return cursor.get('zt-gateway', 'global', 'active_gateway') ?? '';
}
function read_global_option(option, default_value) {
const cursor = uci.cursor();
return cursor.get('zt-gateway', 'global', option) ?? default_value;
}
function drain_active() {
const st = fs.stat(DRAIN_PIDFILE);
return st?.type === 'file';
}
function read_drain_pid() {
const data = fs.readfile(DRAIN_PIDFILE);
if (!data) {
return null;
}
const s = trim(data);
return length(s) > 0 ? +s : null;
}
function read_drain_state() {
let new_ip = '';
let old_ip = '';
const n = fs.readfile(DRAIN_NEWFILE);
if (n) {
new_ip = trim(n);
}
const o = fs.readfile(DRAIN_OLDFILE);
if (o) {
old_ip = trim(o);
}
return { new_ip, old_ip };
}
function run_switch_script_capture(ip, mode) {
const mode_lc = mode ?? 'force';
let cmd = `${SWITCH_SCRIPT} ${shell_quote(ip)} ${shell_quote(mode_lc)}`;
if (mode_lc === 'graceful') {
const to = +read_global_option('drain_timeout', '600');
cmd += ` ${to > 0 ? to : 600}`;
}
const stderr_pipe = '/tmp/zt-gw-stderr';
const code = system(`${cmd} 2>${stderr_pipe}`);
const err = fs.readfile(stderr_pipe) ?? '';
fs.unlink(stderr_pipe);
return { code: code ?? 0, stderr: trim(err) };
}
function run_switch_script_force_capture(ip) {
const stderr_pipe = '/tmp/zt-gw-stderr';
const code = system(`${SWITCH_SCRIPT} ${shell_quote(ip)} force 2>${stderr_pipe}`);
const err = fs.readfile(stderr_pipe) ?? '';
fs.unlink(stderr_pipe);
return { code: code ?? 0, stderr: trim(err) };
}
function system_output(cmd) {
const tmp = `/tmp/zt-gw-out-${getpid()}`;
system(`${cmd} >${tmp} 2>/dev/null`);
const out = fs.readfile(tmp) ?? '';
fs.unlink(tmp);
return out;
}
function shell_quote(s) {
if (!s) {
return "''";
}
if (s ~ /^[A-Za-z0-9_./:@=-]+$/) {
return s;
}
return `'${replace(s, "'", "'\\''")}'`;
}
function rpc_status(req) {
const gws = load_gateways();
const active_region = read_active_region();
let active_ip = '';
const active_gw = gateway_by_region(active_region);
if (active_gw) {
active_ip = active_gw.ip;
}
const drain_state = read_drain_state();
let remaining = 0;
if (drain_active()) {
const out = system_output(`conntrack -L -m 0x100 2>/dev/null | wc -l`);
remaining = +trim(out) || 0;
}
const cursor = uci.cursor();
const settings = {
switch_mode: cursor.get('zt-gateway', 'global', 'switch_mode') ?? 'force',
drain_timeout: +(cursor.get('zt-gateway', 'global', 'drain_timeout') ?? 600),
health_interval: +(cursor.get('zt-gateway', 'global', 'health_interval') ?? 60),
auto_failback: cursor.get('zt-gateway', 'global', 'auto_failback') === '1'
};
ubus.reply(req, {
active_gateway: active_region,
active_ip: active_ip,
settings: settings,
gateways: gws,
drain: {
active: drain_active(),
new_ip: drain_state.new_ip,
old_ip: drain_state.old_ip,
remaining_connections: remaining
}
});
}
function rpc_switch(req, msg) {
const region = msg?.region;
const mode = msg?.mode ?? read_global_option('switch_mode', 'force');
if (!region || (mode !== 'force' && mode !== 'graceful')) {
ubus.reply(req, {
success: false,
message: 'Missing or invalid argument: region required, mode must be force|graceful'
});
return;
}
if (drain_active() && mode === 'graceful') {
ubus.reply(req, {
success: false,
message: 'A graceful drain is already in progress. Cancel it first.'
});
return;
}
const gw = gateway_by_region(region);
if (!gw) {
ubus.reply(req, { success: false, message: `Unknown gateway region: ${region}` });
return;
}
const current_region = read_active_region();
if (current_region === region && !drain_active()) {
ubus.reply(req, { success: true, message: `Already on ${region}.` });
return;
}
const result = run_switch_script_capture(gw.ip, mode);
if (result.code !== 0) {
ubus.reply(req, {
success: false,
message: result.stderr || `Switch script failed with exit code ${result.code}`
});
return;
}
const cursor = uci.cursor();
cursor.set('zt-gateway', 'global', 'active_gateway', region);
cursor.commit('zt-gateway');
ubus.reply(req, {
success: true,
message: `Switched to ${region} (${gw.ip}) via ${mode}`,
active_gateway: region,
mode: mode,
drain_active: (mode === 'graceful')
});
}
function rpc_health(req, msg) {
const region = msg?.region;
if (!region) {
ubus.reply(req, { reachable: false, message: 'Missing region' });
return;
}
const gw = gateway_by_region(region);
if (!gw) {
ubus.reply(req, { reachable: false, message: `Unknown region ${region}` });
return;
}
const out = system_output(
`ping -c 2 -W 3 -I br-zt ${shell_quote(gw.ip)} 2>/dev/null; echo "EXIT:$?"`
);
const exit_match = match(out, /EXIT:(\d+)/);
const exit_code = exit_match ? +exit_match[1] : 1;
let latency_ms = null;
if (exit_code === 0) {
const stats = match(out, /rtt min\/avg\/max[^=]*=\s*[\d.]+\/([\d.]+)\//);
if (stats) {
latency_ms = +stats[1];
}
}
ubus.reply(req, {
region: region,
ip: gw.ip,
reachable: exit_code === 0,
latency_ms: latency_ms
});
}
function rpc_drain_status(req) {
const active = drain_active();
const state = read_drain_state();
let remaining = 0;
if (active) {
const out = system_output(`conntrack -L -m 0x100 2>/dev/null | wc -l`);
remaining = +trim(out) || 0;
}
ubus.reply(req, {
active: active,
new_ip: state.new_ip,
old_ip: state.old_ip,
remaining_connections: remaining
});
}
function rpc_cancel_drain(req) {
if (!drain_active()) {
ubus.reply(req, { success: false, message: 'No drain in progress' });
return;
}
const state = read_drain_state();
const pid = read_drain_pid();
if (pid) {
system(`kill -TERM ${pid} 2>/dev/null`);
}
if (!state.new_ip) {
fs.unlink(DRAIN_PIDFILE);
ubus.reply(req, {
success: true,
message: 'Drain cancelled (new gateway unknown; route left as-is).'
});
return;
}
const result = run_switch_script_force_capture(state.new_ip);
fs.unlink(DRAIN_PIDFILE);
fs.unlink(DRAIN_NEWFILE);
fs.unlink(DRAIN_OLDFILE);
ubus.reply(req, {
success: result.code === 0,
message: result.code === 0
? `Drain cancelled; force-switched to ${state.new_ip}.`
: (result.stderr || `Cancel failed with code ${result.code}`)
});
}
export default {
status: rpc_status,
switch: rpc_switch,
health: rpc_health,
drain_status: rpc_drain_status,
cancel_drain: rpc_cancel_drain
};