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
|
|
|
/* luci-app-zt-gateway - ZeroTier Exit Gateway switching UI
|
|
|
|
|
*
|
|
|
|
|
* Provides a Services -> ZeroTier Gateway page that lists all gateways from
|
|
|
|
|
* /etc/config/zt-gateway, shows the active gateway, health, and drain
|
|
|
|
|
* progress, and lets the user pick a target + switch mode (force|graceful).
|
|
|
|
|
* Calls zt-gateway.* ubus methods exposed by
|
|
|
|
|
* /usr/share/rpcd/ucode/zt-gateway.uc, which in turn invokes
|
|
|
|
|
* /usr/sbin/zt-gateway-switch for the actual system changes.
|
|
|
|
|
*/
|
2026-07-12 23:42:54 +05:30
|
|
|
'use strict';
|
|
|
|
|
'require view';
|
|
|
|
|
'require rpc';
|
|
|
|
|
'require ui';
|
|
|
|
|
'require dom';
|
|
|
|
|
'require poll';
|
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
|
|
|
|
|
|
|
|
const UBUS_NAMESPACE = 'zt-gateway';
|
|
|
|
|
|
|
|
|
|
function ubusStatus() {
|
|
|
|
|
return rpc.declare({
|
|
|
|
|
object: UBUS_NAMESPACE,
|
|
|
|
|
method: 'status',
|
|
|
|
|
params: []
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ubusSwitch(region, mode) {
|
|
|
|
|
return rpc.declare({
|
|
|
|
|
object: UBUS_NAMESPACE,
|
|
|
|
|
method: 'switch',
|
|
|
|
|
params: ['region', 'mode']
|
|
|
|
|
})(region, mode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ubusHealth(region) {
|
|
|
|
|
return rpc.declare({
|
|
|
|
|
object: UBUS_NAMESPACE,
|
|
|
|
|
method: 'health',
|
|
|
|
|
params: ['region']
|
|
|
|
|
})(region);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ubusCancelDrain() {
|
|
|
|
|
return rpc.declare({
|
|
|
|
|
object: UBUS_NAMESPACE,
|
|
|
|
|
method: 'cancel_drain',
|
|
|
|
|
params: []
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return view.extend({
|
|
|
|
|
state: {
|
|
|
|
|
selectedRegion: null,
|
|
|
|
|
switchMode: 'force'
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
load() {
|
|
|
|
|
return ubusStatus().catch(err => {
|
|
|
|
|
ui.addNotification(null, E('p', {}, _('Failed to load status: ') + (err.message || err)));
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderHealthDot(reachable, latencyMs) {
|
|
|
|
|
const cls = reachable ? 'zt-health-up' : 'zt-health-down';
|
|
|
|
|
const tip = reachable
|
2026-07-12 23:42:54 +05:30
|
|
|
? (latencyMs != null ? _('UP (%sms)').format(latencyMs) : _('UP'))
|
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
|
|
|
: _('DOWN');
|
|
|
|
|
return E('span', { class: 'zt-health ' + cls, title: tip }, '●');
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderGatewayRow(gw, activeRegion, isSelected) {
|
|
|
|
|
const isActive = gw.region === activeRegion;
|
|
|
|
|
return E('tr', { class: 'zt-gateway-row', 'data-region': gw.region }, [
|
|
|
|
|
E('td', {}, E('input', {
|
|
|
|
|
type: 'radio',
|
|
|
|
|
name: 'zt_gateway_region',
|
|
|
|
|
value: gw.region,
|
|
|
|
|
change: ev => this.onSelectRegion(ev.target.value),
|
|
|
|
|
checked: isSelected || null,
|
2026-07-12 23:42:54 +05:30
|
|
|
disabled: isActive || null
|
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
|
|
|
})),
|
|
|
|
|
E('td', { class: 'zt-region-cell' }, [
|
|
|
|
|
this.renderHealthDot(false, null),
|
|
|
|
|
' ',
|
|
|
|
|
isActive ? E('strong', {}, gw.region) : document.createTextNode(gw.region)
|
|
|
|
|
]),
|
|
|
|
|
E('td', {}, gw.label),
|
|
|
|
|
E('td', {}, gw.ip),
|
|
|
|
|
E('td', {}, isActive ? E('em', {}, _('active')) : '')
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderGatewaysTable(data) {
|
|
|
|
|
const activeRegion = data.active_gateway;
|
|
|
|
|
const rows = (data.gateways || []).map(gw => {
|
|
|
|
|
const isSelected = gw.region === this.state.selectedRegion;
|
|
|
|
|
return this.renderGatewayRow(gw, activeRegion, isSelected);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return E('table', { class: 'table zt-gateways' }, [
|
|
|
|
|
E('thead', {}, E('tr', {}, [
|
|
|
|
|
E('th', {}, ''),
|
|
|
|
|
E('th', {}, _('Region')),
|
|
|
|
|
E('th', {}, _('Label')),
|
|
|
|
|
E('th', {}, _('IP')),
|
|
|
|
|
E('th', {}, _('Status'))
|
|
|
|
|
])),
|
|
|
|
|
E('tbody', {}, rows)
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderDrainPanel(data) {
|
|
|
|
|
const drain = data.drain || {};
|
|
|
|
|
if (!drain.active && !(drain.remaining_connections > 0)) {
|
|
|
|
|
return E([]);
|
|
|
|
|
}
|
|
|
|
|
return E('div', { class: 'zt-drain-panel' }, [
|
|
|
|
|
E('h4', {}, _('Graceful drain progress')),
|
|
|
|
|
E('p', {}, _('Draining from %s to %s - %d connection(s) remaining')
|
|
|
|
|
.format(drain.old_ip || '?', drain.new_ip || '?', drain.remaining_connections || 0)),
|
|
|
|
|
E('button', {
|
|
|
|
|
class: 'cbi-button cbi-button-negative',
|
|
|
|
|
click: ev => this.onCancelDrain(ev)
|
|
|
|
|
}, _('Cancel drain (force switch)'))
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderActiveSummary(data) {
|
|
|
|
|
const region = data.active_gateway || _('none');
|
|
|
|
|
const ip = data.active_ip || '?';
|
|
|
|
|
return E('div', { class: 'zt-active-summary' }, [
|
|
|
|
|
E('strong', {}, _('Active gateway:')),
|
|
|
|
|
' ',
|
|
|
|
|
E('span', {}, '%s (%s)'.format(region, ip))
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onSelectRegion(region) {
|
|
|
|
|
this.state.selectedRegion = region;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onSwitchModeChange(ev) {
|
|
|
|
|
this.state.switchMode = ev.target.value;
|
|
|
|
|
const timeoutRow = document.querySelector('.zt-drain-timeout-row');
|
|
|
|
|
if (timeoutRow) {
|
|
|
|
|
timeoutRow.style.display = (this.state.switchMode === 'graceful') ? '' : 'none';
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async onSwitchClick(ev) {
|
|
|
|
|
const region = this.state.selectedRegion;
|
|
|
|
|
if (!region) {
|
|
|
|
|
ui.addNotification(null, E('p', {}, _('Select a gateway first.')));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const mode = this.state.switchMode || 'force';
|
|
|
|
|
const btn = ev.target;
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const oldLabel = btn.textContent;
|
|
|
|
|
btn.textContent = _('Switching...');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await ubusSwitch(region, mode);
|
|
|
|
|
if (res && res.success) {
|
|
|
|
|
ui.addNotification(null, E('p', {}, res.message || _('Switch complete.')));
|
|
|
|
|
} else {
|
|
|
|
|
ui.addNotification(null, E('p', {}, (res && res.message) || _('Switch failed.')));
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
ui.addNotification(null, E('p', {}, _('RPC error: ') + (err.message || err)));
|
|
|
|
|
} finally {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
btn.textContent = oldLabel;
|
|
|
|
|
this.refreshState();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async onCancelDrain(ev) {
|
|
|
|
|
const btn = ev.target;
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
const oldLabel = btn.textContent;
|
|
|
|
|
btn.textContent = _('Cancelling...');
|
|
|
|
|
try {
|
|
|
|
|
const res = await ubusCancelDrain();
|
|
|
|
|
ui.addNotification(null, E('p', {}, (res && res.message) || _('Cancel issued.')));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
ui.addNotification(null, E('p', {}, _('RPC error: ') + (err.message || err)));
|
|
|
|
|
} finally {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
btn.textContent = oldLabel;
|
|
|
|
|
this.refreshState();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async refreshHealth(data) {
|
|
|
|
|
const gws = data.gateways || [];
|
|
|
|
|
for (let i = 0; i < gws.length; i++) {
|
|
|
|
|
const region = gws[i].region;
|
|
|
|
|
try {
|
|
|
|
|
const h = await ubusHealth(region);
|
|
|
|
|
const row = document.querySelector('.zt-gateway-row[data-region="%s"]'.format(region));
|
|
|
|
|
if (row && h) {
|
|
|
|
|
const node = row.querySelector('.zt-health');
|
|
|
|
|
if (node) {
|
|
|
|
|
node.classList.toggle('zt-health-up', !!h.reachable);
|
|
|
|
|
node.classList.toggle('zt-health-down', !h.reachable);
|
|
|
|
|
node.title = h.reachable && h.latency_ms != null
|
2026-07-12 23:42:54 +05:30
|
|
|
? _('UP (%sms)').format(h.latency_ms)
|
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
|
|
|
: (h.reachable ? _('UP') : _('DOWN'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
/* health check is best-effort */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async refreshState() {
|
|
|
|
|
try {
|
|
|
|
|
const data = await ubusStatus();
|
|
|
|
|
if (data) {
|
|
|
|
|
this.activeData = data;
|
|
|
|
|
const container = document.getElementById('zt-gateway-root');
|
|
|
|
|
if (container) {
|
|
|
|
|
dom.content(container, this.renderBody(data));
|
|
|
|
|
}
|
|
|
|
|
this.refreshHealth(data);
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
/* ignore - next poll will retry */
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
renderBody(data) {
|
|
|
|
|
const settings = data.settings || {};
|
|
|
|
|
const mode = settings.switch_mode || 'force';
|
|
|
|
|
this.state.switchMode = mode;
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
E('h3', {}, _('ZeroTier Exit Gateway')),
|
|
|
|
|
this.renderActiveSummary(data),
|
|
|
|
|
E('div', { class: 'cbi-section' }, [
|
|
|
|
|
this.renderGatewaysTable(data),
|
|
|
|
|
E('div', { class: 'cbi-page-actions' }, [
|
|
|
|
|
E('div', { class: 'zt-mode-group' }, [
|
|
|
|
|
E('label', {}, _('Switch mode:')),
|
|
|
|
|
E('select', {
|
|
|
|
|
class: 'zt-mode-select',
|
|
|
|
|
change: ev => this.onSwitchModeChange(ev)
|
|
|
|
|
}, [
|
|
|
|
|
E('option', { value: 'force', selected: mode === 'force' || null }, _('Force')),
|
|
|
|
|
E('option', { value: 'graceful', selected: mode === 'graceful' || null }, _('Graceful drain'))
|
|
|
|
|
]),
|
|
|
|
|
E('span', {
|
|
|
|
|
class: 'zt-drain-timeout-row',
|
|
|
|
|
style: mode === 'graceful' ? '' : 'display:none'
|
|
|
|
|
}, [
|
|
|
|
|
' ',
|
|
|
|
|
_('Drain timeout:'),
|
|
|
|
|
' ',
|
|
|
|
|
E('input', {
|
|
|
|
|
type: 'number',
|
|
|
|
|
class: 'zt-drain-timeout',
|
|
|
|
|
value: settings.drain_timeout || 600,
|
|
|
|
|
min: 30,
|
|
|
|
|
step: 30
|
|
|
|
|
}),
|
|
|
|
|
' s'
|
|
|
|
|
])
|
|
|
|
|
]),
|
|
|
|
|
E('button', {
|
|
|
|
|
class: 'cbi-button cbi-button-positive',
|
|
|
|
|
click: ev => this.onSwitchClick(ev)
|
|
|
|
|
}, _('Switch to selected'))
|
|
|
|
|
])
|
|
|
|
|
]),
|
|
|
|
|
this.renderDrainPanel(data)
|
|
|
|
|
];
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
render(data) {
|
|
|
|
|
this.injectCSS();
|
|
|
|
|
if (!data) {
|
|
|
|
|
return E('div', { class: 'alert-message error' }, [
|
|
|
|
|
E('p', {}, _('Could not load gateway state. Verify rpcd is running and the zt-gateway backend is installed.'))
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
this.activeData = data;
|
|
|
|
|
|
|
|
|
|
const root = E('div', { id: 'zt-gateway-root' }, this.renderBody(data));
|
|
|
|
|
|
|
|
|
|
const self = this;
|
2026-07-12 23:42:54 +05:30
|
|
|
Poll.add(function() {
|
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
|
|
|
return self.refreshState();
|
|
|
|
|
}, 5);
|
|
|
|
|
|
|
|
|
|
return root;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
injectCSS() {
|
|
|
|
|
if (document.getElementById('zt-gateway-css')) return;
|
|
|
|
|
const css = '.zt-health{font-size:1.1em;margin-right:4px}' +
|
|
|
|
|
'.zt-health-up{color:#2e8b57}.zt-health-down{color:#c0392b}' +
|
|
|
|
|
'.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}';
|
|
|
|
|
const style = document.createElement('style');
|
|
|
|
|
style.id = 'zt-gateway-css';
|
|
|
|
|
style.textContent = css;
|
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
}
|
|
|
|
|
});
|