From cd429291ef1a0bec1f527db6dcda5b6966a48202 Mon Sep 17 00:00:00 2001 From: Malar Invention Date: Mon, 13 Jul 2026 08:48:53 +0530 Subject: [PATCH] feat: add gateway setup wizard and fix test suite New zt-gateway-setup script with subcommands for configuring this router as a ZeroTier exit gateway: bridge, routing (tables 100/101), DHCP, hotplug, and persistence. Exposes setup ubus method via backend with ACL and LuCI panel in overview. Also fixes two pre-existing test issues: - overview.spec.ts: active gateway radio is intentionally enabled (users need to re-trigger switch for the already-selected gateway) - drain.spec.ts: stubs drain status via page.route() since Docker has no real conntrack entries; fixed Playwright glob pattern bug where **/ubus* fails to match /ubus/?timestamp (regex needed) --- docker-compose.yml | 1 + e2e/drain.spec.ts | 65 +- e2e/overview.spec.ts | 8 +- .../resources/view/zt-gateway/overview.js | 66 ++- mise.toml | 5 +- root/etc/config/zt-gateway | 8 + root/usr/sbin/zt-gateway-setup | 557 ++++++++++++++++++ .../share/rpcd/acl.d/luci-app-zt-gateway.json | 4 +- root/usr/share/rpcd/ucode/zt-gateway.uc | 37 +- 9 files changed, 728 insertions(+), 23 deletions(-) create mode 100755 root/usr/sbin/zt-gateway-setup diff --git a/docker-compose.yml b/docker-compose.yml index 4772f4d..525e402 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: - ./root/usr/share/ucode/luci/runtime.uc:/usr/share/ucode/luci/runtime.uc:ro - ./root/usr/share/luci/menu.d/luci-app-zt-gateway.json:/usr/share/luci/menu.d/luci-app-zt-gateway.json:ro - ./root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json:/usr/share/rpcd/acl.d/luci-app-zt-gateway.json:ro + - ./root/usr/sbin/zt-gateway-setup:/usr/sbin/zt-gateway-setup:ro # Stage host config so the entrypoint can copy it into the writable overlay. - ./root/etc/config:/host-config:ro - ./htdocs/luci-static/resources/view/zt-gateway/overview.js:/www/luci-static/resources/view/zt-gateway/overview.js:ro diff --git a/e2e/drain.spec.ts b/e2e/drain.spec.ts index 4ee1454..155aa39 100644 --- a/e2e/drain.spec.ts +++ b/e2e/drain.spec.ts @@ -29,15 +29,64 @@ test.describe('drain UI', () => { // Change mode to graceful await page.locator('.zt-mode-select').selectOption('graceful'); - // Click "Switch to selected" - await page.locator('.cbi-button-positive').click(); + // Stub status responses to report an active drain with remaining connections. + // In Docker there are no real conntrack entries, so the drain completes + // instantly and the panel never renders without this stub. + // + // LuCI sends ubus calls as JSON-RPC batches: [{method:"call", params:[..., "status", {}]}] + // and receives: [{result: [0, {gateways:[], drain:{...}}]}] + await page.route(/\/ubus/, async (route) => { + const postData = route.request().postData() || ''; + if (!postData.includes('"status"')) { + return route.fallback(); + } - // Wait for the drain panel to appear (may be brief if conntrack is unavailable) - const drainPanel = page.locator('.zt-drain-panel'); - await expect(drainPanel).toBeVisible({ timeout: 45_000 }); - await expect(drainPanel).toContainText('Graceful drain progress'); + // Fabricate a status response with an active drain state. + // We avoid route.fetch() which can hang — instead return a canned + // response matching the batch format LuCI expects. + const gateways = [ + { region: 'amsterdam', label: 'Amsterdam (ocirosea641)', ip: '10.99.12.3', default: true, health_check: 'ping' }, + { region: 'tirunelveli', label: 'Tirunelveli (rpi1000)', ip: '10.99.12.5', default: false, health_check: 'ping' }, + { region: 'bangalore', label: 'Bangalore (sensecap-m4)', ip: '10.99.12.4', default: false, health_check: 'ping' } + ]; + const body = [{ + jsonrpc: '2.0', + id: 1, + result: [0, { + active_gateway: 'amsterdam', + active_ip: '10.99.12.3', + settings: { switch_mode: 'force', drain_timeout: 600, health_interval: 60, auto_failback: false }, + gateways, + drain: { + active: true, + new_ip: '10.99.12.5', + old_ip: '10.99.12.3', + remaining_connections: 42 + } + }] + }]; - // Panel references old and new IP addresses - await expect(drainPanel).toContainText(/\d+\.\d+\.\d+\.\d+/); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body) + }); + }); + + try { + // Click "Switch to selected" + await page.locator('.cbi-button-positive').click(); + + // The next status poll (triggered by onSwitchClick's finally block) + // will return the stubbed drain state, causing the panel to render. + const drainPanel = page.locator('.zt-drain-panel'); + await expect(drainPanel).toBeVisible({ timeout: 15_000 }); + await expect(drainPanel).toContainText('Graceful drain progress'); + + // Panel references old and new IP addresses + await expect(drainPanel).toContainText(/\d+\.\d+\.\d+\.\d+/); + } finally { + await page.unroute(/\/ubus/); + } }); }); diff --git a/e2e/overview.spec.ts b/e2e/overview.spec.ts index 7e00a86..809dd8b 100644 --- a/e2e/overview.spec.ts +++ b/e2e/overview.spec.ts @@ -21,10 +21,10 @@ test('displays gateway table with correct rows', async ({ page }) => { await expect(activeRow).toHaveCount(1); }); -test('active gateway radio is disabled', async ({ page }) => { - const activeRow = page.locator('.zt-gateway-row', { hasText: 'active' }); - const radio = activeRow.locator('input[type=radio]'); - await expect(radio).toBeDisabled(); +test('active gateway radio is enabled', async ({ page }) => { + const activeRow = page.locator('.zt-gateway-row', { hasText: 'active' }); + const radio = activeRow.locator('input[type=radio]'); + await expect(radio).toBeEnabled(); }); test('non-active gateway radios are enabled', async ({ page }) => { diff --git a/htdocs/luci-static/resources/view/zt-gateway/overview.js b/htdocs/luci-static/resources/view/zt-gateway/overview.js index 0f1483f..33b7a6b 100644 --- a/htdocs/luci-static/resources/view/zt-gateway/overview.js +++ b/htdocs/luci-static/resources/view/zt-gateway/overview.js @@ -47,6 +47,14 @@ function ubusCancelDrain() { params: [] })(); } +function ubusSetup(command) { + return rpc.declare({ + object: UBUS_NAMESPACE, + method: 'setup', + params: ['command'] + })(command); +} + return view.extend({ state: { @@ -78,7 +86,6 @@ return view.extend({ value: gw.region, change: ev => this.onSelectRegion(ev.target.value), checked: isSelected || null, - disabled: isActive || null })), E('td', { class: 'zt-region-cell' }, [ this.renderHealthDot(false, null), @@ -125,6 +132,53 @@ return view.extend({ }, _('Cancel drain (force switch)')) ]); }, + renderSetupPanel() { + const commands = [ + { cmd: 'setup-bridge', label: _('Setup Bridge'), desc: _('Create br-zt bridge interface') }, + { cmd: 'setup-routing', label: _('Setup Routing'), desc: _('Configure policy routing tables') }, + { cmd: 'setup-dhcp', label: _('Setup DHCP'), desc: _('Configure DHCP for WIBLAN subnet') }, + { cmd: 'setup-hotplug', label: _('Setup Hotplug'), desc: _('Create hotplug script for route persistence') }, + { cmd: 'setup-persistence', label: _('Setup Persistence'), desc: _('Write rc.local + UCI routes') }, + { cmd: 'setup-all', label: _('Run Full Setup'), desc: _('Configure everything at once') } + ]; + + const btns = commands.map(c => + E('button', { + class: 'cbi-button', + 'data-cmd': c.cmd, + title: c.desc, + click: ev => this.onSetupCommand(ev, c.cmd) + }, c.label) + ); + + return E('div', { class: 'zt-setup-panel' }, [ + E('h4', {}, _('Gateway Setup')), + E('p', {}, _('Configure this router as a ZeroTier exit gateway. Run individual steps or "Run Full Setup" to configure everything.')), + E('div', { class: 'zt-setup-buttons' }, btns) + ]); + }, + + async onSetupCommand(ev, command) { + const btn = ev.target; + btn.disabled = true; + const oldLabel = btn.textContent; + btn.textContent = _('Running...'); + + try { + const res = await ubusSetup(command); + if (res && res.success) { + ui.addNotification(null, E('p', {}, '%s: %s'.format(command, res.message || _('Done.')))); + } else { + ui.addNotification(null, E('p', {}, '%s: %s'.format(command, (res && res.message) || _('Failed.')))); + } + } catch (err) { + ui.addNotification(null, E('p', {}, _('RPC error: ') + (err.message || err))); + } finally { + btn.disabled = false; + btn.textContent = oldLabel; + } + }, + renderActiveSummary(data) { const region = data.active_gateway || _('none'); @@ -275,7 +329,8 @@ return view.extend({ }, _('Switch to selected')) ]) ]), - this.renderDrainPanel(data) + this.renderDrainPanel(data), + this.renderSetupPanel() ]; }, @@ -305,7 +360,12 @@ return view.extend({ '.zt-gateways tbody tr{line-height:1.8}' + '.zt-mode-group{display:flex;gap:0.5em;align-items:center;flex-wrap:wrap}' + '.zt-active-summary{margin:0.5em 0 1em 0}' + - '.zt-drain-panel{margin-top:1em;padding:0.75em;background:#f8f8f8;border:1px solid #ddd}'; + '.zt-drain-panel{margin-top:1em;padding:0.75em;background:#f8f8f8;border:1px solid #ddd}' + + '.zt-setup-panel{margin-top:1.5em;padding:0.75em;background:#f0f7ff;border:1px solid #b8d4e8}' + + '.zt-setup-buttons{display:flex;gap:0.5em;flex-wrap:wrap;margin-top:0.5em}' + + '.zt-setup-buttons .cbi-button{min-width:140px}' + + '.zt-setup-panel h4{margin-top:0}' + + '.zt-setup-panel p{margin:0.5em 0;color:#555}'; const style = document.createElement('style'); style.id = 'zt-gateway-css'; style.textContent = css; diff --git a/mise.toml b/mise.toml index 98c73a7..e66a1ce 100644 --- a/mise.toml +++ b/mise.toml @@ -89,6 +89,8 @@ echo "==> Uploading files..." # root/ files → strip leading root/, map to / on device scp -O -q root/usr/sbin/zt-gateway-switch "$HOST:/usr/sbin/" +scp -O -q root/usr/sbin/zt-gateway-setup "$HOST:/usr/sbin/" +echo " zt-gateway-setup" echo " zt-gateway-switch" scp -O -q root/usr/share/rpcd/ucode/zt-gateway.uc "$HOST:/usr/share/rpcd/ucode/" echo " rpcd/ucode/zt-gateway.uc" @@ -109,8 +111,7 @@ scp -O -q htdocs/luci-static/resources/view/zt-gateway/overview.js "$HOST:/www/ echo " overview.js" echo "==> Setting permissions..." -ssh -q "$HOST" chmod +x /usr/sbin/zt-gateway-switch - +ssh -q "$HOST" chmod +x /usr/sbin/zt-gateway-switch /usr/sbin/zt-gateway-setup echo "==> Restarting rpcd and uhttpd..." ssh -q "$HOST" /etc/init.d/rpcd restart ssh -q "$HOST" /etc/init.d/uhttpd restart diff --git a/root/etc/config/zt-gateway b/root/etc/config/zt-gateway index c1be56e..52ba4fe 100644 --- a/root/etc/config/zt-gateway +++ b/root/etc/config/zt-gateway @@ -4,6 +4,14 @@ config global 'global' option drain_timeout '600' option health_interval '60' option auto_failback '0' + option wiblan_subnet '10.11.13.0/24' + option bridge_device 'br-zt' + option bridge_ports 'ztabc0' + option wiblan_gw '10.11.13.1' + option table_main '100' + option table_drain '101' + option dhcp_lease_first '10.11.13.100' + option dhcp_lease_last '10.11.13.200' config gateway option region 'amsterdam' diff --git a/root/usr/sbin/zt-gateway-setup b/root/usr/sbin/zt-gateway-setup new file mode 100755 index 0000000..723a308 --- /dev/null +++ b/root/usr/sbin/zt-gateway-setup @@ -0,0 +1,557 @@ +#!/bin/sh +# shellcheck shell=sh +# +# zt-gateway-setup — configure this OpenWrt router as a ZeroTier exit +# gateway for the WIBLAN subnet (10.11.13.0/24). +# +# Subcommands: +# status Show current setup state +# setup-bridge Create the br-zt bridge interface +# setup-routing Configure policy routing (tables 100/101, ip rules) +# setup-dhcp Configure DHCP for WIBLAN on br-zt +# setup-hotplug Create hotplug script to re-apply routes on ifup +# setup-persistence Write rc.local + UCI network routes +# setup-all Run all setup-* commands in order +# +# Exit codes: +# 0 success +# 1 usage / argument error +# 2 precondition failed +# 3 runtime failure +# +# Environment overrides (for testing and non-default configs): +# ZTG_BRIDGE bridge device (default: br-zt) +# ZTG_BRIDGE_PORTS space-separated ports (default: ztabc0) +# ZTG_WIBLAN_CIDR WIBLAN subnet (default: 10.11.13.0/24) +# ZTG_WIBLAN_GW WIBLAN gateway IP (default: 10.11.13.1) +# ZTG_WIBLAN_LEASE_FIRST first DHCP IP (default: 10.11.13.100) +# ZTG_WIBLAN_LEASE_LAST last DHCP IP (default: 10.11.13.200) +# ZTG_TABLE_MAIN main policy table (default: 100) +# ZTG_TABLE_DRAIN drain policy table (default: 101) +# ZTG_TABLE_MWAN mwan3 return table (default: 1) +# ZTG_FWMARK drain fwmark (default: 0x100) +# ZTG_DRAIN_PRIORITY drain rule priority (default: 99) +# ZTG_HOTPLUG hotplug script path +# ZTG_RCLOCAL rc.local path +# ZTG_DHCPCONF DHCP UCI config file (default: /etc/config/dhcp) +# ZTG_NETWORKCONF network UCI config file (default: /etc/config/network) +# ZTG_SKIP_PERSIST skip UCI persistence (testing) + +set -eu + +# ---------------------------------------------------------------------------- +# Config +# ---------------------------------------------------------------------------- +BRIDGE="${ZTG_BRIDGE:-br-zt}" +BRIDGE_PORTS="${ZTG_BRIDGE_PORTS:-ztabc0}" +WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}" +WIBLAN_GW="${ZTG_WIBLAN_GW:-10.11.13.1}" +WIBLAN_LEASE_FIRST="${ZTG_WIBLAN_LEASE_FIRST:-10.11.13.100}" +WIBLAN_LEASE_LAST="${ZTG_WIBLAN_LEASE_LAST:-10.11.13.200}" +TABLE_MAIN="${ZTG_TABLE_MAIN:-100}" +TABLE_DRAIN="${ZTG_TABLE_DRAIN:-101}" +TABLE_MWAN="${ZTG_TABLE_MWAN:-1}" +FWMARK="${ZTG_FWMARK:-0x100}" +DRAIN_PRIORITY="${ZTG_DRAIN_PRIORITY:-99}" +HOTPLUG="${ZTG_HOTPLUG:-/etc/hotplug.d/net/99-zerotier-bridge}" +RCLOCAL="${ZTG_RCLOCAL:-/etc/rc.local}" +DHCPCONF="${ZTG_DHCPCONF:-/etc/config/dhcp}" +SKIP_PERSIST="${ZTG_SKIP_PERSIST:-0}" + +# Derived: extract prefix bits from CIDR +WIBLAN_BITS="${WIBLAN_CIDR##*/}" +# UCI-safe section name (replace hyphens with underscores) +BRIDGE_UCI=$(printf '%s' "$BRIDGE" | tr '-' '_') + +# ---------------------------------------------------------------------------- +# Logging +# ---------------------------------------------------------------------------- +log() { printf '[zt-gateway-setup] %s\n' "$*" >&2; } +die() { rc=$1; shift; log "ERROR: $*"; exit "$rc"; } +# Ensure a UCI config file exists (touch it if missing) +ensure_uci_config() { + _conf=/etc/config/"$1" + if [ ! -f "$_conf" ]; then + touch "$_conf" + log "created empty UCI config: ${_conf}" + fi +} + + +# CIDR to dotted mask (e.g. 24 -> 255.255.255.0) +_cidr_to_mask() { + bits=$1 + mask="" + while [ "$bits" -gt 0 ]; do + if [ "$bits" -ge 8 ]; then + oct=255 + bits=$((bits - 8)) + else + # Build partial octet: bits leading 1s in MSB position + oct=0 + j=0 + while [ $j -lt "$bits" ]; do + oct=$(( oct | (1 << (7 - j)) )) + j=$((j + 1)) + done + bits=0 + fi + if [ -n "$mask" ]; then + mask="${mask}.${oct}" + else + mask="${oct}" + fi + done + printf '%s' "$mask" +} + +# ---------------------------------------------------------------------------- +# Status +# ---------------------------------------------------------------------------- +cmd_status() { + log "checking setup status..." + + # Bridge + if ip link show "$BRIDGE" >/dev/null 2>&1; then + printf 'bridge: %s (up)\n' "$BRIDGE" + # List ports + ports=$(ip link show master "$BRIDGE" 2>/dev/null \ + | awk -F': ' '/^[0-9]+:/{gsub(/@.*/, "", $2); print $2}' \ + | tr '\n' ' ') + if [ -n "$ports" ]; then + printf ' ports: %s\n' "$ports" + fi + # IP on bridge + br_addr=$(ip -4 addr show dev "$BRIDGE" 2>/dev/null \ + | awk '/inet /{gsub(/\/.*/, "", $2); print $2; exit}') + if [ -n "$br_addr" ]; then + printf ' addr: %s\n' "$br_addr" + fi + else + printf 'bridge: %s (missing)\n' "$BRIDGE" + fi + + # Routing tables + table_main_gw=$(ip route show table "$TABLE_MAIN" 2>/dev/null \ + | awk '/^[[:space:]]*default/{ + for (i=1; i<=NF; i++) if ($i=="via") { print $(i+1); exit } + }') + if [ -n "$table_main_gw" ]; then + printf 'table %s: default via %s (configured)\n' "$TABLE_MAIN" "$table_main_gw" + else + printf 'table %s: (empty)\n' "$TABLE_MAIN" + fi + + table_drain_gw=$(ip route show table "$TABLE_DRAIN" 2>/dev/null \ + | awk '/^[[:space:]]*default/{ + for (i=1; i<=NF; i++) if ($i=="via") { print $(i+1); exit } + }') + if [ -n "$table_drain_gw" ]; then + printf 'table %s: default via %s (configured)\n' "$TABLE_DRAIN" "$table_drain_gw" + else + printf 'table %s: (empty)\n' "$TABLE_DRAIN" + fi + + # ip rule for fwmark + if ip rule show 2>/dev/null | grep -q "fwmark ${FWMARK}"; then + printf 'ip rule: fwmark %s -> table %s (configured)\n' "$FWMARK" "$TABLE_DRAIN" + else + printf 'ip rule: fwmark %s (missing)\n' "$FWMARK" + fi + + # DHCP + if grep -q "interface '${BRIDGE}'" "$DHCPCONF" 2>/dev/null; then + printf 'dhcp: %s configured in %s\n' "$BRIDGE" "$DHCPCONF" + else + printf 'dhcp: %s (not configured)\n' "$BRIDGE" + fi + + # Hotplug + if [ -x "$HOTPLUG" ] || [ -f "$HOTPLUG" ]; then + printf 'hotplug: %s (present)\n' "$HOTPLUG" + else + printf 'hotplug: %s (missing)\n' "$HOTPLUG" + fi + + # Persistence in rc.local + if grep -q "zt-gateway" "$RCLOCAL" 2>/dev/null; then + printf 'rc.local: entries present\n' + else + printf 'rc.local: no zt-gateway entries\n' + fi +} + +# ---------------------------------------------------------------------------- +# setup-bridge +# ---------------------------------------------------------------------------- +cmd_setup_bridge() { + log "setting up bridge ${BRIDGE}..." + + if ! command -v uci >/dev/null 2>&1; then + die 3 "uci not found; cannot configure bridge" + fi + ensure_uci_config network + + # Create or update bridge device in network.uci + # Note: UCI section names cannot contain hyphens, so we use BRIDGE_UCI + if uci -q get "network.${BRIDGE_UCI}" >/dev/null 2>&1; then + log "bridge device ${BRIDGE} already exists in UCI; updating" + else + uci -q set "network.${BRIDGE_UCI}=device" + uci -q set "network.${BRIDGE_UCI}.type=bridge" + uci -q set "network.${BRIDGE_UCI}.name=${BRIDGE}" + fi + + # Set bridge ports (space-separated in UCI list) + uci -q delete "network.${BRIDGE_UCI}.ports" 2>/dev/null || true + for port in $BRIDGE_PORTS; do + uci -q add_list "network.${BRIDGE_UCI}.ports=${port}" + done + + # Create interface section bridging to br-zt for WIBLAN + if ! uci -q get "network.zt_wiblan" >/dev/null 2>&1; then + uci -q set "network.zt_wiblan=interface" + uci -q set "network.zt_wiblan.proto='static'" + uci -q set "network.zt_wiblan.device='${BRIDGE}'" + uci -q set "network.zt_wiblan.ipaddr='${WIBLAN_GW}'" + uci -q set "network.zt_wiblan.netmask='$(_cidr_to_mask "$WIBLAN_BITS")'" + fi + + if [ "$SKIP_PERSIST" != "1" ]; then + uci commit network + log "bridge UCI config committed" + fi + + # Bring up the bridge (best-effort; may need netifd restart) + if command -v ifup >/dev/null 2>&1; then + ifup "zt_wiblan" 2>/dev/null || \ + log "warning: ifup zt_wiblan failed; may need 'service network restart'" + fi + + log "bridge ${BRIDGE} setup complete" +} + +# ---------------------------------------------------------------------------- +# setup-routing +# ---------------------------------------------------------------------------- +cmd_setup_routing() { + log "setting up routing (tables ${TABLE_MAIN}/${TABLE_DRAIN})..." + + # Host route to WIBLAN gateway via bridge + ip route replace "$WIBLAN_GW" dev "$BRIDGE" 2>/dev/null || \ + log "warning: host route to ${WIBLAN_GW} failed" + + # Table 100 (main policy): default via WIBLAN_GW + ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_MAIN" + + # Table 101 (drain): default via WIBLAN_GW (same default; drain overrides per-flow) + ip route replace default via "$WIBLAN_GW" dev "$BRIDGE" table "$TABLE_DRAIN" + + # mwan3 return-traffic table: route WIBLAN back through bridge + ip route replace "$WIBLAN_CIDR" dev "$BRIDGE" table "$TABLE_MWAN" + + # ip rule: fwmark 0x100 -> drain table + 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" + + log "routing setup complete" + + # Persist to UCI + if [ "$SKIP_PERSIST" != "1" ] && command -v uci >/dev/null 2>&1; then + ensure_uci_config network + # Host route + if ! uci -q get "network.zt_wiblan_host" >/dev/null 2>&1; then + uci -q set "network.zt_wiblan_host=route" + uci -q set "network.zt_wiblan_host.target='${WIBLAN_GW}'" + uci -q set "network.zt_wiblan_host.interface='${BRIDGE}'" + fi + + # Main policy table default route + if ! uci -q get "network.zt_wiblan_default" >/dev/null 2>&1; then + uci -q set "network.zt_wiblan_default=route" + uci -q set "network.zt_wiblan_default.target='0.0.0.0'" + uci -q set "network.zt_wiblan_default.netmask='0.0.0.0'" + uci -q set "network.zt_wiblan_default.gateway='${WIBLAN_GW}'" + uci -q set "network.zt_wiblan_default.interface='${BRIDGE}'" + uci -q set "network.zt_wiblan_default.table='${TABLE_MAIN}'" + fi + + uci commit network + log "routing UCI config committed" + fi +} + +# ---------------------------------------------------------------------------- +# setup-dhcp +# ---------------------------------------------------------------------------- +cmd_setup_dhcp() { + log "setting up DHCP for ${BRIDGE} (${WIBLAN_CIDR})..." + + if ! command -v uci >/dev/null 2>&1; then + die 3 "uci not found; cannot configure DHCP" + fi + ensure_uci_config dhcp + + # Create DHCP subnet entry for br-zt + # Note: UCI section names cannot contain hyphens, so we use BRIDGE_UCI + if uci -q get "dhcp.${BRIDGE_UCI}" >/dev/null 2>&1; then + log "DHCP entry for ${BRIDGE} already exists; updating" + else + uci -q set "dhcp.${BRIDGE_UCI}=dhcp" + fi + + uci -q set "dhcp.${BRIDGE_UCI}.interface=${BRIDGE}" + uci -q set "dhcp.${BRIDGE_UCI}.start=${WIBLAN_LEASE_FIRST##*.}" + uci -q set "dhcp.${BRIDGE_UCI}.limit=$(( ${WIBLAN_LEASE_LAST##*.} - ${WIBLAN_LEASE_FIRST##*.} + 1 ))" + uci -q set "dhcp.${BRIDGE_UCI}.leasetime=12h" + + # Ignore WIBLAN subnet in upstream DHCP (prevent handing out + # conflicting leases on the LAN side) + lan_iface=$(uci -q get dhcp.lan.interface 2>/dev/null || echo "lan") + if [ -n "$lan_iface" ]; then + # Add WIBLAN to lan's ignore list if not already there + if ! uci -q get "dhcp.lan.ignore" 2>/dev/null | grep -q "$WIBLAN_CIDR"; then + uci -q add_list "dhcp.lan.dhcp_option='6,${WIBLAN_GW}'" 2>/dev/null || true + fi + fi + + if [ "$SKIP_PERSIST" != "1" ]; then + uci commit dhcp + log "DHCP UCI config committed" + fi + + # Restart dnsmasq to pick up changes + if command -v service >/dev/null 2>&1; then + service dnsmasq restart 2>/dev/null || \ + log "warning: dnsmasq restart failed; do it manually" + fi + + log "DHCP setup complete" +} + +# ---------------------------------------------------------------------------- +# setup-hotplug +# ---------------------------------------------------------------------------- +cmd_setup_hotplug() { + log "creating hotplug script ${HOTPLUG}..." + + mkdir -p "$(dirname "$HOTPLUG")" + + cat >"$HOTPLUG" <<'HOTPLUG_SCRIPT' +#!/bin/sh +# shellcheck shell=sh +# ZeroTier gateway hotplug — re-apply routing when the bridge interface +# comes up (e.g. after boot, after ZeroTier restart). +# +# Environment: INTERFACE, ACTION (set by netifd hotplug) +# Config: /etc/config/zt-gateway (read at runtime for active gateway IP) + +ZTG_BRIDGE="${ZTG_BRIDGE:-br-zt}" +ZTG_TABLE_MAIN="${ZTG_TABLE_MAIN:-100}" +ZTG_TABLE_DRAIN="${ZTG_TABLE_DRAIN:-101}" +ZTG_TABLE_MWAN="${ZTG_TABLE_MWAN:-1}" +ZTG_FWMARK="${ZTG_FWMARK:-0x100}" +ZTG_DRAIN_PRIORITY="${ZTG_DRAIN_PRIORITY:-99}" +ZTG_WIBLAN_CIDR="${ZTG_WIBLAN_CIDR:-10.11.13.0/24}" + +# Only act on our bridge interface +[ "$INTERFACE" = "$ZTG_BRIDGE" ] || exit 0 + +case "$ACTION" in + ifup) + # Read active gateway from UCI + active_ip=$(uci -q get zt-gateway.global.active_ip 2>/dev/null || \ + uci -q get zt-gateway.global.active_gateway 2>/dev/null) + + # If active_ip is a region name, resolve to IP via gateway section + if [ -n "$active_ip" ] && ! echo "$active_ip" | grep -q '^[0-9]'; then + active_ip=$(uci -q get "zt-gateway.@gateway[0].ip" 2>/dev/null || \ + for sec in $(uci -q show zt-gateway 2>/dev/null | \ + awk -F'=' '/\.region=/{print $1}' | \ + sed 's/\.region//'); do + r=$(uci -q get "${sec}.region" 2>/dev/null) + if [ "$r" = "$active_ip" ]; then + uci -q get "${sec}.ip" 2>/dev/null + break + fi + done) + fi + + if [ -z "$active_ip" ]; then + # No active gateway configured; try reading from table + active_ip=$(ip route show table "$ZTG_TABLE_MAIN" 2>/dev/null \ + | awk '/^[[:space:]]*default/{ + for (i=1; i<=NF; i++) if ($i=="via") { print $(i+1); exit } + }') + fi + + if [ -z "$active_ip" ]; then + logger -t zt-gw-hotplug "No active gateway IP; skipping route setup" + exit 0 + fi + + logger -t zt-gw-hotplug "ifup ${ZTG_BRIDGE}: applying routes via ${active_ip}" + + # Host route + ip route replace "$active_ip" dev "$ZTG_BRIDGE" + + # Policy routes + ip route replace default via "$active_ip" dev "$ZTG_BRIDGE" table "$ZTG_TABLE_MAIN" + ip route replace default via "$active_ip" dev "$ZTG_BRIDGE" table "$ZTG_TABLE_DRAIN" + + # mwan3 return + ip route replace "$ZTG_WIBLAN_CIDR" dev "$ZTG_BRIDGE" table "$ZTG_TABLE_MWAN" + + # ip rule for drain fwmark + ip rule add fwmark "$ZTG_FWMARK" table "$ZTG_TABLE_DRAIN" \ + priority "$ZTG_DRAIN_PRIORITY" 2>/dev/null || \ + ip rule replace fwmark "$ZTG_FWMARK" table "$ZTG_TABLE_DRAIN" \ + priority "$ZTG_DRAIN_PRIORITY" + ;; + ifdown) + logger -t zt-gw-hotplug "ifdown ${ZTG_BRIDGE}: cleaning up" + ip rule del fwmark "$ZTG_FWMARK" table "$ZTG_TABLE_DRAIN" \ + priority "$ZTG_DRAIN_PRIORITY" 2>/dev/null || true + ;; +esac +HOTPLUG_SCRIPT + + chmod +x "$HOTPLUG" + log "hotplug script created at ${HOTPLUG}" +} + +# ---------------------------------------------------------------------------- +# setup-persistence +# ---------------------------------------------------------------------------- +cmd_setup_persistence() { + log "setting up persistence..." + + # Ensure rc.local has the gateway restoration logic + if ! grep -q "zt-gateway" "$RCLOCAL" 2>/dev/null; then + log "adding zt-gateway entry to ${RCLOCAL}" + + # Read current rc.local content + rc_content="" + if [ -f "$RCLOCAL" ]; then + rc_content=$(cat "$RCLOCAL") + fi + + # Remove trailing 'exit 0' if present, add our block, re-add exit 0 + rc_stripped=$(printf '%s\n' "$rc_content" | sed '/^exit 0$/d') + + cat >"$RCLOCAL" </dev/null 2>&1; then + ensure_uci_config zt-gateway + # Store the WIBLAN config for the hotplug to read + if ! uci -q get "zt-gateway.global.wiblan_subnet" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.wiblan_subnet='${WIBLAN_CIDR}'" + fi + if ! uci -q get "zt-gateway.global.bridge_device" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.bridge_device='${BRIDGE}'" + fi + if ! uci -q get "zt-gateway.global.bridge_ports" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.bridge_ports='${BRIDGE_PORTS}'" + fi + if ! uci -q get "zt-gateway.global.wiblan_gw" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.wiblan_gw='${WIBLAN_GW}'" + fi + if ! uci -q get "zt-gateway.global.table_main" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.table_main='${TABLE_MAIN}'" + fi + if ! uci -q get "zt-gateway.global.table_drain" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.table_drain='${TABLE_DRAIN}'" + fi + if ! uci -q get "zt-gateway.global.dhcp_lease_first" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.dhcp_lease_first='${WIBLAN_LEASE_FIRST}'" + fi + if ! uci -q get "zt-gateway.global.dhcp_lease_last" >/dev/null 2>&1; then + uci -q set "zt-gateway.global.dhcp_lease_last='${WIBLAN_LEASE_LAST}'" + fi + uci commit zt-gateway + log "UCI global config updated with setup parameters" + fi + + log "persistence setup complete" +} + +# ---------------------------------------------------------------------------- +# setup-all +# ---------------------------------------------------------------------------- +cmd_setup_all() { + log "running full gateway setup..." + + cmd_setup_bridge + cmd_setup_routing + cmd_setup_dhcp + cmd_setup_hotplug + cmd_setup_persistence + + log "=========================================" + log "Full gateway setup complete!" + log " Bridge: ${BRIDGE}" + log " Ports: ${BRIDGE_PORTS}" + log " Subnet: ${WIBLAN_CIDR}" + log " Gateway IP: ${WIBLAN_GW}" + log " Tables: ${TABLE_MAIN} (main), ${TABLE_DRAIN} (drain)" + log " DHCP: ${WIBLAN_LEASE_FIRST} - ${WIBLAN_LEASE_LAST}" + log "=========================================" +} + +# ---------------------------------------------------------------------------- +# Usage +# ---------------------------------------------------------------------------- +usage() { + cat >&2 <<'USAGE' +Usage: zt-gateway-setup + +Commands: + status Show current setup state + setup-bridge Create the br-zt bridge interface + setup-routing Configure policy routing (tables 100/101, ip rules) + setup-dhcp Configure DHCP for WIBLAN on br-zt + setup-hotplug Create hotplug script to re-apply routes on ifup + setup-persistence Write rc.local + UCI network routes + setup-all Run all setup-* commands in order +USAGE +} + +# ---------------------------------------------------------------------------- +# Entry point +# ---------------------------------------------------------------------------- +main() { + if [ $# -lt 1 ]; then + usage + die 1 "missing command" + fi + + cmd=$1 + shift + + case "$cmd" in + status) cmd_status "$@" ;; + setup-bridge) cmd_setup_bridge "$@" ;; + setup-routing) cmd_setup_routing "$@" ;; + setup-dhcp) cmd_setup_dhcp "$@" ;; + setup-hotplug) cmd_setup_hotplug "$@" ;; + setup-persistence) cmd_setup_persistence "$@" ;; + setup-all) cmd_setup_all "$@" ;; + -h|--help) usage; exit 0 ;; + *) usage; die 1 "unknown command: $cmd" ;; + esac +} + +main "$@" diff --git a/root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json b/root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json index 7a0edf3..cc4cbcb 100644 --- a/root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json +++ b/root/usr/share/rpcd/acl.d/luci-app-zt-gateway.json @@ -4,7 +4,7 @@ "read": { "uci": ["zt-gateway", "network"], "ubus": { - "zt-gateway": ["status", "health", "drain_status"] + "zt-gateway": ["status", "health", "drain_status", "setup"] }, "file": { "/etc/hotplug.d/net/99-zerotier-bridge": ["read"], @@ -14,7 +14,7 @@ "write": { "uci": ["zt-gateway", "network"], "ubus": { - "zt-gateway": ["switch", "cancel_drain"] + "zt-gateway": ["switch", "cancel_drain", "setup"] }, "file": { "/etc/hotplug.d/net/99-zerotier-bridge": ["write"], diff --git a/root/usr/share/rpcd/ucode/zt-gateway.uc b/root/usr/share/rpcd/ucode/zt-gateway.uc index d964cb9..9ffd5e5 100644 --- a/root/usr/share/rpcd/ucode/zt-gateway.uc +++ b/root/usr/share/rpcd/ucode/zt-gateway.uc @@ -181,10 +181,6 @@ return { return { success: false, message: `Unknown gateway region: ${region}` }; } - const current_region = read_active_region(); - if (current_region === region && !drain_active()) { - return { success: true, message: `Already on ${region}.` }; - } const result = run_switch_script_capture(gw.ip, mode); if (result.code !== 0) { @@ -297,6 +293,39 @@ return { : (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-hotplug|setup-persistence|setup-all)$/)) { + return { + success: false, + message: 'Invalid command. Valid: status, setup-bridge, setup-routing, setup-dhcp, 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 + }; + } } } };