/* 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. */ 'use strict'; 'require view'; 'require rpc'; 'require ui'; 'require dom'; 'require poll'; 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: [] })(); } function ubusSetup(command) { return rpc.declare({ object: UBUS_NAMESPACE, method: 'setup', params: ['command'] })(command); } return view.extend({ state: { selectedRegion: null, switchMode: 'force', healthCache: {} }, 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 ? (latencyMs != null ? _('UP (%sms)').format(latencyMs) : _('UP')) : _('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, })), (() => { const cached = this.state.healthCache[gw.region]; const dot = this.renderHealthDot(cached ? cached.reachable : false, cached ? cached.latency_ms : null); return E('td', { class: 'zt-region-cell' }, [ dot, ' ', 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)')) ]); }, 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-wifi-ap', label: _('Setup WiFi AP'), desc: _('Create WIBLAN WiFi AP bridged to br-zt') }, { 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'); 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 || []; const checks = gws.map(gw => { return ubusHealth(gw.region).then(h => { if (h) { this.state.healthCache[gw.region] = { reachable: !!h.reachable, latency_ms: h.latency_ms ?? null }; } return { region: gw.region, h }; }).catch(() => { this.state.healthCache[gw.region] = { reachable: false, latency_ms: null }; return { region: gw.region, h: null }; }); }); const results = await Promise.all(checks); for (const { region, h } of results) { if (!h) continue; const row = document.querySelector('.zt-gateway-row[data-region="%s"]'.format(region)); if (!row) continue; const node = row.querySelector('.zt-health'); if (!node) continue; node.classList.toggle('zt-health-up', !!h.reachable); node.classList.toggle('zt-health-down', !h.reachable); node.title = h.reachable && h.latency_ms != null ? _('UP (%sms)').format(h.latency_ms) : (h.reachable ? _('UP') : _('DOWN')); } }, 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), this.renderSetupPanel() ]; }, 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; Poll.add(function() { 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}' + '.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; document.head.appendChild(style); } });