'use strict'; let uci = require('uci'); let fs = require('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 = []; cursor.foreach('zt-gateway', 'gateway', function(s) { 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 shell_quote(s) { if (!s) { return "''"; } if (match(s, /^[A-Za-z0-9_./:@=-]+$/) != null) { return s; } return `'${replace(s, "'", "'\\''")}'`; } 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-${time()}`; system(`${cmd} >${tmp} 2>/dev/null`); const out = fs.readfile(tmp) ?? ''; fs.unlink(tmp); return out; } return { 'zt-gateway': { status: { call: function(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' }; return { 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 } }; } }, switch: { args: { region: 'string', mode: 'string' }, call: function(req) { const region = req.args?.region; const mode = req.args?.mode ?? read_global_option('switch_mode', 'force'); if (!region || (mode !== 'force' && mode !== 'graceful')) { return { success: false, message: 'Missing or invalid argument: region required, mode must be force|graceful' }; } if (drain_active() && mode === 'graceful') { return { success: false, message: 'A graceful drain is already in progress. Cancel it first.' }; } const gw = gateway_by_region(region); if (!gw) { return { success: false, message: `Unknown gateway region: ${region}` }; } const result = run_switch_script_capture(gw.ip, mode); if (result.code !== 0) { return { success: false, message: result.stderr || `Switch script failed with exit code ${result.code}` }; } const cursor = uci.cursor(); cursor.set('zt-gateway', 'global', 'active_gateway', region); cursor.commit('zt-gateway'); return { success: true, message: `Switched to ${region} (${gw.ip}) via ${mode}`, active_gateway: region, mode: mode, drain_active: (mode === 'graceful') }; } }, health: { args: { region: 'string' }, call: function(req) { const region = req.args?.region; if (!region) { return { reachable: false, message: 'Missing region' }; } const gw = gateway_by_region(region); if (!gw) { return { reachable: false, message: `Unknown region ${region}` }; } 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]; } } return { region: region, ip: gw.ip, reachable: exit_code === 0, latency_ms: latency_ms }; } }, drain_status: { call: function(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; } return { active: active, new_ip: state.new_ip, old_ip: state.old_ip, remaining_connections: remaining }; } }, cancel_drain: { call: function(req) { if (!drain_active()) { return { success: false, message: 'No drain in progress' }; } 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); return { success: true, message: 'Drain cancelled (new gateway unknown; route left as-is).' }; } const result = run_switch_script_force_capture(state.new_ip); fs.unlink(DRAIN_PIDFILE); fs.unlink(DRAIN_NEWFILE); fs.unlink(DRAIN_OLDFILE); return { success: result.code === 0, message: result.code === 0 ? `Drain cancelled; force-switched to ${state.new_ip}.` : (result.stderr || `Cancel failed with code ${result.code}`) }; } }, setup: { args: { command: 'string' }, call: function(req) { const command = req.args?.command; if (!command || !match(command, /^(status|setup-bridge|setup-routing|setup-dhcp|setup-wifi-ap|setup-hotplug|setup-persistence|setup-all)$/)) { return { success: false, message: 'Invalid command. Valid: status, setup-bridge, setup-routing, setup-dhcp, setup-wifi-ap, setup-hotplug, setup-persistence, setup-all' }; } const stderr_pipe = '/tmp/zt-gw-setup-stderr'; const code = system(`/usr/sbin/zt-gateway-setup ${shell_quote(command)} 2>${stderr_pipe}`); const err = fs.readfile(stderr_pipe) ?? ''; fs.unlink(stderr_pipe); if (code !== 0) { return { success: false, message: trim(err) || `Setup failed with exit code ${code}` }; } return { success: true, message: trim(err) || `${command} completed successfully`, command: command }; } } } };