From 994dfffc895a9f7ec919ea46f4f6681fde0bed8b Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 8 Aug 2026 23:30:38 +0800 Subject: [PATCH 01/56] luci-app-wificalling-gateway: add package Per-device transparent Wi-Fi Calling gateway for OpenWrt / ImmortalWrt. Forwards selected LAN clients through a selected sing-box node (AnyTLS, Hysteria2, TUIC, VLESS Reality, VMess WebSocket) with one sing-box process and nftables TPROXY, while other clients keep the normal gateway routing. Observes ePDG/IPsec UDP 500/4500 evidence per device and maintains an encrypted IMS activity log that records only handshake success/failure and sustained encrypted communication; the log has an on/off toggle. Dependencies: luci-base, sing-box, firewall4, kmod-nft-tproxy, kmod-nft-socket, ip-full, tcping - all available in the official OpenWrt feeds (sing-box in openwrt/packages net/sing-box, same stack as luci-app-v2raya). Tested on ImmortalWrt 24.10.6 (Redmi AX6S, aarch64_cortex-a53). Source-compatible with OpenWrt/ImmortalWrt 25.12 (identical ucode dispatcher i18n path and luci.mk LUCI_LC_ALIAS). 33 unit/integration tests pass. Credential-bearing files are mode 0600; raw import links are never logged. Translations: only the .pot template is committed; translations will be contributed via Weblate per OpenWrt policy. Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/LICENSE | 21 ++ .../luci-app-wificalling-gateway/Makefile | 22 ++ .../luci-app-wificalling-gateway/README.md | 10 + .../view/wificalling-gateway/events.js | 61 ++++ .../view/wificalling-gateway/overview.js | 146 ++++++++ .../view/wificalling-gateway/status.js | 40 ++ .../wificalling-gateway/node-import.js | 82 +++++ .../po/templates/wificalling-gateway.pot | 345 ++++++++++++++++++ .../root/etc/config/wificalling-gateway | 10 + .../root/etc/init.d/wificalling-gateway | 71 ++++ .../libexec/wificalling-gateway/compiler.sh | 82 +++++ .../libexec/wificalling-gateway/firewall.sh | 25 ++ .../wificalling-gateway/monitor-loop.sh | 11 + .../libexec/wificalling-gateway/monitor.sh | 112 ++++++ .../wificalling-gateway/node-health.sh | 43 +++ .../wificalling-gateway/passwall-bypass.sh | 34 ++ .../menu.d/luci-app-wificalling-gateway.json | 23 ++ .../acl.d/luci-app-wificalling-gateway.json | 25 ++ 18 files changed, 1163 insertions(+) create mode 100644 applications/luci-app-wificalling-gateway/LICENSE create mode 100644 applications/luci-app-wificalling-gateway/Makefile create mode 100644 applications/luci-app-wificalling-gateway/README.md create mode 100644 applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js create mode 100644 applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js create mode 100644 applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js create mode 100644 applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js create mode 100644 applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot create mode 100644 applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway create mode 100755 applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh create mode 100644 applications/luci-app-wificalling-gateway/root/usr/share/luci/menu.d/luci-app-wificalling-gateway.json create mode 100644 applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json diff --git a/applications/luci-app-wificalling-gateway/LICENSE b/applications/luci-app-wificalling-gateway/LICENSE new file mode 100644 index 000000000000..9642b55503f5 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Wi-Fi Calling Gateway contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile new file mode 100644 index 000000000000..f882fb741e07 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MIT +# +# Copyright (C) 2026 Smth Dagg + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway +LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway +LUCI_DEPENDS:=+luci-base +sing-box +firewall4 +kmod-nft-tproxy +kmod-nft-socket +ip-full +tcping +LUCI_PKGARCH:=all + +PKG_LICENSE:=MIT +PKG_LICENSE_FILES:=LICENSE +PKG_MAINTAINER:=Smth Dagg + +define Package/luci-app-wificalling-gateway/conffiles +/etc/config/wificalling-gateway +endef + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md new file mode 100644 index 000000000000..ad83ce2d2627 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/README.md @@ -0,0 +1,10 @@ +# luci-app-wificalling-gateway + +Per-device transparent Wi-Fi Calling gateway for OpenWrt / ImmortalWrt. + +Routes selected LAN clients through a sing-box node (AnyTLS, Hysteria2, +TUIC, VLESS Reality, VMess WebSocket) with nftables TPROXY, observes +ePDG/IPsec UDP 500/4500 evidence, and records handshake outcomes in an +encrypted IMS activity log. + +See https://github.com/smthdagg/luci-app-wificalling-gateway for full docs. diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js new file mode 100644 index 000000000000..76ff2257b10f --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js @@ -0,0 +1,61 @@ +'use strict'; +'require view'; +'require fs'; +'require poll'; +'require dom'; +'require ui'; +'require uci'; + +return view.extend({ + load: function() { + return Promise.all([ + L.resolveDefault(fs.read('/var/run/wificalling-gateway/events.log'), ''), + uci.load('wificalling-gateway') + ]); + }, + render: function(data) { + var raw = data[0]; + var logEnabled = uci.get('wificalling-gateway', 'main', 'log_enabled'); + function when(epoch) { return epoch ? new Date(epoch * 1000).toLocaleString() : '-'; } + function lines(value) { return value.trim() ? value.trim().split('\n').reverse() : []; } + function wfcLabel(v) { + switch (v) { + case 'registered': return _('Registered'); + case 'connecting': return _('Connecting'); + case 'not_detected': return _('Not detected'); + default: return v || '-'; + } + } + function activityLabel(v) { + switch (v) { + case 'handshake_success': return _('Handshake success'); + case 'handshake_failed': return _('Handshake failed'); + case 'sustained_traffic': return _('Sustained traffic'); + default: return v || '-'; + } + } + function rows(value) { + return lines(value).map(function(line) { + var f = line.split('|'); + return E('tr', { class: 'tr' }, [when(Number(f[0])), f[1], f[2], wfcLabel(f[7]), activityLabel(f[3]), (f[4] || '0') + ' ↑ / ' + (f[5] || '0') + ' ↓', _('Encrypted activity; call/SMS unknown')].map(function(x) { return E('td', { class: 'td' }, String(x)); })); + }); + } + var body = E('tbody', {}, rows(raw)); + var count = E('span', {}, String(lines(raw).length)); + function update(value) { dom.content(body, rows(value)); dom.content(count, String(lines(value).length)); } + var clear = E('button', { class: 'btn cbi-button-negative', click: function() { + ui.showModal(_('Clear activity log?'), [E('p', {}, _('This permanently removes only the Wi-Fi Calling activity history. Settings and system logs are not affected.')), + E('div', { class: 'right' }, [E('button', { class: 'btn', click: ui.hideModal }, _('Cancel')), + E('button', { class: 'btn cbi-button-negative', click: function() { fs.write('/var/run/wificalling-gateway/events.log', '').then(function() { update(''); ui.hideModal(); ui.addNotification(null, E('p', {}, _('Activity log cleared.')), 'info'); }).catch(function(err) { ui.addNotification(null, E('p', {}, _('Unable to clear log:') + ' ' + err.message), 'error'); }); } }, _('Clear log'))])]); + } }, _('Clear log')); + poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/events.log'), '').then(update); }, 5); + var children = [ + E('h2', {}, _('Encrypted IMS activity log')), + E('p', {}, _('Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. Phone numbers, message content, and whether an event is a call or SMS are not visible.')) + ]; + if (logEnabled === '0') + children.push(E('div', { class: 'alert-message warning' }, _('Activity log recording is disabled. Enable it in Settings.'))); + children.push(E('div', { class: 'cbi-section' }, [E('p', {}, [_('Records:') + ' ', count, ' ', clear]), E('table', { class: 'table' }, [E('tr', { class: 'tr table-titles' }, [_('Time'), _('Device'), _('IP'), _('Wi-Fi Calling'), _('Activity'), _('Packet delta'), _('Meaning')].map(function(x) { return E('th', { class: 'th' }, x); })), body])])); + return E([], children); + } +}); diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js new file mode 100644 index 000000000000..5728d270088b --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -0,0 +1,146 @@ +'use strict'; +'require view'; +'require form'; +'require fs'; +'require poll'; +'require uci'; +'require dom'; +'require ui'; +'require wificalling-gateway.node-import as nodeImport'; + +return view.extend({ + load: function() { + return Promise.all([ + L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), + uci.load('wificalling-gateway') + ]); + }, + render: function(data) { + var nodeParsed; + try { nodeParsed = JSON.parse(data[0]); } catch (e) { nodeParsed = { nodes: [] }; } + function nodeById(id, source) { + var nodes = (source || nodeParsed).nodes || []; + for (var i = 0; i < nodes.length; i++) if (nodes[i].id === id) return nodes[i]; + return null; + } + function quality(n) { + if (!n) return '-'; + if (n.state === 'unreachable') return _('Offline'); + if (n.ping_ms == null) return _('Unknown'); + if (n.ping_ms <= 100) return _('Excellent'); + if (n.ping_ms <= 200) return _('Good'); + if (n.ping_ms <= 300) return _('Fair'); + return _('Poor'); + } + function nodeState(n) { + if (!n) return '-'; + if (n.state === 'reachable' || n.state === 'tcp_reachable') return _('Alive'); + if (n.state === 'unreachable') return _('Offline'); + return _('Unknown'); + } + function latency(n) { return n && n.ping_ms != null ? n.ping_ms + ' ms (' + n.measurement + ')' : '-'; } + + var m = new form.Map('wificalling-gateway', _('Wi-Fi Calling Gateway settings'), + _('Configure proxy nodes and assign fixed LAN devices. Monitoring and logs are available from the submenu.')); + var importPanel = E('div', { class: 'cbi-section' }, [ + E('h3', {}, _('Import proxy node')), + E('p', {}, _('Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, or VMess link. It is parsed locally in this browser and is not sent to an external service.')), + E('button', { class: 'btn cbi-button-positive', click: function() { + var input = E('textarea', { class: 'cbi-input-textarea', rows: 6, style: 'width:100%', placeholder: 'anytls://…' }); + ui.showModal(_('Import node link'), [input, E('div', { class: 'right' }, [ + E('button', { class: 'btn', click: ui.hideModal }, _('Cancel')), + E('button', { class: 'btn cbi-button-positive', click: function() { + var parsed; + try { parsed = nodeImport.parse(input.value); } + catch (err) { ui.addNotification(null, E('p', {}, _('Unable to parse node link:') + ' ' + err.message), 'error'); return; } + var sid = uci.add('wificalling-gateway', 'node'); + Object.keys(parsed).forEach(function(key) { if (parsed[key] !== '') uci.set('wificalling-gateway', sid, key, parsed[key]); }); + uci.save().then(function() { + ui.hideModal(); + ui.addNotification(null, E('p', {}, _('Node imported successfully. Reloading settings…')), 'info'); + window.setTimeout(function() { window.location.reload(); }, 500); + }).catch(function(err) { ui.addNotification(null, E('p', {}, _('Unable to save imported node:') + ' ' + err.message), 'error'); }); + } }, _('Import')) + ])]); + } }, _('Import node link')) + ]); + var s = m.section(form.NamedSection, 'main', 'global', _('General')); + s.option(form.Flag, 'enabled', _('Enable')); + var logLevel = s.option(form.ListValue, 'log_level', _('Log level')); + logLevel.value('warn', _('Warning')); logLevel.value('info', _('Information')); logLevel.value('debug', _('Debug')); + var logEnabled = s.option(form.Flag, 'log_enabled', _('Activity log')); + logEnabled.default = '1'; + logEnabled.description = _('Record handshake outcomes and sustained encrypted communication. Turn off to stop writing the activity log.'); + var eventInterval = s.option(form.Value, 'event_interval', _('Sustained activity log interval (seconds)')); + eventInterval.datatype = 'range(30,3600)'; eventInterval.default = '60'; + eventInterval.depends('log_enabled', '1'); + eventInterval.description = _('Continuous traffic is aggregated and written at most once per interval.'); + var maxEvents = s.option(form.Value, 'max_events_per_device', _('Maximum records per device')); + maxEvents.datatype = 'range(1,500)'; maxEvents.default = '20'; + maxEvents.depends('log_enabled', '1'); + maxEvents.description = _('Each device keeps its own newest records, so one device cannot fill the entire log.'); + + s = m.section(form.GridSection, 'node', _('Proxy nodes')); + s.addremove = true; s.nodescriptions = true; s.anonymous = true; s.addbtntitle = _('Add proxy node'); + s.sectiontitle = function(id) { return uci.get('wificalling-gateway', id, 'label') || id; }; + s.option(form.Flag, 'enabled', _('Enable')).default = '1'; + var nodeLabel = s.option(form.Value, 'label', _('Node display name')); + nodeLabel.rmempty = false; nodeLabel.placeholder = _('Example: UK AnyTLS'); + nodeLabel.description = _('This name is shown in the device node selector.'); + var p = s.option(form.ListValue, 'protocol', _('Protocol')); + ['anytls','hysteria2','tuic','vless','vmess'].forEach(function(x) { p.value(x); }); + s.option(form.Value, 'server', _('Server')).datatype = 'host'; + s.option(form.Value, 'port', _('Port')).datatype = 'port'; + var nodeStatus = s.option(form.DummyValue, '_node_status', _('Node status')); + nodeStatus.textvalue = function(id) { return E('span', { id: 'wfc-node-state-' + id }, nodeState(nodeById(id))); }; + var nodePing = s.option(form.DummyValue, '_node_ping', _('Ping / latency')); + nodePing.textvalue = function(id) { return E('span', { id: 'wfc-node-ping-' + id }, latency(nodeById(id))); }; + var nodeQuality = s.option(form.DummyValue, '_node_quality', _('Quality')); + nodeQuality.textvalue = function(id) { return E('span', { id: 'wfc-node-quality-' + id }, quality(nodeById(id))); }; + var secret = s.option(form.Value, 'password', _('Password')); + secret.password = true; secret.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; + s.option(form.Value, 'uuid', _('UUID')); + s.option(form.Value, 'sni', _('TLS server name')); + s.option(form.Flag, 'insecure', _('Allow insecure certificate')); + s.option(form.Value, 'alpn', _('ALPN')); + s.option(form.Value, 'pin_sha256', _('TLS public-key SHA-256 (base64)')); + s.option(form.Value, 'flow', _('VLESS flow')); + s.option(form.Value, 'public_key', _('Reality public key')); + s.option(form.Value, 'short_id', _('Reality short ID')); + s.option(form.Value, 'fingerprint', _('Reality fingerprint')); + var udpMode = s.option(form.ListValue, 'udp_mode', _('TUIC UDP mode')); + udpMode.value('native', _('Native')); udpMode.value('quic', _('QUIC')); + var transport = s.option(form.ListValue, 'transport', _('Transport')); + transport.value('', _('None')); transport.value('ws', _('WebSocket')); + s.option(form.Value, 'path', _('WebSocket path')); + s.option(form.Value, 'host', _('WebSocket Host')); + + s = m.section(form.GridSection, 'device', _('Device policies')); + s.addremove = true; s.nodescriptions = true; s.anonymous = true; s.addbtntitle = _('Add LAN device'); + s.sectiontitle = function(id) { return uci.get('wificalling-gateway', id, 'label') || id; }; + s.option(form.Flag, 'enabled', _('Enable')).default = '1'; + var deviceLabel = s.option(form.Value, 'label', _('Device display name')); + deviceLabel.rmempty = false; deviceLabel.placeholder = _('Example: iPhone 12'); + var routeMode = s.option(form.ListValue, 'route_mode', _('Routing mode')); + routeMode.value('independent', _('Independent tunnel')); routeMode.value('follow_gateway', _('Follow gateway')); + routeMode.default = 'independent'; + var selectedNode = s.option(form.ListValue, 'node', _('Node')); + selectedNode.rmempty = false; selectedNode.depends('route_mode', 'independent'); + selectedNode.description = _('Save the node first, then reload this page to select it for a device.'); + uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); + var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); + ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.189'; + + poll.add(function() { + return L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}').then(function(raw) { + var current; try { current = JSON.parse(raw); } catch (e) { current = { nodes: [] }; } + (current.nodes || []).forEach(function(n) { + [['state', nodeState(n)], ['ping', latency(n)], ['quality', quality(n)]].forEach(function(v) { + var el = document.getElementById('wfc-node-' + v[0] + '-' + n.id); if (el) dom.content(el, v[1]); + }); + }); + }); + }, 5); + return m.render().then(function(formNode) { return E([], [importPanel, formNode]); }); + } +}); diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js new file mode 100644 index 000000000000..2f5185fdb008 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js @@ -0,0 +1,40 @@ +'use strict'; +'require view'; +'require fs'; +'require poll'; +'require dom'; + +return view.extend({ + load: function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}'); }, + render: function(raw) { + function parse(value) { try { return JSON.parse(value); } catch (e) { return { devices: [] }; } } + function when(epoch) { return epoch ? new Date(epoch * 1000).toLocaleString() : '-'; } + function wfcLabel(v) { + switch (v) { + case 'registered': return _('Registered'); + case 'connecting': return _('Connecting'); + case 'not_detected': return _('Not detected'); + case 'likely_registered': return _('Likely registered'); + case 'active_traffic': return _('Active traffic'); + case 'nat_t_seen': return _('NAT-T seen'); + case 'negotiating': return _('Negotiating'); + case 'no_session': return _('No session'); + default: return v || '-'; + } + } + function rows(source) { + return (source.devices || []).map(function(d) { + var values = [d.label, d.ip, wfcLabel(d.wificalling || d.state), d.node || '-', d.epdg_ip || '-', + (d.ike_seen ? '500' : '-') + ' / ' + (d.nat_t_seen ? '4500' : '-'), + d.assured ? _('Yes') : _('No'), d.sent_packets + ' ↑ / ' + d.reply_packets + ' ↓', when(d.last_activity)]; + return E('tr', { class: 'tr' }, values.map(function(x) { return E('td', { class: 'td' }, String(x)); })); + }); + } + var body = E('tbody', {}, rows(parse(raw))); + poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}').then(function(v) { dom.content(body, rows(parse(v))); }); }, 5); + return E([], [E('h2', {}, _('Wi-Fi Calling status')), E('p', {}, _('Registered means an ASSURED bidirectional UDP 4500 tunnel was observed. This is network evidence, not carrier activation confirmation.')), + E('div', { class: 'table cbi-section-table' }, [E('table', { class: 'table' }, [ + E('tr', { class: 'tr table-titles' }, [_('Device'), _('IP'), _('Wi-Fi Calling status'), _('Node'), _('ePDG IP'), _('UDP 500/4500'), _('ASSURED'), _('Packets'), _('Last activity')].map(function(x) { return E('th', { class: 'th' }, x); })), body + ])])]); + } +}); diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js new file mode 100644 index 000000000000..841095cf107f --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -0,0 +1,82 @@ +'use strict'; +'require baseclass'; + +function decodeLabel(value) { + try { return decodeURIComponent(value || ''); } catch (e) { return value || ''; } +} + +function decodeBase64(value) { + var normalized = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, ''); + while (normalized.length % 4) normalized += '='; + var binary = atob(normalized), bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder('utf-8').decode(bytes); +} + +function truthy(value) { + return /^(1|true|yes)$/i.test(value || '') ? '1' : '0'; +} + +function common(protocol, url) { + if (!url.hostname || !url.port) throw new Error(_('Server and port are required')); + return { + enabled: '1', protocol: protocol, server: url.hostname, port: url.port, + label: decodeLabel(url.hash.replace(/^#/, '')) || protocol.toUpperCase() + ' ' + url.hostname + }; +} + +function parseUrl(uri, protocol) { + var url = new URL(uri), p = url.searchParams, out = common(protocol, url); + if (protocol === 'anytls' || protocol === 'hysteria2') { + out.password = decodeURIComponent(url.username || ''); + out.sni = p.get('peer') || p.get('sni') || ''; + out.insecure = truthy(p.get('insecure') || p.get('allowInsecure')); + out.alpn = p.get('alpn') || ''; + out.pin_sha256 = p.get('pinSHA256') || ''; + out.fingerprint = p.get('fingerprint') || p.get('fp') || ''; + out.udp = truthy(p.get('udp')); + } else if (protocol === 'tuic') { + out.uuid = decodeURIComponent(url.username || ''); + out.password = decodeURIComponent(url.password || ''); + out.sni = p.get('sni') || ''; + out.insecure = truthy(p.get('insecure') || p.get('allowInsecure') || p.get('allow_insecure')); + out.alpn = p.get('alpn') || ''; + out.congestion = p.get('congestion_control') || p.get('congestion') || 'bbr'; + out.udp_mode = p.get('udp_relay_mode') || 'native'; + } else if (protocol === 'vless') { + out.uuid = decodeURIComponent(url.username || ''); + out.flow = p.get('flow') || ''; + out.security = p.get('security') || ''; + out.sni = p.get('sni') || ''; + out.public_key = p.get('pbk') || p.get('publicKey') || ''; + out.short_id = p.get('sid') || p.get('shortId') || ''; + out.fingerprint = p.get('fp') || p.get('fingerprint') || 'chrome'; + if (p.get('type') === 'ws') { + out.transport = 'ws'; out.path = p.get('path') || '/'; out.host = p.get('host') || ''; + } + } + return out; +} + +function parseVmess(uri) { + var raw = JSON.parse(decodeBase64(uri.slice('vmess://'.length).trim())); + if (!raw.add || !raw.port || !raw.id) throw new Error(_('VMess server, port and UUID are required')); + var out = { + enabled: '1', protocol: 'vmess', label: raw.ps || 'VMess ' + raw.add, + server: raw.add, port: String(raw.port), uuid: raw.id, alter_id: String(raw.aid || 0), + sni: raw.sni || '', host: raw.host || '', path: raw.path || '' + }; + if (raw.net === 'ws') out.transport = 'ws'; + return out; +} + +function parse(uri) { + var value = (uri || '').trim(), scheme = value.split(':', 1)[0].toLowerCase(); + if (scheme === 'vmess') return parseVmess(value); + if (scheme === 'hy2') scheme = 'hysteria2'; + if (['anytls', 'hysteria2', 'tuic', 'vless'].indexOf(scheme) < 0) + throw new Error(_('Unsupported node link format')); + return parseUrl(value, scheme); +} + +return baseclass.extend({ parse: parse }); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot new file mode 100644 index 000000000000..047ce5288c76 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -0,0 +1,345 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" + +msgid "ALPN" +msgstr "" + +msgid "ASSURED" +msgstr "" + +msgid "Active traffic" +msgstr "" + +msgid "Activity" +msgstr "" + +msgid "Activity Log" +msgstr "" + +msgid "Activity log" +msgstr "" + +msgid "Activity log cleared." +msgstr "" + +msgid "Activity log recording is disabled. Enable it in Settings." +msgstr "" + +msgid "Add LAN device" +msgstr "" + +msgid "Add proxy node" +msgstr "" + +msgid "Alive" +msgstr "" + +msgid "Allow insecure certificate" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Clear activity log?" +msgstr "" + +msgid "Clear log" +msgstr "" + +msgid "Configure proxy nodes and assign fixed LAN devices. Monitoring and logs are available from the submenu." +msgstr "" + +msgid "Connecting" +msgstr "" + +msgid "Continuous traffic is aggregated and written at most once per interval." +msgstr "" + +msgid "Debug" +msgstr "" + +msgid "Device" +msgstr "" + +msgid "Device display name" +msgstr "" + +msgid "Device policies" +msgstr "" + +msgid "Each device keeps its own newest records, so one device cannot fill the entire log." +msgstr "" + +msgid "Enable" +msgstr "" + +msgid "Encrypted IMS activity log" +msgstr "" + +msgid "Encrypted activity; call/SMS unknown" +msgstr "" + +msgid "Example: UK AnyTLS" +msgstr "" + +msgid "Example: iPhone 12" +msgstr "" + +msgid "Excellent" +msgstr "" + +msgid "Fair" +msgstr "" + +msgid "Follow gateway" +msgstr "" + +msgid "General" +msgstr "" + +msgid "Good" +msgstr "" + +msgid "Handshake failed" +msgstr "" + +msgid "Handshake success" +msgstr "" + +msgid "IP" +msgstr "" + +msgid "Import" +msgstr "" + +msgid "Import node link" +msgstr "" + +msgid "Import proxy node" +msgstr "" + +msgid "Independent tunnel" +msgstr "" + +msgid "Information" +msgstr "" + +msgid "LAN IPv4 addresses" +msgstr "" + +msgid "Last activity" +msgstr "" + +msgid "Likely registered" +msgstr "" + +msgid "Log level" +msgstr "" + +msgid "Maximum records per device" +msgstr "" + +msgid "Meaning" +msgstr "" + +msgid "NAT-T seen" +msgstr "" + +msgid "Native" +msgstr "" + +msgid "Negotiating" +msgstr "" + +msgid "No" +msgstr "" + +msgid "No session" +msgstr "" + +msgid "Node" +msgstr "" + +msgid "Node display name" +msgstr "" + +msgid "Node imported successfully. Reloading settings…" +msgstr "" + +msgid "Node status" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Not detected" +msgstr "" + +msgid "Not set" +msgstr "" + +msgid "Offline" +msgstr "" + +msgid "Packet delta" +msgstr "" + +msgid "Packets" +msgstr "" + +msgid "Password" +msgstr "" + +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, or VMess link. It is parsed locally in this browser and is not sent to an external service." +msgstr "" + +msgid "Ping / latency" +msgstr "" + +msgid "Poor" +msgstr "" + +msgid "Port" +msgstr "" + +msgid "Protocol" +msgstr "" + +msgid "Proxy nodes" +msgstr "" + +msgid "QUIC" +msgstr "" + +msgid "Quality" +msgstr "" + +msgid "Reality fingerprint" +msgstr "" + +msgid "Reality public key" +msgstr "" + +msgid "Reality short ID" +msgstr "" + +msgid "Record handshake outcomes and sustained encrypted communication. Turn off to stop writing the activity log." +msgstr "" + +msgid "Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. Phone numbers, message content, and whether an event is a call or SMS are not visible." +msgstr "" + +msgid "Records:" +msgstr "" + +msgid "Registered" +msgstr "" + +msgid "Registered means an ASSURED bidirectional UDP 4500 tunnel was observed. This is network evidence, not carrier activation confirmation." +msgstr "" + +msgid "Routing mode" +msgstr "" + +msgid "Save the node first, then reload this page to select it for a device." +msgstr "" + +msgid "Server" +msgstr "" + +msgid "Server and port are required" +msgstr "" + +msgid "Set" +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "Sustained activity log interval (seconds)" +msgstr "" + +msgid "Sustained traffic" +msgstr "" + +msgid "TLS public-key SHA-256 (base64)" +msgstr "" + +msgid "TLS server name" +msgstr "" + +msgid "TUIC UDP mode" +msgstr "" + +msgid "This name is shown in the device node selector." +msgstr "" + +msgid "This permanently removes only the Wi-Fi Calling activity history. Settings and system logs are not affected." +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Transport" +msgstr "" + +msgid "UDP 500/4500" +msgstr "" + +msgid "UUID" +msgstr "" + +msgid "Unable to clear log:" +msgstr "" + +msgid "Unable to parse node link:" +msgstr "" + +msgid "Unable to save imported node:" +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Unsupported node link format" +msgstr "" + +msgid "VLESS flow" +msgstr "" + +msgid "VMess server, port and UUID are required" +msgstr "" + +msgid "Warning" +msgstr "" + +msgid "WebSocket" +msgstr "" + +msgid "WebSocket Host" +msgstr "" + +msgid "WebSocket path" +msgstr "" + +msgid "Wi-Fi Calling" +msgstr "" + +msgid "Wi-Fi Calling Gateway settings" +msgstr "" + +msgid "Wi-Fi Calling Status" +msgstr "" + +msgid "Wi-Fi Calling status" +msgstr "" + +msgid "Yes" +msgstr "" + +msgid "ePDG IP" +msgstr "" diff --git a/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway new file mode 100644 index 000000000000..7711eea8bff9 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway @@ -0,0 +1,10 @@ +config global 'main' + option enabled '0' + option log_level 'warn' + option monitor_interval '5' + option event_interval '60' + option max_events_per_device '20' + option log_enabled '1' + +# Add nodes and device policies in LuCI. Secrets are intentionally not included. +# Reserve each client address with static DHCP before enabling a policy. diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway new file mode 100755 index 000000000000..c81051bea542 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -0,0 +1,71 @@ +#!/bin/sh /etc/rc.common +USE_PROCD=1 +START=99 +STOP=10 + +APP=wificalling-gateway +RUNDIR=/var/run/$APP + +append_node() { + local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id credential auxiliary pin_sha256 + config_get_bool enabled "$s" enabled 1 + [ "$enabled" -eq 1 ] || return 0 + config_get label "$s" label "$s"; config_get protocol "$s" protocol + config_get server "$s" server; config_get port "$s" port; config_get password "$s" password + config_get sni "$s" sni; config_get insecure "$s" insecure 0; config_get alpn "$s" alpn + config_get uuid "$s" uuid; config_get congestion "$s" congestion bbr; config_get udp_mode "$s" udp_mode native + config_get public_key "$s" public_key; config_get short_id "$s" short_id; config_get fingerprint "$s" fingerprint chrome + config_get security "$s" security; config_get transport "$s" transport; config_get path "$s" path; config_get host "$s" host + config_get pin_sha256 "$s" pin_sha256 + config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 + case "$label$protocol$server$password$sni$uuid$public_key$short_id$host" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + credential=$password; auxiliary=$uuid + case "$protocol" in + vless|vmess) credential=$uuid; auxiliary=${flow:-$alter_id} ;; + esac + printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" >> "$RUNDIR/normalized.conf" + printf '%s|%s|%s|%s|%s\n' "$s" "$label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" +} + +append_ip() { DEVICE_IPS="${DEVICE_IPS}${DEVICE_IPS:+,}$1"; } +append_device() { + local s="$1" enabled label node route_mode + config_get_bool enabled "$s" enabled 1; [ "$enabled" -eq 1 ] || return 0 + config_get label "$s" label "$s"; config_get node "$s" node; config_get route_mode "$s" route_mode independent + DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip + [ "$route_mode" = independent ] || return 0 + printf 'device|%s|%s|%s\n' "$label" "$node" "$DEVICE_IPS" >> "$RUNDIR/normalized.conf" + IFS=,; for ip in $DEVICE_IPS; do printf '%s|%s|node-%s\n' "$label" "$ip" "$node" >> "$RUNDIR/clients"; done; unset IFS +} + +start_service() { + chmod 600 "/etc/config/$APP" 2>/dev/null || true + config_load "$APP"; config_get_bool enabled main enabled 0; [ "$enabled" -eq 1 ] || return 0 + mkdir -p "$RUNDIR"; chmod 700 "$RUNDIR"; : > "$RUNDIR/normalized.conf"; : > "$RUNDIR/clients"; : > "$RUNDIR/nodes" + config_get log_level main log_level warn; printf 'global|log_level|%s\n' "$log_level" >> "$RUNDIR/normalized.conf" + config_get event_interval main event_interval 60 + config_get max_events_per_device main max_events_per_device 20 + case "$event_interval" in ''|*[!0-9]*) event_interval=60;; esac + case "$max_events_per_device" in ''|*[!0-9]*) max_events_per_device=20;; esac + [ "$event_interval" -ge 30 ] && [ "$event_interval" -le 3600 ] || event_interval=60 + [ "$max_events_per_device" -ge 1 ] && [ "$max_events_per_device" -le 500 ] || max_events_per_device=20 + config_get_bool log_enabled main log_enabled 1 + config_foreach append_node node; config_foreach append_device device + /usr/libexec/$APP/compiler.sh "$RUNDIR/normalized.conf" "$RUNDIR/sing-box.json" || return 1 + /usr/bin/sing-box check -c "$RUNDIR/sing-box.json" || { logger -t "$APP" "sing-box rejected generated configuration"; return 1; } + /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" + procd_open_instance sing-box + procd_set_param command /usr/bin/sing-box run -c "$RUNDIR/sing-box.json" + procd_set_param respawn 3600 5 5 + procd_set_param limits nofile="65535 65535" + procd_close_instance + procd_open_instance monitor + procd_set_param command /usr/libexec/$APP/monitor-loop.sh "$RUNDIR/clients" "$RUNDIR/status.json" "$RUNDIR/nodes" "$RUNDIR/node-status.json" "$RUNDIR/events.log" "$RUNDIR/monitor.state" "$event_interval" "$max_events_per_device" "$log_enabled" + procd_set_param respawn + procd_close_instance +} + +stop_service() { /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients"; } +reload_service() { restart; } + +service_triggers() { procd_add_reload_trigger "$APP"; } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh new file mode 100755 index 000000000000..b5a56b278f10 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +input=${1:?normalized configuration required} +output=${2:?output path required} +tmp="${output}.tmp.$$" +trap 'rm -f "$tmp"' EXIT HUP INT TERM + +awk -F '|' ' +function esc(s, x) { x=s; gsub(/\\/, "\\\\", x); gsub(/\"/, "\\\"", x); gsub(/\r/, "\\r", x); gsub(/\n/, "\\n", x); return x } +function q(s) { return "\"" esc(s) "\"" } +function fail(s) { print "wificalling-gateway: " s > "/dev/stderr"; exit 2 } +function private4(ip, a) { + if (ip !~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) return 0 + split(ip,a,"."); if (a[1]>255||a[2]>255||a[3]>255||a[4]>255) return 0 + return a[1]==10 || (a[1]==172 && a[2]>=16 && a[2]<=31) || (a[1]==192 && a[2]==168) +} +function tls(sni, insecure, alpn, pin, extra) { + extra="\"enabled\":true,\"server_name\":" q(sni) ",\"insecure\":" (insecure=="1"?"true":"false") + if (alpn!="") extra=extra ",\"alpn\":[" q(alpn) "]" + if (pin!="") extra=extra ",\"certificate_public_key_sha256\":[" q(pin) "]" + return "{" extra "}" +} +$1=="global" { if ($2=="log_level") level=$3; next } +$1=="node" { + id=$2; proto=$3 + if (id=="" || seen_node[id]++) fail("duplicate or empty node id: " id) + if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess") fail("unsupported protocol: " proto) + if ($4=="" || $5 !~ /^[0-9]+$/ || $5<1 || $5>65535) fail("invalid server or port for node: " id) + node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto; next +} +$1=="device" { + if (!node_proto[$3]) fail("device references unknown node: " $3) + n=split($4, ips, ","); if (n<1 || $4=="") fail("device has no client IP: " $2) + normalized="" + for(i=1;i<=n;i++) { + ip=ips[i]; gsub(/^[ \t]+|[ \t]+$/, "", ip) + if (!private4(ip)) fail("client IP must be private IPv4: " ip) + if (owner[ip] && owner[ip]!=$2) fail("duplicate client IP assignment: " ip) + owner[ip]=$2; normalized=normalized (normalized?",":"") ip + } + dev[++nd]=$2; devnode[nd]=$3; devips[nd]=normalized; next +} +END { + if (nn<1) fail("at least one enabled node is required") + if (level=="") level="warn" + print "{" + print " \"log\":{\"level\":" q(level) ",\"timestamp\":true}," + print " \"inbounds\":[{\"type\":\"tproxy\",\"tag\":\"wfc-tcp\",\"listen\":\"0.0.0.0\",\"listen_port\":11441,\"network\":\"tcp\"},{\"type\":\"tproxy\",\"tag\":\"wfc-udp\",\"listen\":\"0.0.0.0\",\"listen_port\":11442,\"network\":\"udp\"}]," + print " \"outbounds\":[" + for(k=1;k<=nn;k++) { + split(node[k],f,"|"); id=f[2]; p=f[3] + s="{\"type\":" q(p) ",\"tag\":" q("node-" id) ",\"server\":" q(f[4]) ",\"server_port\":" f[5] + if (p=="anytls") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + if (p=="hysteria2") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + if (p=="tuic") s=s ",\"uuid\":" q(f[10]) ",\"password\":" q(f[6]) ",\"congestion_control\":" q(f[11]?f[11]:"bbr") ",\"udp_relay_mode\":" q(f[12]?f[12]:"native") ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + if (p=="vless") { + s=s ",\"uuid\":" q(f[6]) + if (f[10]!="") s=s ",\"flow\":" q(f[10]) + if (f[16]=="reality") s=s ",\"tls\":{\"enabled\":true,\"server_name\":" q(f[7]) ",\"reality\":{\"enabled\":true,\"public_key\":" q(f[13]) ",\"short_id\":" q(f[14]) "},\"utls\":{\"enabled\":true,\"fingerprint\":" q(f[15]?f[15]:"chrome") "}}" + } + if (p=="vmess") { + s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]?f[10]:0) + if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" + } + s=s "}"; print " " s "," + } + print " {\"type\":\"direct\",\"tag\":\"direct\"}" + print " ]," + print " \"route\":{\"auto_detect_interface\":true,\"final\":\"direct\",\"rules\":[" + print " {\"ip_is_private\":true,\"action\":\"route\",\"outbound\":\"direct\"}" (nd?",":"") + for(k=1;k<=nd;k++) { + n=split(devips[k],ips,","); list="" + for(i=1;i<=n;i++) list=list (list?",":"") q(ips[i] "/32") + print " {\"source_ip_cidr\":[" list "],\"action\":\"route\",\"outbound\":" q("node-" devnode[k]) "}" (k "$tmp" || exit $? +chmod 600 "$tmp" +mv "$tmp" "$output" +trap - EXIT HUP INT TERM diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh new file mode 100755 index 000000000000..8972ea5f5cd1 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu +action=${1:-start}; clients=${2:-/var/run/wificalling-gateway/clients} +table='inet wificalling_gateway' +bypass_helper="${0%/*}/passwall-bypass.sh" +[ "$action" = stop ] && { "$bypass_helper" clear "$clients"; nft delete table $table 2>/dev/null || true; ip rule del fwmark 0x66 table 166 2>/dev/null || true; ip route flush table 166 2>/dev/null || true; exit 0; } + +ips=$(awk -F '|' 'NF>=2 { printf "%s%s", (n++?", ":""), $2 }' "$clients") +[ -n "$ips" ] || exit 1 +nft delete table $table 2>/dev/null || true +nft -f - </dev/null || true +ip route replace local 0.0.0.0/0 dev lo table 166 +"$bypass_helper" ensure "$clients" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh new file mode 100755 index 000000000000..d1448c5ac641 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh @@ -0,0 +1,11 @@ +#!/bin/sh +clients=$1; output=$2; nodes=$3; node_output=$4; events=$5; state=$6; event_interval=${7:-60}; max_events=${8:-20}; log_enabled=${9:-1}; tick=0 +while :; do + /usr/libexec/wificalling-gateway/passwall-bypass.sh ensure "$clients" + /usr/libexec/wificalling-gateway/monitor.sh "$clients" /proc/net/nf_conntrack "$output" "$state" "$events" "$event_interval" "$max_events" "$log_enabled" + if [ "$tick" -eq 0 ]; then + /usr/libexec/wificalling-gateway/node-health.sh "$nodes" "$node_output" + fi + tick=$(( (tick + 1) % 6 )) + sleep 5 +done diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh new file mode 100755 index 000000000000..8d52f4b3e971 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -0,0 +1,112 @@ +#!/bin/sh +set -eu + +clients=${1:?client map required} +conntrack=${2:-/proc/net/nf_conntrack} +output=${3:-/var/run/wificalling-gateway/status.json} +output_dir=${output%/*} +[ "$output_dir" != "$output" ] || output_dir=. +state=${4:-$output_dir/monitor.state} +events=${5:-$output_dir/events.log} +event_interval=${6:-60} +max_events=${7:-20} +log_enabled=${8:-1} +tmp="${output}.tmp.$$" +state_tmp="${state}.tmp.$$" +event_tmp="${events}.tmp.$$" +trim_tmp="${events}.trim.$$" +trap 'rm -f "$tmp" "$state_tmp" "$event_tmp" "$trim_tmp"' EXIT HUP INT TERM + +now=${WFC_NOW:-$(date +%s)} +touch "$state" "$events" +: > "$state_tmp" +: > "$event_tmp" + +awk -F '|' -v now="$now" -v clients_file="$clients" -v conntrack_file="$conntrack" \ + -v state_file="$state" -v state_out="$state_tmp" -v event_out="$event_tmp" -v event_interval="$event_interval" -v log_enabled="$log_enabled" ' +function q(s, x) { x=s; gsub(/\\/,"\\\\",x); gsub(/\"/,"\\\"",x); return "\"" x "\"" } +FILENAME==clients_file { + if ($1!="" && $2!="") { n++; label[n]=$1; ip[n]=$2; node[n]=$3; index_by_ip[$2]=n } + next +} +FILENAME==state_file { + i=index_by_ip[$2] + if (i) { + old_wfc[i]=$3; old_sent[i]=$4+0; old_reply[i]=$5+0; old_last[i]=$6+0 + old_event[i]=$7+0; old_streak[i]=$8+0; old_acc_sent[i]=$9+0; old_acc_reply[i]=$10+0 + old_traffic_since[i]=($11!="" ? $11+0 : 0) + } + next +} +FILENAME==conntrack_file { + line=$0 + for (i=1;i<=n;i++) { + if (line !~ ("src=" ip[i] " ")) continue + if (match(line,/dst=[0-9.]+/)) dst=substr(line,RSTART+4,RLENGTH-4) + is500=(line ~ /dport=500 /); is4500=(line ~ /dport=4500 /) + if (!is500 && !is4500) continue + if (is500) ike[i]=1 + if (is4500) natt[i]=1 + if (is4500 && line ~ /\[ASSURED\]/) assured[i]=1 + epdg[i]=dst + count=0; rest=line + while (match(rest,/packets=[0-9]+/)) { + val=substr(rest,RSTART+8,RLENGTH-8)+0; count++ + if (count==1) sent[i]=val; else if(count==2) reply[i]=val + rest=substr(rest,RSTART+RLENGTH) + } + } + next +} +END { + print "{\"generated_at\":" now ",\"disclaimer\":\"Encrypted IPsec evidence only; calls and SMS cannot be distinguished.\",\"devices\":[" + for(i=1;i<=n;i++) { + wfc=(assured[i]?"registered":natt[i]||ike[i]?"connecting":"not_detected") + legacy=(assured[i] && sent[i]+reply[i]>=100?"active_traffic":assured[i]?"likely_registered":natt[i]?"nat_t_seen":ike[i]?"negotiating":"no_session") + ds=(sent[i]>=old_sent[i]?sent[i]-old_sent[i]:sent[i]) + dr=(reply[i]>=old_reply[i]?reply[i]-old_reply[i]:reply[i]) + activity=(ds+dr>0?"encrypted_ims_traffic":"none") + last=(ds+dr>0?now:old_last[i]) + if (ds+dr>0) { + streak=(old_streak[i]+1) + traffic_since=(old_streak[i]==0 ? now : old_traffic_since[i]) + } else { + streak=0; traffic_since=0 + } + acc_sent=old_acc_sent[i]+ds; acc_reply=old_acc_reply[i]+dr + handshake_success=(old_wfc[i]!="registered" && wfc=="registered") + handshake_failed=(wfc=="not_detected" && (old_wfc[i]=="registered" || old_wfc[i]=="connecting")) + sustained=(!handshake_success && wfc=="registered" && streak>=1 && traffic_since>0 && now-traffic_since>=3 && now-old_event[i]>=event_interval) + printf "%s{", (i>1?",":"") + printf "\"label\":%s,\"ip\":%s,\"node\":%s,\"state\":%s,\"wificalling\":%s,", q(label[i]),q(ip[i]),q(node[i]),q(legacy),q(wfc) + printf "\"epdg_ip\":%s,\"ike_seen\":%s,\"nat_t_seen\":%s,\"assured\":%s,", q(epdg[i]),(ike[i]?"true":"false"),(natt[i]?"true":"false"),(assured[i]?"true":"false") + printf "\"sent_packets\":%d,\"reply_packets\":%d,\"delta_sent\":%d,\"delta_reply\":%d,\"last_activity\":%d,\"activity_evidence\":%s}", sent[i]+0,reply[i]+0,ds,dr,last,q(activity) + if (log_enabled) { + if (handshake_success) { + print now "|" label[i] "|" ip[i] "|handshake_success|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out + old_event[i]=now; acc_sent=0; acc_reply=0 + } else if (handshake_failed) { + print now "|" label[i] "|" ip[i] "|handshake_failed|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out + old_event[i]=now; acc_sent=0; acc_reply=0 + } else if (sustained) { + print now "|" label[i] "|" ip[i] "|sustained_traffic|" acc_sent "|" acc_reply "|call_or_sms_unknown|" wfc > event_out + old_event[i]=now; acc_sent=0; acc_reply=0 + } + } + print label[i] "|" ip[i] "|" wfc "|" sent[i]+0 "|" reply[i]+0 "|" last "|" old_event[i]+0 "|" streak "|" acc_sent "|" acc_reply "|" traffic_since+0 > state_out + } + print "]}" +} +' "$clients" "$state" "$conntrack" > "$tmp" + +cat "$event_tmp" >> "$events" +awk -F '|' -v limit="$max_events" ' +FNR==NR { count[$2 FS $3]++; next } +{ key=$2 FS $3; seen[key]++; if (seen[key] > count[key]-limit) print } +' "$events" "$events" > "$trim_tmp" +mv "$trim_tmp" "$events" +chmod 644 "$tmp" "$events" +chmod 600 "$state_tmp" +mv "$state_tmp" "$state" +mv "$tmp" "$output" +trap - EXIT HUP INT TERM diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh new file mode 100755 index 000000000000..660011351668 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +nodes=${1:?node list required} +output=${2:-/var/run/wificalling-gateway/node-status.json} +tmp="${output}.tmp.$$" +trap 'rm -f "$tmp"' EXIT HUP INT TERM + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +{ + printf '{"generated_at":%s,"note":"ICMP ping only; this is not a proxy protocol handshake.","nodes":[' "$(date +%s)" + first=1 + while IFS='|' read -r id label protocol server port; do + [ -n "$id" ] || continue + ping_output=$(ping -c 1 -W 1 "$server" 2>/dev/null || true) + latency=$(printf '%s\n' "$ping_output" | sed -n 's/.*time[=<]\{0,1\}\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) + state=no_icmp_reply; ping_json=null; measurement=icmp + if [ -n "$latency" ]; then + state=reachable; ping_json=$latency + else + case "$protocol" in + anytls|vless|vmess) + measurement=tcp + tcp_output=$(tcping -c 1 -t 1 -p "$port" "$server" 2>/dev/null || true) + latency=$(printf '%s\n' "$tcp_output" | sed -n 's/.*time=\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) + if [ -n "$latency" ]; then state=tcp_reachable; ping_json=$latency; else state=unreachable; fi + ;; + esac + fi + [ "$first" -eq 1 ] || printf ',' + first=0 + printf '{"id":"%s","label":"%s","protocol":"%s","server":"%s","port":%s,"state":"%s","measurement":"%s","ping_ms":%s}' \ + "$(json_escape "$id")" "$(json_escape "$label")" "$(json_escape "$protocol")" \ + "$(json_escape "$server")" "$port" "$state" "$measurement" "$ping_json" + done < "$nodes" + printf ']}\n' +} > "$tmp" +chmod 644 "$tmp" +mv "$tmp" "$output" +trap - EXIT HUP INT TERM diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh new file mode 100755 index 000000000000..124cccc4a965 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +action=${1:-ensure} +clients=${2:-/var/run/wificalling-gateway/clients} +comment=WFC_GATEWAY_BYPASS + +clear_chain() { + chain=$1 + nft -a list chain inet passwall "$chain" 2>/dev/null | + awk -v marker="$comment" '$0 ~ marker { print $NF }' | + while read -r handle; do + case "$handle" in ''|*[!0-9]*) continue;; esac + nft delete rule inet passwall "$chain" handle "$handle" 2>/dev/null || true + done +} + +nft list table inet passwall >/dev/null 2>&1 || exit 0 + +if [ "$action" = clear ]; then + clear_chain PSW_MANGLE + clear_chain PSW_NAT + exit 0 +fi + +[ -f "$clients" ] || exit 0 +ips=$(awk -F '|' 'NF>=2 { printf "%s%s", (n++?", ":""), $2 }' "$clients") +[ -n "$ips" ] || { "$0" clear "$clients"; exit 0; } + +for chain in PSW_MANGLE PSW_NAT; do + if ! nft list chain inet passwall "$chain" 2>/dev/null | grep -q "$comment"; then + nft insert rule inet passwall "$chain" ip saddr { $ips } counter return comment "$comment" + fi +done diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/luci/menu.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/luci/menu.d/luci-app-wificalling-gateway.json new file mode 100644 index 000000000000..15e4b10d4a6c --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/share/luci/menu.d/luci-app-wificalling-gateway.json @@ -0,0 +1,23 @@ +{ + "admin/services/wificalling-gateway": { + "title": "Wi-Fi Calling Gateway", + "order": 70, + "action": { "type": "firstchild" }, + "depends": { "acl": [ "luci-app-wificalling-gateway" ] } + }, + "admin/services/wificalling-gateway/settings": { + "title": "Settings", + "order": 10, + "action": { "type": "view", "path": "wificalling-gateway/overview" } + }, + "admin/services/wificalling-gateway/status": { + "title": "Wi-Fi Calling Status", + "order": 20, + "action": { "type": "view", "path": "wificalling-gateway/status" } + }, + "admin/services/wificalling-gateway/events": { + "title": "Activity Log", + "order": 30, + "action": { "type": "view", "path": "wificalling-gateway/events" } + } +} diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json new file mode 100644 index 000000000000..78f800744129 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -0,0 +1,25 @@ +{ + "luci-app-wificalling-gateway": { + "description": "Manage Wi-Fi Calling Gateway", + "read": { + "uci": [ "wificalling-gateway" ], + "ubus": { "file": [ "read" ] }, + "file": { + "/var/run/wificalling-gateway/status.json": [ "read" ], + "/var/run/wificalling-gateway/node-status.json": [ "read" ], + "/var/run/wificalling-gateway/events.log": [ "read" ], + "/tmp/run/wificalling-gateway/status.json": [ "read" ], + "/tmp/run/wificalling-gateway/node-status.json": [ "read" ], + "/tmp/run/wificalling-gateway/events.log": [ "read" ] + } + }, + "write": { + "uci": [ "wificalling-gateway" ], + "ubus": { "file": [ "write" ] }, + "file": { + "/var/run/wificalling-gateway/events.log": [ "write" ], + "/tmp/run/wificalling-gateway/events.log": [ "write" ] + } + } + } +} From 2d86e7a586efb624b3c4dadc3145be9c39359333 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 8 Aug 2026 23:30:38 +0800 Subject: [PATCH 02/56] luci-app-wificalling-gateway: add Simplified Chinese translation Add po/zh_Hans/wificalling-gateway.po with 113 translated strings. Protocol names and technical fields (TLS, UDP, UUID, SNI, ALPN, Reality, WebSocket, ePDG, IMS, ASSURED, QUIC) are kept in English. Signed-off-by: Smth Dagg --- .../po/zh_Hans/wificalling-gateway.po | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po new file mode 100644 index 000000000000..377fb5ba330d --- /dev/null +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -0,0 +1,346 @@ +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: zh_CN\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" + +msgid "ALPN" +msgstr "ALPN" + +msgid "ASSURED" +msgstr "ASSURED" + +msgid "Active traffic" +msgstr "活动流量" + +msgid "Activity" +msgstr "活动" + +msgid "Activity Log" +msgstr "活动日志" + +msgid "Activity log" +msgstr "活动日志" + +msgid "Activity log cleared." +msgstr "活动日志已清空。" + +msgid "Activity log recording is disabled. Enable it in Settings." +msgstr "活动日志记录已关闭,可在设置中开启。" + +msgid "Add LAN device" +msgstr "添加局域网设备" + +msgid "Add proxy node" +msgstr "添加代理节点" + +msgid "Alive" +msgstr "在线" + +msgid "Allow insecure certificate" +msgstr "允许不安全证书" + +msgid "Cancel" +msgstr "取消" + +msgid "Clear activity log?" +msgstr "清空活动日志?" + +msgid "Clear log" +msgstr "清空日志" + +msgid "Configure proxy nodes and assign fixed LAN devices. Monitoring and logs are available from the submenu." +msgstr "配置代理节点并绑定固定的局域网设备。监控和日志可在子菜单中查看。" + +msgid "Connecting" +msgstr "连接中" + +msgid "Continuous traffic is aggregated and written at most once per interval." +msgstr "持续流量会被聚合,每个间隔最多写入一次。" + +msgid "Debug" +msgstr "调试" + +msgid "Device" +msgstr "设备" + +msgid "Device display name" +msgstr "设备显示名称" + +msgid "Device policies" +msgstr "设备策略" + +msgid "Each device keeps its own newest records, so one device cannot fill the entire log." +msgstr "每台设备独立保留各自的最新记录,单台设备不会占满整个日志。" + +msgid "Enable" +msgstr "启用" + +msgid "Encrypted IMS activity log" +msgstr "加密 IMS 活动日志" + +msgid "Encrypted activity; call/SMS unknown" +msgstr "加密活动;无法区分通话/短信" + +msgid "Example: UK AnyTLS" +msgstr "示例:UK AnyTLS" + +msgid "Example: iPhone 12" +msgstr "示例:iPhone 12" + +msgid "Excellent" +msgstr "优秀" + +msgid "Fair" +msgstr "一般" + +msgid "Follow gateway" +msgstr "跟随网关" + +msgid "General" +msgstr "常规" + +msgid "Good" +msgstr "良好" + +msgid "Handshake failed" +msgstr "握手失败" + +msgid "Handshake success" +msgstr "握手成功" + +msgid "IP" +msgstr "IP" + +msgid "Import" +msgstr "导入" + +msgid "Import node link" +msgstr "导入节点链接" + +msgid "Import proxy node" +msgstr "导入代理节点" + +msgid "Independent tunnel" +msgstr "独立通道" + +msgid "Information" +msgstr "信息" + +msgid "LAN IPv4 addresses" +msgstr "局域网 IPv4 地址" + +msgid "Last activity" +msgstr "最后活动" + +msgid "Likely registered" +msgstr "可能已注册" + +msgid "Log level" +msgstr "日志级别" + +msgid "Maximum records per device" +msgstr "每台设备最大记录数" + +msgid "Meaning" +msgstr "含义" + +msgid "NAT-T seen" +msgstr "已发现 NAT-T" + +msgid "Native" +msgstr "原生" + +msgid "Negotiating" +msgstr "协商中" + +msgid "No" +msgstr "否" + +msgid "No session" +msgstr "无会话" + +msgid "Node" +msgstr "节点" + +msgid "Node display name" +msgstr "节点显示名称" + +msgid "Node imported successfully. Reloading settings…" +msgstr "节点导入成功。正在重新加载设置…" + +msgid "Node status" +msgstr "节点状态" + +msgid "None" +msgstr "无" + +msgid "Not detected" +msgstr "未检测到" + +msgid "Not set" +msgstr "未设置" + +msgid "Offline" +msgstr "离线" + +msgid "Packet delta" +msgstr "数据包增量" + +msgid "Packets" +msgstr "数据包" + +msgid "Password" +msgstr "密码" + +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, or VMess link. It is parsed locally in this browser and is not sent to an external service." +msgstr "粘贴一个 AnyTLS、Hysteria2/Hy2、TUIC、VLESS 或 VMess 链接。链接仅在本浏览器中本地解析,不会发送到外部服务。" + +msgid "Ping / latency" +msgstr "Ping / 延迟" + +msgid "Poor" +msgstr "较差" + +msgid "Port" +msgstr "端口" + +msgid "Protocol" +msgstr "协议" + +msgid "Proxy nodes" +msgstr "代理节点" + +msgid "QUIC" +msgstr "QUIC" + +msgid "Quality" +msgstr "质量" + +msgid "Reality fingerprint" +msgstr "Reality 指纹" + +msgid "Reality public key" +msgstr "Reality 公钥" + +msgid "Reality short ID" +msgstr "Reality 短 ID" + +msgid "Record handshake outcomes and sustained encrypted communication. Turn off to stop writing the activity log." +msgstr "记录握手结果与持续加密通讯。关闭后将停止写入活动日志。" + +msgid "Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. Phone numbers, message content, and whether an event is a call or SMS are not visible." +msgstr "记录握手成功或失败,以及响铃、通话等持续加密通讯。短暂流量脉冲不记录。电话号码、消息内容,以及是通话还是短信均不可见。" + +msgid "Records:" +msgstr "记录数:" + +msgid "Registered" +msgstr "已注册" + +msgid "Registered means an ASSURED bidirectional UDP 4500 tunnel was observed. This is network evidence, not carrier activation confirmation." +msgstr "已注册表示观察到 ASSURED 的双向 UDP 4500 隧道。这是网络层面的证据,不代表运营商激活已完成。" + +msgid "Routing mode" +msgstr "路由模式" + +msgid "Save the node first, then reload this page to select it for a device." +msgstr "请先保存节点,再刷新本页以便为设备选择该节点。" + +msgid "Server" +msgstr "服务器" + +msgid "Server and port are required" +msgstr "服务器和端口为必填项" + +msgid "Set" +msgstr "已设置" + +msgid "Settings" +msgstr "设置" + +msgid "Sustained activity log interval (seconds)" +msgstr "持续活动日志间隔(秒)" + +msgid "Sustained traffic" +msgstr "持续通讯" + +msgid "TLS public-key SHA-256 (base64)" +msgstr "TLS 公钥 SHA-256 (base64)" + +msgid "TLS server name" +msgstr "TLS 服务器名称" + +msgid "TUIC UDP mode" +msgstr "TUIC UDP 模式" + +msgid "This name is shown in the device node selector." +msgstr "此名称显示在设备节点选择器中。" + +msgid "This permanently removes only the Wi-Fi Calling activity history. Settings and system logs are not affected." +msgstr "此操作仅永久删除 Wi-Fi Calling 活动历史。设置和系统日志不受影响。" + +msgid "Time" +msgstr "时间" + +msgid "Transport" +msgstr "传输" + +msgid "UDP 500/4500" +msgstr "UDP 500/4500" + +msgid "UUID" +msgstr "UUID" + +msgid "Unable to clear log:" +msgstr "无法清空日志:" + +msgid "Unable to parse node link:" +msgstr "无法解析节点链接:" + +msgid "Unable to save imported node:" +msgstr "无法保存导入的节点:" + +msgid "Unknown" +msgstr "未知" + +msgid "Unsupported node link format" +msgstr "不支持的节点链接格式" + +msgid "VLESS flow" +msgstr "VLESS 流控" + +msgid "VMess server, port and UUID are required" +msgstr "VMess 服务器、端口和 UUID 为必填项" + +msgid "Warning" +msgstr "警告" + +msgid "WebSocket" +msgstr "WebSocket" + +msgid "WebSocket Host" +msgstr "WebSocket 主机" + +msgid "WebSocket path" +msgstr "WebSocket 路径" + +msgid "Wi-Fi Calling" +msgstr "Wi-Fi Calling" + +msgid "Wi-Fi Calling Gateway settings" +msgstr "Wi-Fi Calling Gateway 设置" + +msgid "Wi-Fi Calling Status" +msgstr "Wi-Fi Calling 状态" + +msgid "Wi-Fi Calling status" +msgstr "Wi-Fi Calling 状态" + +msgid "Yes" +msgstr "是" + +msgid "ePDG IP" +msgstr "ePDG IP" From d9c7e7cee246778210383c8368db245eb24cf650 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sun, 9 Aug 2026 10:01:46 +0800 Subject: [PATCH 03/56] luci-app-wificalling-gateway: fix review issues - Drop tcping dependency (not in feeds); make TCP probe optional. - VLESS/VMess: emit TLS block for non-Reality TLS; fix alter_id JSON. - Add security form field for VLESS; mask UUID in UI. - Check firewall exit status; add device delimiter guard. - firewall.sh: exit 0 on empty clients; remove unused monitor_interval. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../resources/view/wificalling-gateway/overview.js | 6 +++++- .../root/etc/config/wificalling-gateway | 1 - .../root/etc/init.d/wificalling-gateway | 6 ++++-- .../root/usr/libexec/wificalling-gateway/compiler.sh | 4 +++- .../root/usr/libexec/wificalling-gateway/firewall.sh | 2 +- .../usr/libexec/wificalling-gateway/node-health.sh | 10 ++++++---- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index f882fb741e07..d6aa14098df3 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway -LUCI_DEPENDS:=+luci-base +sing-box +firewall4 +kmod-nft-tproxy +kmod-nft-socket +ip-full +tcping +LUCI_DEPENDS:=+luci-base +sing-box +firewall4 +kmod-nft-tproxy +kmod-nft-socket +ip-full LUCI_PKGARCH:=all PKG_LICENSE:=MIT diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 5728d270088b..d5cf98e9f549 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -99,8 +99,12 @@ return view.extend({ nodeQuality.textvalue = function(id) { return E('span', { id: 'wfc-node-quality-' + id }, quality(nodeById(id))); }; var secret = s.option(form.Value, 'password', _('Password')); secret.password = true; secret.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; - s.option(form.Value, 'uuid', _('UUID')); + var uuidField = s.option(form.Value, 'uuid', _('UUID')); + uuidField.password = true; uuidField.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; s.option(form.Value, 'sni', _('TLS server name')); + var securityOpt = s.option(form.ListValue, 'security', _('Security')); + securityOpt.value(''); securityOpt.value('tls'); securityOpt.value('reality'); + securityOpt.depends('protocol', 'vless'); s.option(form.Flag, 'insecure', _('Allow insecure certificate')); s.option(form.Value, 'alpn', _('ALPN')); s.option(form.Value, 'pin_sha256', _('TLS public-key SHA-256 (base64)')); diff --git a/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway index 7711eea8bff9..7397e39f42e0 100644 --- a/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway @@ -1,7 +1,6 @@ config global 'main' option enabled '0' option log_level 'warn' - option monitor_interval '5' option event_interval '60' option max_events_per_device '20' option log_enabled '1' diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index c81051bea542..06a6a8e63018 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -21,7 +21,8 @@ append_node() { case "$label$protocol$server$password$sni$uuid$public_key$short_id$host" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in - vless|vmess) credential=$uuid; auxiliary=${flow:-$alter_id} ;; + vless) credential=$uuid; auxiliary=${flow:-$alter_id} ;; + vmess) credential=$uuid; auxiliary=$alter_id ;; esac printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" >> "$RUNDIR/normalized.conf" printf '%s|%s|%s|%s|%s\n' "$s" "$label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" @@ -32,6 +33,7 @@ append_device() { local s="$1" enabled label node route_mode config_get_bool enabled "$s" enabled 1; [ "$enabled" -eq 1 ] || return 0 config_get label "$s" label "$s"; config_get node "$s" node; config_get route_mode "$s" route_mode independent + case "$label$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip [ "$route_mode" = independent ] || return 0 printf 'device|%s|%s|%s\n' "$label" "$node" "$DEVICE_IPS" >> "$RUNDIR/normalized.conf" @@ -53,7 +55,7 @@ start_service() { config_foreach append_node node; config_foreach append_device device /usr/libexec/$APP/compiler.sh "$RUNDIR/normalized.conf" "$RUNDIR/sing-box.json" || return 1 /usr/bin/sing-box check -c "$RUNDIR/sing-box.json" || { logger -t "$APP" "sing-box rejected generated configuration"; return 1; } - /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" + /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" || { logger -t "$APP" "firewall setup failed"; return 1; } procd_open_instance sing-box procd_set_param command /usr/bin/sing-box run -c "$RUNDIR/sing-box.json" procd_set_param respawn 3600 5 5 diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index b5a56b278f10..e220b28d9def 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -58,10 +58,12 @@ END { s=s ",\"uuid\":" q(f[6]) if (f[10]!="") s=s ",\"flow\":" q(f[10]) if (f[16]=="reality") s=s ",\"tls\":{\"enabled\":true,\"server_name\":" q(f[7]) ",\"reality\":{\"enabled\":true,\"public_key\":" q(f[13]) ",\"short_id\":" q(f[14]) "},\"utls\":{\"enabled\":true,\"fingerprint\":" q(f[15]?f[15]:"chrome") "}}" + else if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls(f[7],f[8],f[9],f[20]) } if (p=="vmess") { - s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]?f[10]:0) + s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]~/^[0-9]+$/?f[10]:0) if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" + if (f[7]!="") s=s ",\"tls\":" tls(f[7],f[8],f[9],f[20]) } s=s "}"; print " " s "," } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh index 8972ea5f5cd1..cf41040df1ae 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh @@ -6,7 +6,7 @@ bypass_helper="${0%/*}/passwall-bypass.sh" [ "$action" = stop ] && { "$bypass_helper" clear "$clients"; nft delete table $table 2>/dev/null || true; ip rule del fwmark 0x66 table 166 2>/dev/null || true; ip route flush table 166 2>/dev/null || true; exit 0; } ips=$(awk -F '|' 'NF>=2 { printf "%s%s", (n++?", ":""), $2 }' "$clients") -[ -n "$ips" ] || exit 1 +[ -n "$ips" ] || exit 0 nft delete table $table 2>/dev/null || true nft -f - </dev/null || true) - latency=$(printf '%s\n' "$tcp_output" | sed -n 's/.*time=\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) - if [ -n "$latency" ]; then state=tcp_reachable; ping_json=$latency; else state=unreachable; fi + if command -v tcping >/dev/null 2>&1; then + measurement=tcp + tcp_output=$(tcping -c 1 -t 1 -p "$port" "$server" 2>/dev/null || true) + latency=$(printf '%s\n' "$tcp_output" | sed -n 's/.*time=\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) + if [ -n "$latency" ]; then state=tcp_reachable; ping_json=$latency; else state=unreachable; fi + fi ;; esac fi From d5f9ac498e6d894d63582eb323b2977e0e9a16bc Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sun, 9 Aug 2026 15:03:25 +0800 Subject: [PATCH 04/56] luci-app-wificalling-gateway: fix second review round - VLESS: auxiliary=$flow (not ${flow:-$alter_id}) so empty flow does not emit "flow":"0" which sing-box rejects. - VMess: TLS gated on security field (f[16]=="tls") in addition to SNI; parseVmess reads raw.tls from the share link and sets security. - firewall: on startup failure, call firewall.sh stop to clean up the TPROXY table before aborting (prevents blackhole with no listener). Signed-off-by: Smth Dagg --- .../luci-static/resources/wificalling-gateway/node-import.js | 3 ++- .../root/etc/init.d/wificalling-gateway | 4 ++-- .../root/usr/libexec/wificalling-gateway/compiler.sh | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index 841095cf107f..d32259c639e7 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -64,7 +64,8 @@ function parseVmess(uri) { var out = { enabled: '1', protocol: 'vmess', label: raw.ps || 'VMess ' + raw.add, server: raw.add, port: String(raw.port), uuid: raw.id, alter_id: String(raw.aid || 0), - sni: raw.sni || '', host: raw.host || '', path: raw.path || '' + sni: raw.sni || '', host: raw.host || '', path: raw.path || '', + security: raw.tls === 'tls' ? 'tls' : '' }; if (raw.net === 'ws') out.transport = 'ws'; return out; diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 06a6a8e63018..59533ffbc995 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -21,7 +21,7 @@ append_node() { case "$label$protocol$server$password$sni$uuid$public_key$short_id$host" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in - vless) credential=$uuid; auxiliary=${flow:-$alter_id} ;; + vless) credential=$uuid; auxiliary=$flow ;; vmess) credential=$uuid; auxiliary=$alter_id ;; esac printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" >> "$RUNDIR/normalized.conf" @@ -55,7 +55,7 @@ start_service() { config_foreach append_node node; config_foreach append_device device /usr/libexec/$APP/compiler.sh "$RUNDIR/normalized.conf" "$RUNDIR/sing-box.json" || return 1 /usr/bin/sing-box check -c "$RUNDIR/sing-box.json" || { logger -t "$APP" "sing-box rejected generated configuration"; return 1; } - /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" || { logger -t "$APP" "firewall setup failed"; return 1; } + /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" || { logger -t "$APP" "firewall setup failed"; /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients"; return 1; } procd_open_instance sing-box procd_set_param command /usr/bin/sing-box run -c "$RUNDIR/sing-box.json" procd_set_param respawn 3600 5 5 diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index e220b28d9def..7edcba47ff6f 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -63,7 +63,7 @@ END { if (p=="vmess") { s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]~/^[0-9]+$/?f[10]:0) if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" - if (f[7]!="") s=s ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls(f[7],f[8],f[9],f[20]) } s=s "}"; print " " s "," } From 140fb5d58268819ddeb368979324b7d1c8b65689 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sun, 9 Aug 2026 18:40:47 +0800 Subject: [PATCH 05/56] luci-app-wificalling-gateway: label empty security option as "None" nit fix: value('') rendered as a blank dropdown row; now value('', _('None')). Signed-off-by: Smth Dagg --- .../luci-static/resources/view/wificalling-gateway/overview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index d5cf98e9f549..24e991fd72bc 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -103,7 +103,7 @@ return view.extend({ uuidField.password = true; uuidField.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; s.option(form.Value, 'sni', _('TLS server name')); var securityOpt = s.option(form.ListValue, 'security', _('Security')); - securityOpt.value(''); securityOpt.value('tls'); securityOpt.value('reality'); + securityOpt.value('', _('None')); securityOpt.value('tls'); securityOpt.value('reality'); securityOpt.depends('protocol', 'vless'); s.option(form.Flag, 'insecure', _('Allow insecure certificate')); s.option(form.Value, 'alpn', _('ALPN')); From 2192d2ec5aa59c8a89c4f0e378c0d91aabbe9e4e Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 10 Aug 2026 10:14:06 +0800 Subject: [PATCH 06/56] luci-app-wificalling-gateway: fix third review round - security: depends on vless AND vmess (preserves imported tls across edits) - tls(): omit server_name when empty (sing-box uses server address) - delimiter guard: add $flow to checked fields Signed-off-by: Smth Dagg --- .../resources/view/wificalling-gateway/overview.js | 1 + .../root/etc/init.d/wificalling-gateway | 2 +- .../root/usr/libexec/wificalling-gateway/compiler.sh | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 24e991fd72bc..9981f9ee8b05 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -105,6 +105,7 @@ return view.extend({ var securityOpt = s.option(form.ListValue, 'security', _('Security')); securityOpt.value('', _('None')); securityOpt.value('tls'); securityOpt.value('reality'); securityOpt.depends('protocol', 'vless'); + securityOpt.depends('protocol', 'vmess'); s.option(form.Flag, 'insecure', _('Allow insecure certificate')); s.option(form.Value, 'alpn', _('ALPN')); s.option(form.Value, 'pin_sha256', _('TLS public-key SHA-256 (base64)')); diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 59533ffbc995..676917c48187 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -18,7 +18,7 @@ append_node() { config_get security "$s" security; config_get transport "$s" transport; config_get path "$s" path; config_get host "$s" host config_get pin_sha256 "$s" pin_sha256 config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 - case "$label$protocol$server$password$sni$uuid$public_key$short_id$host" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + case "$label$protocol$server$password$sni$uuid$public_key$short_id$host$flow" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index 7edcba47ff6f..f6376295cc20 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -16,7 +16,9 @@ function private4(ip, a) { return a[1]==10 || (a[1]==172 && a[2]>=16 && a[2]<=31) || (a[1]==192 && a[2]==168) } function tls(sni, insecure, alpn, pin, extra) { - extra="\"enabled\":true,\"server_name\":" q(sni) ",\"insecure\":" (insecure=="1"?"true":"false") + extra="\"enabled\":true" + if (sni!="") extra=extra ",\"server_name\":" q(sni) + extra=extra ",\"insecure\":" (insecure=="1"?"true":"false") if (alpn!="") extra=extra ",\"alpn\":[" q(alpn) "]" if (pin!="") extra=extra ",\"certificate_public_key_sha256\":[" q(pin) "]" return "{" extra "}" From 41498737ada156c43748293245bfc260fd4e9c66 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 10 Aug 2026 11:40:03 +0800 Subject: [PATCH 07/56] luci-app-wificalling-gateway: clear status.json and monitor.state on start Fix stale status page after device add/modify: clear both files at startup so the status page shows only current configuration, not leftover data from the previous run. Signed-off-by: Smth Dagg --- .../root/etc/init.d/wificalling-gateway | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 676917c48187..061135469430 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -43,7 +43,7 @@ append_device() { start_service() { chmod 600 "/etc/config/$APP" 2>/dev/null || true config_load "$APP"; config_get_bool enabled main enabled 0; [ "$enabled" -eq 1 ] || return 0 - mkdir -p "$RUNDIR"; chmod 700 "$RUNDIR"; : > "$RUNDIR/normalized.conf"; : > "$RUNDIR/clients"; : > "$RUNDIR/nodes" + mkdir -p "$RUNDIR"; chmod 700 "$RUNDIR"; : > "$RUNDIR/normalized.conf"; : > "$RUNDIR/clients"; : > "$RUNDIR/nodes"; : > "$RUNDIR/status.json"; : > "$RUNDIR/monitor.state" config_get log_level main log_level warn; printf 'global|log_level|%s\n' "$log_level" >> "$RUNDIR/normalized.conf" config_get event_interval main event_interval 60 config_get max_events_per_device main max_events_per_device 20 From 60f81a242834c8987aea5c5ad7260d7b9624e551 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 10 Aug 2026 15:00:42 +0800 Subject: [PATCH 08/56] luci-app-wificalling-gateway: fix fourth review round - init.d: stop truncating monitor.state on start (it is the monitor's per-device baseline; clearing it fabricated handshake_success events on the first tick after every restart). Only status.json is cleared. - init.d: extend the delimiter guard to every field interpolated into the | -delimited record (alpn, congestion, udp_mode, fingerprint, security, transport, path, pin_sha256). - init.d: also clear status.json in stop_service for the stopped case. - compiler.sh: when sni (f[7]) is empty, fall back to the WS Host (f[19]) as server_name for VLESS and VMess; the TLS decision itself still uses the original sni so plain ws stays cleartext. - overview.js: reject Security = reality on VMess (no compiler arm). Signed-off-by: Smth Dagg --- .../resources/view/wificalling-gateway/overview.js | 7 +++++++ .../root/etc/init.d/wificalling-gateway | 14 +++++++++++--- .../usr/libexec/wificalling-gateway/compiler.sh | 11 +++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 9981f9ee8b05..fded6f2d80d4 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -106,6 +106,13 @@ return view.extend({ securityOpt.value('', _('None')); securityOpt.value('tls'); securityOpt.value('reality'); securityOpt.depends('protocol', 'vless'); securityOpt.depends('protocol', 'vmess'); + // The compiler has no reality arm for VMess; selecting it would emit a + // cleartext outbound that sing-box check accepts. Reject it up front. + securityOpt.validate = function(section_id, value) { + if (value == 'reality' && this.map.getSectionValue(section_id, 'protocol') == 'vmess') + return false; + return true; + }; s.option(form.Flag, 'insecure', _('Allow insecure certificate')); s.option(form.Value, 'alpn', _('ALPN')); s.option(form.Value, 'pin_sha256', _('TLS public-key SHA-256 (base64)')); diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 061135469430..34df840254c7 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -18,7 +18,7 @@ append_node() { config_get security "$s" security; config_get transport "$s" transport; config_get path "$s" path; config_get host "$s" host config_get pin_sha256 "$s" pin_sha256 config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 - case "$label$protocol$server$password$sni$uuid$public_key$short_id$host$flow" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + case "$label$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; @@ -43,7 +43,10 @@ append_device() { start_service() { chmod 600 "/etc/config/$APP" 2>/dev/null || true config_load "$APP"; config_get_bool enabled main enabled 0; [ "$enabled" -eq 1 ] || return 0 - mkdir -p "$RUNDIR"; chmod 700 "$RUNDIR"; : > "$RUNDIR/normalized.conf"; : > "$RUNDIR/clients"; : > "$RUNDIR/nodes"; : > "$RUNDIR/status.json"; : > "$RUNDIR/monitor.state" + # Clear display state only. monitor.state is the monitor's per-device + # baseline (old_wfc/old_sent/old_reply); truncating it would fabricate + # handshake_success events on the first tick after every restart. + mkdir -p "$RUNDIR"; chmod 700 "$RUNDIR"; : > "$RUNDIR/normalized.conf"; : > "$RUNDIR/clients"; : > "$RUNDIR/nodes"; : > "$RUNDIR/status.json" config_get log_level main log_level warn; printf 'global|log_level|%s\n' "$log_level" >> "$RUNDIR/normalized.conf" config_get event_interval main event_interval 60 config_get max_events_per_device main max_events_per_device 20 @@ -67,7 +70,12 @@ start_service() { procd_close_instance } -stop_service() { /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients"; } +stop_service() { + /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients" + # Also drop the stale snapshot when the gateway is stopped/disabled so + # the status page does not keep rendering the last state indefinitely. + : > "$RUNDIR/status.json" 2>/dev/null || true +} reload_service() { restart; } service_triggers() { procd_add_reload_trigger "$APP"; } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index f6376295cc20..4fc838bc8e49 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -60,12 +60,19 @@ END { s=s ",\"uuid\":" q(f[6]) if (f[10]!="") s=s ",\"flow\":" q(f[10]) if (f[16]=="reality") s=s ",\"tls\":{\"enabled\":true,\"server_name\":" q(f[7]) ",\"reality\":{\"enabled\":true,\"public_key\":" q(f[13]) ",\"short_id\":" q(f[14]) "},\"utls\":{\"enabled\":true,\"fingerprint\":" q(f[15]?f[15]:"chrome") "}}" - else if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + # TLS is decided by security/sni alone; only the server_name falls + # back to the WS Host (f[19]) when sni (f[7]) is empty, so a plain + # ws node with a Host header but no TLS never gains a tls block. + else if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls((f[7]!=""?f[7]:f[19]),f[8],f[9],f[20]) } if (p=="vmess") { s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]~/^[0-9]+$/?f[10]:0) if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" - if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + # Imported VMess links carry the TLS name in the WS Host (f[19]) when + # sni (f[7]) is empty and the server is a bare IP; fall back to it so + # certificate verification has a name to check. Plain ws nodes with + # a Host header but no TLS stay cleartext. + if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls((f[7]!=""?f[7]:f[19]),f[8],f[9],f[20]) } s=s "}"; print " " s "," } From ab5366c840dd3809129c140bdcdab4bb13e53083 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 10:43:08 +0800 Subject: [PATCH 09/56] luci-app-wificalling-gateway: add Trojan and WireGuard node protocols - compiler: trojan outbound (password + TLS); wireguard endpoint form for sing-box >= 1.11 and legacy outbound for 1.10.x, selected from the installed sing-box version at service start (the legacy wireguard outbound was removed in 1.13.0) - LuCI node form: wireguard private key / local address / reserved / MTU - import: trojan:// and wg:// share links (Clash Meta / sing-box style) - node-health: tcping fallback for trojan (TCP-based) - i18n: new strings with Simplified Chinese translations Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/README.md | 6 +-- .../view/wificalling-gateway/overview.js | 9 ++++- .../wificalling-gateway/node-import.js | 12 +++++- .../po/templates/wificalling-gateway.pot | 14 ++++++- .../po/zh_Hans/wificalling-gateway.po | 16 +++++++- .../root/etc/init.d/wificalling-gateway | 21 ++++++++-- .../libexec/wificalling-gateway/compiler.sh | 40 +++++++++++++++++-- .../wificalling-gateway/node-health.sh | 2 +- 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index ad83ce2d2627..422d39a6e47e 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -3,8 +3,8 @@ Per-device transparent Wi-Fi Calling gateway for OpenWrt / ImmortalWrt. Routes selected LAN clients through a sing-box node (AnyTLS, Hysteria2, -TUIC, VLESS Reality, VMess WebSocket) with nftables TPROXY, observes -ePDG/IPsec UDP 500/4500 evidence, and records handshake outcomes in an -encrypted IMS activity log. +TUIC, VLESS Reality, VMess WebSocket, Trojan, WireGuard) with nftables +TPROXY, observes ePDG/IPsec UDP 500/4500 evidence, and records handshake +outcomes in an encrypted IMS activity log. See https://github.com/smthdagg/luci-app-wificalling-gateway for full docs. diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index fded6f2d80d4..9e4a57812a56 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -44,7 +44,7 @@ return view.extend({ _('Configure proxy nodes and assign fixed LAN devices. Monitoring and logs are available from the submenu.')); var importPanel = E('div', { class: 'cbi-section' }, [ E('h3', {}, _('Import proxy node')), - E('p', {}, _('Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, or VMess link. It is parsed locally in this browser and is not sent to an external service.')), + E('p', {}, _('Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard (wg://) link. It is parsed locally in this browser and is not sent to an external service.')), E('button', { class: 'btn cbi-button-positive', click: function() { var input = E('textarea', { class: 'cbi-input-textarea', rows: 6, style: 'width:100%', placeholder: 'anytls://…' }); ui.showModal(_('Import node link'), [input, E('div', { class: 'right' }, [ @@ -88,7 +88,7 @@ return view.extend({ nodeLabel.rmempty = false; nodeLabel.placeholder = _('Example: UK AnyTLS'); nodeLabel.description = _('This name is shown in the device node selector.'); var p = s.option(form.ListValue, 'protocol', _('Protocol')); - ['anytls','hysteria2','tuic','vless','vmess'].forEach(function(x) { p.value(x); }); + ['anytls','hysteria2','tuic','vless','vmess','trojan','wireguard'].forEach(function(x) { p.value(x); }); s.option(form.Value, 'server', _('Server')).datatype = 'host'; s.option(form.Value, 'port', _('Port')).datatype = 'port'; var nodeStatus = s.option(form.DummyValue, '_node_status', _('Node status')); @@ -126,6 +126,11 @@ return view.extend({ transport.value('', _('None')); transport.value('ws', _('WebSocket')); s.option(form.Value, 'path', _('WebSocket path')); s.option(form.Value, 'host', _('WebSocket Host')); + var wgKey = s.option(form.Value, 'private_key', _('WireGuard private key')); + wgKey.password = true; wgKey.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; + s.option(form.Value, 'local_address', _('WireGuard local address')); + s.option(form.Value, 'reserved', _('WireGuard reserved (comma-separated)')); + s.option(form.Value, 'mtu', _('WireGuard MTU')); s = m.section(form.GridSection, 'device', _('Device policies')); s.addremove = true; s.nodescriptions = true; s.anonymous = true; s.addbtntitle = _('Add LAN device'); diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index d32259c639e7..c0f7c98cace2 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -27,7 +27,7 @@ function common(protocol, url) { function parseUrl(uri, protocol) { var url = new URL(uri), p = url.searchParams, out = common(protocol, url); - if (protocol === 'anytls' || protocol === 'hysteria2') { + if (protocol === 'anytls' || protocol === 'hysteria2' || protocol === 'trojan') { out.password = decodeURIComponent(url.username || ''); out.sni = p.get('peer') || p.get('sni') || ''; out.insecure = truthy(p.get('insecure') || p.get('allowInsecure')); @@ -54,6 +54,13 @@ function parseUrl(uri, protocol) { if (p.get('type') === 'ws') { out.transport = 'ws'; out.path = p.get('path') || '/'; out.host = p.get('host') || ''; } + } else if (protocol === 'wireguard') { + // wg://@:?private_key=…&local_address=…&reserved=…&mtu=… + out.public_key = decodeURIComponent(url.username || ''); + out.private_key = p.get('private_key') || ''; + out.local_address = (p.get('local_address') || p.get('ip') || '').split(',')[0] || ''; + out.reserved = p.get('reserved') || ''; + out.mtu = p.get('mtu') || ''; } return out; } @@ -75,7 +82,8 @@ function parse(uri) { var value = (uri || '').trim(), scheme = value.split(':', 1)[0].toLowerCase(); if (scheme === 'vmess') return parseVmess(value); if (scheme === 'hy2') scheme = 'hysteria2'; - if (['anytls', 'hysteria2', 'tuic', 'vless'].indexOf(scheme) < 0) + if (scheme === 'wg') scheme = 'wireguard'; + if (['anytls', 'hysteria2', 'tuic', 'vless', 'trojan', 'wireguard'].indexOf(scheme) < 0) throw new Error(_('Unsupported node link format')); return parseUrl(value, scheme); } diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 047ce5288c76..30e192d825a3 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -194,7 +194,7 @@ msgstr "" msgid "Password" msgstr "" -msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, or VMess link. It is parsed locally in this browser and is not sent to an external service." +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard (wg://) link. It is parsed locally in this browser and is not sent to an external service." msgstr "" msgid "Ping / latency" @@ -329,6 +329,18 @@ msgstr "" msgid "Wi-Fi Calling" msgstr "" +msgid "WireGuard MTU" +msgstr "" + +msgid "WireGuard local address" +msgstr "" + +msgid "WireGuard private key" +msgstr "" + +msgid "WireGuard reserved (comma-separated)" +msgstr "" + msgid "Wi-Fi Calling Gateway settings" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 377fb5ba330d..de3a4ea43b7b 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -195,8 +195,8 @@ msgstr "数据包" msgid "Password" msgstr "密码" -msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, or VMess link. It is parsed locally in this browser and is not sent to an external service." -msgstr "粘贴一个 AnyTLS、Hysteria2/Hy2、TUIC、VLESS 或 VMess 链接。链接仅在本浏览器中本地解析,不会发送到外部服务。" +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard (wg://) link. It is parsed locally in this browser and is not sent to an external service." +msgstr "粘贴一个 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan 或 WireGuard (wg://) 链接。链接仅在本浏览器中本地解析,不会发送到外部服务。" msgid "Ping / latency" msgstr "Ping / 延迟" @@ -330,6 +330,18 @@ msgstr "WebSocket 路径" msgid "Wi-Fi Calling" msgstr "Wi-Fi Calling" +msgid "WireGuard MTU" +msgstr "WireGuard MTU" + +msgid "WireGuard local address" +msgstr "WireGuard 本地地址" + +msgid "WireGuard private key" +msgstr "WireGuard 私钥" + +msgid "WireGuard reserved (comma-separated)" +msgstr "WireGuard 保留位(逗号分隔)" + msgid "Wi-Fi Calling Gateway settings" msgstr "Wi-Fi Calling Gateway 设置" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 34df840254c7..fbf14ab34895 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -6,8 +6,19 @@ STOP=10 APP=wificalling-gateway RUNDIR=/var/run/$APP +# sing-box removed the wireguard OUTBOUND in 1.13.0 (deprecated in 1.11.0, +# gated behind ENABLE_DEPRECATED_WIREGUARD_OUTBOUND on 1.11/1.12); the +# wireguard ENDPOINT works from 1.11.0 on. Emit the endpoint form for +# 1.11+, and the legacy outbound only for ancient 1.10.x installs. +wireguard_style() { + ver=$(/usr/bin/sing-box version 2>/dev/null | sed -n 's/.*version[[:space:]]*\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1.\2/p' | head -n 1) + major=${ver%%.*}; minor=${ver#*.}; minor=${minor%%.*} + case "$major" in ''|*[!0-9]*) printf 'endpoint'; return;; esac + if [ "$major" -eq 1 ] && [ "$minor" -lt 11 ]; then printf 'legacy'; else printf 'endpoint'; fi +} + append_node() { - local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id credential auxiliary pin_sha256 + local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id credential auxiliary pin_sha256 private_key local_address reserved mtu config_get_bool enabled "$s" enabled 1 [ "$enabled" -eq 1 ] || return 0 config_get label "$s" label "$s"; config_get protocol "$s" protocol @@ -18,13 +29,16 @@ append_node() { config_get security "$s" security; config_get transport "$s" transport; config_get path "$s" path; config_get host "$s" host config_get pin_sha256 "$s" pin_sha256 config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 - case "$label$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + config_get private_key "$s" private_key; config_get local_address "$s" local_address; config_get reserved "$s" reserved; config_get mtu "$s" mtu + case "$label$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; vmess) credential=$uuid; auxiliary=$alter_id ;; + trojan) credential=$password; auxiliary= ;; + wireguard) credential=$private_key; auxiliary= ;; esac - printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" >> "$RUNDIR/normalized.conf" + printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" "$private_key" "$local_address" "$reserved" "$mtu" >> "$RUNDIR/normalized.conf" printf '%s|%s|%s|%s|%s\n' "$s" "$label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" } @@ -48,6 +62,7 @@ start_service() { # handshake_success events on the first tick after every restart. mkdir -p "$RUNDIR"; chmod 700 "$RUNDIR"; : > "$RUNDIR/normalized.conf"; : > "$RUNDIR/clients"; : > "$RUNDIR/nodes"; : > "$RUNDIR/status.json" config_get log_level main log_level warn; printf 'global|log_level|%s\n' "$log_level" >> "$RUNDIR/normalized.conf" + printf 'global|wireguard_style|%s\n' "$(wireguard_style)" >> "$RUNDIR/normalized.conf" config_get event_interval main event_interval 60 config_get max_events_per_device main max_events_per_device 20 case "$event_interval" in ''|*[!0-9]*) event_interval=60;; esac diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index 4fc838bc8e49..b8707388bd19 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -23,13 +23,15 @@ function tls(sni, insecure, alpn, pin, extra) { if (pin!="") extra=extra ",\"certificate_public_key_sha256\":[" q(pin) "]" return "{" extra "}" } -$1=="global" { if ($2=="log_level") level=$3; next } +$1=="global" { if ($2=="log_level") level=$3; if ($2=="wireguard_style") wg_style=$3; next } $1=="node" { id=$2; proto=$3 if (id=="" || seen_node[id]++) fail("duplicate or empty node id: " id) - if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess") fail("unsupported protocol: " proto) + if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess" && proto!="trojan" && proto!="wireguard") fail("unsupported protocol: " proto) if ($4=="" || $5 !~ /^[0-9]+$/ || $5<1 || $5>65535) fail("invalid server or port for node: " id) - node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto; next + node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto + if (proto=="wireguard") wg_nodes[++nw]=nn + next } $1=="device" { if (!node_proto[$3]) fail("device references unknown node: " $3) @@ -46,12 +48,32 @@ $1=="device" { END { if (nn<1) fail("at least one enabled node is required") if (level=="") level="warn" + if (wg_style=="") wg_style="legacy" print "{" + # The wireguard outbound was removed in sing-box 1.13.0 (deprecated in + # 1.11.0, gated behind ENABLE_DEPRECATED_WIREGUARD_OUTBOUND on 1.11/1.12); + # the wireguard endpoint works from 1.11.0 on. init.d picks the style from + # the installed sing-box version; "endpoint" emits an endpoints block and + # routes straight to the endpoint tag, "legacy" keeps the old outbound. + if (nw>0 && wg_style=="endpoint") { + print " \"endpoints\":[" + for(w=1;w<=nw;w++) { + split(node[wg_nodes[w]],f,"|"); id=f[2] + s="{\"type\":\"wireguard\",\"tag\":" q("wg-" id) ",\"address\":[" q(f[22]) "],\"private_key\":" q(f[21]) + s=s ",\"peers\":[{\"address\":" q(f[4]) ",\"port\":" f[5] ",\"public_key\":" q(f[13]) ",\"allowed_ips\":[\"0.0.0.0/0\"]" + if (f[23]!="") { nr=split(f[23],rv,","); rv_s=rv[1]; for(ri=2;ri<=nr;ri++) rv_s=rv_s "," rv[ri]; s=s ",\"reserved\":[" rv_s "]" } + s=s "}]" + if (f[24]!="") s=s ",\"mtu\":" f[24] + s=s "}"; print " " s (w/dev/null 2>&1; then measurement=tcp tcp_output=$(tcping -c 1 -t 1 -p "$port" "$server" 2>/dev/null || true) From 0d573b240b849558a6a99570b475bbe1318882ff Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 11:44:06 +0800 Subject: [PATCH 10/56] luci-app-wificalling-gateway: auto-manage DHCP leases from policies - dhcp-sync.sh: on service start, create/update wfc_-prefixed DHCP host bindings from the live lease table (pins the device's current MAC to its policy IP, healing rotated iOS private MACs) and drop bindings whose policy was removed; user-managed hosts are never touched; dnsmasq restarts only when something changed - LuCI device policies: DHCP binding status column (Bound / MAC changed / Not bound yet / Device offline / Following gateway) with new read ACLs for dhcp config and /tmp/dhcp.leases - i18n: new strings translated Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/README.md | 2 +- .../view/wificalling-gateway/overview.js | 32 ++++++- .../po/templates/wificalling-gateway.pot | 15 ++++ .../po/zh_Hans/wificalling-gateway.po | 15 ++++ .../root/etc/init.d/wificalling-gateway | 3 + .../libexec/wificalling-gateway/dhcp-sync.sh | 86 +++++++++++++++++++ .../acl.d/luci-app-wificalling-gateway.json | 5 +- 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 422d39a6e47e..02fe88c6a2f0 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -5,6 +5,6 @@ Per-device transparent Wi-Fi Calling gateway for OpenWrt / ImmortalWrt. Routes selected LAN clients through a sing-box node (AnyTLS, Hysteria2, TUIC, VLESS Reality, VMess WebSocket, Trojan, WireGuard) with nftables TPROXY, observes ePDG/IPsec UDP 500/4500 evidence, and records handshake -outcomes in an encrypted IMS activity log. +outcomes in an encrypted IMS activity log. DHCP static leases are auto-synced from the device policies (bind/clean MAC-IP on add/remove). See https://github.com/smthdagg/luci-app-wificalling-gateway for full docs. diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 9e4a57812a56..99e6e3ea79a6 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -12,7 +12,9 @@ return view.extend({ load: function() { return Promise.all([ L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), - uci.load('wificalling-gateway') + uci.load('wificalling-gateway'), + L.resolveDefault(fs.read('/tmp/dhcp.leases'), ''), + uci.load('dhcp') ]); }, render: function(data) { @@ -39,6 +41,26 @@ return view.extend({ return _('Unknown'); } function latency(n) { return n && n.ping_ms != null ? n.ping_ms + ' ms (' + n.measurement + ')' : '-'; } + // Live DHCP lease map (IP -> MAC) and plugin-managed static bindings + // (wfc_ host sections) for the device policy status column. + var leaseMac = {}; + (data[2] || '').split('\n').forEach(function(line) { + var p = line.split(/\s+/); + if (p.length >= 3 && /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(p[1])) + leaseMac[p[1]] = p[2]; + }); + var wfcHost = {}; + uci.sections('dhcp', 'host').forEach(function(h) { + if ((h['.name'] || '').indexOf('wfc_') === 0 && h.ip) + wfcHost[h.ip] = { mac: h.mac || '', name: h.name || '' }; + }); + function dhcpState(ip) { + var mac = leaseMac[ip], host = wfcHost[ip]; + if (host && host.mac && mac && host.mac.toLowerCase() === mac.toLowerCase()) return _('Bound'); + if (host && host.mac && mac) return _('MAC changed, rebind on reconnect'); + if (mac) return _('Not bound yet'); + return _('Device offline'); + } var m = new form.Map('wificalling-gateway', _('Wi-Fi Calling Gateway settings'), _('Configure proxy nodes and assign fixed LAN devices. Monitoring and logs are available from the submenu.')); @@ -147,6 +169,14 @@ return view.extend({ uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.189'; + var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); + dhcpBinding.textvalue = function(id) { + if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') + return _('Following gateway'); + var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; + if (!Array.isArray(ipList)) ipList = [ipList]; + return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); + }; poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}').then(function(raw) { diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 30e192d825a3..79cefbecfdbb 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -41,6 +41,9 @@ msgstr "" msgid "Allow insecure certificate" msgstr "" +msgid "Bound" +msgstr "" + msgid "Cancel" msgstr "" @@ -62,6 +65,9 @@ msgstr "" msgid "Debug" msgstr "" +msgid "DHCP binding" +msgstr "" + msgid "Device" msgstr "" @@ -98,6 +104,9 @@ msgstr "" msgid "Follow gateway" msgstr "" +msgid "Following gateway" +msgstr "" + msgid "General" msgstr "" @@ -176,6 +185,12 @@ msgstr "" msgid "None" msgstr "" +msgid "MAC changed, rebind on reconnect" +msgstr "" + +msgid "Not bound yet" +msgstr "" + msgid "Not detected" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index de3a4ea43b7b..61dcf1670ba5 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -42,6 +42,9 @@ msgstr "在线" msgid "Allow insecure certificate" msgstr "允许不安全证书" +msgid "Bound" +msgstr "已绑定" + msgid "Cancel" msgstr "取消" @@ -63,6 +66,9 @@ msgstr "持续流量会被聚合,每个间隔最多写入一次。" msgid "Debug" msgstr "调试" +msgid "DHCP binding" +msgstr "DHCP 绑定" + msgid "Device" msgstr "设备" @@ -99,6 +105,9 @@ msgstr "一般" msgid "Follow gateway" msgstr "跟随网关" +msgid "Following gateway" +msgstr "跟随网关" + msgid "General" msgstr "常规" @@ -177,6 +186,12 @@ msgstr "节点状态" msgid "None" msgstr "无" +msgid "MAC changed, rebind on reconnect" +msgstr "MAC 已变化,重连后自动重绑" + +msgid "Not bound yet" +msgstr "待绑定" + msgid "Not detected" msgstr "未检测到" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index fbf14ab34895..be244c47e9cc 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -71,6 +71,9 @@ start_service() { [ "$max_events_per_device" -ge 1 ] && [ "$max_events_per_device" -le 500 ] || max_events_per_device=20 config_get_bool log_enabled main log_enabled 1 config_foreach append_node node; config_foreach append_device device + # Keep DHCP static leases (MAC -> IP) in sync with the independent + # device policies; harmless when no clients are configured. + /usr/libexec/$APP/dhcp-sync.sh "$RUNDIR/clients" || logger -t "$APP" "dhcp-sync failed" /usr/libexec/$APP/compiler.sh "$RUNDIR/normalized.conf" "$RUNDIR/sing-box.json" || return 1 /usr/bin/sing-box check -c "$RUNDIR/sing-box.json" || { logger -t "$APP" "sing-box rejected generated configuration"; return 1; } /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" || { logger -t "$APP" "firewall setup failed"; /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients"; return 1; } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh new file mode 100755 index 000000000000..7cc57a83f417 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh @@ -0,0 +1,86 @@ +#!/bin/sh +set -eu + +# Auto-manage DHCP static leases (MAC -> IP bindings) for independent device +# policies. The nftables policy rules match a fixed client IPv4, so the +# device must keep that address; hand-made leases silently break when the +# device's MAC changes (iOS rotates its private Wi-Fi address) or when the +# policy is edited. This script recreates the binding from the live lease +# table on every service start and drops bindings whose policy disappeared. +# Only hosts created by this plugin (section name prefix "wfc_") are touched; +# user-managed hosts are left alone. +# +# Usage: dhcp-sync.sh (clients: label|ip|node per line) + +clients=${1:?clients file required} +[ -f /tmp/dhcp.leases ] || exit 0 +# WFC_DNSMASQ overrides the dnsmasq init script (used by the test suite). +dnsmasq_init=${WFC_DNSMASQ:-/etc/init.d/dnsmasq} + +valid_ip() { + case "$1" in + ''|*[!0-9.]*|*..*|.*|*.) return 1;; + esac + return 0 +} + +# 1) Map policy IP -> device label (sanitized) from the clients file. +want= +while IFS='|' read -r label ip node; do + valid_ip "$ip" || continue + label=$(printf '%s' "$label" | tr -d "'\"\\") + want="$want $ip" + eval "want_label_$(printf '%s' "$ip" | tr '.' '_')=\$label" +done < "$clients" + +# 2) Map currently-leased IP -> MAC from the live lease table. dnsmasq +# lease lines are: expiry MAC IP hostname clientid. +ip2mac= +while read -r expiry mac ip hostname rest; do + valid_ip "$ip" || continue + case "$mac" in ''|*[!0-9A-Fa-f:]*|*..*) continue;; esac + ip2mac="$ip2mac $ip=$mac" +done < /tmp/dhcp.leases + +# 3) Sync the wfc_ hosts. A host is created/updated only when the policy IP +# is actually in use by some device right now (that MAC is the one to pin); +# an idle policy IP keeps any existing binding and logs a hint instead. +changed=0 +for host in $(uci show dhcp 2>/dev/null | sed -n 's/^dhcp\.\(wfc_[^=]*\)=host$/\1/p'); do + host_ip=$(uci get "dhcp.$host.ip" 2>/dev/null || true) + if valid_ip "$host_ip" && [ -n "$host_ip" ]; then + ip_ok=0 + for w in $want; do [ "$w" = "$host_ip" ] && ip_ok=1; done + [ "$ip_ok" -eq 1 ] && continue + fi + # Policy for this binding is gone: drop it. + uci -q delete "dhcp.$host"; changed=1 +done + +for ip in $want; do + mac= + for entry in $ip2mac; do + case "$entry" in "$ip="*) mac=${entry#*=};; esac + done + [ -n "$mac" ] || { logger -t wificalling-gateway "dhcp-sync: no live lease for policy IP $ip; binding stays as-is (reconnect the device to rebind)"; continue; } + sec=wfc_$(printf '%s' "$ip" | tr '.' '_') + old_mac=$(uci get "dhcp.$sec.mac" 2>/dev/null || true) + if [ "$old_mac" = "$mac" ] && [ "$(uci get "dhcp.$sec.ip" 2>/dev/null || true)" = "$ip" ]; then + continue + fi + label= + eval "label=\$want_label_$(printf '%s' "$ip" | tr '.' '_')" + uci -q set "dhcp.$sec=host" + uci -q set "dhcp.$sec.name=$label" + uci -q set "dhcp.$sec.ip=$ip" + uci -q set "dhcp.$sec.mac=$mac" + if [ -n "$old_mac" ] && [ "$old_mac" != "$mac" ]; then + logger -t wificalling-gateway "dhcp-sync: device $label ($ip) MAC changed $old_mac -> $mac, binding updated" + fi + changed=1 +done + +if [ "$changed" -eq 1 ]; then + uci commit dhcp + "$dnsmasq_init" restart +fi diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json index 78f800744129..c18bf3a1a772 100644 --- a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -2,7 +2,7 @@ "luci-app-wificalling-gateway": { "description": "Manage Wi-Fi Calling Gateway", "read": { - "uci": [ "wificalling-gateway" ], + "uci": [ "wificalling-gateway", "dhcp" ], "ubus": { "file": [ "read" ] }, "file": { "/var/run/wificalling-gateway/status.json": [ "read" ], @@ -10,7 +10,8 @@ "/var/run/wificalling-gateway/events.log": [ "read" ], "/tmp/run/wificalling-gateway/status.json": [ "read" ], "/tmp/run/wificalling-gateway/node-status.json": [ "read" ], - "/tmp/run/wificalling-gateway/events.log": [ "read" ] + "/tmp/run/wificalling-gateway/events.log": [ "read" ], + "/tmp/dhcp.leases": [ "read" ] } }, "write": { From 9c71a9b78f4206e01bff0ee5bbee4b52b80c84f7 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 11:54:00 +0800 Subject: [PATCH 11/56] luci-app-wificalling-gateway: fix lease column order and label delimiter guard - the DHCP binding status column keyed lease lines on the MAC field instead of the IP (dnsmasq lines are expiry MAC IP hostname), so every policy showed Device offline; now keyed on the IP - the node delimiter guard rejected nodes whose label contains '|' (common subscription labels); labels never enter normalized.conf and are now excluded from the guard and sanitized in the nodes/clients files instead - generic placeholder 192.168.31.x replaces the baked-in IP Signed-off-by: Smth Dagg --- .../view/wificalling-gateway/overview.js | 9 +++++---- .../root/etc/init.d/wificalling-gateway | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 99e6e3ea79a6..ad9dc099aeef 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -42,12 +42,13 @@ return view.extend({ } function latency(n) { return n && n.ping_ms != null ? n.ping_ms + ' ms (' + n.measurement + ')' : '-'; } // Live DHCP lease map (IP -> MAC) and plugin-managed static bindings - // (wfc_ host sections) for the device policy status column. + // (wfc_ host sections) for the device policy status column. dnsmasq + // lease lines are: expiry MAC IP hostname clientid. var leaseMac = {}; (data[2] || '').split('\n').forEach(function(line) { var p = line.split(/\s+/); - if (p.length >= 3 && /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(p[1])) - leaseMac[p[1]] = p[2]; + if (p.length >= 3 && /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(p[2])) + leaseMac[p[2]] = p[1]; }); var wfcHost = {}; uci.sections('dhcp', 'host').forEach(function(h) { @@ -168,7 +169,7 @@ return view.extend({ selectedNode.description = _('Save the node first, then reload this page to select it for a device.'); uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); - ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.189'; + ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.x'; var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); dhcpBinding.textvalue = function(id) { if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index be244c47e9cc..872f8e547f2b 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -30,7 +30,12 @@ append_node() { config_get pin_sha256 "$s" pin_sha256 config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 config_get private_key "$s" private_key; config_get local_address "$s" local_address; config_get reserved "$s" reserved; config_get mtu "$s" mtu - case "$label$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + # The delimiter guard covers every field interpolated into the + # | -delimited normalized.conf record. label is deliberately excluded: + # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and + # labels never reach normalized.conf — the nodes file (health checks) + # gets a sanitized copy instead. + case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; @@ -39,7 +44,8 @@ append_node() { wireguard) credential=$private_key; auxiliary= ;; esac printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" "$private_key" "$local_address" "$reserved" "$mtu" >> "$RUNDIR/normalized.conf" - printf '%s|%s|%s|%s|%s\n' "$s" "$label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" + safe_label=$(printf '%s' "$label" | tr '|' ' ') + printf '%s|%s|%s|%s|%s\n' "$s" "$safe_label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" } append_ip() { DEVICE_IPS="${DEVICE_IPS}${DEVICE_IPS:+,}$1"; } @@ -47,11 +53,12 @@ append_device() { local s="$1" enabled label node route_mode config_get_bool enabled "$s" enabled 1; [ "$enabled" -eq 1 ] || return 0 config_get label "$s" label "$s"; config_get node "$s" node; config_get route_mode "$s" route_mode independent - case "$label$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac + case "$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip [ "$route_mode" = independent ] || return 0 - printf 'device|%s|%s|%s\n' "$label" "$node" "$DEVICE_IPS" >> "$RUNDIR/normalized.conf" - IFS=,; for ip in $DEVICE_IPS; do printf '%s|%s|node-%s\n' "$label" "$ip" "$node" >> "$RUNDIR/clients"; done; unset IFS + safe_label=$(printf '%s' "$label" | tr '|' ' ') + printf 'device|%s|%s|%s\n' "$safe_label" "$node" "$DEVICE_IPS" >> "$RUNDIR/normalized.conf" + IFS=,; for ip in $DEVICE_IPS; do printf '%s|%s|node-%s\n' "$safe_label" "$ip" "$node" >> "$RUNDIR/clients"; done; unset IFS } start_service() { From c87ec1bb3bc6a1f98ff6654db541eabb73558be5 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 12:10:12 +0800 Subject: [PATCH 12/56] luci-app-wificalling-gateway: show DHCP binding state in the edit modal The DummyValue rendered its (always null) cfgvalue inside the grid edit modal, so the DHCP binding column showed null there even though the row text was correct. renderWidget is overridden to render the same live state text as the row; add/remove auto-binding verified end-to-end. Signed-off-by: Smth Dagg --- .../resources/view/wificalling-gateway/overview.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index ad9dc099aeef..ec474de9db3b 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -171,12 +171,20 @@ return view.extend({ var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.x'; var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); - dhcpBinding.textvalue = function(id) { + function bindingState(id) { if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') return _('Following gateway'); var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; if (!Array.isArray(ipList)) ipList = [ipList]; return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); + } + // Grid row renders via textvalue; the edit modal renders the widget + // with cfgvalue (always null for a DummyValue), so override + // renderWidget to show the same live state in both places. + dhcpBinding.rawhtml = true; + dhcpBinding.textvalue = function(id) { return bindingState(id); }; + dhcpBinding.renderWidget = function(section_id, option_index, cfgvalue) { + return E('output', { 'for': this.cbid(section_id) }, bindingState(section_id)); }; poll.add(function() { From deedac909441168ea63640fcf4ac6d84f1b7a593 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 12:39:21 +0800 Subject: [PATCH 13/56] luci-app-wificalling-gateway: fix footer Save button, restore binding column The LuCI 24.10 default Save handler resolves the Map through a DOM instance lookup that fails on this firmware, and Map.save() alone never commits the session-scoped UCI changeset (only apply does) - the Save button silently did nothing while Save & Apply worked. The footer Save button is now bound directly to m.save() + ui.changes.apply() so plain Save persists configuration like Save & Apply. The DHCP binding status column is restored (DummyValue rmempty + renderWidget override). Signed-off-by: Smth Dagg --- .../view/wificalling-gateway/overview.js | 77 +++++++++++++++---- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index ec474de9db3b..37a527e077a9 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -170,22 +170,29 @@ return view.extend({ uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.x'; - var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); - function bindingState(id) { - if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') - return _('Following gateway'); - var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; - if (!Array.isArray(ipList)) ipList = [ipList]; - return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); - } - // Grid row renders via textvalue; the edit modal renders the widget - // with cfgvalue (always null for a DummyValue), so override - // renderWidget to show the same live state in both places. - dhcpBinding.rawhtml = true; - dhcpBinding.textvalue = function(id) { return bindingState(id); }; - dhcpBinding.renderWidget = function(section_id, option_index, cfgvalue) { - return E('output', { 'for': this.cbid(section_id) }, bindingState(section_id)); - }; + var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); + // A DummyValue has no editable value: without rmempty the save + // parse rejects it as "must not be empty", silently breaking the + // "Save" button (Save & Apply still worked via the staged-changes + // fallback). The grid row renders via textvalue; the edit modal + // renders the widget with cfgvalue (always null), so renderWidget + // is overridden to show the same live state in both places. + dhcpBinding.rmempty = true; + function bindingState(id) { + if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') + return _('Following gateway'); + var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; + if (!Array.isArray(ipList)) ipList = [ipList]; + return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); + } + // Grid row renders via textvalue; the edit modal renders the widget + // with cfgvalue (always null for a DummyValue), so override + // renderWidget to show the same live state in both places. + dhcpBinding.rawhtml = true; + dhcpBinding.textvalue = function(id) { return bindingState(id); }; + dhcpBinding.renderWidget = function(section_id, option_index, cfgvalue) { + return E('output', { 'for': this.cbid(section_id) }, bindingState(section_id)); + }; poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}').then(function(raw) { @@ -197,6 +204,42 @@ return view.extend({ }); }); }, 5); - return m.render().then(function(formNode) { return E([], [importPanel, formNode]); }); + this.mapInstance = m; + return m.render().then(function(formNode) { + var nodes = E([], [importPanel, formNode]); + // LuCI 24.10's footer "Save" button handler is resolved through + // the view prototype during footer creation; on this firmware it + // ends up unbound (the button does nothing, while "Save & Apply" + // still works via the staged-changes fallback). Bind the form + // save directly once the footer exists. + window.setTimeout(function() { + var btn = document.querySelector('#view button.cbi-button-save'); + if (btn && !btn._wfcSaveBound) { + btn._wfcSaveBound = true; + // The LuCI 24.10 default "Save" handler resolves the Map + // through a DOM instance lookup that fails on this + // firmware, and Map.save() alone never commits the + // session-scoped UCI changeset anyway (only apply does). + // Bind save + apply directly so plain "Save" persists + // the configuration like "Save & Apply". + btn.addEventListener('click', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + m.save().then(function() { + return ui.changes.apply(true); + }).catch(function() {}); + }); + } + }, 200); + return nodes; + }); + }, + handleSave: function(ev) { + // The LuCI 24.10 default resolves the Map through a DOM instance + // lookup that silently fails on this firmware, so the "Save" + // button did nothing while "Save & Apply" still worked (apply + // commits the staged changes as a fallback). Save through the + // form instance directly instead. + return this.mapInstance ? this.mapInstance.save() : Promise.resolve(); } }); From ec3f39a04de7c3e3f9b87ca541376bb643d575c7 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 13:44:36 +0800 Subject: [PATCH 14/56] luci-app-wificalling-gateway: sanitize dhcp-host names, validate wireguard nodes - dhcp-sync: dhcp-host names are scrubbed of spaces/commas/quotes/ semicolons (dnsmasq rejects such host lines, breaking LAN DNS/DHCP); lease file location read from dhcp.@dnsmasq[0].leasefile - compiler: wireguard nodes fail early with a node-specific message when keys/address are missing or reserved/mtu are non-numeric - node-import: wg:// private keys keep '+' intact (URLSearchParams decodes '+' to a space, corrupting base64) - init.d: local declarations; po: entries re-sorted to ASCII order Signed-off-by: Smth Dagg --- .../wificalling-gateway/node-import.js | 4 ++- .../po/templates/wificalling-gateway.pot | 24 +++++++-------- .../po/zh_Hans/wificalling-gateway.po | 30 +++++++++---------- .../root/etc/init.d/wificalling-gateway | 3 ++ .../libexec/wificalling-gateway/compiler.sh | 8 +++++ .../libexec/wificalling-gateway/dhcp-sync.sh | 13 ++++++-- 6 files changed, 51 insertions(+), 31 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index c0f7c98cace2..0a9ae22b6d82 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -57,7 +57,9 @@ function parseUrl(uri, protocol) { } else if (protocol === 'wireguard') { // wg://@:?private_key=…&local_address=…&reserved=…&mtu=… out.public_key = decodeURIComponent(url.username || ''); - out.private_key = p.get('private_key') || ''; + // URLSearchParams decodes '+' to a space, which corrupts the + // base64 private key; base64 never contains spaces, so restore. + out.private_key = (p.get('private_key') || '').replace(/ /g, '+'); out.local_address = (p.get('local_address') || p.get('ip') || '').split(',')[0] || ''; out.reserved = p.get('reserved') || ''; out.mtu = p.get('mtu') || ''; diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 79cefbecfdbb..dc11b00d660b 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -62,10 +62,10 @@ msgstr "" msgid "Continuous traffic is aggregated and written at most once per interval." msgstr "" -msgid "Debug" +msgid "DHCP binding" msgstr "" -msgid "DHCP binding" +msgid "Debug" msgstr "" msgid "Device" @@ -149,6 +149,9 @@ msgstr "" msgid "Log level" msgstr "" +msgid "MAC changed, rebind on reconnect" +msgstr "" + msgid "Maximum records per device" msgstr "" @@ -185,9 +188,6 @@ msgstr "" msgid "None" msgstr "" -msgid "MAC changed, rebind on reconnect" -msgstr "" - msgid "Not bound yet" msgstr "" @@ -344,25 +344,25 @@ msgstr "" msgid "Wi-Fi Calling" msgstr "" -msgid "WireGuard MTU" +msgid "Wi-Fi Calling Gateway settings" msgstr "" -msgid "WireGuard local address" +msgid "Wi-Fi Calling Status" msgstr "" -msgid "WireGuard private key" +msgid "Wi-Fi Calling status" msgstr "" -msgid "WireGuard reserved (comma-separated)" +msgid "WireGuard MTU" msgstr "" -msgid "Wi-Fi Calling Gateway settings" +msgid "WireGuard local address" msgstr "" -msgid "Wi-Fi Calling Status" +msgid "WireGuard private key" msgstr "" -msgid "Wi-Fi Calling status" +msgid "WireGuard reserved (comma-separated)" msgstr "" msgid "Yes" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 61dcf1670ba5..5f2b033932d1 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -63,12 +63,12 @@ msgstr "连接中" msgid "Continuous traffic is aggregated and written at most once per interval." msgstr "持续流量会被聚合,每个间隔最多写入一次。" -msgid "Debug" -msgstr "调试" - msgid "DHCP binding" msgstr "DHCP 绑定" +msgid "Debug" +msgstr "调试" + msgid "Device" msgstr "设备" @@ -150,6 +150,9 @@ msgstr "可能已注册" msgid "Log level" msgstr "日志级别" +msgid "MAC changed, rebind on reconnect" +msgstr "MAC 已变化,重连后自动重绑" + msgid "Maximum records per device" msgstr "每台设备最大记录数" @@ -186,9 +189,6 @@ msgstr "节点状态" msgid "None" msgstr "无" -msgid "MAC changed, rebind on reconnect" -msgstr "MAC 已变化,重连后自动重绑" - msgid "Not bound yet" msgstr "待绑定" @@ -345,6 +345,15 @@ msgstr "WebSocket 路径" msgid "Wi-Fi Calling" msgstr "Wi-Fi Calling" +msgid "Wi-Fi Calling Gateway settings" +msgstr "Wi-Fi Calling Gateway 设置" + +msgid "Wi-Fi Calling Status" +msgstr "Wi-Fi Calling 状态" + +msgid "Wi-Fi Calling status" +msgstr "Wi-Fi Calling 状态" + msgid "WireGuard MTU" msgstr "WireGuard MTU" @@ -357,15 +366,6 @@ msgstr "WireGuard 私钥" msgid "WireGuard reserved (comma-separated)" msgstr "WireGuard 保留位(逗号分隔)" -msgid "Wi-Fi Calling Gateway settings" -msgstr "Wi-Fi Calling Gateway 设置" - -msgid "Wi-Fi Calling Status" -msgstr "Wi-Fi Calling 状态" - -msgid "Wi-Fi Calling status" -msgstr "Wi-Fi Calling 状态" - msgid "Yes" msgstr "是" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 872f8e547f2b..ed30b31c6226 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -11,6 +11,7 @@ RUNDIR=/var/run/$APP # wireguard ENDPOINT works from 1.11.0 on. Emit the endpoint form for # 1.11+, and the legacy outbound only for ancient 1.10.x installs. wireguard_style() { + local ver major minor ver=$(/usr/bin/sing-box version 2>/dev/null | sed -n 's/.*version[[:space:]]*\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1.\2/p' | head -n 1) major=${ver%%.*}; minor=${ver#*.}; minor=${minor%%.*} case "$major" in ''|*[!0-9]*) printf 'endpoint'; return;; esac @@ -44,6 +45,7 @@ append_node() { wireguard) credential=$private_key; auxiliary= ;; esac printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" "$private_key" "$local_address" "$reserved" "$mtu" >> "$RUNDIR/normalized.conf" + local safe_label safe_label=$(printf '%s' "$label" | tr '|' ' ') printf '%s|%s|%s|%s|%s\n' "$s" "$safe_label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" } @@ -56,6 +58,7 @@ append_device() { case "$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip [ "$route_mode" = independent ] || return 0 + local safe_label safe_label=$(printf '%s' "$label" | tr '|' ' ') printf 'device|%s|%s|%s\n' "$safe_label" "$node" "$DEVICE_IPS" >> "$RUNDIR/normalized.conf" IFS=,; for ip in $DEVICE_IPS; do printf '%s|%s|node-%s\n' "$safe_label" "$ip" "$node" >> "$RUNDIR/clients"; done; unset IFS diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index b8707388bd19..7afa508b1f31 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -29,6 +29,14 @@ $1=="node" { if (id=="" || seen_node[id]++) fail("duplicate or empty node id: " id) if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess" && proto!="trojan" && proto!="wireguard") fail("unsupported protocol: " proto) if ($4=="" || $5 !~ /^[0-9]+$/ || $5<1 || $5>65535) fail("invalid server or port for node: " id) + # WireGuard requires a private key, peer public key and local address; + # reserved bytes and MTU must be numeric or the emitted JSON breaks + # (and sing-box check would fail for every node at once). + if (proto=="wireguard") { + if ($21=="" || $13=="" || $22=="") fail("wireguard node " id " is missing private_key, peer_public_key or local_address") + if ($23!="" && $23 !~ /^[0-9,]+$/) fail("wireguard node " id " reserved must be comma-separated numbers: " $23) + if ($24!="" && $24 !~ /^[0-9]+$/) fail("wireguard node " id " mtu must be a number: " $24) + } node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto if (proto=="wireguard") wg_nodes[++nw]=nn next diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh index 7cc57a83f417..db48686eccab 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh @@ -13,7 +13,11 @@ set -eu # Usage: dhcp-sync.sh (clients: label|ip|node per line) clients=${1:?clients file required} -[ -f /tmp/dhcp.leases ] || exit 0 +# The dnsmasq lease file location is a UCI option; fall back to the +# default path when unset. +leasefile=$(uci -q get dhcp.@dnsmasq[0].leasefile 2>/dev/null || true) +[ -n "$leasefile" ] || leasefile=/tmp/dhcp.leases +[ -f "$leasefile" ] || exit 0 # WFC_DNSMASQ overrides the dnsmasq init script (used by the test suite). dnsmasq_init=${WFC_DNSMASQ:-/etc/init.d/dnsmasq} @@ -28,7 +32,10 @@ valid_ip() { want= while IFS='|' read -r label ip node; do valid_ip "$ip" || continue - label=$(printf '%s' "$label" | tr -d "'\"\\") + # dhcp-host names must not contain quotes, commas, semicolons or + # spaces (dnsmasq would reject the whole host line); keep only a + # safe subset for the host name field. + label=$(printf '%s' "$label" | tr -d "'\"\\,; ") want="$want $ip" eval "want_label_$(printf '%s' "$ip" | tr '.' '_')=\$label" done < "$clients" @@ -40,7 +47,7 @@ while read -r expiry mac ip hostname rest; do valid_ip "$ip" || continue case "$mac" in ''|*[!0-9A-Fa-f:]*|*..*) continue;; esac ip2mac="$ip2mac $ip=$mac" -done < /tmp/dhcp.leases +done < "$leasefile" # 3) Sync the wfc_ hosts. A host is created/updated only when the policy IP # is actually in use by some device right now (that MAC is the one to pin); From a342a0cd0a8075fe880abeb8863f2e3bfd26f3d8 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 13:53:57 +0800 Subject: [PATCH 15/56] luci-app-wificalling-gateway: mark sustained traffic as likely_call Sustained bidirectional traffic after registration is logged as likely_call (the RTP signature of ringing or in-call voice). The IPsec tunnel stays fully encrypted: SMS cannot be reliably distinguished and is not logged; numbers, content and call direction remain invisible. Activity log page labels the meaning column accordingly; i18n updated. Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/README.md | 156 +++++++++++++++++- .../view/wificalling-gateway/events.js | 10 +- .../po/templates/wificalling-gateway.pot | 4 +- .../po/zh_Hans/wificalling-gateway.po | 6 +- .../libexec/wificalling-gateway/monitor.sh | 5 +- 5 files changed, 168 insertions(+), 13 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 02fe88c6a2f0..927bf35e5fac 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -1,10 +1,152 @@ -# luci-app-wificalling-gateway +# Wi-Fi Calling Gateway -Per-device transparent Wi-Fi Calling gateway for OpenWrt / ImmortalWrt. +[English](README_EN.md) · [安装](docs/zh-CN/INSTALL.md) · [配置](docs/zh-CN/CONFIGURATION.md) · [排错](docs/zh-CN/TROUBLESHOOTING.md) · [开发与维护](DEVELOPER.md) -Routes selected LAN clients through a sing-box node (AnyTLS, Hysteria2, -TUIC, VLESS Reality, VMess WebSocket, Trojan, WireGuard) with nftables -TPROXY, observes ePDG/IPsec UDP 500/4500 evidence, and records handshake -outcomes in an encrypted IMS activity log. DHCP static leases are auto-synced from the device policies (bind/clean MAC-IP on add/remove). +面向 OpenWrt / ImmortalWrt 的独立 LuCI 插件。它把指定局域网设备通过指定的 sing-box 节点转发,同时让其他设备继续走路由器默认路由,并观察 Wi‑Fi Calling 常用的 ePDG/IPsec UDP 500、4500 会话证据。 -See https://github.com/smthdagg/luci-app-wificalling-gateway for full docs. +### 设置 + +![Wi-Fi Calling Gateway 设置页面](docs/images/overview.png) + +### Wi-Fi Calling 状态 + +![Wi-Fi Calling 状态页面](docs/images/device-status.png) + +### 活动日志 + +![加密 IMS 活动日志页面](docs/images/activity-log.png) + +### iPhone 实机观察 + +下图为实际 iPhone 在飞行模式及 Wi‑Fi 环境中显示 **EE WiFiCall** 的状态: + +

+ iPhone 实机显示 EE WiFiCall +

+ +该截图证明终端已显示 Wi‑Fi Calling 注册状态;是否完成号码激活及呼叫能力,仍应以实际通话或运营商确认结果为准。 + +## 功能 + +- 支持 **AnyTLS、Hysteria2、TUIC、VLESS Reality、VMess WebSocket、Trojan 与 WireGuard** 七种节点协议。 +- 支持直接粘贴 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan (trojan://) 与 WireGuard (wg://) 分享链接并自动解析导入。 +- **DHCP 静态租约自动管理**:添加/删除设备策略时自动绑定/清理 MAC-IP 静态租约,兼容 iOS 私有 Wi-Fi 地址(MAC 随机变化);设备策略表实时显示绑定状态(已绑定 / 待绑定 / MAC 已变化 / 设备未在线)。 +- 每台设备可绑定一个节点;一个策略可包含多个固定私网 IPv4 地址。 +- `独立通道`:通过插件节点转发;`跟随网关`:插件不拦截,设备走路由器默认路由。 +- 单个 sing-box 进程、nftables TPROXY、TCP 与 UDP 透明转发。 +- 节点 ICMP/TCP 可达性与延迟检测(TCP 系协议在 ICMP 被阻断时自动回退 tcping)。 +- 内置简体中文界面(语言包随安装包提供);中文说明与状态,协议名与技术字段(TLS、UDP、UUID、SNI、ALPN、Reality、WebSocket 等)保留英文。 +- 设置、Wi‑Fi Calling 状态、加密 IMS 活动日志分为三个独立管理页面。 +- 观察 UDP 500/4500,显示注册状态、ePDG、ASSURED、包计数及最后活动时间。 +- 只记录握手成功/失败与持续加密通讯(响铃或通话,持续数秒以上);每台设备默认独立保留最近 20 条,可在设置中调整或关闭活动日志。 +- 启动前执行 `sing-box check`;配置和运行时凭据权限设为 `0600`。 + +## 节点协议选择(重要) + +> **⚠️ 网关出口节点请使用 TCP 系协议(AnyTLS / VLESS / VMess / Trojan)。** +> +> - TCP 系在公网丢包/抖动下提供可靠有序的传输,IPsec keepalive 与 RTP 语音不丢,适合作为 Wi‑Fi Calling 出口。 +> - **UDP/QUIC 系(Hysteria2、TUIC)实测不适合**:节点的"在线"状态仅代表 ICMP 可达(不是代理握手成功),UDP-in-UDP 在公网抖动下会导致拨号立即中断;曾实测因 Hysteria2 节点代理链路不通导致被路由设备**无互联网**。 +> - WireGuard 为 UDP 协议但自带保活与重传机制,可作为出口(插件自动适配 sing-box ≥1.11 的 endpoint 形式)。 + +## 为什么要绑定 DHCP 静态 IP + +本插件的防火墙规则**按 IP 识别设备**:设备策略里填写的 `source_ip` 会被写入 nftables 的 `clients4` 集合,凡是匹配该 IP 的流量才会被 TPROXY 转发到 sing-box 节点。**如果设备实际拿到的 IP 与策略不一致,规则就匹配不到,设备流量不会经过网关**——这曾经是"配置了但没生效"的最常见原因。 + +因此设备 IP 必须固定,固定方式就是 DHCP 静态租约(把设备的 MAC 与策略 IP 绑定)。从 1.7.0 起插件在服务启动时自动从当前租约同步这份绑定: + +- 添加设备策略 → 自动为策略 IP 绑定当前使用该 IP 的设备的 MAC; +- 删除设备策略 → 自动清理对应绑定; +- iOS 的"私有无线局域网地址"导致 MAC 变化时,设备重连 Wi-Fi(或重启)后插件自动按新 MAC 重新绑定,无需手工改配置。 + +设备策略表里的「DHCP 绑定」列实时显示状态:`已绑定` / `待绑定`(设备在线但尚未绑定)/ `MAC 已变化,重连后自动重绑` / `设备未在线`。 + +## 监控能力边界(重要) + +Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由器只能观察到外层隧道的包量,看不到隧道内的 SIP 信令、语音或短信内容。因此: + +- **通话可以推断**:注册后出现持续双向加密流量(响铃或通话的 RTP 特征,持续数秒以上)→ 活动日志标记为「**通话进行中(根据持续加密流量推断)**」; +- **短信无法可靠区分**:短信(IMS 短信)是短突发流量,与 keepalive、系统推送等无法区分,因此**不记录**,也不会误报为短信; +- **电话号码、消息内容、呼叫方向永远不可见**。 + +活动日志记录的是:握手成功 / 握手失败 / 持续通讯(推断为通话)。这是路由器侧的网络证据,不是运营商侧的确认。 + +## 设备使用提示 + +- iOS 默认启用"私有无线局域网地址",MAC 会随机变化,导致手工 DHCP 绑定失效。本插件(≥1.7.0)在服务启动时自动从当前租约重新绑定设备 MAC,**设备重连 Wi-Fi(或重启)即可自动恢复**,无需手工改配置。 +- 添加设备策略后,若设备 IP 与策略不符,重启设备网络(关 Wi-Fi 再开)让其重新获取 DHCP 地址。 + +## 支持环境 + +| 项目 | 支持范围 | +|---|---| +| 固件 | OpenWrt / ImmortalWrt / iStoreOS,firewall4 + nftables | +| 24.10 系(opkg/IPK) | OpenWrt 24.10、ImmortalWrt 24.10、iStoreOS 24.10 共用一个 IPK,全部实测 | +| 25.12 系(apk/APK) | OpenWrt / ImmortalWrt 25.12 共用一个 noarch APK,四种芯片全部实测 | +| 25.12 芯片实测 | x86_64 ✅ aarch64 ✅ armv7 ✅ mipsel ✅(官方 25.12.3 rootfs + qemu 用户态模拟) | +| 已实机验证 | ImmortalWrt 24.10.6,Redmi AX6S,aarch64_cortex-a53(真实路由器) | +| iStoreOS 实测 | **24.10.7 完整固件(QEMU 全系统模拟,与用户报错同版本)**:安装 + 服务 active + LuCI 设置/状态/活动日志页面全中文实测通过 | +| 容器/模拟验证 | OpenWrt 24.10.8 / 25.12.3 官方 rootfs;iStoreOS 24.10.5(Docker)、24.10.7(QEMU 完整固件) | +| sing-box | 建议 1.13.0 或更高;IPK 不锁版本(兼容各源较旧版本),25.12 官方源自带(armv7/mipsel 实测自动装 1.12.17)。WireGuard 节点自动适配:sing-box ≥1.11 用 endpoint 形式,1.10.x 及更早用旧版 outbound(均经 1.10.0/1.11.7/1.12.0/1.13.18 实测) | +| LuCI | JavaScript 视图(现代 LuCI) | +| 网络 | IPv4 LAN 策略;设备策略自动同步 DHCP 静态租约(增删设备自动绑定/清理 MAC-IP,兼容 iOS 私有 MAC 变化) | +| 包架构 | IPK `all`(Shell 与 LuCI 资源);APK `noarch`(25.12 apk 不接受 `all`,官方包按目标架构分发) | + +依赖:`luci-base`、`sing-box`、`firewall4`、`kmod-nft-tproxy`、`kmod-nft-socket`、`ip-full`。 + +## 快速安装 + +从 [Releases](../../releases) 下载最新稳定版(当前为 1.7.1),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 + +**OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: + +```sh +opkg update +opkg install ./luci-app-wificalling-gateway_1.7.1-1_all.ipk +/etc/init.d/rpcd restart +``` + +> iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: +> +> ```sh +> opkg install /root/luci-app-wificalling-gateway_1.7.1-1_all.ipk +> ``` +> +> 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): +> +> ```sh +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.7.1-1_all.ipk && tar xzf data.tar.gz -C / +> /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start +> ``` + +**OpenWrt / ImmortalWrt 25.12.x(apk / APK)** —— 一个 noarch 包,覆盖 x86_64 / aarch64 / armv7 / mipsel 全芯片,已全部实测: + +```sh +apk update +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.1-r1_noarch.apk +/etc/init.d/rpcd restart +``` + +然后进入 **服务 → Wi‑Fi Calling Gateway**。先添加并保存节点,再添加设备策略。详细步骤见[安装说明](docs/zh-CN/INSTALL.md)和[配置说明](docs/zh-CN/CONFIGURATION.md)。 + +## 重要边界 + +> **⚠️ 定位要求(Wi-Fi Calling 生效前提)** +> +> 运营商要求设备定位与 SIM 卡归属地一致才能激活 Wi-Fi Calling。本插件通过对应国家的节点提供该国 IP,但**不控制设备自身的定位**(GPS / 基站 / wloc)。设备需要通过虚拟定位将位置设为 SIM 卡归属地,否则 Wi-Fi Calling 无法触发。 +> +> **解决方法**:使用 [ios-location-spoofer](https://github.com/smthdagg/ios-location-spoofer) 配合小火箭(Shadowrocket)劫持 iOS 定位到 SIM 卡归属地。这是独立于本插件的项目。 + +本插件只提供网络转发和可观察证据,不修改手机定位、运营商账户、IMS 配置或紧急呼叫地址。`likely_registered` 仅表示观察到双向 `ASSURED` UDP 4500;Wi‑Fi Calling 图标、UDP 500/4500 或高流量均不能单独证明号码已激活或电话一定能接通。请遵守运营商条款和所在地法律,并在真实设备上完成通话验证。 + +## 项目文档 + +- [安装与升级](docs/zh-CN/INSTALL.md) +- [节点和设备配置](docs/zh-CN/CONFIGURATION.md) +- [常见问题与排错](docs/zh-CN/TROUBLESHOOTING.md) +- [开发与维护(面向贡献者 / 自动化接管)](DEVELOPER.md) +- [安全策略](SECURITY.md) · [更新记录](CHANGELOG.md) + +## 许可证 + +[MIT](LICENSE)。本项目与 Apple、任何移动运营商、OpenWrt、ImmortalWrt、sing-box 或 PassWall 均无隶属关系。 diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js index 76ff2257b10f..bae4e6b9ead7 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js @@ -34,10 +34,16 @@ return view.extend({ default: return v || '-'; } } + function meaningLabel(v) { + switch (v) { + case 'likely_call': return _('Call in progress (inferred from sustained encrypted traffic)'); + default: return _('Encrypted activity; call/SMS unknown'); + } + } function rows(value) { return lines(value).map(function(line) { var f = line.split('|'); - return E('tr', { class: 'tr' }, [when(Number(f[0])), f[1], f[2], wfcLabel(f[7]), activityLabel(f[3]), (f[4] || '0') + ' ↑ / ' + (f[5] || '0') + ' ↓', _('Encrypted activity; call/SMS unknown')].map(function(x) { return E('td', { class: 'td' }, String(x)); })); + return E('tr', { class: 'tr' }, [when(Number(f[0])), f[1], f[2], wfcLabel(f[7]), activityLabel(f[3]), (f[4] || '0') + ' ↑ / ' + (f[5] || '0') + ' ↓', meaningLabel(f[6])].map(function(x) { return E('td', { class: 'td' }, String(x)); })); }); } var body = E('tbody', {}, rows(raw)); @@ -51,7 +57,7 @@ return view.extend({ poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/events.log'), '').then(update); }, 5); var children = [ E('h2', {}, _('Encrypted IMS activity log')), - E('p', {}, _('Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. Phone numbers, message content, and whether an event is a call or SMS are not visible.')) + E('p', {}, _('Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. The tunnel content is encrypted: a call is inferred from sustained bidirectional traffic, SMS cannot be distinguished, and phone numbers or message content are never visible.')) ]; if (logEnabled === '0') children.push(E('div', { class: 'alert-message warning' }, _('Activity log recording is disabled. Enable it in Settings.'))); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index dc11b00d660b..d8fa4ea49501 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -44,6 +44,8 @@ msgstr "" msgid "Bound" msgstr "" +msgid "Call in progress (inferred from sustained encrypted traffic)" +msgstr "" msgid "Cancel" msgstr "" @@ -245,7 +247,7 @@ msgstr "" msgid "Record handshake outcomes and sustained encrypted communication. Turn off to stop writing the activity log." msgstr "" -msgid "Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. Phone numbers, message content, and whether an event is a call or SMS are not visible." +msgid "Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. The tunnel content is encrypted: a call is inferred from sustained bidirectional traffic, SMS cannot be distinguished, and phone numbers or message content are never visible." msgstr "" msgid "Records:" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 5f2b033932d1..41f42d1147f3 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -45,6 +45,8 @@ msgstr "允许不安全证书" msgid "Bound" msgstr "已绑定" +msgid "Call in progress (inferred from sustained encrypted traffic)" +msgstr "通话进行中(根据持续加密流量推断)" msgid "Cancel" msgstr "取消" @@ -246,8 +248,8 @@ msgstr "Reality 短 ID" msgid "Record handshake outcomes and sustained encrypted communication. Turn off to stop writing the activity log." msgstr "记录握手结果与持续加密通讯。关闭后将停止写入活动日志。" -msgid "Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. Phone numbers, message content, and whether an event is a call or SMS are not visible." -msgstr "记录握手成功或失败,以及响铃、通话等持续加密通讯。短暂流量脉冲不记录。电话号码、消息内容,以及是通话还是短信均不可见。" +msgid "Records handshake success or failure and sustained encrypted communication such as ringing or calls. Brief traffic bursts are not logged. The tunnel content is encrypted: a call is inferred from sustained bidirectional traffic, SMS cannot be distinguished, and phone numbers or message content are never visible." +msgstr "记录握手成功或失败,以及响铃、通话等持续加密通讯。短暂流量脉冲不记录。隧道内容全程加密:通话根据持续双向流量推断,短信无法区分,电话号码与消息内容永远不可见。" msgid "Records:" msgstr "记录数:" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh index 8d52f4b3e971..dd9f1f5a7678 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -89,7 +89,10 @@ END { print now "|" label[i] "|" ip[i] "|handshake_failed|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out old_event[i]=now; acc_sent=0; acc_reply=0 } else if (sustained) { - print now "|" label[i] "|" ip[i] "|sustained_traffic|" acc_sent "|" acc_reply "|call_or_sms_unknown|" wfc > event_out + # Sustained bidirectional traffic after registration is the + # signature of a voice call (ringing or in-call RTP); the tunnel + # content stays encrypted, so this is an inference, not a decode. + print now "|" label[i] "|" ip[i] "|sustained_traffic|" acc_sent "|" acc_reply "|likely_call|" wfc > event_out old_event[i]=now; acc_sent=0; acc_reply=0 } } From 0112328e4b40d45c692d0bf62208bc863d29916d Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 11 Aug 2026 17:44:43 +0800 Subject: [PATCH 16/56] luci-app-wificalling-gateway: extend delimiter guard to auxiliary/alter_id The vmess alter_id reaches normalized.conf via auxiliary (f[10]) but was not in the delimiter guard - a crafted aid containing '|' would shift the record and reopen the cleartext-VMess outbound. Signed-off-by: Smth Dagg --- .../root/etc/init.d/wificalling-gateway | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index ed30b31c6226..ad5393d8bd28 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -36,7 +36,7 @@ append_node() { # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and # labels never reach normalized.conf — the nodes file (health checks) # gets a sanitized copy instead. - case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$auxiliary$alter_id" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; From ef6b990a4b6ab424142f6f132f89e76f276400b7 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Thu, 13 Aug 2026 16:24:43 +0800 Subject: [PATCH 17/56] luci-app-wificalling-gateway: fix 18.06 install and review round 5 (1.7.2) - drop the hard firewall4 dependency: the plugin configures nftables itself and never talks to the firewall4 daemon; on 18.06-style feeds opkg rejected the whole package with 'cannot find dependency firewall4'. DEPENDS now lists the actual runtime needs, and init.d preflights nft/sing-box with a readable failure message - Save button: remove the DOM-patching workaround (it co-registered a second click listener with LuCI's own handler, double-saving and applying without consent); save through the supported handleSave view hook and commit the 24.10 session-scoped changeset via ui.changes.apply(true), guarded for older LuCI - DHCP binding column reads the dnsmasq lease file from dhcp.@dnsmasq[0].leasefile (same source as dhcp-sync.sh); ACL also covers /etc/dhcp.leases - compiler.sh: reject WireGuard reserved values with empty elements (1,,2, ,1, 1,) - node import: restore '+' in pinSHA256 (and standard-base64 pbk/sid) - dhcp-sync.sh: allowlist dhcp-host names to [A-Za-z0-9_-] (<=63 chars), log a failed dnsmasq restart instead of hiding it - init.d: drop the dead auxiliary guard field; .pot/.po entry gap Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 28 +++-- .../luci-app-wificalling-gateway/README.md | 14 +-- .../view/wificalling-gateway/overview.js | 106 ++++++++---------- .../wificalling-gateway/node-import.js | 20 ++-- .../po/templates/wificalling-gateway.pot | 1 + .../po/zh_Hans/wificalling-gateway.po | 1 + .../root/etc/init.d/wificalling-gateway | 13 ++- .../libexec/wificalling-gateway/compiler.sh | 2 +- .../libexec/wificalling-gateway/dhcp-sync.sh | 17 ++- .../acl.d/luci-app-wificalling-gateway.json | 3 +- 10 files changed, 106 insertions(+), 99 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index d6aa14098df3..51aeca5326cf 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,22 +1,20 @@ -# SPDX-License-Identifier: MIT -# -# Copyright (C) 2026 Smth Dagg - include $(TOPDIR)/rules.mk -LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway -LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway -LUCI_DEPENDS:=+luci-base +sing-box +firewall4 +kmod-nft-tproxy +kmod-nft-socket +ip-full -LUCI_PKGARCH:=all - +PKG_NAME:=luci-app-wificalling-gateway +PKG_VERSION:=1.7.2 +PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE PKG_MAINTAINER:=Smth Dagg -define Package/luci-app-wificalling-gateway/conffiles -/etc/config/wificalling-gateway -endef - -include ../../luci.mk +LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway +LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway +# The gateway configures nftables itself (firewall.sh) and never talks to +# the firewall4 daemon, so the hard firewall4 dependency is what made opkg +# fail on 18.06-style feeds ("cannot find dependency firewall4"). Depend +# on the actual runtime needs instead; init.d preflights nft/sing-box with +# a readable message on firmwares that cannot run the gateway. +LUCI_DEPENDS:=+luci-base +sing-box +nftables +kmod-nft-tproxy +kmod-nft-socket +ip-full +LUCI_PKGARCH:=all -# call BuildPackage - OpenWrt buildroot signature +include $(TOPDIR)/feeds/luci/luci.mk diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 927bf35e5fac..54554bce6be9 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -80,7 +80,7 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 | 项目 | 支持范围 | |---|---| -| 固件 | OpenWrt / ImmortalWrt / iStoreOS,firewall4 + nftables | +| 固件 | OpenWrt / ImmortalWrt / iStoreOS(22.03+ / 23.05+ 系),nftables + TPROXY;**不支持 18.06/Lede**(源里没有 firewall4,通常也缺 nftables TPROXY 内核模块与 sing-box,详见[排错](docs/zh-CN/TROUBLESHOOTING.md)) | | 24.10 系(opkg/IPK) | OpenWrt 24.10、ImmortalWrt 24.10、iStoreOS 24.10 共用一个 IPK,全部实测 | | 25.12 系(apk/APK) | OpenWrt / ImmortalWrt 25.12 共用一个 noarch APK,四种芯片全部实测 | | 25.12 芯片实测 | x86_64 ✅ aarch64 ✅ armv7 ✅ mipsel ✅(官方 25.12.3 rootfs + qemu 用户态模拟) | @@ -92,30 +92,30 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 | 网络 | IPv4 LAN 策略;设备策略自动同步 DHCP 静态租约(增删设备自动绑定/清理 MAC-IP,兼容 iOS 私有 MAC 变化) | | 包架构 | IPK `all`(Shell 与 LuCI 资源);APK `noarch`(25.12 apk 不接受 `all`,官方包按目标架构分发) | -依赖:`luci-base`、`sing-box`、`firewall4`、`kmod-nft-tproxy`、`kmod-nft-socket`、`ip-full`。 +依赖:`luci-base`、`sing-box`、`nftables`、`kmod-nft-tproxy`、`kmod-nft-socket`、`ip-full`。(插件直接配置 nftables,不依赖 firewall4 守护进程;1.7.1 及更早依赖里写死的 `firewall4` 正是 18.06/Lede 上安装失败的根因,1.7.2 起已移除。) ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.7.1),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.7.2),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.7.1-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.7.2-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.7.1-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.7.2-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.7.1-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.7.2-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -123,7 +123,7 @@ opkg install ./luci-app-wificalling-gateway_1.7.1-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.1-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.2-r1_noarch.apk /etc/init.d/rpcd restart ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 37a527e077a9..58c7ab2c3cdc 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -10,12 +10,18 @@ return view.extend({ load: function() { - return Promise.all([ - L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), - uci.load('wificalling-gateway'), - L.resolveDefault(fs.read('/tmp/dhcp.leases'), ''), - uci.load('dhcp') - ]); + return uci.load('dhcp').then(function() { + // dnsmasq's lease file is a UCI option; read the same location + // dhcp-sync.sh binds from, so the status column never + // contradicts the actual bindings on routers that move the + // lease file (e.g. to persist across reboots). + var leasefile = uci.get('dhcp', '@dnsmasq[0]', 'leasefile') || '/tmp/dhcp.leases'; + return Promise.all([ + L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), + uci.load('wificalling-gateway'), + L.resolveDefault(fs.read(leasefile), '') + ]); + }); }, render: function(data) { var nodeParsed; @@ -170,29 +176,26 @@ return view.extend({ uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.x'; - var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); - // A DummyValue has no editable value: without rmempty the save - // parse rejects it as "must not be empty", silently breaking the - // "Save" button (Save & Apply still worked via the staged-changes - // fallback). The grid row renders via textvalue; the edit modal - // renders the widget with cfgvalue (always null), so renderWidget - // is overridden to show the same live state in both places. - dhcpBinding.rmempty = true; - function bindingState(id) { - if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') - return _('Following gateway'); - var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; - if (!Array.isArray(ipList)) ipList = [ipList]; - return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); - } - // Grid row renders via textvalue; the edit modal renders the widget - // with cfgvalue (always null for a DummyValue), so override - // renderWidget to show the same live state in both places. - dhcpBinding.rawhtml = true; - dhcpBinding.textvalue = function(id) { return bindingState(id); }; - dhcpBinding.renderWidget = function(section_id, option_index, cfgvalue) { - return E('output', { 'for': this.cbid(section_id) }, bindingState(section_id)); - }; + var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); + // A DummyValue has no editable value: without rmempty the save + // parse rejects it as "must not be empty", silently breaking the + // "Save" button. The grid row renders via textvalue; the edit + // modal renders the widget with cfgvalue (always null), so + // renderWidget is overridden to show the same live state in both + // places. + dhcpBinding.rmempty = true; + function bindingState(id) { + if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') + return _('Following gateway'); + var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; + if (!Array.isArray(ipList)) ipList = [ipList]; + return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); + } + dhcpBinding.rawhtml = true; + dhcpBinding.textvalue = function(id) { return bindingState(id); }; + dhcpBinding.renderWidget = function(section_id, option_index, cfgvalue) { + return E('output', { 'for': this.cbid(section_id) }, bindingState(section_id)); + }; poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}').then(function(raw) { @@ -206,40 +209,21 @@ return view.extend({ }, 5); this.mapInstance = m; return m.render().then(function(formNode) { - var nodes = E([], [importPanel, formNode]); - // LuCI 24.10's footer "Save" button handler is resolved through - // the view prototype during footer creation; on this firmware it - // ends up unbound (the button does nothing, while "Save & Apply" - // still works via the staged-changes fallback). Bind the form - // save directly once the footer exists. - window.setTimeout(function() { - var btn = document.querySelector('#view button.cbi-button-save'); - if (btn && !btn._wfcSaveBound) { - btn._wfcSaveBound = true; - // The LuCI 24.10 default "Save" handler resolves the Map - // through a DOM instance lookup that fails on this - // firmware, and Map.save() alone never commits the - // session-scoped UCI changeset anyway (only apply does). - // Bind save + apply directly so plain "Save" persists - // the configuration like "Save & Apply". - btn.addEventListener('click', function(ev) { - ev.preventDefault(); - ev.stopPropagation(); - m.save().then(function() { - return ui.changes.apply(true); - }).catch(function() {}); - }); - } - }, 200); - return nodes; + return E([], [importPanel, formNode]); }); }, handleSave: function(ev) { - // The LuCI 24.10 default resolves the Map through a DOM instance - // lookup that silently fails on this firmware, so the "Save" - // button did nothing while "Save & Apply" still worked (apply - // commits the staged changes as a fallback). Save through the - // form instance directly instead. - return this.mapInstance ? this.mapInstance.save() : Promise.resolve(); + // On LuCI 24.10 Map.save() only stages a session-scoped UCI + // changeset; the changes are committed by ui.changes.apply() + // (upstream's own Save & Apply path), and the default handler's + // #maincontent .cbi-map lookup also fails under out-of-tree + // themes. Save through the form instance and commit+apply so the + // plain "Save" button persists. Older LuCI applies inside + // Map.save() and has no ui.changes, hence the guard. + var m = this.mapInstance; + if (!m) return Promise.resolve(); + return m.save().then(function() { + if (ui.changes) return ui.changes.apply(true); + }); } }); diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index 0a9ae22b6d82..16234d2a35c8 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -32,7 +32,10 @@ function parseUrl(uri, protocol) { out.sni = p.get('peer') || p.get('sni') || ''; out.insecure = truthy(p.get('insecure') || p.get('allowInsecure')); out.alpn = p.get('alpn') || ''; - out.pin_sha256 = p.get('pinSHA256') || ''; + // URLSearchParams decodes '+' to a space; the SHA-256 pin is + // standard-alphabet base64 (every other link carries a '+'), so + // restore it like the WireGuard private_key below. + out.pin_sha256 = (p.get('pinSHA256') || '').replace(/ /g, '+'); out.fingerprint = p.get('fingerprint') || p.get('fp') || ''; out.udp = truthy(p.get('udp')); } else if (protocol === 'tuic') { @@ -44,12 +47,15 @@ function parseUrl(uri, protocol) { out.congestion = p.get('congestion_control') || p.get('congestion') || 'bbr'; out.udp_mode = p.get('udp_relay_mode') || 'native'; } else if (protocol === 'vless') { - out.uuid = decodeURIComponent(url.username || ''); - out.flow = p.get('flow') || ''; - out.security = p.get('security') || ''; - out.sni = p.get('sni') || ''; - out.public_key = p.get('pbk') || p.get('publicKey') || ''; - out.short_id = p.get('sid') || p.get('shortId') || ''; + out.uuid = decodeURIComponent(url.username || ''); + out.flow = p.get('flow') || ''; + out.security = p.get('security') || ''; + out.sni = p.get('sni') || ''; + // pbk/sid are base64url in practice, but some generators emit + // standard base64, which URLSearchParams would corrupt the same + // way (see pinSHA256): restoring '+' is a no-op on base64url. + out.public_key = (p.get('pbk') || p.get('publicKey') || '').replace(/ /g, '+'); + out.short_id = (p.get('sid') || p.get('shortId') || '').replace(/ /g, '+'); out.fingerprint = p.get('fp') || p.get('fingerprint') || 'chrome'; if (p.get('type') === 'ws') { out.transport = 'ws'; out.path = p.get('path') || '/'; out.host = p.get('host') || ''; diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index d8fa4ea49501..d5910d9884dc 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -46,6 +46,7 @@ msgstr "" msgid "Call in progress (inferred from sustained encrypted traffic)" msgstr "" + msgid "Cancel" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 41f42d1147f3..42518f80eb4b 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -47,6 +47,7 @@ msgstr "已绑定" msgid "Call in progress (inferred from sustained encrypted traffic)" msgstr "通话进行中(根据持续加密流量推断)" + msgid "Cancel" msgstr "取消" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index ad5393d8bd28..9d59a0d87371 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -35,8 +35,10 @@ append_node() { # | -delimited normalized.conf record. label is deliberately excluded: # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and # labels never reach normalized.conf — the nodes file (health checks) - # gets a sanitized copy instead. - case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$auxiliary$alter_id" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + # gets a sanitized copy instead. credential/auxiliary (f[6]/f[8]) are + # derived from password/uuid/flow/alter_id/private_key, all guarded + # here, so they need no separate check. + case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$alter_id" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; @@ -67,6 +69,13 @@ append_device() { start_service() { chmod 600 "/etc/config/$APP" 2>/dev/null || true config_load "$APP"; config_get_bool enabled main enabled 0; [ "$enabled" -eq 1 ] || return 0 + # Clear preflight: the transparent proxy is nftables-based and the + # config is compiled for sing-box. Fail early with a readable message + # instead of the opaque opkg dependency errors older firmwares (e.g. + # 18.06, whose feed has no firewall4 and usually no nftables TPROXY + # kmods) hit at install time. + command -v nft >/dev/null 2>&1 || { logger -t "$APP" "nftables not found: this plugin needs OpenWrt 22.03+ / ImmortalWrt 23.05+ with nftables TPROXY support"; return 1; } + [ -x /usr/bin/sing-box ] || { logger -t "$APP" "sing-box not found: install sing-box from your feed before enabling the gateway"; return 1; } # Clear display state only. monitor.state is the monitor's per-device # baseline (old_wfc/old_sent/old_reply); truncating it would fabricate # handshake_success events on the first tick after every restart. diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index 7afa508b1f31..6dd82a7d2430 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -34,7 +34,7 @@ $1=="node" { # (and sing-box check would fail for every node at once). if (proto=="wireguard") { if ($21=="" || $13=="" || $22=="") fail("wireguard node " id " is missing private_key, peer_public_key or local_address") - if ($23!="" && $23 !~ /^[0-9,]+$/) fail("wireguard node " id " reserved must be comma-separated numbers: " $23) + if ($23!="" && $23 !~ /^[0-9]+(,[0-9]+)*$/) fail("wireguard node " id " reserved must be comma-separated numbers: " $23) if ($24!="" && $24 !~ /^[0-9]+$/) fail("wireguard node " id " mtu must be a number: " $24) } node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh index db48686eccab..6d89f32811ad 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh @@ -32,10 +32,13 @@ valid_ip() { want= while IFS='|' read -r label ip node; do valid_ip "$ip" || continue - # dhcp-host names must not contain quotes, commas, semicolons or - # spaces (dnsmasq would reject the whole host line); keep only a - # safe subset for the host name field. - label=$(printf '%s' "$label" | tr -d "'\"\\,; ") + # dhcp-host names are interpolated into the dnsmasq config; anything + # outside a hostname's alphabet (spaces, quotes, commas, semicolons, + # '.', '#', control characters, a >63-char label) makes dnsmasq reject + # the whole host line and abort its config parse, taking LAN-wide + # DNS/DHCP down. Allowlist the safe subset and cap the length; an + # empty result is harmless (dnsmasq.init omits an empty name field). + label=$(printf '%s' "$label" | tr -cd 'A-Za-z0-9_-' | cut -c1-63) want="$want $ip" eval "want_label_$(printf '%s' "$ip" | tr '.' '_')=\$label" done < "$clients" @@ -89,5 +92,9 @@ done if [ "$changed" -eq 1 ]; then uci commit dhcp - "$dnsmasq_init" restart + # A rejected dhcp-host line aborts dnsmasq's config parse (LAN-wide + # DNS/DHCP outage); surface a restart failure instead of hiding it. + if ! "$dnsmasq_init" restart; then + logger -t wificalling-gateway "dhcp-sync: dnsmasq restart failed after lease update; check the dhcp-host configuration" + fi fi diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json index c18bf3a1a772..5d76ea6e9572 100644 --- a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -11,7 +11,8 @@ "/tmp/run/wificalling-gateway/status.json": [ "read" ], "/tmp/run/wificalling-gateway/node-status.json": [ "read" ], "/tmp/run/wificalling-gateway/events.log": [ "read" ], - "/tmp/dhcp.leases": [ "read" ] + "/tmp/dhcp.leases": [ "read" ], + "/etc/dhcp.leases": [ "read" ] } }, "write": { From 35c4b3a58e5bb40dd2b87d60711d5502f391db00 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Thu, 13 Aug 2026 16:35:56 +0800 Subject: [PATCH 18/56] luci-app-wificalling-gateway: bump to 1.7.3 Version bump to 1.7.3 (dedicated 18.06 package variant, docs updated). Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 51aeca5326cf..5a6ded08c860 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.7.2 +PKG_VERSION:=1.7.3 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 54554bce6be9..aef5f6925dfe 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -80,7 +80,7 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 | 项目 | 支持范围 | |---|---| -| 固件 | OpenWrt / ImmortalWrt / iStoreOS(22.03+ / 23.05+ 系),nftables + TPROXY;**不支持 18.06/Lede**(源里没有 firewall4,通常也缺 nftables TPROXY 内核模块与 sing-box,详见[排错](docs/zh-CN/TROUBLESHOOTING.md)) | +| 固件 | OpenWrt / ImmortalWrt / iStoreOS(22.03+ / 23.05+ 系),nftables + TPROXY;**18.06/Lede 有专包**(见下方「18.06 专包」) | | 24.10 系(opkg/IPK) | OpenWrt 24.10、ImmortalWrt 24.10、iStoreOS 24.10 共用一个 IPK,全部实测 | | 25.12 系(apk/APK) | OpenWrt / ImmortalWrt 25.12 共用一个 noarch APK,四种芯片全部实测 | | 25.12 芯片实测 | x86_64 ✅ aarch64 ✅ armv7 ✅ mipsel ✅(官方 25.12.3 rootfs + qemu 用户态模拟) | @@ -96,26 +96,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.7.2),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.7.3),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.7.2-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.7.3-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.7.2-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.7.3-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.7.2-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.7.3-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -123,12 +123,27 @@ opkg install ./luci-app-wificalling-gateway_1.7.2-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.2-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.3-r1_noarch.apk /etc/init.d/rpcd restart ``` 然后进入 **服务 → Wi‑Fi Calling Gateway**。先添加并保存节点,再添加设备策略。详细步骤见[安装说明](docs/zh-CN/INSTALL.md)和[配置说明](docs/zh-CN/CONFIGURATION.md)。 +### 18.06/Lede 专包 + +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.7.3-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): + +```sh +opkg update +opkg install ./luci-app-wificalling-gateway_1.7.3-1_18.06_all.ipk +/etc/init.d/wificalling-gateway enable +``` + +注意: + +- **LuCI 页面**依赖 19.07+ 的 JS 视图架构,18.06 的 Lua dispatcher 无法渲染,专包因此不注册菜单;配置请走命令行 UCI(`uci set wificalling-gateway.main.enabled=1` 等)。 +- **sing-box 与 TPROXY 内核模块**(内核 ≥ 4.11)需要你的源提供;缺失时服务启动会通过 `logread -e wificalling-gateway` 给出明确原因。 + ## 重要边界 > **⚠️ 定位要求(Wi-Fi Calling 生效前提)** From 07baaf4656ca2a2dc397a98f2442b22129836613 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 15 Aug 2026 10:25:07 +0800 Subject: [PATCH 19/56] luci-app-wificalling-gateway: fix VMess/Reality validation in node form securityOpt.validate called the non-existent this.map.getSectionValue(), so editing a VLESS node crashed with 'this.map.getSectionValue is not a function' (introduced in 60f81a2; it was never a LuCI API). Read the protocol from the form's current value via this.section.formvalue(), which is what the adjacent depends() calls effectively use: uci.get() would read stored state, letting a new VMess+Reality node through and falsely rejecting an existing VMess node switched to VLESS. Signed-off-by: Smth Dagg --- .../luci-static/resources/view/wificalling-gateway/overview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 58c7ab2c3cdc..039980f85e83 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -138,7 +138,7 @@ return view.extend({ // The compiler has no reality arm for VMess; selecting it would emit a // cleartext outbound that sing-box check accepts. Reject it up front. securityOpt.validate = function(section_id, value) { - if (value == 'reality' && this.map.getSectionValue(section_id, 'protocol') == 'vmess') + if (value == 'reality' && this.section.formvalue(section_id, 'protocol') == 'vmess') return false; return true; }; From 78c872efecbc62ba028a0f572e65bde97f723df8 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 15 Aug 2026 17:12:52 +0800 Subject: [PATCH 20/56] luci-app-wificalling-gateway: readable VMess/Reality validation message The validator returned false, which the LuCI tooltip rendered literally as "false"; return a translatable error message instead (review round 7 nit). Signed-off-by: Smth Dagg --- .../luci-static/resources/view/wificalling-gateway/overview.js | 2 +- .../po/templates/wificalling-gateway.pot | 3 +++ .../po/zh_Hans/wificalling-gateway.po | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 039980f85e83..2e9806a78ed2 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -139,7 +139,7 @@ return view.extend({ // cleartext outbound that sing-box check accepts. Reject it up front. securityOpt.validate = function(section_id, value) { if (value == 'reality' && this.section.formvalue(section_id, 'protocol') == 'vmess') - return false; + return _('Reality security is not available for VMess nodes'); return true; }; s.option(form.Flag, 'insecure', _('Allow insecure certificate')); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index d5910d9884dc..9af2a94932fa 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -242,6 +242,9 @@ msgstr "" msgid "Reality public key" msgstr "" +msgid "Reality security is not available for VMess nodes" +msgstr "" + msgid "Reality short ID" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 42518f80eb4b..a0192c7ecbc7 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -243,6 +243,9 @@ msgstr "Reality 指纹" msgid "Reality public key" msgstr "Reality 公钥" +msgid "Reality security is not available for VMess nodes" +msgstr "VMess 节点不支持 Reality 安全协议" + msgid "Reality short ID" msgstr "Reality 短 ID" From ea5387e5f0fb777cd641eb4baafe7efb67bd9946 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sun, 16 Aug 2026 18:10:34 +0800 Subject: [PATCH 21/56] luci-app-wificalling-gateway: wg psk, handshake health, service monitor - WireGuard preshared key support: pre_shared_key in init.d (f[25], delimiter guarded), compiler (per-peer in the endpoint form, top-level in the legacy outbound), form field and [Interface]/[Peer] conf-block import - compiler: skip a device policy whose node was deleted instead of failing the whole gateway (warning, stale device falls back to direct) - node-health: WireGuard nodes verified by a real sing-box handshake (temporary endpoint + echo service, 60 s cache, verified exit IP) instead of ICMP; compact output exported under the docroot and read via GET to avoid /ubus truncation - overview: ARP fallback liveness, connected-device picker, LAN subnet placeholder, wg_handshake-aware quality/latency - service-health.sh + procd loop: sing-box/monitor state, config validity and stale-config detection, nft rule count, node summary; rendered as Service status on the status page - 22 new zh-cn translations Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 5 +- .../view/wificalling-gateway/overview.js | 171 ++++++++++++++++-- .../view/wificalling-gateway/status.js | 57 +++++- .../po/templates/wificalling-gateway.pot | 68 ++++++- .../po/zh_Hans/wificalling-gateway.po | 68 ++++++- .../root/etc/init.d/wificalling-gateway | 16 +- .../libexec/wificalling-gateway/compiler.sh | 8 +- .../wificalling-gateway/node-health.sh | 96 ++++++++-- .../service-health-loop.sh | 6 + .../wificalling-gateway/service-health.sh | 95 ++++++++++ .../acl.d/luci-app-wificalling-gateway.json | 1 + 12 files changed, 547 insertions(+), 46 deletions(-) create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health-loop.sh create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 5a6ded08c860..8f5d6bc06f7c 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.7.3 +PKG_VERSION:=1.8.0 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index aef5f6925dfe..115fdd3b2f62 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -29,8 +29,11 @@ ## 功能 - 支持 **AnyTLS、Hysteria2、TUIC、VLESS Reality、VMess WebSocket、Trojan 与 WireGuard** 七种节点协议。 -- 支持直接粘贴 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan (trojan://) 与 WireGuard (wg://) 分享链接并自动解析导入。 +- 支持直接粘贴 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan (trojan://)、WireGuard (wg://) 分享链接,以及标准 WireGuard `[Interface]/[Peer]` 配置块;WireGuard 支持预共享密钥(PSK)。 +- **WireGuard 节点真实握手健康检查**:不用 ICMP 猜,临时起 sing-box endpoint 验证隧道握手并显示验证通过的出口 IP(60 秒缓存)。 - **DHCP 静态租约自动管理**:添加/删除设备策略时自动绑定/清理 MAC-IP 静态租约,兼容 iOS 私有 Wi-Fi 地址(MAC 随机变化);设备策略表实时显示绑定状态(已绑定 / 待绑定 / MAC 已变化 / 设备未在线)。 +- **从已连接设备直接添加**:编辑设备策略时可从 DHCP/ARP 检测到的在线设备中选择,自动填写名称与 IP(静态 IP / 纯 AP 路由器场景由 ARP 兜底判断在线)。 +- **服务健康监控**:「Wi-Fi Calling 状态」页顶部显示 sing-box/monitor 进程、配置有效性、**配置过期告警**(改了配置没重启服务)、nftables 规则数、节点健康汇总。 - 每台设备可绑定一个节点;一个策略可包含多个固定私网 IPv4 地址。 - `独立通道`:通过插件节点转发;`跟随网关`:插件不拦截,设备走路由器默认路由。 - 单个 sing-box 进程、nftables TPROXY、TCP 与 UDP 透明转发。 diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 2e9806a78ed2..15e5eb47da6d 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -17,9 +17,14 @@ return view.extend({ // lease file (e.g. to persist across reboots). var leasefile = uci.get('dhcp', '@dnsmasq[0]', 'leasefile') || '/tmp/dhcp.leases'; return Promise.all([ - L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), + // The node status file is exported under the uhttpd docroot + // and read with a plain GET: the /ubus JSON-RPC channel + // truncates larger replies on some firmwares, leaving the + // status blank. + L.resolveDefault(fetch('/wificalling-node-status.json').then(function(r) { return r.text(); }), '{}'), uci.load('wificalling-gateway'), - L.resolveDefault(fs.read(leasefile), '') + L.resolveDefault(fs.read(leasefile), ''), + L.resolveDefault(fs.read('/proc/net/arp'), '') ]); }); }, @@ -33,28 +38,52 @@ return view.extend({ } function quality(n) { if (!n) return '-'; + if (n.state === 'handshake_ok') return _('Good'); + if (n.state === 'handshake_failed') return _('Offline'); if (n.state === 'unreachable') return _('Offline'); - if (n.ping_ms == null) return _('Unknown'); - if (n.ping_ms <= 100) return _('Excellent'); - if (n.ping_ms <= 200) return _('Good'); - if (n.ping_ms <= 300) return _('Fair'); + // ping_ms may arrive as a JSON number or a quoted string + // (WireGuard handshake rows carry the verified exit IP). + var ms = parseFloat(n.ping_ms); + if (isNaN(ms)) return _('Unknown'); + if (ms <= 100) return _('Excellent'); + if (ms <= 200) return _('Good'); + if (ms <= 300) return _('Fair'); return _('Poor'); } function nodeState(n) { if (!n) return '-'; - if (n.state === 'reachable' || n.state === 'tcp_reachable') return _('Alive'); - if (n.state === 'unreachable') return _('Offline'); + if (n.state === 'handshake_ok' || n.state === 'reachable' || n.state === 'tcp_reachable') return _('Alive'); + if (n.state === 'handshake_failed' || n.state === 'unreachable') return _('Offline'); return _('Unknown'); } - function latency(n) { return n && n.ping_ms != null ? n.ping_ms + ' ms (' + n.measurement + ')' : '-'; } + function latency(n) { + if (!n) return '-'; + // WireGuard handshake rows carry the verified exit IP instead + // of an ICMP latency. + if (n.measurement === 'wg_handshake') return n.ping_ms || '-'; + return n.ping_ms != null ? n.ping_ms + ' ms (' + n.measurement + ')' : '-'; + } // Live DHCP lease map (IP -> MAC) and plugin-managed static bindings // (wfc_ host sections) for the device policy status column. dnsmasq // lease lines are: expiry MAC IP hostname clientid. - var leaseMac = {}; + var leaseMac = {}, leaseHost = {}; (data[2] || '').split('\n').forEach(function(line) { var p = line.split(/\s+/); - if (p.length >= 3 && /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(p[2])) + if (p.length >= 4 && /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(p[2])) { leaseMac[p[2]] = p[1]; + if (p[3] && p[3] !== '*') + leaseHost[p[2]] = p[3]; + } + }); + // Devices seen in the ARP cache but not in the DHCP leases (static + // IPs, or a router that does not run DHCP at all) still show up in + // the connected-devices picker and count as online. + var arpDevices = {}; + (data[3] || '').split('\n').slice(1).forEach(function(line) { + var p = line.trim().split(/\s+/); + if (p.length >= 4 && /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(p[0]) + && /^[0-9a-fA-F:]+$/.test(p[2])) + arpDevices[p[0]] = p[2]; }); var wfcHost = {}; uci.sections('dhcp', 'host').forEach(function(h) { @@ -66,21 +95,98 @@ return view.extend({ if (host && host.mac && mac && host.mac.toLowerCase() === mac.toLowerCase()) return _('Bound'); if (host && host.mac && mac) return _('MAC changed, rebind on reconnect'); if (mac) return _('Not bound yet'); + // No DHCP lease (static IP, or a router that does not run DHCP + // at all, e.g. a secondary/AP router): the ARP cache is the only + // liveness source, so a recently-seen device is online, not + // offline. Only report offline when neither source knows it. + if (arpDevices[ip]) return _('Online (static IP)'); return _('Device offline'); } + // The router's LAN subnet hint for the IP placeholder, derived from + // the address the admin uses to reach LuCI (e.g. 192.168.31.x). + function lanSubnetHint() { + var host = location.hostname || ''; + if (/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/.test(host)) { + var parts = host.split('.'); + return parts.slice(0, 3).join('.') + '.x'; + } + return '192.168.x.x'; + } + // Connected LAN devices (DHCP hostname when known, ARP-only entries + // otherwise) for the add-device picker; the router itself and IPs + // already bound to a device policy are excluded. + var detected = {}; + Object.keys(leaseHost).forEach(function(ip) { + detected[ip] = { name: leaseHost[ip], mac: leaseMac[ip] }; + }); + Object.keys(arpDevices).forEach(function(ip) { + if (!detected[ip]) + detected[ip] = { name: '', mac: arpDevices[ip] }; + }); + var routerHost = (location.hostname || '').toLowerCase(); + var boundIps = {}; + uci.sections('wificalling-gateway', 'device').forEach(function(d) { + (d.source_ip || []).forEach(function(ip) { boundIps[ip] = true; }); + }); + var detectedDevices = Object.keys(detected) + .filter(function(ip) { + return ip !== routerHost && !boundIps[ip]; + }) + .map(function(ip) { return { ip: ip, name: detected[ip].name }; }) + .sort(function(a, b) { + var na = (a.name || a.ip).toLowerCase(), nb = (b.name || b.ip).toLowerCase(); + return na < nb ? -1 : (na > nb ? 1 : 0); + }); + + // Parse a standard WireGuard config block ([Interface]/[Peer]) into + // the same node object the link importer produces, so a conf file + // can be pasted directly instead of being converted to wg:// first. + function parseWireguardConf(text) { + var section = null, iface = {}, peer = {}; + text.split('\n').forEach(function(line) { + var t = line.trim(); + if (t === '[Interface]') { section = 'iface'; return; } + if (t === '[Peer]') { section = 'peer'; return; } + if (!section || !t || t.indexOf('#') === 0) return; + var eq = t.indexOf('='); + if (eq < 0) return; + var key = t.slice(0, eq).trim(), val = t.slice(eq + 1).trim(); + if (section === 'iface') iface[key] = val; else peer[key] = val; + }); + if (!iface.PrivateKey || !iface.Address || !peer.PublicKey || !peer.Endpoint) + throw new Error(_('WireGuard conf needs PrivateKey, Address, Peer PublicKey and Endpoint')); + var endpoint = peer.Endpoint.trim().split(':'); + if (endpoint.length !== 2 || !/^[0-9]+$/.test(endpoint[1])) + throw new Error(_('Invalid WireGuard endpoint: ') + peer.Endpoint); + return { + enabled: '1', protocol: 'wireguard', + label: 'WireGuard ' + endpoint[0], + server: endpoint[0], port: endpoint[1], + public_key: peer.PublicKey, + private_key: iface.PrivateKey, + local_address: iface.Address.split(',')[0].trim(), + reserved: iface.Reserved || '', + mtu: iface.MTU || '', + pre_shared_key: peer.PresharedKey || '' + }; + } var m = new form.Map('wificalling-gateway', _('Wi-Fi Calling Gateway settings'), _('Configure proxy nodes and assign fixed LAN devices. Monitoring and logs are available from the submenu.')); var importPanel = E('div', { class: 'cbi-section' }, [ E('h3', {}, _('Import proxy node')), - E('p', {}, _('Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard (wg://) link. It is parsed locally in this browser and is not sent to an external service.')), + E('p', {}, _('Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service.')), E('button', { class: 'btn cbi-button-positive', click: function() { var input = E('textarea', { class: 'cbi-input-textarea', rows: 6, style: 'width:100%', placeholder: 'anytls://…' }); ui.showModal(_('Import node link'), [input, E('div', { class: 'right' }, [ E('button', { class: 'btn', click: ui.hideModal }, _('Cancel')), E('button', { class: 'btn cbi-button-positive', click: function() { var parsed; - try { parsed = nodeImport.parse(input.value); } + try { + parsed = /^\s*\[Interface\]/m.test(input.value) + ? parseWireguardConf(input.value) + : nodeImport.parse(input.value); + } catch (err) { ui.addNotification(null, E('p', {}, _('Unable to parse node link:') + ' ' + err.message), 'error'); return; } var sid = uci.add('wificalling-gateway', 'node'); Object.keys(parsed).forEach(function(key) { if (parsed[key] !== '') uci.set('wificalling-gateway', sid, key, parsed[key]); }); @@ -160,6 +266,8 @@ return view.extend({ s.option(form.Value, 'local_address', _('WireGuard local address')); s.option(form.Value, 'reserved', _('WireGuard reserved (comma-separated)')); s.option(form.Value, 'mtu', _('WireGuard MTU')); + var wgPsk = s.option(form.Value, 'pre_shared_key', _('WireGuard preshared key')); + wgPsk.password = true; wgPsk.depends('protocol', 'wireguard'); s = m.section(form.GridSection, 'device', _('Device policies')); s.addremove = true; s.nodescriptions = true; s.anonymous = true; s.addbtntitle = _('Add LAN device'); @@ -175,7 +283,40 @@ return view.extend({ selectedNode.description = _('Save the node first, then reload this page to select it for a device.'); uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); - ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = '192.168.31.x'; + ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = lanSubnetHint(); + var devicePicker = s.option(form.DummyValue, '_device_picker', _('From connected devices')); + devicePicker.rmempty = true; + devicePicker.renderWidget = function(section_id) { + if (!detectedDevices.length) + return E('span', {}, _('No connected devices detected')); + var select = E('select', { class: 'cbi-input-select', change: function(ev) { + var ip = select.value; if (!ip) return; + var dev = detectedDevices.find(function(d) { return d.ip === ip; }); + var labelInput = document.getElementById('cbid.wificalling-gateway.' + section_id + '.label'); + if (labelInput) { + labelInput.value = (dev && dev.name) ? dev.name : ''; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + } + var dynlist = document.getElementById('cbid.wificalling-gateway.' + section_id + '.source_ip'); + if (dynlist) { + var existing = Array.prototype.map.call( + dynlist.querySelectorAll('.item input[type=hidden]'), + function(input) { return input.value; }); + if (existing.indexOf(ip) < 0) { + var ipInput = document.getElementById('widget.cbid.wificalling-gateway.' + section_id + '.source_ip'); + if (ipInput) { + ipInput.value = ip; + ipInput.dispatchEvent(new Event('input', { bubbles: true })); + var addBtn = dynlist.querySelector('.add-item .cbi-button-add'); + if (addBtn) addBtn.click(); + } + } + } + } }, detectedDevices.map(function(d) { + return E('option', { value: d.ip }, (d.name || d.ip) + ' (' + d.ip + ')'); + })); + return E('span', {}, [select, E('em', { class: 'cbi-value-description' }, _('Pick a device to fill its label and IP.'))]); + }; var dhcpBinding = s.option(form.DummyValue, '_dhcp_binding', _('DHCP binding')); // A DummyValue has no editable value: without rmempty the save // parse rejects it as "must not be empty", silently breaking the @@ -198,7 +339,7 @@ return view.extend({ }; poll.add(function() { - return L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}').then(function(raw) { + return L.resolveDefault(fetch('/wificalling-node-status.json').then(function(r) { return r.text(); }), '{}').then(function(raw) { var current; try { current = JSON.parse(raw); } catch (e) { current = { nodes: [] }; } (current.nodes || []).forEach(function(n) { [['state', nodeState(n)], ['ping', latency(n)], ['quality', quality(n)]].forEach(function(v) { diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js index 2f5185fdb008..163fe07c0982 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js @@ -5,8 +5,13 @@ 'require dom'; return view.extend({ - load: function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}'); }, - render: function(raw) { + load: function() { + return Promise.all([ + L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}'), + L.resolveDefault(fs.read('/var/run/wificalling-gateway/service-health.json'), '{}') + ]); + }, + render: function(data) { function parse(value) { try { return JSON.parse(value); } catch (e) { return { devices: [] }; } } function when(epoch) { return epoch ? new Date(epoch * 1000).toLocaleString() : '-'; } function wfcLabel(v) { @@ -22,6 +27,51 @@ return view.extend({ default: return v || '-'; } } + + /* ---------- 服务状态 ---------- */ + var healthRaw = data[1]; + function parseHealth(value) { try { return JSON.parse(value); } catch (e) { return {}; } } + function healthText(h) { + if (!h || h.config_present == null) return _('No health data yet'); + var parts = []; + parts.push(h.singbox_running ? _('sing-box: running') : _('sing-box: not running')); + parts.push(h.monitor_running ? _('monitor: running') : _('monitor: not running')); + if (h.config_present) + parts.push(h.config_valid ? _('config: valid') : _('config: invalid')); + else + parts.push(_('config: not generated')); + if (h.nft_rules != null) parts.push(_('nftables rules') + ': ' + h.nft_rules); + if (h.devices != null) parts.push(_('device policies') + ': ' + h.devices); + if (h.nodes && h.nodes.total != null) + parts.push(_('nodes online') + ': ' + h.nodes.ok + '/' + h.nodes.total); + return parts.join(' · '); + } + function healthAlerts(h) { + var alerts = []; + if (h && h.singbox_running === 0 && h.config_present) + alerts.push(_('sing-box is not running: the gateway cannot route the tunnel. Check logread -e wificalling-gateway.')); + if (h && h.config_stale) + alerts.push(_('The configuration changed but the gateway was not restarted: sing-box still runs the previous config. Restart the service to apply it.')); + if (h && h.config_present && h.config_valid === 0) + alerts.push(_('The generated sing-box config failed validation.')); + return alerts; + } + var healthBox = E('div', { class: 'cbi-section' }, []); + function renderHealth(value) { + var h = parseHealth(value); + var alerts = healthAlerts(h); + dom.content(healthBox, [ + E('h3', {}, _('Service status')), + E('p', {}, healthText(h)), + alerts.length ? E('ul', {}, alerts.map(function(a) { return E('li', { class: 'alert-message warning' }, a); })) : null + ]); + } + renderHealth(healthRaw); + poll.add(function() { + return L.resolveDefault(fs.read('/var/run/wificalling-gateway/service-health.json'), '{}').then(renderHealth); + }, 30); + + /* ---------- 设备隧道状态 ---------- */ function rows(source) { return (source.devices || []).map(function(d) { var values = [d.label, d.ip, wfcLabel(d.wificalling || d.state), d.node || '-', d.epdg_ip || '-', @@ -30,9 +80,10 @@ return view.extend({ return E('tr', { class: 'tr' }, values.map(function(x) { return E('td', { class: 'td' }, String(x)); })); }); } - var body = E('tbody', {}, rows(parse(raw))); + var body = E('tbody', {}, rows(parse(data[0]))); poll.add(function() { return L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}').then(function(v) { dom.content(body, rows(parse(v))); }); }, 5); return E([], [E('h2', {}, _('Wi-Fi Calling status')), E('p', {}, _('Registered means an ASSURED bidirectional UDP 4500 tunnel was observed. This is network evidence, not carrier activation confirmation.')), + healthBox, E('div', { class: 'table cbi-section-table' }, [E('table', { class: 'table' }, [ E('tr', { class: 'tr table-titles' }, [_('Device'), _('IP'), _('Wi-Fi Calling status'), _('Node'), _('ePDG IP'), _('UDP 500/4500'), _('ASSURED'), _('Packets'), _('Last activity')].map(function(x) { return E('th', { class: 'th' }, x); })), body ])])]); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 9af2a94932fa..b22bed0a5401 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -110,6 +110,9 @@ msgstr "" msgid "Following gateway" msgstr "" +msgid "From connected devices" +msgstr "" + msgid "General" msgstr "" @@ -140,6 +143,9 @@ msgstr "" msgid "Information" msgstr "" +msgid "Invalid WireGuard endpoint: " +msgstr "" + msgid "LAN IPv4 addresses" msgstr "" @@ -173,6 +179,12 @@ msgstr "" msgid "No" msgstr "" +msgid "No connected devices detected" +msgstr "" + +msgid "No health data yet" +msgstr "" + msgid "No session" msgstr "" @@ -203,6 +215,9 @@ msgstr "" msgid "Offline" msgstr "" +msgid "Online (static IP)" +msgstr "" + msgid "Packet delta" msgstr "" @@ -212,7 +227,10 @@ msgstr "" msgid "Password" msgstr "" -msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard (wg://) link. It is parsed locally in this browser and is not sent to an external service." +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service." +msgstr "" + +msgid "Pick a device to fill its label and IP." msgstr "" msgid "Ping / latency" @@ -275,6 +293,9 @@ msgstr "" msgid "Server and port are required" msgstr "" +msgid "Service status" +msgstr "" + msgid "Set" msgstr "" @@ -296,6 +317,12 @@ msgstr "" msgid "TUIC UDP mode" msgstr "" +msgid "The configuration changed but the gateway was not restarted: sing-box still runs the previous config. Restart the service to apply it." +msgstr "" + +msgid "The generated sing-box config failed validation." +msgstr "" + msgid "This name is shown in the device node selector." msgstr "" @@ -362,9 +389,15 @@ msgstr "" msgid "WireGuard MTU" msgstr "" +msgid "WireGuard conf needs PrivateKey, Address, Peer PublicKey and Endpoint" +msgstr "" + msgid "WireGuard local address" msgstr "" +msgid "WireGuard preshared key" +msgstr "" + msgid "WireGuard private key" msgstr "" @@ -374,5 +407,38 @@ msgstr "" msgid "Yes" msgstr "" +msgid "config: invalid" +msgstr "" + +msgid "config: not generated" +msgstr "" + +msgid "config: valid" +msgstr "" + +msgid "device policies" +msgstr "" + msgid "ePDG IP" msgstr "" + +msgid "monitor: not running" +msgstr "" + +msgid "monitor: running" +msgstr "" + +msgid "nftables rules" +msgstr "" + +msgid "nodes online" +msgstr "" + +msgid "sing-box is not running: the gateway cannot route the tunnel. Check logread -e wificalling-gateway." +msgstr "" + +msgid "sing-box: not running" +msgstr "" + +msgid "sing-box: running" +msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index a0192c7ecbc7..3a1dc5d06ed2 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -111,6 +111,9 @@ msgstr "跟随网关" msgid "Following gateway" msgstr "跟随网关" +msgid "From connected devices" +msgstr "从已连接设备选择" + msgid "General" msgstr "常规" @@ -141,6 +144,9 @@ msgstr "独立通道" msgid "Information" msgstr "信息" +msgid "Invalid WireGuard endpoint: " +msgstr "无效的 WireGuard 端点:" + msgid "LAN IPv4 addresses" msgstr "局域网 IPv4 地址" @@ -174,6 +180,12 @@ msgstr "协商中" msgid "No" msgstr "否" +msgid "No connected devices detected" +msgstr "未检测到已连接设备" + +msgid "No health data yet" +msgstr "暂无健康数据" + msgid "No session" msgstr "无会话" @@ -204,6 +216,9 @@ msgstr "未设置" msgid "Offline" msgstr "离线" +msgid "Online (static IP)" +msgstr "在线(静态 IP)" + msgid "Packet delta" msgstr "数据包增量" @@ -213,9 +228,12 @@ msgstr "数据包" msgid "Password" msgstr "密码" -msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard (wg://) link. It is parsed locally in this browser and is not sent to an external service." +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service." msgstr "粘贴一个 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan 或 WireGuard (wg://) 链接。链接仅在本浏览器中本地解析,不会发送到外部服务。" +msgid "Pick a device to fill its label and IP." +msgstr "选择设备以自动填写名称和 IP。" + msgid "Ping / latency" msgstr "Ping / 延迟" @@ -276,6 +294,9 @@ msgstr "服务器" msgid "Server and port are required" msgstr "服务器和端口为必填项" +msgid "Service status" +msgstr "服务状态" + msgid "Set" msgstr "已设置" @@ -297,6 +318,12 @@ msgstr "TLS 服务器名称" msgid "TUIC UDP mode" msgstr "TUIC UDP 模式" +msgid "The configuration changed but the gateway was not restarted: sing-box still runs the previous config. Restart the service to apply it." +msgstr "配置已修改但网关未重启:sing-box 仍在运行旧配置。请重启服务使配置生效。" + +msgid "The generated sing-box config failed validation." +msgstr "生成的 sing-box 配置未通过校验。" + msgid "This name is shown in the device node selector." msgstr "此名称显示在设备节点选择器中。" @@ -363,9 +390,15 @@ msgstr "Wi-Fi Calling 状态" msgid "WireGuard MTU" msgstr "WireGuard MTU" +msgid "WireGuard conf needs PrivateKey, Address, Peer PublicKey and Endpoint" +msgstr "WireGuard 配置需要 PrivateKey、Address、Peer PublicKey 和 Endpoint" + msgid "WireGuard local address" msgstr "WireGuard 本地地址" +msgid "WireGuard preshared key" +msgstr "WireGuard 预共享密钥" + msgid "WireGuard private key" msgstr "WireGuard 私钥" @@ -375,5 +408,38 @@ msgstr "WireGuard 保留位(逗号分隔)" msgid "Yes" msgstr "是" +msgid "config: invalid" +msgstr "配置:无效" + +msgid "config: not generated" +msgstr "配置:未生成" + +msgid "config: valid" +msgstr "配置:有效" + +msgid "device policies" +msgstr "设备策略" + msgid "ePDG IP" msgstr "ePDG IP" + +msgid "monitor: not running" +msgstr "监控:未运行" + +msgid "monitor: running" +msgstr "监控:运行中" + +msgid "nftables rules" +msgstr "nftables 规则" + +msgid "nodes online" +msgstr "节点在线" + +msgid "sing-box is not running: the gateway cannot route the tunnel. Check logread -e wificalling-gateway." +msgstr "sing-box 未运行:网关无法转发隧道。请检查 logread -e wificalling-gateway。" + +msgid "sing-box: not running" +msgstr "sing-box:未运行" + +msgid "sing-box: running" +msgstr "sing-box:运行中" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 9d59a0d87371..cfb035974c14 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -19,7 +19,7 @@ wireguard_style() { } append_node() { - local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id credential auxiliary pin_sha256 private_key local_address reserved mtu + local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id credential auxiliary pin_sha256 private_key local_address reserved mtu pre_shared_key config_get_bool enabled "$s" enabled 1 [ "$enabled" -eq 1 ] || return 0 config_get label "$s" label "$s"; config_get protocol "$s" protocol @@ -30,7 +30,7 @@ append_node() { config_get security "$s" security; config_get transport "$s" transport; config_get path "$s" path; config_get host "$s" host config_get pin_sha256 "$s" pin_sha256 config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 - config_get private_key "$s" private_key; config_get local_address "$s" local_address; config_get reserved "$s" reserved; config_get mtu "$s" mtu + config_get private_key "$s" private_key; config_get local_address "$s" local_address; config_get reserved "$s" reserved; config_get mtu "$s" mtu; config_get pre_shared_key "$s" pre_shared_key # The delimiter guard covers every field interpolated into the # | -delimited normalized.conf record. label is deliberately excluded: # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and @@ -38,7 +38,7 @@ append_node() { # gets a sanitized copy instead. credential/auxiliary (f[6]/f[8]) are # derived from password/uuid/flow/alter_id/private_key, all guarded # here, so they need no separate check. - case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$alter_id" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$alter_id$pre_shared_key" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; @@ -46,7 +46,7 @@ append_node() { trojan) credential=$password; auxiliary= ;; wireguard) credential=$private_key; auxiliary= ;; esac - printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" "$private_key" "$local_address" "$reserved" "$mtu" >> "$RUNDIR/normalized.conf" + printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" "$private_key" "$local_address" "$reserved" "$mtu" "$pre_shared_key" >> "$RUNDIR/normalized.conf" local safe_label safe_label=$(printf '%s' "$label" | tr '|' ' ') printf '%s|%s|%s|%s|%s\n' "$s" "$safe_label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" @@ -102,7 +102,13 @@ start_service() { procd_set_param limits nofile="65535 65535" procd_close_instance procd_open_instance monitor - procd_set_param command /usr/libexec/$APP/monitor-loop.sh "$RUNDIR/clients" "$RUNDIR/status.json" "$RUNDIR/nodes" "$RUNDIR/node-status.json" "$RUNDIR/events.log" "$RUNDIR/monitor.state" "$event_interval" "$max_events_per_device" "$log_enabled" + procd_set_param command /usr/libexec/$APP/monitor-loop.sh "$RUNDIR/clients" "$RUNDIR/status.json" "$RUNDIR/nodes" "/www/wificalling-node-status.json" "$RUNDIR/events.log" "$RUNDIR/monitor.state" "$event_interval" "$max_events_per_device" "$log_enabled" + procd_set_param respawn + procd_close_instance + procd_open_instance health + # Service health snapshot for the LuCI status page (sing-box state, + # stale-config detection, nft rules, node health summary). + procd_set_param command /usr/libexec/$APP/service-health-loop.sh procd_set_param respawn procd_close_instance } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index 6dd82a7d2430..aee14295c8be 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -42,7 +42,10 @@ $1=="node" { next } $1=="device" { - if (!node_proto[$3]) fail("device references unknown node: " $3) + # A stale device-policy node reference must not take down the whole + # gateway: skip the device so the rest keeps proxying (the stale + # device falls back to direct routing) and warn instead of failing. + if (!node_proto[$3]) { print "wificalling-gateway: device references unknown node " $3 "; skipping" > "/dev/stderr"; next } n=split($4, ips, ","); if (n<1 || $4=="") fail("device has no client IP: " $2) normalized="" for(i=1;i<=n;i++) { @@ -70,8 +73,10 @@ END { s="{\"type\":\"wireguard\",\"tag\":" q("wg-" id) ",\"address\":[" q(f[22]) "],\"private_key\":" q(f[21]) s=s ",\"peers\":[{\"address\":" q(f[4]) ",\"port\":" f[5] ",\"public_key\":" q(f[13]) ",\"allowed_ips\":[\"0.0.0.0/0\"]" if (f[23]!="") { nr=split(f[23],rv,","); rv_s=rv[1]; for(ri=2;ri<=nr;ri++) rv_s=rv_s "," rv[ri]; s=s ",\"reserved\":[" rv_s "]" } + if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) s=s "}]" if (f[24]!="") s=s ",\"mtu\":" f[24] + if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) s=s "}"; print " " s (w/dev/null || echo 0) + age=$(($(date +%s) - ${cache_ts:-0})) + if [ "$age" -lt 60 ] 2>/dev/null; then + [ "$(sed -n '2p' "$cache")" = ok ] || return 1 + sed -n '3p' "$cache" + return 0 + fi + fi + priv=$(uci -q get "wificalling-gateway.$id.private_key") || return 1 + pub=$(uci -q get "wificalling-gateway.$id.public_key") || return 1 + local_addr=$(uci -q get "wificalling-gateway.$id.local_address") || return 1 + psk=$(uci -q get "wificalling-gateway.$id.pre_shared_key") || true + mtu=$(uci -q get "wificalling-gateway.$id.mtu") || true + lport=$((19000 + (${id#cfg} % 1000))) 2>/dev/null || lport=19099 + cfg="/tmp/wg-health-$id.json" + { + printf '{"log":{"level":"warn"},"inbounds":[{"type":"http","tag":"probe","listen":"127.0.0.1","listen_port":%s}],' "$lport" + printf '"endpoints":[{"type":"wireguard","tag":"wg","address":[%s],"private_key":%s,"peers":[{"address":%s,"port":%s,"public_key":%s,"allowed_ips":["0.0.0.0/0"]' \ + "\"$local_addr\"" "\"$priv\"" "\"$server\"" "$port" "\"$pub\"" + [ -n "$psk" ] && printf ',"pre_shared_key":"%s"' "$psk" + printf '}],"mtu":%s}],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"wg"}}' "${mtu:-1420}" + } > "$cfg" + "$sing_box" run -c "$cfg" > /tmp/wg-health-$id.log 2>&1 & + pid=$! + sleep 2 + # busybox wget honours http_proxy; the probe listens on 127.0.0.1. + ip=$(http_proxy="http://127.0.0.1:$lport" wget -qO- -T 6 'http://ip-api.com/json/?fields=query' 2>/dev/null | sed -n 's/.*"query":"\([0-9.]*\)".*/\1/p' || true) + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + rm -f "$cfg" /tmp/wg-health-$id.log + if [ -n "$ip" ]; then + printf '%s\nok\n%s\n' "$(date +%s)" "$ip" > "$cache" + printf '%s' "$ip" + return 0 + fi + printf '%s\nfailed\n' "$(date +%s)" > "$cache" + return 1 +} + { - printf '{"generated_at":%s,"note":"ICMP ping only; this is not a proxy protocol handshake.","nodes":[' "$(date +%s)" + printf '{"generated_at":%s,"nodes":[' "$(date +%s)" first=1 while IFS='|' read -r id label protocol server port; do [ -n "$id" ] || continue - ping_output=$(ping -c 1 -W 1 "$server" 2>/dev/null || true) - latency=$(printf '%s\n' "$ping_output" | sed -n 's/.*time[=<]\{0,1\}\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) state=no_icmp_reply; ping_json=null; measurement=icmp - if [ -n "$latency" ]; then - state=reachable; ping_json=$latency + # WireGuard nodes are validated by a real handshake, not ICMP. + if [ "$protocol" = wireguard ]; then + measurement=wg_handshake + if exit_ip=$(wg_handshake_test "$id" "$server" "$port"); then + state=handshake_ok; ping_json="\"$exit_ip\"" + else + state=handshake_failed; ping_json=null + fi else - case "$protocol" in - anytls|vless|vmess|trojan) - if command -v tcping >/dev/null 2>&1; then - measurement=tcp - tcp_output=$(tcping -c 1 -t 1 -p "$port" "$server" 2>/dev/null || true) - latency=$(printf '%s\n' "$tcp_output" | sed -n 's/.*time=\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) - if [ -n "$latency" ]; then state=tcp_reachable; ping_json=$latency; else state=unreachable; fi - fi - ;; - esac + ping_output=$(ping -c 1 -W 1 "$server" 2>/dev/null || true) + latency=$(printf '%s\n' "$ping_output" | sed -n 's/.*time[=<]\{0,1\}\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) + if [ -n "$latency" ]; then + state=reachable; ping_json=$latency + else + case "$protocol" in + anytls|vless|vmess|trojan) + if command -v tcping >/dev/null 2>&1; then + measurement=tcp + tcp_output=$(tcping -c 1 -t 1 -p "$port" "$server" 2>/dev/null || true) + latency=$(printf '%s\n' "$tcp_output" | sed -n 's/.*time=\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) + if [ -n "$latency" ]; then state=tcp_reachable; ping_json=$latency; else state=unreachable; fi + fi + ;; + esac + fi fi [ "$first" -eq 1 ] || printf ',' first=0 - printf '{"id":"%s","label":"%s","protocol":"%s","server":"%s","port":%s,"state":"%s","measurement":"%s","ping_ms":%s}' \ - "$(json_escape "$id")" "$(json_escape "$label")" "$(json_escape "$protocol")" \ - "$(json_escape "$server")" "$port" "$state" "$measurement" "$ping_json" + printf '{"id":"%s","state":"%s","measurement":"%s","ping_ms":%s}' \ + "$(json_escape "$id")" "$state" "$measurement" "$ping_json" done < "$nodes" printf ']}\n' } > "$tmp" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health-loop.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health-loop.sh new file mode 100755 index 000000000000..e472c4d607b7 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health-loop.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Periodic driver for service-health.sh (procd instance, ~60 s cadence). +while :; do + /usr/libexec/wificalling-gateway/service-health.sh + sleep 60 +done diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh new file mode 100755 index 000000000000..f97ec9e4e481 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# Service health snapshot for the LuCI status page: monitor/sing-box +# process state, generated-config validity and staleness (the admin edited +# UCI but the gateway was not restarted, so sing-box still runs the old +# config), nftables rule count, device count and a node health summary. +# Every check is defensive: a missing file or binary reports the state +# instead of failing the whole report. +# +# Usage: service-health.sh [output] [node-status] + +set -eu + +output=${1:-/var/run/wificalling-gateway/service-health.json} +node_status=${2:-/www/wificalling-node-status.json} +rundir=${WFC_RUNDIR:-/var/run/wificalling-gateway} +uci_config=${WFC_UCI_CONFIG:-/etc/config/wificalling-gateway} +tmp="${output}.tmp.$$" +trap 'rm -f "$tmp"' EXIT HUP INT TERM + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +now=$(date +%s) + +# File age in seconds (busybox-safe; -1 when unknown). +file_age() { + local f="$1" + if [ -f "$f" ] && date -r "$f" +%s >/dev/null 2>&1; then + echo $((now - $(date -r "$f" +%s))) + else + echo -1 + fi +} + +monitor_pid=$(pgrep -f 'monitor-loop.sh' 2>/dev/null | head -n 1 || true) +monitor_running=0; [ -n "$monitor_pid" ] && monitor_running=1 +sb_pid=$(pgrep -f '/usr/bin/sing-box run' 2>/dev/null | head -n 1 || true) +sb_running=0; [ -n "$sb_pid" ] && sb_running=1 + +sb_config=0; sb_config_valid=0; sb_config_age=-1; sb_config_stale=0 +if [ -f "$rundir/sing-box.json" ]; then + sb_config=1 + sb_config_age=$(file_age "$rundir/sing-box.json") + # True only when the UCI config changed AFTER the running proxy config + # was generated - i.e. the admin edited nodes/devices but the gateway + # was not restarted, so sing-box still runs the old config. A large + # config age by itself is normal: it is only regenerated on restart. + if [ -f "$uci_config" ] \ + && [ "$uci_config" -nt "$rundir/sing-box.json" ]; then + sb_config_stale=1 + fi + if command -v sing-box >/dev/null 2>&1; then + if sing-box check -c "$rundir/sing-box.json" >/dev/null 2>&1; then + sb_config_valid=1 + fi + fi +fi + +norm_fresh=0; norm_age=-1 +if [ -f "$rundir/normalized.conf" ]; then + norm_age=$(file_age "$rundir/normalized.conf") + [ "$norm_age" -ge 0 ] && [ "$norm_age" -le 120 ] && norm_fresh=1 +fi + +nft_rules=0 +if command -v nft >/dev/null 2>&1; then + nft_rules=$(nft list ruleset 2>/dev/null | grep -c -E 'tproxy|redirect' || true) +fi + +devices=$(grep -c '^device|' "$rundir/normalized.conf" 2>/dev/null || true) +[ -n "$devices" ] || devices=0 + +nodes_total=0; nodes_ok=0; nodes_down=0; nodes_unknown=0 +if [ -f "$node_status" ]; then + nodes_total=$(grep -o '"id":"' "$node_status" | wc -l) + nodes_ok=$(grep -o '"state":"\(reachable\|tcp_reachable\|handshake_ok\)"' "$node_status" | wc -l) + nodes_down=$(grep -o '"state":"\(unreachable\|handshake_failed\)"' "$node_status" | wc -l) + nodes_unknown=$((nodes_total - nodes_ok - nodes_down)) + [ "$nodes_unknown" -lt 0 ] && nodes_unknown=0 +fi + +{ + printf '{"generated_at":%s,' "$now" + printf '"monitor_running":%s,"singbox_running":%s,' "$monitor_running" "$sb_running" + printf '"config_present":%s,"config_valid":%s,"config_age":%s,"config_stale":%s,' \ + "$sb_config" "$sb_config_valid" "$sb_config_age" "$sb_config_stale" + printf '"norm_fresh":%s,"norm_age":%s,"nft_rules":%s,"devices":%s,' \ + "$norm_fresh" "$norm_age" "$nft_rules" "$devices" + printf '"nodes":{"total":%s,"ok":%s,"down":%s,"unknown":%s}}\n' \ + "$nodes_total" "$nodes_ok" "$nodes_down" "$nodes_unknown" +} > "$tmp" +chmod 644 "$tmp" +mv "$tmp" "$output" +trap - EXIT HUP INT TERM diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json index 5d76ea6e9572..51faa8355e9b 100644 --- a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -7,6 +7,7 @@ "file": { "/var/run/wificalling-gateway/status.json": [ "read" ], "/var/run/wificalling-gateway/node-status.json": [ "read" ], + "/var/run/wificalling-gateway/service-health.json": [ "read" ], "/var/run/wificalling-gateway/events.log": [ "read" ], "/tmp/run/wificalling-gateway/status.json": [ "read" ], "/tmp/run/wificalling-gateway/node-status.json": [ "read" ], From 5b7f35c0a8935f5a1a731aa135d5cd3b1fcf3940 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 17 Aug 2026 00:44:07 +0800 Subject: [PATCH 22/56] luci-app-wificalling-gateway: node test, handshake reasons, test lock wloc 1.2.0 additions, Wi-Fi Calling part only (their 1.2 reverted some 1.8.0-aligned features; only the new bits were taken): - per-row nodeTest button: fresh WG handshake bypassing the 60 s cache (reuses wg_handshake_test from node-health.sh), TCP probe via tcping/busybox nc for other protocols; banner notifications - rpcd exec plugin luci.wificalling-gateway (node_test method) + ACL - handshake failure reasons (config_missing/timeout/unreachable) in the node-status reason field, tooltip on the status column, fast fail on missing keys - mkdir lock serializes handshake tests (5 s ticks vs ~8 s tests), PID-liveness takeover of stale locks, busy reason - reserved bytes forwarded into the probe config (WARP endpoints), probe port from an id hash - node form fields modalonly; device picker DOM ids fixed to widget.cbid - 10 new zh-cn translations Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 3 +- .../view/wificalling-gateway/overview.js | 144 ++++++++++++++++-- .../po/templates/wificalling-gateway.pot | 30 ++++ .../po/zh_Hans/wificalling-gateway.po | 30 ++++ .../usr/libexec/rpcd/luci.wificalling-gateway | 40 +++++ .../wificalling-gateway/node-health.sh | 62 ++++++-- .../libexec/wificalling-gateway/node-test.sh | 83 ++++++++++ .../acl.d/luci-app-wificalling-gateway.json | 5 +- 9 files changed, 372 insertions(+), 27 deletions(-) create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway create mode 100755 applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 8f5d6bc06f7c..cd3d275c6c6d 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.0 +PKG_VERSION:=1.8.1 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 115fdd3b2f62..592fdbb62448 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -30,7 +30,8 @@ - 支持 **AnyTLS、Hysteria2、TUIC、VLESS Reality、VMess WebSocket、Trojan 与 WireGuard** 七种节点协议。 - 支持直接粘贴 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan (trojan://)、WireGuard (wg://) 分享链接,以及标准 WireGuard `[Interface]/[Peer]` 配置块;WireGuard 支持预共享密钥(PSK)。 -- **WireGuard 节点真实握手健康检查**:不用 ICMP 猜,临时起 sing-box endpoint 验证隧道握手并显示验证通过的出口 IP(60 秒缓存)。 +- **WireGuard 节点真实握手健康检查**:不用 ICMP 猜,临时起 sing-box endpoint 验证隧道握手并显示验证通过的出口 IP(60 秒缓存);握手失败区分原因(配置缺失/超时/不可达),节点表格悬停可见。 +- **节点即时测试**:节点表格每行有「nodeTest」按钮——WireGuard 节点立即重做一次握手测试(绕过缓存),其他协议做 TCP 探测,结果横幅显示(含出口 IP 与失败原因)。 - **DHCP 静态租约自动管理**:添加/删除设备策略时自动绑定/清理 MAC-IP 静态租约,兼容 iOS 私有 Wi-Fi 地址(MAC 随机变化);设备策略表实时显示绑定状态(已绑定 / 待绑定 / MAC 已变化 / 设备未在线)。 - **从已连接设备直接添加**:编辑设备策略时可从 DHCP/ARP 检测到的在线设备中选择,自动填写名称与 IP(静态 IP / 纯 AP 路由器场景由 ARP 兜底判断在线)。 - **服务健康监控**:「Wi-Fi Calling 状态」页顶部显示 sing-box/monitor 进程、配置有效性、**配置过期告警**(改了配置没重启服务)、nftables 规则数、节点健康汇总。 diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 15e5eb47da6d..948e82b2409d 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -6,8 +6,16 @@ 'require uci'; 'require dom'; 'require ui'; +'require rpc'; 'require wificalling-gateway.node-import as nodeImport'; +var nodeTestRpc = rpc.declare({ + object: 'luci.wificalling-gateway', + method: 'node_test', + params: ['id'], + expect: {} +}); + return view.extend({ load: function() { return uci.load('dhcp').then(function() { @@ -56,6 +64,68 @@ return view.extend({ if (n.state === 'handshake_failed' || n.state === 'unreachable') return _('Offline'); return _('Unknown'); } + // Short reason label and full explanation for failed WG handshakes + // (reason comes from node-health.sh's cache: config_missing / + // timeout / unreachable). + function wgFailReason(reason) { + if (reason === 'config_missing') return _('Missing config'); + if (reason === 'timeout') return _('Timeout'); + if (reason === 'unreachable') return _('Unreachable'); + return reason || ''; + } + function wgFailDetail(reason) { + if (reason === 'config_missing') return _('Missing key/address'); + if (reason === 'timeout') return _('Handshake timed out (key/psk mismatch?)'); + if (reason === 'unreachable') return _('Server unreachable'); + return ''; + } + // Banner-style notification with an optional tooltip detail. + function testNotify(message, kind, detail) { + var mc = document.querySelector('#maincontent') || document.body; + var msg = E('div', { + 'class': 'alert-message fade-in ' + (kind || 'info'), + style: 'display:flex;align-items:center;padding:8px 12px', + title: detail || null + }, [ + E('div', { style: 'flex:1' }, E('p', { style: 'margin:0' }, message)), + E('button', { + 'class': 'btn', + click: function() { mc.removeChild(msg); } + }, '×') + ]); + mc.insertBefore(msg, mc.firstChild); + } + // Manual connection test for one node: fresh WG handshake (bypasses + // the monitor's 60 s cache) or a TCP reachability probe. + function runNodeTest(id, btn) { + if (btn.disabled) return; + btn.disabled = true; + var original = btn.textContent; + btn.textContent = _('Testing…'); + nodeTestRpc(id).then(function(r) { + btn.disabled = false; + btn.textContent = original; + if (r && r.state === 'handshake_ok') { + testNotify(_('Handshake OK') + ' — ' + r.exit_ip, 'info'); + } + else if (r && r.state === 'handshake_failed') { + testNotify(_('Handshake failed') + ' (' + wgFailReason(r.reason) + ')', 'error', wgFailDetail(r.reason)); + } + else if (r && r.state === 'tcp_reachable') { + testNotify(_('Alive') + (r.ping_ms ? ' — ' + r.ping_ms + ' ms' : ''), 'info'); + } + else if (r && r.state === 'unreachable') { + testNotify(_('Offline'), 'error'); + } + else { + testNotify(_('Unable to test node: ') + wgFailReason(r && r.reason), 'error'); + } + }).catch(function(e) { + btn.disabled = false; + btn.textContent = original; + testNotify(_('Unable to test node: ') + String(e), 'error'); + }); + } function latency(n) { if (!n) return '-'; // WireGuard handshake rows carry the verified exit IP instead @@ -222,21 +292,33 @@ return view.extend({ var nodeLabel = s.option(form.Value, 'label', _('Node display name')); nodeLabel.rmempty = false; nodeLabel.placeholder = _('Example: UK AnyTLS'); nodeLabel.description = _('This name is shown in the device node selector.'); + // The GridSection already renders a Name column from the section + // title; showing the label field again would duplicate it. + nodeLabel.modalonly = true; var p = s.option(form.ListValue, 'protocol', _('Protocol')); ['anytls','hysteria2','tuic','vless','vmess','trojan','wireguard'].forEach(function(x) { p.value(x); }); s.option(form.Value, 'server', _('Server')).datatype = 'host'; s.option(form.Value, 'port', _('Port')).datatype = 'port'; var nodeStatus = s.option(form.DummyValue, '_node_status', _('Node status')); - nodeStatus.textvalue = function(id) { return E('span', { id: 'wfc-node-state-' + id }, nodeState(nodeById(id))); }; + nodeStatus.textvalue = function(id) { + var n = nodeById(id); + var detail = (n && n.state === 'handshake_failed') ? wgFailDetail(n.reason) : ''; + return E('span', { id: 'wfc-node-state-' + id, title: detail || null }, nodeState(n)); + }; var nodePing = s.option(form.DummyValue, '_node_ping', _('Ping / latency')); nodePing.textvalue = function(id) { return E('span', { id: 'wfc-node-ping-' + id }, latency(nodeById(id))); }; var nodeQuality = s.option(form.DummyValue, '_node_quality', _('Quality')); nodeQuality.textvalue = function(id) { return E('span', { id: 'wfc-node-quality-' + id }, quality(nodeById(id))); }; + // Every remaining field stays editable in the per-node modal but + // is hidden from the table so rows stay compact (Edit shows them). var secret = s.option(form.Value, 'password', _('Password')); secret.password = true; secret.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; + secret.modalonly = true; var uuidField = s.option(form.Value, 'uuid', _('UUID')); uuidField.password = true; uuidField.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; - s.option(form.Value, 'sni', _('TLS server name')); + uuidField.modalonly = true; + var sniOpt = s.option(form.Value, 'sni', _('TLS server name')); + sniOpt.modalonly = true; var securityOpt = s.option(form.ListValue, 'security', _('Security')); securityOpt.value('', _('None')); securityOpt.value('tls'); securityOpt.value('reality'); securityOpt.depends('protocol', 'vless'); @@ -248,26 +330,57 @@ return view.extend({ return _('Reality security is not available for VMess nodes'); return true; }; - s.option(form.Flag, 'insecure', _('Allow insecure certificate')); - s.option(form.Value, 'alpn', _('ALPN')); - s.option(form.Value, 'pin_sha256', _('TLS public-key SHA-256 (base64)')); - s.option(form.Value, 'flow', _('VLESS flow')); - s.option(form.Value, 'public_key', _('Reality public key')); - s.option(form.Value, 'short_id', _('Reality short ID')); - s.option(form.Value, 'fingerprint', _('Reality fingerprint')); + securityOpt.modalonly = true; + var insecureOpt = s.option(form.Flag, 'insecure', _('Allow insecure certificate')); + insecureOpt.modalonly = true; + var alpnOpt = s.option(form.Value, 'alpn', _('ALPN')); + alpnOpt.modalonly = true; + var pinOpt = s.option(form.Value, 'pin_sha256', _('TLS public-key SHA-256 (base64)')); + pinOpt.modalonly = true; + var flowOpt = s.option(form.Value, 'flow', _('VLESS flow')); + flowOpt.modalonly = true; + var pubKeyOpt = s.option(form.Value, 'public_key', _('Reality public key')); + pubKeyOpt.modalonly = true; + var shortIdOpt = s.option(form.Value, 'short_id', _('Reality short ID')); + shortIdOpt.modalonly = true; + var fpOpt = s.option(form.Value, 'fingerprint', _('Reality fingerprint')); + fpOpt.modalonly = true; var udpMode = s.option(form.ListValue, 'udp_mode', _('TUIC UDP mode')); udpMode.value('native', _('Native')); udpMode.value('quic', _('QUIC')); + udpMode.modalonly = true; var transport = s.option(form.ListValue, 'transport', _('Transport')); transport.value('', _('None')); transport.value('ws', _('WebSocket')); - s.option(form.Value, 'path', _('WebSocket path')); - s.option(form.Value, 'host', _('WebSocket Host')); + transport.modalonly = true; + var pathOpt = s.option(form.Value, 'path', _('WebSocket path')); + pathOpt.modalonly = true; + var hostOpt = s.option(form.Value, 'host', _('WebSocket Host')); + hostOpt.modalonly = true; var wgKey = s.option(form.Value, 'private_key', _('WireGuard private key')); wgKey.password = true; wgKey.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; - s.option(form.Value, 'local_address', _('WireGuard local address')); - s.option(form.Value, 'reserved', _('WireGuard reserved (comma-separated)')); - s.option(form.Value, 'mtu', _('WireGuard MTU')); + wgKey.modalonly = true; + var localAddrOpt = s.option(form.Value, 'local_address', _('WireGuard local address')); + localAddrOpt.modalonly = true; + var reservedOpt = s.option(form.Value, 'reserved', _('WireGuard reserved (comma-separated)')); + reservedOpt.modalonly = true; + var mtuOpt = s.option(form.Value, 'mtu', _('WireGuard MTU')); + mtuOpt.modalonly = true; var wgPsk = s.option(form.Value, 'pre_shared_key', _('WireGuard preshared key')); wgPsk.password = true; wgPsk.depends('protocol', 'wireguard'); + wgPsk.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; + // The per-row connection test goes before the Edit/Delete buttons. + var nodeRowActions = s.renderRowActions; + s.renderRowActions = function(section_id, more_label, trEl) { + var tdEl = nodeRowActions.call(this, section_id, more_label, trEl); + if (!tdEl.lastElementChild) return tdEl; + var testBtn = E('button', { + 'class': 'btn cbi-button cbi-button-action', + id: 'wfc-node-test-' + section_id, + title: _('Run a fresh connection test for this node'), + click: function() { runNodeTest(section_id, this); } + }, 'nodeTest'); + tdEl.lastElementChild.insertBefore(testBtn, tdEl.lastElementChild.firstChild); + return tdEl; + }; s = m.section(form.GridSection, 'device', _('Device policies')); s.addremove = true; s.nodescriptions = true; s.anonymous = true; s.addbtntitle = _('Add LAN device'); @@ -286,13 +399,14 @@ return view.extend({ ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = lanSubnetHint(); var devicePicker = s.option(form.DummyValue, '_device_picker', _('From connected devices')); devicePicker.rmempty = true; + devicePicker.textvalue = function() { return ''; }; devicePicker.renderWidget = function(section_id) { if (!detectedDevices.length) return E('span', {}, _('No connected devices detected')); var select = E('select', { class: 'cbi-input-select', change: function(ev) { var ip = select.value; if (!ip) return; var dev = detectedDevices.find(function(d) { return d.ip === ip; }); - var labelInput = document.getElementById('cbid.wificalling-gateway.' + section_id + '.label'); + var labelInput = document.getElementById('widget.cbid.wificalling-gateway.' + section_id + '.label'); if (labelInput) { labelInput.value = (dev && dev.name) ? dev.name : ''; labelInput.dispatchEvent(new Event('input', { bubbles: true })); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index b22bed0a5401..3de346591e46 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -119,12 +119,18 @@ msgstr "" msgid "Good" msgstr "" +msgid "Handshake OK" +msgstr "" + msgid "Handshake failed" msgstr "" msgid "Handshake success" msgstr "" +msgid "Handshake timed out (key/psk mismatch?)" +msgstr "" + msgid "IP" msgstr "" @@ -167,6 +173,12 @@ msgstr "" msgid "Meaning" msgstr "" +msgid "Missing config" +msgstr "" + +msgid "Missing key/address" +msgstr "" + msgid "NAT-T seen" msgstr "" @@ -284,6 +296,9 @@ msgstr "" msgid "Routing mode" msgstr "" +msgid "Run a fresh connection test for this node" +msgstr "" + msgid "Save the node first, then reload this page to select it for a device." msgstr "" @@ -293,6 +308,9 @@ msgstr "" msgid "Server and port are required" msgstr "" +msgid "Server unreachable" +msgstr "" + msgid "Service status" msgstr "" @@ -317,6 +335,9 @@ msgstr "" msgid "TUIC UDP mode" msgstr "" +msgid "Testing…" +msgstr "" + msgid "The configuration changed but the gateway was not restarted: sing-box still runs the previous config. Restart the service to apply it." msgstr "" @@ -332,6 +353,9 @@ msgstr "" msgid "Time" msgstr "" +msgid "Timeout" +msgstr "" + msgid "Transport" msgstr "" @@ -350,9 +374,15 @@ msgstr "" msgid "Unable to save imported node:" msgstr "" +msgid "Unable to test node: " +msgstr "" + msgid "Unknown" msgstr "" +msgid "Unreachable" +msgstr "" + msgid "Unsupported node link format" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 3a1dc5d06ed2..ae79ef08a8c6 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -120,12 +120,18 @@ msgstr "常规" msgid "Good" msgstr "良好" +msgid "Handshake OK" +msgstr "握手成功" + msgid "Handshake failed" msgstr "握手失败" msgid "Handshake success" msgstr "握手成功" +msgid "Handshake timed out (key/psk mismatch?)" +msgstr "握手超时(密钥/PSK 不匹配?)" + msgid "IP" msgstr "IP" @@ -168,6 +174,12 @@ msgstr "每台设备最大记录数" msgid "Meaning" msgstr "含义" +msgid "Missing config" +msgstr "缺少配置" + +msgid "Missing key/address" +msgstr "缺少密钥/地址" + msgid "NAT-T seen" msgstr "已发现 NAT-T" @@ -285,6 +297,9 @@ msgstr "已注册表示观察到 ASSURED 的双向 UDP 4500 隧道。这是网 msgid "Routing mode" msgstr "路由模式" +msgid "Run a fresh connection test for this node" +msgstr "对该节点执行一次全新连接测试" + msgid "Save the node first, then reload this page to select it for a device." msgstr "请先保存节点,再刷新本页以便为设备选择该节点。" @@ -294,6 +309,9 @@ msgstr "服务器" msgid "Server and port are required" msgstr "服务器和端口为必填项" +msgid "Server unreachable" +msgstr "服务器不可达" + msgid "Service status" msgstr "服务状态" @@ -318,6 +336,9 @@ msgstr "TLS 服务器名称" msgid "TUIC UDP mode" msgstr "TUIC UDP 模式" +msgid "Testing…" +msgstr "测试中…" + msgid "The configuration changed but the gateway was not restarted: sing-box still runs the previous config. Restart the service to apply it." msgstr "配置已修改但网关未重启:sing-box 仍在运行旧配置。请重启服务使配置生效。" @@ -333,6 +354,9 @@ msgstr "此操作仅永久删除 Wi-Fi Calling 活动历史。设置和系统日 msgid "Time" msgstr "时间" +msgid "Timeout" +msgstr "超时" + msgid "Transport" msgstr "传输" @@ -351,9 +375,15 @@ msgstr "无法解析节点链接:" msgid "Unable to save imported node:" msgstr "无法保存导入的节点:" +msgid "Unable to test node: " +msgstr "无法测试节点:" + msgid "Unknown" msgstr "未知" +msgid "Unreachable" +msgstr "不可达" + msgid "Unsupported node link format" msgstr "不支持的节点链接格式" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway b/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway new file mode 100755 index 000000000000..81b7c92a911b --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway @@ -0,0 +1,40 @@ +#!/bin/sh +# luci.wificalling-gateway — rpcd exec plugin: manual node connection test. +# +# Protocol (rpcd exec plugin): `$0 list` prints the method table, `$0 call +# ` receives the JSON params on stdin and prints one JSON object. +# The node id passes as argv to node-test.sh — never through a shell. + +. /usr/share/libubox/jshn.sh + +node_test() { + read -r input + json_load "$input" + json_get_var id id + if [ -z "$id" ] || [ "$id" = "null" ]; then + echo '{"state":"failed","reason":"no_node_id"}' + exit 0 + fi + if [ ! -x /usr/libexec/wificalling-gateway/node-test.sh ]; then + echo '{"state":"failed","reason":"no_test_helper"}' + exit 0 + fi + /usr/libexec/wificalling-gateway/node-test.sh "$id" + exit 0 +} + +case "$1" in + list) + json_init + json_add_object node_test + json_close_object + json_dump + ;; + call) + [ "$2" = node_test ] && node_test + echo '{"state":"failed","reason":"unknown_method"}' + ;; + *) + echo '{"state":"failed","reason":"bad_call"}' + ;; +esac diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 47f2b9c2d80a..78a87878c25d 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -18,6 +18,14 @@ json_escape() { # gateway must not route Wi-Fi Calling over. The result is cached for 60 s # (the monitor loop runs every 5 s and a handshake test takes seconds). # Prints the verified exit IP on success. +# The reserved field is forwarded too: WARP-style endpoints need it and +# would otherwise fail every handshake. Cache line 3 carries the failure +# reason (config_missing / timeout / unreachable) so the status export can +# tell a bad node apart from a dead server. +# A mkdir lock serializes the actual tests: the monitor loop can tick a +# fresh instance before this one finished (a handshake takes up to ~8 s, +# the loop ticks every 5 s), and two instances racing on the same probe +# port would hand each other the wrong exit IP. wg_handshake_test() { id=$1; server=$2; port=$3 cache="/tmp/wg-health-$id" @@ -30,18 +38,45 @@ wg_handshake_test() { return 0 fi fi - priv=$(uci -q get "wificalling-gateway.$id.private_key") || return 1 - pub=$(uci -q get "wificalling-gateway.$id.public_key") || return 1 - local_addr=$(uci -q get "wificalling-gateway.$id.local_address") || return 1 + lock=/tmp/wg-health.lock + if ! mkdir "$lock" 2>/dev/null; then + # A tick killed mid-test (SIGHUP/reboot) can leave the lock + # behind; its holder PID is gone, so take it over. + lock_pid=$(cat "$lock/pid" 2>/dev/null || echo 0) + if ! kill -0 "$lock_pid" 2>/dev/null; then + rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + mkdir "$lock" 2>/dev/null || true + fi + fi + if ! [ -d "$lock" ]; then + # Another monitor tick is testing right now; use the cache + # as-is (even stale) instead of racing on the probe port. + if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then + sed -n '3p' "$cache" + return 0 + fi + return 1 + fi + echo $$ > "$lock/pid" + priv=$(uci -q get "wificalling-gateway.$id.private_key") || true + pub=$(uci -q get "wificalling-gateway.$id.public_key") || true + local_addr=$(uci -q get "wificalling-gateway.$id.local_address") || true + if [ -z "$priv" ] || [ -z "$pub" ] || [ -z "$local_addr" ]; then + printf '%s\nfailed\nconfig_missing\n' "$(date +%s)" > "$cache" + rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + return 1 + fi psk=$(uci -q get "wificalling-gateway.$id.pre_shared_key") || true mtu=$(uci -q get "wificalling-gateway.$id.mtu") || true - lport=$((19000 + (${id#cfg} % 1000))) 2>/dev/null || lport=19099 + reserved=$(uci -q get "wificalling-gateway.$id.reserved") || true + lport=$((19000 + (0x$(printf '%s' "$id" | md5sum | cut -c1-4) % 1000))) 2>/dev/null || lport=19099 cfg="/tmp/wg-health-$id.json" { printf '{"log":{"level":"warn"},"inbounds":[{"type":"http","tag":"probe","listen":"127.0.0.1","listen_port":%s}],' "$lport" printf '"endpoints":[{"type":"wireguard","tag":"wg","address":[%s],"private_key":%s,"peers":[{"address":%s,"port":%s,"public_key":%s,"allowed_ips":["0.0.0.0/0"]' \ "\"$local_addr\"" "\"$priv\"" "\"$server\"" "$port" "\"$pub\"" [ -n "$psk" ] && printf ',"pre_shared_key":"%s"' "$psk" + [ -n "$reserved" ] && printf ',"reserved":[%s]' "$(printf '%s' "$reserved" | tr -d ' ')" printf '}],"mtu":%s}],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"wg"}}' "${mtu:-1420}" } > "$cfg" "$sing_box" run -c "$cfg" > /tmp/wg-health-$id.log 2>&1 & @@ -51,13 +86,21 @@ wg_handshake_test() { ip=$(http_proxy="http://127.0.0.1:$lport" wget -qO- -T 6 'http://ip-api.com/json/?fields=query' 2>/dev/null | sed -n 's/.*"query":"\([0-9.]*\)".*/\1/p' || true) kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true - rm -f "$cfg" /tmp/wg-health-$id.log if [ -n "$ip" ]; then + rm -f "$cfg" /tmp/wg-health-$id.log printf '%s\nok\n%s\n' "$(date +%s)" "$ip" > "$cache" + rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true printf '%s' "$ip" return 0 fi - printf '%s\nfailed\n' "$(date +%s)" > "$cache" + if grep -q 'handshake did not complete' /tmp/wg-health-$id.log 2>/dev/null; then + reason=timeout + else + reason=unreachable + fi + rm -f "$cfg" /tmp/wg-health-$id.log + printf '%s\nfailed\n%s\n' "$(date +%s)" "$reason" > "$cache" + rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true return 1 } @@ -66,7 +109,7 @@ wg_handshake_test() { first=1 while IFS='|' read -r id label protocol server port; do [ -n "$id" ] || continue - state=no_icmp_reply; ping_json=null; measurement=icmp + state=no_icmp_reply; ping_json=null; measurement=icmp; reason_json=null # WireGuard nodes are validated by a real handshake, not ICMP. if [ "$protocol" = wireguard ]; then measurement=wg_handshake @@ -74,6 +117,7 @@ wg_handshake_test() { state=handshake_ok; ping_json="\"$exit_ip\"" else state=handshake_failed; ping_json=null + reason_json="\"$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null || echo unreachable)\"" fi else ping_output=$(ping -c 1 -W 1 "$server" 2>/dev/null || true) @@ -95,8 +139,8 @@ wg_handshake_test() { fi [ "$first" -eq 1 ] || printf ',' first=0 - printf '{"id":"%s","state":"%s","measurement":"%s","ping_ms":%s}' \ - "$(json_escape "$id")" "$state" "$measurement" "$ping_json" + printf '{"id":"%s","state":"%s","measurement":"%s","ping_ms":%s,"reason":%s}' \ + "$(json_escape "$id")" "$state" "$measurement" "$ping_json" "${reason_json:-null}" done < "$nodes" printf ']}\n' } > "$tmp" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh new file mode 100755 index 000000000000..e0455f9888d6 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# node-test.sh — manual connection test for one proxy node (the LuCI +# "nodeTest" row button). +# +# WireGuard nodes run the same handshake probe the monitor loop uses (the +# function is extracted from node-health.sh, so there is exactly one +# implementation), bypassing the 60 s result cache so the user gets a fresh +# answer on demand. Every other protocol gets a TCP reachability probe of +# the node's server:port (tcping when installed, busybox nc otherwise). +# Prints one JSON object; always exits 0 so rpcd forwards the reply +# untouched. + +set -eu + +id=${1:?node id required} + +server=$(uci -q get "wificalling-gateway.$id.server") || true +port=$(uci -q get "wificalling-gateway.$id.port") || true +if [ -z "$server" ] || [ -z "$port" ]; then + printf '{"state":"failed","reason":"config_missing"}\n' + exit 0 +fi + +proto=$(uci -q get "wificalling-gateway.$id.protocol") || true +if [ "$proto" = wireguard ]; then + health=${WFC_HEALTH:-/usr/libexec/wificalling-gateway/node-health.sh} + [ -f "$health" ] || { + printf '{"state":"failed","reason":"no_health_script"}\n' + exit 0 + } + + # Extract the handshake function from the monitor script so the + # manual test and the monitor loop share one implementation. + func=$(mktemp /tmp/wg-test-func.XXXXXX) + trap 'rm -f "$func"' EXIT HUP INT TERM + awk '/^wg_handshake_test\(\)/,/^}/' "$health" > "$func" + sing_box=${WFC_SING_BOX:-/usr/bin/sing-box} + . "$func" + + # The monitor loop may be mid-test right now; wait for its lock so + # this run is authoritative (a handshake takes up to ~8 s, give it 20 s). + n=0 + while [ -d /tmp/wg-health.lock ]; do + n=$((n + 1)) + [ "$n" -ge 40 ] && { + printf '{"state":"failed","reason":"busy"}\n' + exit 0 + } + sleep 1 + done + + # Bypass the 60 s cache: the cached result is exactly what the user + # is asking to re-check. + rm -f "/tmp/wg-health-$id" + + if exit_ip=$(wg_handshake_test "$id" "$server" "$port"); then + printf '{"state":"handshake_ok","exit_ip":"%s"}\n' "$exit_ip" + else + reason=$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null || echo unreachable) + printf '{"state":"handshake_failed","reason":"%s"}\n' "$reason" + fi + exit 0 +fi + +# Non-WireGuard protocols: TCP reachability of the node server. +if command -v tcping >/dev/null 2>&1; then + ms=$(tcping -c 1 -t 2 -p "$port" "$server" 2>/dev/null | + sed -n 's/.*time=\([0-9][0-9.]*\)[[:space:]]*ms.*/\1/p' | head -n 1) + if [ -n "$ms" ]; then + printf '{"state":"tcp_reachable","ping_ms":"%s"}\n' "$ms" + else + printf '{"state":"unreachable","reason":"tcp_failed"}\n' + fi +elif command -v nc >/dev/null 2>&1; then + if nc -w 3 "$server" "$port" >/dev/null 2>&1; then + printf '{"state":"tcp_reachable","ping_ms":null}\n' + else + printf '{"state":"unreachable","reason":"tcp_failed"}\n' + fi +else + printf '{"state":"failed","reason":"no_tcp_probe"}\n' +fi +exit 0 diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json index 51faa8355e9b..990ca4791916 100644 --- a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -18,7 +18,10 @@ }, "write": { "uci": [ "wificalling-gateway" ], - "ubus": { "file": [ "write" ] }, + "ubus": { + "file": [ "write" ], + "luci.wificalling-gateway": [ "node_test" ] + }, "file": { "/var/run/wificalling-gateway/events.log": [ "write" ], "/tmp/run/wificalling-gateway/events.log": [ "write" ] From 092e9b3a7d14723d341abe8720a9d6c52ed262b1 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 17 Aug 2026 01:06:21 +0800 Subject: [PATCH 23/56] luci-app-wificalling-gateway: fix review round 8 findings - compiler: drop duplicate top-level pre_shared_key in the endpoint form (sing-box rejects unknown fields; per-peer field stays) - node-health: probe config (WG private key/PSK) mode 0600 + signal cleanup; wg_handshake_test variables local; echo service via HTTPS, configurable through main.probe_url - node-status back under $RUNDIR behind the rpcd ACL (docroot export reverted); overview reads via fs.read - ACL: add /proc/net/arp (ARP fallback and device picker were dead) - service-health: singbox_running matches the gateway instance only - device picker: getUIElement instead of DOM ids; source_ip via L.toArray - status.js comments in English Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../view/wificalling-gateway/overview.js | 46 +++++++------------ .../view/wificalling-gateway/status.js | 4 +- .../root/etc/config/wificalling-gateway | 3 ++ .../root/etc/init.d/wificalling-gateway | 2 +- .../libexec/wificalling-gateway/compiler.sh | 1 - .../wificalling-gateway/node-health.sh | 20 ++++++-- .../wificalling-gateway/service-health.sh | 7 ++- .../acl.d/luci-app-wificalling-gateway.json | 1 + 9 files changed, 45 insertions(+), 41 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index cd3d275c6c6d..30ae62596528 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.1 +PKG_VERSION:=1.8.2 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 948e82b2409d..1621c648c08f 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -25,11 +25,7 @@ return view.extend({ // lease file (e.g. to persist across reboots). var leasefile = uci.get('dhcp', '@dnsmasq[0]', 'leasefile') || '/tmp/dhcp.leases'; return Promise.all([ - // The node status file is exported under the uhttpd docroot - // and read with a plain GET: the /ubus JSON-RPC channel - // truncates larger replies on some firmwares, leaving the - // status blank. - L.resolveDefault(fetch('/wificalling-node-status.json').then(function(r) { return r.text(); }), '{}'), + L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), uci.load('wificalling-gateway'), L.resolveDefault(fs.read(leasefile), ''), L.resolveDefault(fs.read('/proc/net/arp'), '') @@ -196,7 +192,7 @@ return view.extend({ var routerHost = (location.hostname || '').toLowerCase(); var boundIps = {}; uci.sections('wificalling-gateway', 'device').forEach(function(d) { - (d.source_ip || []).forEach(function(ip) { boundIps[ip] = true; }); + L.toArray(d.source_ip).forEach(function(ip) { boundIps[ip] = true; }); }); var detectedDevices = Object.keys(detected) .filter(function(ip) { @@ -403,28 +399,21 @@ return view.extend({ devicePicker.renderWidget = function(section_id) { if (!detectedDevices.length) return E('span', {}, _('No connected devices detected')); + var self = this; var select = E('select', { class: 'cbi-input-select', change: function(ev) { var ip = select.value; if (!ip) return; var dev = detectedDevices.find(function(d) { return d.ip === ip; }); - var labelInput = document.getElementById('widget.cbid.wificalling-gateway.' + section_id + '.label'); - if (labelInput) { - labelInput.value = (dev && dev.name) ? dev.name : ''; - labelInput.dispatchEvent(new Event('input', { bubbles: true })); - } - var dynlist = document.getElementById('cbid.wificalling-gateway.' + section_id + '.source_ip'); - if (dynlist) { - var existing = Array.prototype.map.call( - dynlist.querySelectorAll('.item input[type=hidden]'), - function(input) { return input.value; }); - if (existing.indexOf(ip) < 0) { - var ipInput = document.getElementById('widget.cbid.wificalling-gateway.' + section_id + '.source_ip'); - if (ipInput) { - ipInput.value = ip; - ipInput.dispatchEvent(new Event('input', { bubbles: true })); - var addBtn = dynlist.querySelector('.add-item .cbi-button-add'); - if (addBtn) addBtn.click(); - } - } + // Address the modal widgets through the form model + // (getUIElement), not DOM ids: inside a GridSection the + // row and the modal instantiate the same option twice. + var labelEl = self.section.getOption('label').getUIElement(section_id); + if (labelEl) + labelEl.setValue((dev && dev.name) ? dev.name : ''); + var ipEl = self.section.getOption('source_ip').getUIElement(section_id); + if (ipEl) { + var vals = L.toArray(ipEl.getValue()); + if (vals.indexOf(ip) < 0) + ipEl.setValue(vals.concat([ip])); } } }, detectedDevices.map(function(d) { return E('option', { value: d.ip }, (d.name || d.ip) + ' (' + d.ip + ')'); @@ -442,9 +431,8 @@ return view.extend({ function bindingState(id) { if ((uci.get('wificalling-gateway', id, 'route_mode') || 'independent') !== 'independent') return _('Following gateway'); - var ipList = uci.get('wificalling-gateway', id, 'source_ip') || []; - if (!Array.isArray(ipList)) ipList = [ipList]; - return ipList.map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); + return L.toArray(uci.get('wificalling-gateway', id, 'source_ip')) + .map(function(ip) { return ip + ': ' + dhcpState(ip); }).join('
'); } dhcpBinding.rawhtml = true; dhcpBinding.textvalue = function(id) { return bindingState(id); }; @@ -453,7 +441,7 @@ return view.extend({ }; poll.add(function() { - return L.resolveDefault(fetch('/wificalling-node-status.json').then(function(r) { return r.text(); }), '{}').then(function(raw) { + return L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}').then(function(raw) { var current; try { current = JSON.parse(raw); } catch (e) { current = { nodes: [] }; } (current.nodes || []).forEach(function(n) { [['state', nodeState(n)], ['ping', latency(n)], ['quality', quality(n)]].forEach(function(v) { diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js index 163fe07c0982..842d4c74e5b7 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js @@ -28,7 +28,7 @@ return view.extend({ } } - /* ---------- 服务状态 ---------- */ + /* ---------- Service status ---------- */ var healthRaw = data[1]; function parseHealth(value) { try { return JSON.parse(value); } catch (e) { return {}; } } function healthText(h) { @@ -71,7 +71,7 @@ return view.extend({ return L.resolveDefault(fs.read('/var/run/wificalling-gateway/service-health.json'), '{}').then(renderHealth); }, 30); - /* ---------- 设备隧道状态 ---------- */ + /* ---------- Device tunnel status ---------- */ function rows(source) { return (source.devices || []).map(function(d) { var values = [d.label, d.ip, wfcLabel(d.wificalling || d.state), d.node || '-', d.epdg_ip || '-', diff --git a/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway index 7397e39f42e0..38f46df04a8a 100644 --- a/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway @@ -4,6 +4,9 @@ config global 'main' option event_interval '60' option max_events_per_device '20' option log_enabled '1' + # Echo service used by the WireGuard handshake probe (HTTPS; must + # answer with a JSON "query" field, e.g. ip-api.com). + option probe_url 'https://ip-api.com/json/?fields=query' # Add nodes and device policies in LuCI. Secrets are intentionally not included. # Reserve each client address with static DHCP before enabling a policy. diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index cfb035974c14..5507a5d678b2 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -102,7 +102,7 @@ start_service() { procd_set_param limits nofile="65535 65535" procd_close_instance procd_open_instance monitor - procd_set_param command /usr/libexec/$APP/monitor-loop.sh "$RUNDIR/clients" "$RUNDIR/status.json" "$RUNDIR/nodes" "/www/wificalling-node-status.json" "$RUNDIR/events.log" "$RUNDIR/monitor.state" "$event_interval" "$max_events_per_device" "$log_enabled" + procd_set_param command /usr/libexec/$APP/monitor-loop.sh "$RUNDIR/clients" "$RUNDIR/status.json" "$RUNDIR/nodes" "$RUNDIR/node-status.json" "$RUNDIR/events.log" "$RUNDIR/monitor.state" "$event_interval" "$max_events_per_device" "$log_enabled" procd_set_param respawn procd_close_instance procd_open_instance health diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index aee14295c8be..2ca6c883ba50 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -76,7 +76,6 @@ END { if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) s=s "}]" if (f[24]!="") s=s ",\"mtu\":" f[24] - if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) s=s "}"; print " " s (w/dev/null || echo 0) @@ -71,21 +71,31 @@ wg_handshake_test() { reserved=$(uci -q get "wificalling-gateway.$id.reserved") || true lport=$((19000 + (0x$(printf '%s' "$id" | md5sum | cut -c1-4) % 1000))) 2>/dev/null || lport=19099 cfg="/tmp/wg-health-$id.json" - { + # The probe config carries the WG private key and PSK: create it mode + # 0600 and clean it (and the log) up on any exit, including signals. + ( umask 077; { printf '{"log":{"level":"warn"},"inbounds":[{"type":"http","tag":"probe","listen":"127.0.0.1","listen_port":%s}],' "$lport" printf '"endpoints":[{"type":"wireguard","tag":"wg","address":[%s],"private_key":%s,"peers":[{"address":%s,"port":%s,"public_key":%s,"allowed_ips":["0.0.0.0/0"]' \ "\"$local_addr\"" "\"$priv\"" "\"$server\"" "$port" "\"$pub\"" [ -n "$psk" ] && printf ',"pre_shared_key":"%s"' "$psk" [ -n "$reserved" ] && printf ',"reserved":[%s]' "$(printf '%s' "$reserved" | tr -d ' ')" printf '}],"mtu":%s}],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"wg"}}' "${mtu:-1420}" - } > "$cfg" + } > "$cfg"; ) + trap 'rm -f "$cfg" /tmp/wg-health-$id.log' EXIT HUP INT TERM "$sing_box" run -c "$cfg" > /tmp/wg-health-$id.log 2>&1 & pid=$! sleep 2 - # busybox wget honours http_proxy; the probe listens on 127.0.0.1. - ip=$(http_proxy="http://127.0.0.1:$lport" wget -qO- -T 6 'http://ip-api.com/json/?fields=query' 2>/dev/null | sed -n 's/.*"query":"\([0-9.]*\)".*/\1/p' || true) + # Verify the tunnel with an echo service through the probe. The URL is + # UCI-configurable (main.probe_url) and HTTPS by default: plain HTTP + # would leak the exit IP and is a false-negative source when the host + # is unreachable. busybox wget honours http_proxy and speaks HTTPS on + # 22.03+ builds. + probe_url=$(uci -q get wificalling-gateway.main.probe_url) || true + [ -n "$probe_url" ] || probe_url='https://ip-api.com/json/?fields=query' + ip=$(http_proxy="http://127.0.0.1:$lport" wget -qO- -T 6 "$probe_url" 2>/dev/null | sed -n 's/.*"query":"\([0-9.]*\)".*/\1/p' || true) kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true + trap - EXIT HUP INT TERM if [ -n "$ip" ]; then rm -f "$cfg" /tmp/wg-health-$id.log printf '%s\nok\n%s\n' "$(date +%s)" "$ip" > "$cache" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh index f97ec9e4e481..c92212bf0a8a 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh @@ -11,7 +11,7 @@ set -eu output=${1:-/var/run/wificalling-gateway/service-health.json} -node_status=${2:-/www/wificalling-node-status.json} +node_status=${2:-/var/run/wificalling-gateway/node-status.json} rundir=${WFC_RUNDIR:-/var/run/wificalling-gateway} uci_config=${WFC_UCI_CONFIG:-/etc/config/wificalling-gateway} tmp="${output}.tmp.$$" @@ -35,7 +35,10 @@ file_age() { monitor_pid=$(pgrep -f 'monitor-loop.sh' 2>/dev/null | head -n 1 || true) monitor_running=0; [ -n "$monitor_pid" ] && monitor_running=1 -sb_pid=$(pgrep -f '/usr/bin/sing-box run' 2>/dev/null | head -n 1 || true) +# Match the gateway's own instance only: the temporary handshake-probe +# sing-box (node-health.sh) would otherwise light this up while the real +# one is dead, suppressing the alert this section exists for. +sb_pid=$(pgrep -f "sing-box run -c $rundir/sing-box.json" 2>/dev/null | head -n 1 || true) sb_running=0; [ -n "$sb_pid" ] && sb_running=1 sb_config=0; sb_config_valid=0; sb_config_age=-1; sb_config_stale=0 diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json index 990ca4791916..fb4944954ea4 100644 --- a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -8,6 +8,7 @@ "/var/run/wificalling-gateway/status.json": [ "read" ], "/var/run/wificalling-gateway/node-status.json": [ "read" ], "/var/run/wificalling-gateway/service-health.json": [ "read" ], + "/proc/net/arp": [ "read" ], "/var/run/wificalling-gateway/events.log": [ "read" ], "/tmp/run/wificalling-gateway/status.json": [ "read" ], "/tmp/run/wificalling-gateway/node-status.json": [ "read" ], From 1d3ba0e6764993665ba6335341a1e175fdec650f Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 17 Aug 2026 14:01:18 +0800 Subject: [PATCH 24/56] luci-app-wificalling-gateway: fix review round 9 findings - lock: contended mkdir falls back to the cache instead of racing on the probe port; stale-lock takeover no longer mistakes PID 0 - probe runs in a subshell with its own EXIT trap (caller trap untouched); verdict decided inside before cleanup - echo service via curl -x (hard dependency, CONNECT through the http inbound); wget fallback; +curl in DEPENDS - pre_shared_key and _device_picker modalonly - nits: _('Test') button label, ui.addNotification, rpcd id param, 40 s comment Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 4 +- .../luci-app-wificalling-gateway/README.md | 2 +- .../view/wificalling-gateway/overview.js | 27 ++---- .../po/templates/wificalling-gateway.pot | 3 + .../po/zh_Hans/wificalling-gateway.po | 3 + .../usr/libexec/rpcd/luci.wificalling-gateway | 1 + .../wificalling-gateway/node-health.sh | 91 +++++++++++-------- .../libexec/wificalling-gateway/node-test.sh | 2 +- 8 files changed, 74 insertions(+), 59 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 30ae62596528..bb9e403af81e 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.2 +PKG_VERSION:=1.8.3 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE @@ -14,7 +14,7 @@ LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway # fail on 18.06-style feeds ("cannot find dependency firewall4"). Depend # on the actual runtime needs instead; init.d preflights nft/sing-box with # a readable message on firmwares that cannot run the gateway. -LUCI_DEPENDS:=+luci-base +sing-box +nftables +kmod-nft-tproxy +kmod-nft-socket +ip-full +LUCI_DEPENDS:=+luci-base +sing-box +curl +nftables +kmod-nft-tproxy +kmod-nft-socket +ip-full LUCI_PKGARCH:=all include $(TOPDIR)/feeds/luci/luci.mk diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 592fdbb62448..7fdc9636ddc2 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -31,7 +31,7 @@ - 支持 **AnyTLS、Hysteria2、TUIC、VLESS Reality、VMess WebSocket、Trojan 与 WireGuard** 七种节点协议。 - 支持直接粘贴 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan (trojan://)、WireGuard (wg://) 分享链接,以及标准 WireGuard `[Interface]/[Peer]` 配置块;WireGuard 支持预共享密钥(PSK)。 - **WireGuard 节点真实握手健康检查**:不用 ICMP 猜,临时起 sing-box endpoint 验证隧道握手并显示验证通过的出口 IP(60 秒缓存);握手失败区分原因(配置缺失/超时/不可达),节点表格悬停可见。 -- **节点即时测试**:节点表格每行有「nodeTest」按钮——WireGuard 节点立即重做一次握手测试(绕过缓存),其他协议做 TCP 探测,结果横幅显示(含出口 IP 与失败原因)。 +- **节点即时测试**:节点表格每行有「测试」按钮——WireGuard 节点立即重做一次握手测试(绕过缓存),其他协议做 TCP 探测,结果横幅显示(含出口 IP 与失败原因)。 - **DHCP 静态租约自动管理**:添加/删除设备策略时自动绑定/清理 MAC-IP 静态租约,兼容 iOS 私有 Wi-Fi 地址(MAC 随机变化);设备策略表实时显示绑定状态(已绑定 / 待绑定 / MAC 已变化 / 设备未在线)。 - **从已连接设备直接添加**:编辑设备策略时可从 DHCP/ARP 检测到的在线设备中选择,自动填写名称与 IP(静态 IP / 纯 AP 路由器场景由 ARP 兜底判断在线)。 - **服务健康监控**:「Wi-Fi Calling 状态」页顶部显示 sing-box/monitor 进程、配置有效性、**配置过期告警**(改了配置没重启服务)、nftables 规则数、节点健康汇总。 diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 1621c648c08f..e117ea088e0e 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -75,24 +75,14 @@ return view.extend({ if (reason === 'unreachable') return _('Server unreachable'); return ''; } - // Banner-style notification with an optional tooltip detail. - function testNotify(message, kind, detail) { - var mc = document.querySelector('#maincontent') || document.body; - var msg = E('div', { - 'class': 'alert-message fade-in ' + (kind || 'info'), - style: 'display:flex;align-items:center;padding:8px 12px', - title: detail || null - }, [ - E('div', { style: 'flex:1' }, E('p', { style: 'margin:0' }, message)), - E('button', { - 'class': 'btn', - click: function() { mc.removeChild(msg); } - }, '×') - ]); - mc.insertBefore(msg, mc.firstChild); - } // Manual connection test for one node: fresh WG handshake (bypasses // the monitor's 60 s cache) or a TCP reachability probe. + function testNotify(message, kind, detail) { + var p = E('p', {}, message); + if (detail) + p.appendChild(E('em', {}, detail)); + ui.addNotification(null, p, kind); + } function runNodeTest(id, btn) { if (btn.disabled) return; btn.disabled = true; @@ -363,6 +353,7 @@ return view.extend({ var wgPsk = s.option(form.Value, 'pre_shared_key', _('WireGuard preshared key')); wgPsk.password = true; wgPsk.depends('protocol', 'wireguard'); wgPsk.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; + wgPsk.modalonly = true; // The per-row connection test goes before the Edit/Delete buttons. var nodeRowActions = s.renderRowActions; s.renderRowActions = function(section_id, more_label, trEl) { @@ -373,7 +364,7 @@ return view.extend({ id: 'wfc-node-test-' + section_id, title: _('Run a fresh connection test for this node'), click: function() { runNodeTest(section_id, this); } - }, 'nodeTest'); + }, _('Test')); tdEl.lastElementChild.insertBefore(testBtn, tdEl.lastElementChild.firstChild); return tdEl; }; @@ -395,7 +386,7 @@ return view.extend({ ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = lanSubnetHint(); var devicePicker = s.option(form.DummyValue, '_device_picker', _('From connected devices')); devicePicker.rmempty = true; - devicePicker.textvalue = function() { return ''; }; + devicePicker.modalonly = true; devicePicker.renderWidget = function(section_id) { if (!detectedDevices.length) return E('span', {}, _('No connected devices detected')); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 3de346591e46..07cacf7681bb 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -335,6 +335,9 @@ msgstr "" msgid "TUIC UDP mode" msgstr "" +msgid "Test" +msgstr "" + msgid "Testing…" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index ae79ef08a8c6..a3da4d470e75 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -336,6 +336,9 @@ msgstr "TLS 服务器名称" msgid "TUIC UDP mode" msgstr "TUIC UDP 模式" +msgid "Test" +msgstr "测试" + msgid "Testing…" msgstr "测试中…" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway b/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway index 81b7c92a911b..b3183113f12b 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway @@ -27,6 +27,7 @@ case "$1" in list) json_init json_add_object node_test + json_add_string 'id' 'id' json_close_object json_dump ;; diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 73388a5d815f..e3474fbd6494 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -41,21 +41,19 @@ wg_handshake_test() { lock=/tmp/wg-health.lock if ! mkdir "$lock" 2>/dev/null; then # A tick killed mid-test (SIGHUP/reboot) can leave the lock - # behind; its holder PID is gone, so take it over. + # behind. If its holder is still alive, use the cache as-is + # (even stale) instead of racing on the probe port; otherwise + # take the lock over. lock_pid=$(cat "$lock/pid" 2>/dev/null || echo 0) - if ! kill -0 "$lock_pid" 2>/dev/null; then - rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true - mkdir "$lock" 2>/dev/null || true - fi - fi - if ! [ -d "$lock" ]; then - # Another monitor tick is testing right now; use the cache - # as-is (even stale) instead of racing on the probe port. - if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then - sed -n '3p' "$cache" - return 0 + if [ "${lock_pid:-0}" -gt 0 ] && kill -0 "$lock_pid" 2>/dev/null; then + if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then + sed -n '3p' "$cache" + return 0 + fi + return 1 fi - return 1 + rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + mkdir "$lock" 2>/dev/null || return 1 fi echo $$ > "$lock/pid" priv=$(uci -q get "wificalling-gateway.$id.private_key") || true @@ -72,7 +70,10 @@ wg_handshake_test() { lport=$((19000 + (0x$(printf '%s' "$id" | md5sum | cut -c1-4) % 1000))) 2>/dev/null || lport=19099 cfg="/tmp/wg-health-$id.json" # The probe config carries the WG private key and PSK: create it mode - # 0600 and clean it (and the log) up on any exit, including signals. + # 0600. The probe itself runs in a subshell that owns its EXIT trap, + # so the config/log cleanup stays local and the caller's trap is + # untouched (this function is shared with node-test.sh, which has its + # own cleanup). ( umask 077; { printf '{"log":{"level":"warn"},"inbounds":[{"type":"http","tag":"probe","listen":"127.0.0.1","listen_port":%s}],' "$lport" printf '"endpoints":[{"type":"wireguard","tag":"wg","address":[%s],"private_key":%s,"peers":[{"address":%s,"port":%s,"public_key":%s,"allowed_ips":["0.0.0.0/0"]' \ @@ -81,35 +82,51 @@ wg_handshake_test() { [ -n "$reserved" ] && printf ',"reserved":[%s]' "$(printf '%s' "$reserved" | tr -d ' ')" printf '}],"mtu":%s}],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"wg"}}' "${mtu:-1420}" } > "$cfg"; ) - trap 'rm -f "$cfg" /tmp/wg-health-$id.log' EXIT HUP INT TERM - "$sing_box" run -c "$cfg" > /tmp/wg-health-$id.log 2>&1 & - pid=$! - sleep 2 # Verify the tunnel with an echo service through the probe. The URL is - # UCI-configurable (main.probe_url) and HTTPS by default: plain HTTP - # would leak the exit IP and is a false-negative source when the host - # is unreachable. busybox wget honours http_proxy and speaks HTTPS on - # 22.03+ builds. - probe_url=$(uci -q get wificalling-gateway.main.probe_url) || true - [ -n "$probe_url" ] || probe_url='https://ip-api.com/json/?fields=query' - ip=$(http_proxy="http://127.0.0.1:$lport" wget -qO- -T 6 "$probe_url" 2>/dev/null | sed -n 's/.*"query":"\([0-9.]*\)".*/\1/p' || true) - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - trap - EXIT HUP INT TERM - if [ -n "$ip" ]; then - rm -f "$cfg" /tmp/wg-health-$id.log + # UCI-configurable (main.probe_url) and HTTPS by default. curl is a + # hard dependency of the package and drives the probe via -x through + # the http inbound; wget (http_proxy) is the fallback for stripped + # images where /usr/bin/wget is busybox. + # The whole probe runs in a subshell that owns its EXIT trap, so the + # config/log cleanup stays local and the caller's trap is untouched + # (this function is shared with node-test.sh). The verdict is + # produced inside the subshell too: the log is needed for the + # timeout/unreachable distinction and is gone by the time the trap + # fires. + result=$( ( + trap 'rm -f "$cfg" /tmp/wg-health-$id.log' EXIT HUP INT TERM + "$sing_box" run -c "$cfg" > /tmp/wg-health-$id.log 2>&1 & + pid=$! + sleep 2 + probe_url=$(uci -q get wificalling-gateway.main.probe_url) || true + [ -n "$probe_url" ] || probe_url='https://ip-api.com/json/?fields=query' + body= + if command -v curl >/dev/null 2>&1; then + body=$(curl -s --max-time 6 -x "http://127.0.0.1:$lport" "$probe_url" 2>/dev/null || true) + else + body=$(http_proxy="http://127.0.0.1:$lport" wget -qO- -T 6 "$probe_url" 2>/dev/null || true) + fi + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + if ip=$(printf '%s' "$body" | sed -n 's/.*"query":"\([0-9.]*\)".*/\1/p'); [ -n "$ip" ]; then + printf 'OK %s' "$ip" + elif grep -q 'handshake did not complete' /tmp/wg-health-$id.log 2>/dev/null; then + printf 'FAIL timeout' + else + printf 'FAIL unreachable' + fi + ) ) + case "$result" in + OK*) ip=${result#OK } ;; + FAIL*) reason=${result#FAIL } ;; + esac + if [ -n "${ip:-}" ]; then printf '%s\nok\n%s\n' "$(date +%s)" "$ip" > "$cache" rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true printf '%s' "$ip" return 0 fi - if grep -q 'handshake did not complete' /tmp/wg-health-$id.log 2>/dev/null; then - reason=timeout - else - reason=unreachable - fi - rm -f "$cfg" /tmp/wg-health-$id.log - printf '%s\nfailed\n%s\n' "$(date +%s)" "$reason" > "$cache" + printf '%s\nfailed\n%s\n' "$(date +%s)" "${reason:-unreachable}" > "$cache" rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true return 1 } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh index e0455f9888d6..3d0e615c8480 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh @@ -38,7 +38,7 @@ if [ "$proto" = wireguard ]; then . "$func" # The monitor loop may be mid-test right now; wait for its lock so - # this run is authoritative (a handshake takes up to ~8 s, give it 20 s). + # this run is authoritative (a handshake takes up to ~8 s, give it 40 s). n=0 while [ -d /tmp/wg-health.lock ]; do n=$((n + 1)) From 43f6aeafe6545378d2dc971a9a3e06b8644ffa29 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 17 Aug 2026 14:04:29 +0800 Subject: [PATCH 25/56] luci-app-wificalling-gateway: bump README install references to 1.8.3 Keep the install commands and the current-version line in sync with the released artifacts (generic ipk, 18.06 variant, noarch apk); the quick install section was still pointing at 1.7.3. Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 7fdc9636ddc2..4d0f89e24e94 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.7.3),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.3),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.7.3-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.3-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.7.3-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.3-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.7.3-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.3-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.7.3-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.3-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.3-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.7.3-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.7.3-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.3-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.7.3-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.3-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` From 05c74a632ed605933b38b6d0d20508f04f54df06 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 18 Aug 2026 15:39:02 +0800 Subject: [PATCH 26/56] luci-app-wificalling-gateway: fix review round 10 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lock: a pidless lock is a normal transient state, not stale — held until the directory is older than the probe budget (~60 s) - release_lock verifies the pid is still ours before rm -rf, so a takeover is not undone by the old holder - contended path without a usable cache reports reason=busy instead of a failed handshake (no probe was attempted) - nits: testNotify comment/separator, local result Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++---- .../view/wificalling-gateway/overview.js | 9 ++-- .../po/templates/wificalling-gateway.pot | 6 +++ .../po/zh_Hans/wificalling-gateway.po | 6 +++ .../wificalling-gateway/node-health.sh | 42 +++++++++++++------ 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index bb9e403af81e..bc4fba0ce199 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.3 +PKG_VERSION:=1.8.4 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 4d0f89e24e94..3be38a8a1f18 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.3),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.4),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.3-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.4-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.3-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.4-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.3-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.4-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.3-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.3-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.4-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.3-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.3-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.4-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.3-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.4-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index e117ea088e0e..58bf03937d74 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -67,22 +67,25 @@ return view.extend({ if (reason === 'config_missing') return _('Missing config'); if (reason === 'timeout') return _('Timeout'); if (reason === 'unreachable') return _('Unreachable'); + if (reason === 'busy') return _('Test in progress'); return reason || ''; } function wgFailDetail(reason) { if (reason === 'config_missing') return _('Missing key/address'); if (reason === 'timeout') return _('Handshake timed out (key/psk mismatch?)'); if (reason === 'unreachable') return _('Server unreachable'); + if (reason === 'busy') return _('Another test is running right now'); return ''; } - // Manual connection test for one node: fresh WG handshake (bypasses - // the monitor's 60 s cache) or a TCP reachability probe. + // Banner-style notification with an optional detail suffix. function testNotify(message, kind, detail) { var p = E('p', {}, message); if (detail) - p.appendChild(E('em', {}, detail)); + p.appendChild(E('em', {}, ' — ' + detail)); ui.addNotification(null, p, kind); } + // Manual connection test for one node: fresh WG handshake (bypasses + // the monitor's 60 s cache) or a TCP reachability probe. function runNodeTest(id, btn) { if (btn.disabled) return; btn.disabled = true; diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 07cacf7681bb..d55508b7c49a 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -41,6 +41,9 @@ msgstr "" msgid "Allow insecure certificate" msgstr "" +msgid "Another test is running right now" +msgstr "" + msgid "Bound" msgstr "" @@ -338,6 +341,9 @@ msgstr "" msgid "Test" msgstr "" +msgid "Test in progress" +msgstr "" + msgid "Testing…" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index a3da4d470e75..669d804e5ad2 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -42,6 +42,9 @@ msgstr "在线" msgid "Allow insecure certificate" msgstr "允许不安全证书" +msgid "Another test is running right now" +msgstr "另一个测试正在进行中" + msgid "Bound" msgstr "已绑定" @@ -339,6 +342,9 @@ msgstr "TUIC UDP 模式" msgid "Test" msgstr "测试" +msgid "Test in progress" +msgstr "测试进行中" + msgid "Testing…" msgstr "测试中…" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index e3474fbd6494..431db6af4546 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -27,7 +27,7 @@ json_escape() { # the loop ticks every 5 s), and two instances racing on the same probe # port would hand each other the wrong exit IP. wg_handshake_test() { - local id=$1 server=$2 port=$3 cache cache_ts age lock lock_pid priv pub local_addr psk mtu reserved lport cfg pid ip reason probe_url + local id=$1 server=$2 port=$3 cache cache_ts age lock lock_pid lock_age held priv pub local_addr psk mtu reserved lport cfg pid ip reason probe_url result cache="/tmp/wg-health-$id" if [ -f "$cache" ]; then cache_ts=$(sed -n '1p' "$cache" 2>/dev/null || echo 0) @@ -38,21 +38,39 @@ wg_handshake_test() { return 0 fi fi + # Only the holder may release: a pidless/foreign lock belongs to a + # newer holder after a takeover, and deleting it would un-serialize. + release_lock() { + [ "$(cat "$1/pid" 2>/dev/null || true)" = "$$" ] || return 0 + rm -rf "$1" + } lock=/tmp/wg-health.lock if ! mkdir "$lock" 2>/dev/null; then - # A tick killed mid-test (SIGHUP/reboot) can leave the lock - # behind. If its holder is still alive, use the cache as-is - # (even stale) instead of racing on the probe port; otherwise - # take the lock over. - lock_pid=$(cat "$lock/pid" 2>/dev/null || echo 0) - if [ "${lock_pid:-0}" -gt 0 ] && kill -0 "$lock_pid" 2>/dev/null; then + # Contended. A pidless lock is a normal transient state (between + # mkdir and echo $$, and during every release), not necessarily a + # stale one: treat it as held and only take it over once the + # directory is older than the probe budget. A live pid means a + # real holder either way. + lock_pid=$(cat "$lock/pid" 2>/dev/null || true) + held=1 + if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then + : + elif date -r "$lock" +%s >/dev/null 2>&1; then + lock_age=$(($(date +%s) - $(date -r "$lock" +%s))) + [ "$lock_age" -lt 60 ] 2>/dev/null || held=0 + fi + if [ "$held" -eq 1 ]; then if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then sed -n '3p' "$cache" return 0 fi - return 1 + # A test is in flight: report busy instead of a failed + # handshake so the status page does not claim the peer is + # unreachable when no probe was even attempted. + printf '%s\nfailed\nbusy\n' "$(date +%s)" > "$cache" + return 2 fi - rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + rm -rf "$lock" mkdir "$lock" 2>/dev/null || return 1 fi echo $$ > "$lock/pid" @@ -61,7 +79,7 @@ wg_handshake_test() { local_addr=$(uci -q get "wificalling-gateway.$id.local_address") || true if [ -z "$priv" ] || [ -z "$pub" ] || [ -z "$local_addr" ]; then printf '%s\nfailed\nconfig_missing\n' "$(date +%s)" > "$cache" - rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + release_lock "$lock" return 1 fi psk=$(uci -q get "wificalling-gateway.$id.pre_shared_key") || true @@ -122,12 +140,12 @@ wg_handshake_test() { esac if [ -n "${ip:-}" ]; then printf '%s\nok\n%s\n' "$(date +%s)" "$ip" > "$cache" - rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + release_lock "$lock" printf '%s' "$ip" return 0 fi printf '%s\nfailed\n%s\n' "$(date +%s)" "${reason:-unreachable}" > "$cache" - rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true + release_lock "$lock" return 1 } From 7231868ed00b615f22ca895b6e8d1f6752b78d6a Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 19 Aug 2026 08:16:05 +0800 Subject: [PATCH 27/56] luci-app-wificalling-gateway: fix review round 11 findings - dead-pid holder reclaimed immediately (not held for 60 s with the pidless case) - busy marker no longer writes into the 60 s result cache (probe resumes on the next tick after contention clears) - contention returns state=testing ("Test in progress") instead of handshake_failed so Status/Quality columns no longer show Offline for a node no probe was attempted on Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 ++--- .../view/wificalling-gateway/overview.js | 1 + .../wificalling-gateway/node-health.sh | 53 +++++++++++-------- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index bc4fba0ce199..6f731b94d703 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.4 +PKG_VERSION:=1.8.5 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 3be38a8a1f18..9bd6450eaf7a 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.4),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.5),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.4-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.5-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.4-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.5-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.4-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.5-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.4-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.4-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.5-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.4-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.4-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.5-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.4-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.5-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 58bf03937d74..9759488b97e5 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -57,6 +57,7 @@ return view.extend({ function nodeState(n) { if (!n) return '-'; if (n.state === 'handshake_ok' || n.state === 'reachable' || n.state === 'tcp_reachable') return _('Alive'); + if (n.state === 'testing') return _('Test in progress'); if (n.state === 'handshake_failed' || n.state === 'unreachable') return _('Offline'); return _('Unknown'); } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 431db6af4546..bb7950769401 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -46,30 +46,32 @@ wg_handshake_test() { } lock=/tmp/wg-health.lock if ! mkdir "$lock" 2>/dev/null; then - # Contended. A pidless lock is a normal transient state (between - # mkdir and echo $$, and during every release), not necessarily a - # stale one: treat it as held and only take it over once the - # directory is older than the probe budget. A live pid means a - # real holder either way. - lock_pid=$(cat "$lock/pid" 2>/dev/null || true) - held=1 - if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then - : - elif date -r "$lock" +%s >/dev/null 2>&1; then - lock_age=$(($(date +%s) - $(date -r "$lock" +%s))) - [ "$lock_age" -lt 60 ] 2>/dev/null || held=0 - fi - if [ "$held" -eq 1 ]; then - if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then - sed -n '3p' "$cache" - return 0 - fi - # A test is in flight: report busy instead of a failed - # handshake so the status page does not claim the peer is - # unreachable when no probe was even attempted. - printf '%s\nfailed\nbusy\n' "$(date +%s)" > "$cache" - return 2 + # Contended. A pidless lock is a normal transient state (between + # mkdir and echo $$, and during every release), not necessarily a + # stale one: treat it as held and only take it over once the + # directory is older than the probe budget. A live pid means a + # real holder; a dead pid (holder killed mid-hold, not in the + # transient windows) can be reclaimed immediately. + lock_pid=$(cat "$lock/pid" 2>/dev/null || true) + held=1 + if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then + : # live holder → held + elif [ -n "$lock_pid" ]; then + # dead pid: holder killed mid-hold — reclaim immediately + held=0 + elif date -r "$lock" +%s >/dev/null 2>&1; then + lock_age=$(($(date +%s) - $(date -r "$lock" +%s))) + [ "$lock_age" -lt 60 ] 2>/dev/null || held=0 + fi + if [ "$held" -eq 1 ]; then + if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then + sed -n '3p' "$cache" + return 0 fi + # A test is in flight: don't write the result cache (it would + # suppress probing for 60 s after the lock is released). + return 2 + fi rm -rf "$lock" mkdir "$lock" 2>/dev/null || return 1 fi @@ -160,6 +162,11 @@ wg_handshake_test() { measurement=wg_handshake if exit_ip=$(wg_handshake_test "$id" "$server" "$port"); then state=handshake_ok; ping_json="\"$exit_ip\"" + elif [ $? -eq 2 ]; then + # Contention: no probe was attempted, don't claim the + # peer is down — report "testing" so the status page + # shows a neutral third state (review round 11). + state=testing; ping_json=null; reason_json=null else state=handshake_failed; ping_json=null reason_json="\"$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null || echo unreachable)\"" From 9d94844bf3393ff50efeb52ce071acf0d7b489fc Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 19 Aug 2026 17:42:44 +0800 Subject: [PATCH 28/56] luci-app-wificalling-gateway: round 12 fixes, unused-node skip - node-test.sh: contended handshake reports busy instead of reading the empty cache and falling back to unreachable - node-health.sh: restore lock block indentation - compiler: skip nodes not referenced by any device policy (used[] tracking) to shrink the generated config and sing-box memory Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++--- .../libexec/wificalling-gateway/compiler.sh | 8 ++- .../wificalling-gateway/node-health.sh | 50 +++++++++---------- .../libexec/wificalling-gateway/node-test.sh | 4 ++ 5 files changed, 43 insertions(+), 35 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 6f731b94d703..7db0277a0f3a 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.5 +PKG_VERSION:=1.8.6 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 9bd6450eaf7a..410413117809 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.5),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.6),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.5-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.6-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.5-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.6-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.5-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.6-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.5-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.5-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.6-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.5-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.5-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.6-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.5-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.6-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index 2ca6c883ba50..f7240e7894ae 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -54,7 +54,7 @@ $1=="device" { if (owner[ip] && owner[ip]!=$2) fail("duplicate client IP assignment: " ip) owner[ip]=$2; normalized=normalized (normalized?",":"") ip } - dev[++nd]=$2; devnode[nd]=$3; devips[nd]=normalized; next + dev[++nd]=$2; devnode[nd]=$3; devips[nd]=normalized; used[$3]=1; next } END { if (nn<1) fail("at least one enabled node is required") @@ -84,7 +84,11 @@ END { print " \"inbounds\":[{\"type\":\"tproxy\",\"tag\":\"wfc-tcp\",\"listen\":\"0.0.0.0\",\"listen_port\":11441,\"network\":\"tcp\"},{\"type\":\"tproxy\",\"tag\":\"wfc-udp\",\"listen\":\"0.0.0.0\",\"listen_port\":11442,\"network\":\"udp\"}]," print " \"outbounds\":[" for(k=1;k<=nn;k++) { - split(node[k],f,"|"); id=f[2]; p=f[3] + split(node[k],f,"|"); id=f[2] + # Nodes not referenced by any device policy are skipped: they would + # produce outbounds that consume sing-box memory for nothing. + if (!used[id]) continue + p=f[3] if (p=="wireguard" && wg_style=="endpoint") continue s="{\"type\":" q(p) ",\"tag\":" q("node-" id) ",\"server\":" q(f[4]) ",\"server_port\":" f[5] if (p=="anytls") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index bb7950769401..49f2bc262634 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -46,32 +46,32 @@ wg_handshake_test() { } lock=/tmp/wg-health.lock if ! mkdir "$lock" 2>/dev/null; then - # Contended. A pidless lock is a normal transient state (between - # mkdir and echo $$, and during every release), not necessarily a - # stale one: treat it as held and only take it over once the - # directory is older than the probe budget. A live pid means a - # real holder; a dead pid (holder killed mid-hold, not in the - # transient windows) can be reclaimed immediately. - lock_pid=$(cat "$lock/pid" 2>/dev/null || true) - held=1 - if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then - : # live holder → held - elif [ -n "$lock_pid" ]; then - # dead pid: holder killed mid-hold — reclaim immediately - held=0 - elif date -r "$lock" +%s >/dev/null 2>&1; then - lock_age=$(($(date +%s) - $(date -r "$lock" +%s))) - [ "$lock_age" -lt 60 ] 2>/dev/null || held=0 - fi - if [ "$held" -eq 1 ]; then - if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then - sed -n '3p' "$cache" - return 0 + # Contended. A pidless lock is a normal transient state (between + # mkdir and echo $$, and during every release), not necessarily a + # stale one: treat it as held and only take it over once the + # directory is older than the probe budget. A live pid means a + # real holder; a dead pid (holder killed mid-hold, not in the + # transient windows) can be reclaimed immediately. + lock_pid=$(cat "$lock/pid" 2>/dev/null || true) + held=1 + if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then + : # live holder → held + elif [ -n "$lock_pid" ]; then + # dead pid: holder killed mid-hold — reclaim immediately + held=0 + elif date -r "$lock" +%s >/dev/null 2>&1; then + lock_age=$(($(date +%s) - $(date -r "$lock" +%s))) + [ "$lock_age" -lt 60 ] 2>/dev/null || held=0 + fi + if [ "$held" -eq 1 ]; then + if [ -f "$cache" ] && [ "$(sed -n '2p' "$cache")" = ok ]; then + sed -n '3p' "$cache" + return 0 + fi + # A test is in flight: don't write the result cache (it would + # suppress probing for 60 s after the lock is released). + return 2 fi - # A test is in flight: don't write the result cache (it would - # suppress probing for 60 s after the lock is released). - return 2 - fi rm -rf "$lock" mkdir "$lock" 2>/dev/null || return 1 fi diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh index 3d0e615c8480..32986cf59546 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh @@ -55,6 +55,10 @@ if [ "$proto" = wireguard ]; then if exit_ip=$(wg_handshake_test "$id" "$server" "$port"); then printf '{"state":"handshake_ok","exit_ip":"%s"}\n' "$exit_ip" + elif [ $? -eq 2 ]; then + # Contention with another test in flight: no probe was attempted, + # report busy rather than a failed handshake. + printf '{"state":"failed","reason":"busy"}\n' else reason=$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null || echo unreachable) printf '{"state":"handshake_failed","reason":"%s"}\n' "$reason" From a28175bf6f268f0797c0fe4fdd1c0293cdcf1098 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 19 Aug 2026 18:11:32 +0800 Subject: [PATCH 29/56] luci-app-wificalling-gateway: fix endpoint unused-skip and comma bug - endpoints: skip WG endpoints with no device-policy reference (the outbound-only skip left orphan endpoints consuming memory) - endpoints: emit commas based on prior output, not the loop index, so skipped endpoints no longer break JSON parsing Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++++++------- .../usr/libexec/wificalling-gateway/compiler.sh | 9 ++++++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 7db0277a0f3a..ffcfca879106 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.6 +PKG_VERSION:=1.8.7 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 410413117809..7d3f11e08fb8 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.6),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.7),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.6-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.7-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.6-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.7-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.6-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.7-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.6-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.6-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.7-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.6-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.6-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.7-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.6-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.7-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index f7240e7894ae..a2cefa55bd0e 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -68,15 +68,22 @@ END { # routes straight to the endpoint tag, "legacy" keeps the old outbound. if (nw>0 && wg_style=="endpoint") { print " \"endpoints\":[" + first=1 for(w=1;w<=nw;w++) { split(node[wg_nodes[w]],f,"|"); id=f[2] + # Same unused-node skip as the outbounds: an endpoint no policy + # routes to would only consume memory. + if (!used[id]) continue s="{\"type\":\"wireguard\",\"tag\":" q("wg-" id) ",\"address\":[" q(f[22]) "],\"private_key\":" q(f[21]) s=s ",\"peers\":[{\"address\":" q(f[4]) ",\"port\":" f[5] ",\"public_key\":" q(f[13]) ",\"allowed_ips\":[\"0.0.0.0/0\"]" if (f[23]!="") { nr=split(f[23],rv,","); rv_s=rv[1]; for(ri=2;ri<=nr;ri++) rv_s=rv_s "," rv[ri]; s=s ",\"reserved\":[" rv_s "]" } if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) s=s "}]" if (f[24]!="") s=s ",\"mtu\":" f[24] - s=s "}"; print " " s (w Date: Thu, 20 Aug 2026 08:57:54 +0800 Subject: [PATCH 30/56] luci-app-wificalling-gateway: fix review round 13 findings - endpoints: leading comma after the first emitted endpoint (the 1.8.7 separator produced invalid JSON for two referenced WG nodes) - wg required-field validation deferred to the emit loops (wg_check): unreferenced broken WG nodes are skipped, referenced ones still fail - runNodeTest: handle state=failed with a reason Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++++------ .../view/wificalling-gateway/overview.js | 4 +++ .../libexec/wificalling-gateway/compiler.sh | 25 ++++++++++++------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index ffcfca879106..9198298f5005 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.7 +PKG_VERSION:=1.8.8 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 7d3f11e08fb8..b11219e28560 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.7),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.8),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.7-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.8-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.7-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.8-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.7-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.8-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.7-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.7-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.8-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.7-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.7-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.8-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.7-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.8-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 9759488b97e5..340612f6b5ff 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -107,6 +107,10 @@ return view.extend({ else if (r && r.state === 'unreachable') { testNotify(_('Offline'), 'error'); } + else if (r && r.state === 'failed') { + // e.g. busy: no probe was attempted, explain why. + testNotify(_('Unable to test node: ') + wgFailReason(r.reason), 'error', wgFailDetail(r.reason)); + } else { testNotify(_('Unable to test node: ') + wgFailReason(r && r.reason), 'error'); } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index a2cefa55bd0e..a45f5792afe7 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -15,6 +15,14 @@ function private4(ip, a) { split(ip,a,"."); if (a[1]>255||a[2]>255||a[3]>255||a[4]>255) return 0 return a[1]==10 || (a[1]==172 && a[2]>=16 && a[2]<=31) || (a[1]==192 && a[2]==168) } +# WireGuard required-field validation, run in the emit loops where +# used[] already exists: an unreferenced node (which is skipped anyway) +# must not fail the whole compile. +function wg_check(f, id) { + if (f[21]=="" || f[13]=="" || f[22]=="") fail("wireguard node " id " is missing private_key, peer_public_key or local_address") + if (f[23]!="" && f[23] !~ /^[0-9]+(,[0-9]+)*$/) fail("wireguard node " id " reserved must be comma-separated numbers: " f[23]) + if (f[24]!="" && f[24] !~ /^[0-9]+$/) fail("wireguard node " id " mtu must be a number: " f[24]) +} function tls(sni, insecure, alpn, pin, extra) { extra="\"enabled\":true" if (sni!="") extra=extra ",\"server_name\":" q(sni) @@ -32,11 +40,6 @@ $1=="node" { # WireGuard requires a private key, peer public key and local address; # reserved bytes and MTU must be numeric or the emitted JSON breaks # (and sing-box check would fail for every node at once). - if (proto=="wireguard") { - if ($21=="" || $13=="" || $22=="") fail("wireguard node " id " is missing private_key, peer_public_key or local_address") - if ($23!="" && $23 !~ /^[0-9]+(,[0-9]+)*$/) fail("wireguard node " id " reserved must be comma-separated numbers: " $23) - if ($24!="" && $24 !~ /^[0-9]+$/) fail("wireguard node " id " mtu must be a number: " $24) - } node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto if (proto=="wireguard") wg_nodes[++nw]=nn next @@ -74,16 +77,18 @@ END { # Same unused-node skip as the outbounds: an endpoint no policy # routes to would only consume memory. if (!used[id]) continue + wg_check(f, id) s="{\"type\":\"wireguard\",\"tag\":" q("wg-" id) ",\"address\":[" q(f[22]) "],\"private_key\":" q(f[21]) s=s ",\"peers\":[{\"address\":" q(f[4]) ",\"port\":" f[5] ",\"public_key\":" q(f[13]) ",\"allowed_ips\":[\"0.0.0.0/0\"]" if (f[23]!="") { nr=split(f[23],rv,","); rv_s=rv[1]; for(ri=2;ri<=nr;ri++) rv_s=rv_s "," rv[ri]; s=s ",\"reserved\":[" rv_s "]" } if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) s=s "}]" if (f[24]!="") s=s ",\"mtu\":" f[24] - # Comma depends on whether anything was emitted before, not on the - # loop index: skipped endpoints must not leave a trailing comma. - s=s "}"; print " " s (first?"":",") - first=0 + # Leading comma for every endpoint after the first emitted one: + # the loop index cannot tell the last emitted endpoint apart from + # skipped ones, and trailing commas break JSON. + s=s "}" + if (first) { print " " s; first=0 } else { print " ," s } } print " ]," } @@ -97,6 +102,8 @@ END { if (!used[id]) continue p=f[3] if (p=="wireguard" && wg_style=="endpoint") continue + if (p=="wireguard") wg_check(f, id) + if (p=="wireguard" && wg_style=="endpoint") continue s="{\"type\":" q(p) ",\"tag\":" q("node-" id) ",\"server\":" q(f[4]) ",\"server_port\":" f[5] if (p=="anytls") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) if (p=="hysteria2") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) From f3156f066edcb49df67d5875959fbcdcee0c0579 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Thu, 20 Aug 2026 13:33:57 +0800 Subject: [PATCH 31/56] luci-app-wificalling-gateway: fix review round 14 cleanups - outbounds: drop the duplicated wireguard+endpoint skip - node rule: remove the stale validation comment (moved to wg_check) - runNodeTest: busy renders as an informational banner Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++++++------- .../resources/view/wificalling-gateway/overview.js | 5 +++-- .../usr/libexec/wificalling-gateway/compiler.sh | 4 ---- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 9198298f5005..1e0970f2197b 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.8 +PKG_VERSION:=1.8.9 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index b11219e28560..12ea8fa5dfe8 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.8),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.9),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.8-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.9-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.8-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.9-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.8-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.9-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.8-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.8-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.9-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.8-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.8-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.9-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.8-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.9-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 340612f6b5ff..d88122e1bff4 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -108,8 +108,9 @@ return view.extend({ testNotify(_('Offline'), 'error'); } else if (r && r.state === 'failed') { - // e.g. busy: no probe was attempted, explain why. - testNotify(_('Unable to test node: ') + wgFailReason(r.reason), 'error', wgFailDetail(r.reason)); + // e.g. busy: no probe was attempted - informational, + // not a failure banner (round 14). + testNotify(wgFailReason(r.reason), 'info', wgFailDetail(r.reason)); } else { testNotify(_('Unable to test node: ') + wgFailReason(r && r.reason), 'error'); diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index a45f5792afe7..a921e800b02d 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -37,9 +37,6 @@ $1=="node" { if (id=="" || seen_node[id]++) fail("duplicate or empty node id: " id) if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess" && proto!="trojan" && proto!="wireguard") fail("unsupported protocol: " proto) if ($4=="" || $5 !~ /^[0-9]+$/ || $5<1 || $5>65535) fail("invalid server or port for node: " id) - # WireGuard requires a private key, peer public key and local address; - # reserved bytes and MTU must be numeric or the emitted JSON breaks - # (and sing-box check would fail for every node at once). node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto if (proto=="wireguard") wg_nodes[++nw]=nn next @@ -103,7 +100,6 @@ END { p=f[3] if (p=="wireguard" && wg_style=="endpoint") continue if (p=="wireguard") wg_check(f, id) - if (p=="wireguard" && wg_style=="endpoint") continue s="{\"type\":" q(p) ",\"tag\":" q("node-" id) ",\"server\":" q(f[4]) ",\"server_port\":" f[5] if (p=="anytls") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) if (p=="hysteria2") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) From 513987f9bad1df1af5c1a6c64fefef2e5c79a57f Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Fri, 21 Aug 2026 09:57:04 +0800 Subject: [PATCH 32/56] luci-app-wificalling-gateway: gate info banner on busy only The round-14 change made the whole state=failed branch informational, downgrading real failures (config_missing, no_health_script, no_tcp_probe) to non-error banners. Info is now applied only for reason=busy. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++++++------- .../resources/view/wificalling-gateway/overview.js | 9 ++++++--- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 1e0970f2197b..cf9e0459637a 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.9 +PKG_VERSION:=1.8.10 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 12ea8fa5dfe8..c2046eafa9ba 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.9),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.10),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.9-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.10-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.9-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.10-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.9-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.10-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.9-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.9-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.10-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.9-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.9-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.10-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.9-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.10-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index d88122e1bff4..02f96e6bc2e3 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -108,9 +108,12 @@ return view.extend({ testNotify(_('Offline'), 'error'); } else if (r && r.state === 'failed') { - // e.g. busy: no probe was attempted - informational, - // not a failure banner (round 14). - testNotify(wgFailReason(r.reason), 'info', wgFailDetail(r.reason)); + // busy: no probe was attempted - informational, not a + // failure banner. Every other failure reason is real. + if (r.reason === 'busy') + testNotify(wgFailReason(r.reason), 'info', wgFailDetail(r.reason)); + else + testNotify(_('Unable to test node: ') + wgFailReason(r.reason), 'error', wgFailDetail(r.reason)); } else { testNotify(_('Unable to test node: ') + wgFailReason(r && r.reason), 'error'); From 4d3d855dd9af2d55982caa462d3eeaacedcc9ba1 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Fri, 21 Aug 2026 17:23:30 +0800 Subject: [PATCH 33/56] luci-app-wificalling-gateway: map reasons, split config_missing - wgFailReason/Detail: map no_health_script and no_tcp_probe (with an install-tcping-or-nc detail); rpcd-internal errors stay raw - node-test.sh: missing server/port emits distinct no_server reason - 5 new zh-cn translations Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++++++------- .../view/wificalling-gateway/overview.js | 5 +++++ .../po/templates/wificalling-gateway.pot | 15 +++++++++++++++ .../po/zh_Hans/wificalling-gateway.po | 15 +++++++++++++++ .../usr/libexec/wificalling-gateway/node-test.sh | 2 +- 6 files changed, 44 insertions(+), 9 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index cf9e0459637a..abb5ad080509 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.10 +PKG_VERSION:=1.8.11 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index c2046eafa9ba..bc78e0153245 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.10),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.11),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.10-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.11-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.10-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.11-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.10-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.11-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.10-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.10-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.11-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.10-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.10-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.11-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.10-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.11-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 02f96e6bc2e3..dce06297c095 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -66,16 +66,21 @@ return view.extend({ // timeout / unreachable). function wgFailReason(reason) { if (reason === 'config_missing') return _('Missing config'); + if (reason === 'no_server') return _('Missing server/port'); if (reason === 'timeout') return _('Timeout'); if (reason === 'unreachable') return _('Unreachable'); if (reason === 'busy') return _('Test in progress'); + if (reason === 'no_health_script') return _('Health check helper missing'); + if (reason === 'no_tcp_probe') return _('No TCP probe available'); return reason || ''; } function wgFailDetail(reason) { if (reason === 'config_missing') return _('Missing key/address'); + if (reason === 'no_server') return _('No server or port configured'); if (reason === 'timeout') return _('Handshake timed out (key/psk mismatch?)'); if (reason === 'unreachable') return _('Server unreachable'); if (reason === 'busy') return _('Another test is running right now'); + if (reason === 'no_tcp_probe') return _('Install tcping or nc to probe this node'); return ''; } // Banner-style notification with an optional detail suffix. diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index d55508b7c49a..4c6d45b15a1e 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -134,6 +134,9 @@ msgstr "" msgid "Handshake timed out (key/psk mismatch?)" msgstr "" +msgid "Health check helper missing" +msgstr "" + msgid "IP" msgstr "" @@ -152,6 +155,9 @@ msgstr "" msgid "Information" msgstr "" +msgid "Install tcping or nc to probe this node" +msgstr "" + msgid "Invalid WireGuard endpoint: " msgstr "" @@ -182,6 +188,9 @@ msgstr "" msgid "Missing key/address" msgstr "" +msgid "Missing server/port" +msgstr "" + msgid "NAT-T seen" msgstr "" @@ -194,12 +203,18 @@ msgstr "" msgid "No" msgstr "" +msgid "No TCP probe available" +msgstr "" + msgid "No connected devices detected" msgstr "" msgid "No health data yet" msgstr "" +msgid "No server or port configured" +msgstr "" + msgid "No session" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 669d804e5ad2..c4feb0caf84b 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -135,6 +135,9 @@ msgstr "握手成功" msgid "Handshake timed out (key/psk mismatch?)" msgstr "握手超时(密钥/PSK 不匹配?)" +msgid "Health check helper missing" +msgstr "健康检查助手缺失" + msgid "IP" msgstr "IP" @@ -153,6 +156,9 @@ msgstr "独立通道" msgid "Information" msgstr "信息" +msgid "Install tcping or nc to probe this node" +msgstr "请安装 tcping 或 nc 以探测该节点" + msgid "Invalid WireGuard endpoint: " msgstr "无效的 WireGuard 端点:" @@ -183,6 +189,9 @@ msgstr "缺少配置" msgid "Missing key/address" msgstr "缺少密钥/地址" +msgid "Missing server/port" +msgstr "缺少服务器/端口" + msgid "NAT-T seen" msgstr "已发现 NAT-T" @@ -195,12 +204,18 @@ msgstr "协商中" msgid "No" msgstr "否" +msgid "No TCP probe available" +msgstr "无可用 TCP 探测工具" + msgid "No connected devices detected" msgstr "未检测到已连接设备" msgid "No health data yet" msgstr "暂无健康数据" +msgid "No server or port configured" +msgstr "未配置服务器或端口" + msgid "No session" msgstr "无会话" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh index 32986cf59546..fdf0845a38dc 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh @@ -17,7 +17,7 @@ id=${1:?node id required} server=$(uci -q get "wificalling-gateway.$id.server") || true port=$(uci -q get "wificalling-gateway.$id.port") || true if [ -z "$server" ] || [ -z "$port" ]; then - printf '{"state":"failed","reason":"config_missing"}\n' + printf '{"state":"failed","reason":"no_server"}\n' exit 0 fi From 492032c6a2856f9fb01364d4bc99cfca1c367611 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 22 Aug 2026 07:36:26 +0800 Subject: [PATCH 34/56] luci-app-wificalling-gateway: fix round 17 nits - unreachable branch passes wgFailDetail (tcp_failed no longer dropped) - Device offline and Security added to the catalogs - wgFailReason comment covers the node-test reasons Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 14 +++++++------- .../resources/view/wificalling-gateway/overview.js | 9 +++++---- .../po/templates/wificalling-gateway.pot | 6 ++++++ .../po/zh_Hans/wificalling-gateway.po | 6 ++++++ 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index abb5ad080509..f21ba8654337 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.11 +PKG_VERSION:=1.8.12 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index bc78e0153245..44b76b2e73da 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.11),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.12),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.11-1_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.12-1_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.11-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_1.8.12-1_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.11-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.12-1_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,7 +127,7 @@ opkg install ./luci-app-wificalling-gateway_1.8.11-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.11-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.12-r1_noarch.apk /etc/init.d/rpcd restart ``` @@ -135,11 +135,11 @@ apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.11-r1_noarch.apk ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.11-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.12-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.11-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_1.8.12-1_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index dce06297c095..d28befb2fe56 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -61,9 +61,10 @@ return view.extend({ if (n.state === 'handshake_failed' || n.state === 'unreachable') return _('Offline'); return _('Unknown'); } - // Short reason label and full explanation for failed WG handshakes - // (reason comes from node-health.sh's cache: config_missing / - // timeout / unreachable). + // Short reason label and full explanation for a failed node test. + // Reasons come from node-health.sh's cache (config_missing / + // timeout / unreachable) and from node-test.sh (no_server / + // no_health_script / no_tcp_probe / busy). function wgFailReason(reason) { if (reason === 'config_missing') return _('Missing config'); if (reason === 'no_server') return _('Missing server/port'); @@ -110,7 +111,7 @@ return view.extend({ testNotify(_('Alive') + (r.ping_ms ? ' — ' + r.ping_ms + ' ms' : ''), 'info'); } else if (r && r.state === 'unreachable') { - testNotify(_('Offline'), 'error'); + testNotify(_('Offline'), 'error', wgFailDetail('unreachable')); } else if (r && r.state === 'failed') { // busy: no probe was attempted - informational, not a diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 4c6d45b15a1e..b89c3db72f56 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -80,6 +80,9 @@ msgstr "" msgid "Device display name" msgstr "" +msgid "Device offline" +msgstr "" + msgid "Device policies" msgstr "" @@ -320,6 +323,9 @@ msgstr "" msgid "Save the node first, then reload this page to select it for a device." msgstr "" +msgid "Security" +msgstr "" + msgid "Server" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index c4feb0caf84b..70ec94d1302f 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -81,6 +81,9 @@ msgstr "设备" msgid "Device display name" msgstr "设备显示名称" +msgid "Device offline" +msgstr "设备未在线" + msgid "Device policies" msgstr "设备策略" @@ -321,6 +324,9 @@ msgstr "对该节点执行一次全新连接测试" msgid "Save the node first, then reload this page to select it for a device." msgstr "请先保存节点,再刷新本页以便为设备选择该节点。" +msgid "Security" +msgstr "安全协议" + msgid "Server" msgstr "服务器" From 4723fd1d693e2370933adbacef82568c80a1b2c0 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 22 Aug 2026 15:18:06 +0800 Subject: [PATCH 35/56] luci-app-wificalling-gateway: fix review round 18 findings - tcp_failed mapped in wgFailReason/wgFailDetail; unreachable branch passes r.reason instead of the hardcoded literal - README: relative doc links converted to absolute URLs into the standalone repo (dead links once merged); install commands de-versioned with placeholders Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 26 +++++++++---------- .../view/wificalling-gateway/overview.js | 5 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index f21ba8654337..e0bdf7ab3a82 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.12 +PKG_VERSION:=1.8.13 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index 44b76b2e73da..a77bd813e052 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -1,6 +1,6 @@ # Wi-Fi Calling Gateway -[English](README_EN.md) · [安装](docs/zh-CN/INSTALL.md) · [配置](docs/zh-CN/CONFIGURATION.md) · [排错](docs/zh-CN/TROUBLESHOOTING.md) · [开发与维护](DEVELOPER.md) +[English](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/README_EN.md) · [安装](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/INSTALL.md) · [配置](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/CONFIGURATION.md) · [排错](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/TROUBLESHOOTING.md) · [开发与维护](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/DEVELOPER.md) 面向 OpenWrt / ImmortalWrt 的独立 LuCI 插件。它把指定局域网设备通过指定的 sing-box 节点转发,同时让其他设备继续走路由器默认路由,并观察 Wi‑Fi Calling 常用的 ePDG/IPsec UDP 500、4500 会话证据。 @@ -100,26 +100,26 @@ Wi‑Fi Calling 的 ePDG/IPsec 隧道(UDP 4500 内)**全程加密**,路由 ## 快速安装 -从 [Releases](../../releases) 下载最新稳定版(当前为 1.8.12),上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 +从 [Releases](https://github.com/smthdagg/luci-app-wificalling-gateway/releases) 下载最新稳定版,上传到路由器后安装。**24.10 全系用一个 `.ipk`,25.12 全系用一个 `.apk`(noarch,不分芯片)**。 **OpenWrt / ImmortalWrt / iStoreOS 24.10.x(opkg / IPK)** —— 一个包通用,已实机验证: ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.12-1_all.ipk +opkg install ./luci-app-wificalling-gateway_<版本>_all.ipk /etc/init.d/rpcd restart ``` > iStoreOS 提示:部分 opkg 对 `./` 相对路径或上传位置会报误导性的 "No such file or directory"。请确认文件**真实上传成功**后再用绝对路径安装: > > ```sh -> opkg install /root/luci-app-wificalling-gateway_1.8.12-1_all.ipk +> opkg install /root/luci-app-wificalling-gateway_<版本>_all.ipk > ``` > > 若 iStoreOS 的定制 opkg 对本地文件报 `incompatible with the architectures configured`(已实测),可改用**解包安装**(24.10.7 完整固件实测通过): > > ```sh -> cd /tmp && tar xzf luci-app-wificalling-gateway_1.8.12-1_all.ipk && tar xzf data.tar.gz -C / +> cd /tmp && tar xzf luci-app-wificalling-gateway_<版本>_all.ipk && tar xzf data.tar.gz -C / > /etc/init.d/wificalling-gateway enable && /etc/init.d/wificalling-gateway start > ``` @@ -127,19 +127,19 @@ opkg install ./luci-app-wificalling-gateway_1.8.12-1_all.ipk ```sh apk update -apk add --allow-untrusted ./luci-app-wificalling-gateway_1.8.12-r1_noarch.apk +apk add --allow-untrusted ./luci-app-wificalling-gateway_<版本>_noarch.apk /etc/init.d/rpcd restart ``` -然后进入 **服务 → Wi‑Fi Calling Gateway**。先添加并保存节点,再添加设备策略。详细步骤见[安装说明](docs/zh-CN/INSTALL.md)和[配置说明](docs/zh-CN/CONFIGURATION.md)。 +然后进入 **服务 → Wi‑Fi Calling Gateway**。先添加并保存节点,再添加设备策略。详细步骤见[安装说明](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/INSTALL.md)和[配置说明](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/CONFIGURATION.md)。 ### 18.06/Lede 专包 -18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_1.8.12-1_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): +18.06 的软件源没有 `firewall4`,也通常没有 sing-box 与 TPROXY 内核模块,通用包在 18.06 上装不上。Release 里的 **`luci-app-wificalling-gateway_<版本>_18.06_all.ipk`** 专包只依赖 18.06 源自带的 `luci-base`、`nftables`、`ip-full`(官方 18.06.9 rootfs 实测安装成功): ```sh opkg update -opkg install ./luci-app-wificalling-gateway_1.8.12-1_18.06_all.ipk +opkg install ./luci-app-wificalling-gateway_<版本>_18.06_all.ipk /etc/init.d/wificalling-gateway enable ``` @@ -160,10 +160,10 @@ opkg install ./luci-app-wificalling-gateway_1.8.12-1_18.06_all.ipk ## 项目文档 -- [安装与升级](docs/zh-CN/INSTALL.md) -- [节点和设备配置](docs/zh-CN/CONFIGURATION.md) -- [常见问题与排错](docs/zh-CN/TROUBLESHOOTING.md) -- [开发与维护(面向贡献者 / 自动化接管)](DEVELOPER.md) +- [安装与升级](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/INSTALL.md) +- [节点和设备配置](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/CONFIGURATION.md) +- [常见问题与排错](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/TROUBLESHOOTING.md) +- [开发与维护(面向贡献者 / 自动化接管)](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/DEVELOPER.md) - [安全策略](SECURITY.md) · [更新记录](CHANGELOG.md) ## 许可证 diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index d28befb2fe56..61d002ed5377 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -64,7 +64,7 @@ return view.extend({ // Short reason label and full explanation for a failed node test. // Reasons come from node-health.sh's cache (config_missing / // timeout / unreachable) and from node-test.sh (no_server / - // no_health_script / no_tcp_probe / busy). + // no_health_script / no_tcp_probe / tcp_failed / busy). function wgFailReason(reason) { if (reason === 'config_missing') return _('Missing config'); if (reason === 'no_server') return _('Missing server/port'); @@ -82,6 +82,7 @@ return view.extend({ if (reason === 'unreachable') return _('Server unreachable'); if (reason === 'busy') return _('Another test is running right now'); if (reason === 'no_tcp_probe') return _('Install tcping or nc to probe this node'); + if (reason === 'tcp_failed') return _('Server unreachable'); return ''; } // Banner-style notification with an optional detail suffix. @@ -111,7 +112,7 @@ return view.extend({ testNotify(_('Alive') + (r.ping_ms ? ' — ' + r.ping_ms + ' ms' : ''), 'info'); } else if (r && r.state === 'unreachable') { - testNotify(_('Offline'), 'error', wgFailDetail('unreachable')); + testNotify(_('Offline'), 'error', wgFailDetail(r.reason)); } else if (r && r.state === 'failed') { // busy: no probe was attempted - informational, not a From 555b9311d212e7c85ada79823c52e72b7986079c Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 22 Aug 2026 15:33:38 +0800 Subject: [PATCH 36/56] luci-app-wificalling-gateway: validate client IPs at the source append_ip now rejects anything that is not a dotted quad (hand-written UCI bypasses the LuCI datatype check, and the IP reaches the nftables clients4 set verbatim). Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../root/etc/init.d/wificalling-gateway | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index e0bdf7ab3a82..7215f6902b12 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.13 +PKG_VERSION:=1.8.14 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 5507a5d678b2..bca0e8b6a67e 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -52,7 +52,18 @@ append_node() { printf '%s|%s|%s|%s|%s\n' "$s" "$safe_label" "$protocol" "$server" "$port" >> "$RUNDIR/nodes" } -append_ip() { DEVICE_IPS="${DEVICE_IPS}${DEVICE_IPS:+,}$1"; } +append_ip() { + # The IP reaches the nftables clients4 set and the generated config + # verbatim; reject anything that is not a plain dotted quad so a + # hand-written UCI value cannot inject into either. + case "$1" in + ''|*[!0-9.]*|*..*|.*|*.) + logger -t "$APP" "invalid client IP skipped: $1" + return + ;; + esac + DEVICE_IPS="${DEVICE_IPS}${DEVICE_IPS:+,}$1" +} append_device() { local s="$1" enabled label node route_mode config_get_bool enabled "$s" enabled 1; [ "$enabled" -eq 1 ] || return 0 From 2f66b3a466039873155c775cd6ba29c493e8f551 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 22 Aug 2026 15:44:39 +0800 Subject: [PATCH 37/56] luci-app-wificalling-gateway: document the Online (static IP) state The DHCP binding column gained an Online (static IP) state in 1.8.0 (ARP fallback for static-IP and DHCP-less setups), but the feature list in both READMEs still enumerated only the four original states. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index a77bd813e052..bff8a1a6666d 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -32,7 +32,7 @@ - 支持直接粘贴 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan (trojan://)、WireGuard (wg://) 分享链接,以及标准 WireGuard `[Interface]/[Peer]` 配置块;WireGuard 支持预共享密钥(PSK)。 - **WireGuard 节点真实握手健康检查**:不用 ICMP 猜,临时起 sing-box endpoint 验证隧道握手并显示验证通过的出口 IP(60 秒缓存);握手失败区分原因(配置缺失/超时/不可达),节点表格悬停可见。 - **节点即时测试**:节点表格每行有「测试」按钮——WireGuard 节点立即重做一次握手测试(绕过缓存),其他协议做 TCP 探测,结果横幅显示(含出口 IP 与失败原因)。 -- **DHCP 静态租约自动管理**:添加/删除设备策略时自动绑定/清理 MAC-IP 静态租约,兼容 iOS 私有 Wi-Fi 地址(MAC 随机变化);设备策略表实时显示绑定状态(已绑定 / 待绑定 / MAC 已变化 / 设备未在线)。 +- **DHCP 静态租约自动管理**:添加/删除设备策略时自动绑定/清理 MAC-IP 静态租约,兼容 iOS 私有 Wi-Fi 地址(MAC 随机变化);设备策略表实时显示绑定状态(已绑定 / 待绑定 / MAC 已变化 / 在线(静态 IP) / 设备未在线)——静态 IP 或纯 AP 路由器场景由 ARP 缓存兜底判断在线。 - **从已连接设备直接添加**:编辑设备策略时可从 DHCP/ARP 检测到的在线设备中选择,自动填写名称与 IP(静态 IP / 纯 AP 路由器场景由 ARP 兜底判断在线)。 - **服务健康监控**:「Wi-Fi Calling 状态」页顶部显示 sing-box/monitor 进程、配置有效性、**配置过期告警**(改了配置没重启服务)、nftables 规则数、节点健康汇总。 - 每台设备可绑定一个节点;一个策略可包含多个固定私网 IPv4 地址。 From 5337170f39652e042c642a93f4452472d06125de Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sun, 23 Aug 2026 09:24:47 +0800 Subject: [PATCH 38/56] luci-app-wificalling-gateway: full IP validation, README assets, tcp_failed - append_ip mirrors compiler private4(): quad+range+RFC1918, skip with log instead of failing the whole compile on hand-written UCI values - README: screenshots to raw.githubusercontent, SECURITY.md and CHANGELOG.md links to blob URLs (all six were relative) - wgFailReason: tcp_failed arm for symmetry with wgFailDetail Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../luci-app-wificalling-gateway/README.md | 10 +++---- .../view/wificalling-gateway/overview.js | 1 + .../root/etc/init.d/wificalling-gateway | 27 ++++++++++++------- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 7215f6902b12..195db6a4f77d 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.14 +PKG_VERSION:=1.8.16 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md index bff8a1a6666d..9555b62bfa76 100644 --- a/applications/luci-app-wificalling-gateway/README.md +++ b/applications/luci-app-wificalling-gateway/README.md @@ -6,22 +6,22 @@ ### 设置 -![Wi-Fi Calling Gateway 设置页面](docs/images/overview.png) +![Wi-Fi Calling Gateway 设置页面](https://raw.githubusercontent.com/smthdagg/luci-app-wificalling-gateway/main/docs/images/overview.png) ### Wi-Fi Calling 状态 -![Wi-Fi Calling 状态页面](docs/images/device-status.png) +![Wi-Fi Calling 状态页面](https://raw.githubusercontent.com/smthdagg/luci-app-wificalling-gateway/main/docs/images/device-status.png) ### 活动日志 -![加密 IMS 活动日志页面](docs/images/activity-log.png) +![加密 IMS 活动日志页面](https://raw.githubusercontent.com/smthdagg/luci-app-wificalling-gateway/main/docs/images/activity-log.png) ### iPhone 实机观察 下图为实际 iPhone 在飞行模式及 Wi‑Fi 环境中显示 **EE WiFiCall** 的状态:

- iPhone 实机显示 EE WiFiCall + iPhone 实机显示 EE WiFiCall

该截图证明终端已显示 Wi‑Fi Calling 注册状态;是否完成号码激活及呼叫能力,仍应以实际通话或运营商确认结果为准。 @@ -164,7 +164,7 @@ opkg install ./luci-app-wificalling-gateway_<版本>_18.06_all.ipk - [节点和设备配置](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/CONFIGURATION.md) - [常见问题与排错](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/docs/zh-CN/TROUBLESHOOTING.md) - [开发与维护(面向贡献者 / 自动化接管)](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/DEVELOPER.md) -- [安全策略](SECURITY.md) · [更新记录](CHANGELOG.md) +- [安全策略](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/SECURITY.md) · [更新记录](https://github.com/smthdagg/luci-app-wificalling-gateway/blob/main/CHANGELOG.md) ## 许可证 diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 61d002ed5377..0a839c8d61aa 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -71,6 +71,7 @@ return view.extend({ if (reason === 'timeout') return _('Timeout'); if (reason === 'unreachable') return _('Unreachable'); if (reason === 'busy') return _('Test in progress'); + if (reason === 'tcp_failed') return _('Unreachable'); if (reason === 'no_health_script') return _('Health check helper missing'); if (reason === 'no_tcp_probe') return _('No TCP probe available'); return reason || ''; diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index bca0e8b6a67e..5c4be058d532 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -53,16 +53,25 @@ append_node() { } append_ip() { - # The IP reaches the nftables clients4 set and the generated config - # verbatim; reject anything that is not a plain dotted quad so a - # hand-written UCI value cannot inject into either. - case "$1" in - ''|*[!0-9.]*|*..*|.*|*.) - logger -t "$APP" "invalid client IP skipped: $1" - return - ;; + # The IP reaches the nftables clients4 set and compiler.sh's + # private4() gate verbatim. Mirror private4() here so a hand-written + # UCI value is skipped with a log line instead of failing the whole + # compile. + local ip="$1" a b c d + case "$ip" in + *.*.*.*.*|*[!0-9.]*|*..*|.*|*.|*[0-9][0-9][0-9][0-9]*) ip= ;; + *.*.*.*) ;; + *) ip= ;; esac - DEVICE_IPS="${DEVICE_IPS}${DEVICE_IPS:+,}$1" + a=${ip%%.*}; b=${ip#*.}; b=${b%%.*}; c=${ip%.*}; c=${c##*.}; d=${ip##*.} + if [ -n "$ip" ] && [ "$a" -le 255 ] && [ "$b" -le 255 ] && [ "$c" -le 255 ] && [ "$d" -le 255 ] && + { [ "$a" -eq 10 ] || + { [ "$a" -eq 172 ] && [ "$b" -ge 16 ] && [ "$b" -le 31 ]; } || + { [ "$a" -eq 192 ] && [ "$b" -eq 168 ]; }; }; then + DEVICE_IPS="${DEVICE_IPS}${DEVICE_IPS:+,}$ip" + else + logger -t "$APP" "invalid client IP skipped: $1" + fi } append_device() { local s="$1" enabled label node route_mode From d7322c0db29c1dd56848ecb26c0e214aae0111e7 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 24 Aug 2026 15:26:04 +0800 Subject: [PATCH 39/56] luci-app-wificalling-gateway: skip empty-IP device, RFC1918 input check - init.d: skip (log) a device whose every source_ip failed validation, instead of leaving an empty DEVICE_IPS that fails the whole compile - overview.js: LAN IPv4 field validates RFC1918 at input time Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../view/wificalling-gateway/overview.js | 19 ++++++++++++++++++- .../root/etc/init.d/wificalling-gateway | 7 +++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 195db6a4f77d..1a52412e707e 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.16 +PKG_VERSION:=1.8.17 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 0a839c8d61aa..1f7cfc70102e 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -403,7 +403,24 @@ return view.extend({ selectedNode.description = _('Save the node first, then reload this page to select it for a device.'); uci.sections('wificalling-gateway', 'node').forEach(function(node) { selectedNode.value(node['.name'], node.label || node['.name']); }); var ips = s.option(form.DynamicList, 'source_ip', _('LAN IPv4 addresses')); - ips.datatype = 'ip4addr'; ips.rmempty = false; ips.placeholder = lanSubnetHint(); + ips.datatype = 'ip4addr'; + ips.rmempty = false; + ips.placeholder = lanSubnetHint(); + // Only RFC1918 can reach the nftables set and the generated config: + // reject public/CGNAT addresses at input time with a readable hint. + ips.validate = function(section_id, value) { + var m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\s*$/.exec(value || ''); + if (!m) return _('Invalid IPv4 address'); + for (var i = 1; i <= 4; i++) { + var octet = m[i]; + if (+octet > 255 || (octet.length > 1 && octet.charAt(0) === '0')) + return _('Invalid IPv4 address'); + } + var a = +m[1], b = +m[2]; + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) + return true; + return _('Only private IPv4 addresses (RFC1918) are supported'); + }; var devicePicker = s.option(form.DummyValue, '_device_picker', _('From connected devices')); devicePicker.rmempty = true; devicePicker.modalonly = true; diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 5c4be058d532..c086c41fce91 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -79,6 +79,13 @@ append_device() { config_get label "$s" label "$s"; config_get node "$s" node; config_get route_mode "$s" route_mode independent case "$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip + if [ -z "$DEVICE_IPS" ]; then + # Every source_ip was rejected by the validation: skip the device + # (it falls back to direct routing) instead of failing the whole + # gateway - mirrors the stale-node skip in compiler.sh. + logger -t "$APP" "device $s has no valid client IP, skipping" + return 0 + fi [ "$route_mode" = independent ] || return 0 local safe_label safe_label=$(printf '%s' "$label" | tr '|' ' ') From b7b0c938251f637ff227a0e2869651a978b6fe8c Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 24 Aug 2026 18:49:30 +0800 Subject: [PATCH 40/56] luci-app-wificalling-gateway: align wloc 1.3 gateway transports and debounce Port the portable wificalling-gateway additions from the wloc integration project v1.3.0-r1 (Wi-Fi Calling only, no wloc domain code): - compiler.sh: grpc/httpupgrade transports for VLESS/VMess, explicit xhttp rejection (sing-box cannot run it) - node-import.js: import grpc/httpupgrade vless:// and vmess:// links, readable error on xhttp - monitor.sh: one handshake success/failed event per 15 s per device - po: xhttp error string (zh-Hans) Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../wificalling-gateway/node-import.js | 29 +++++++++++++++++-- .../po/templates/wificalling-gateway.pot | 3 ++ .../po/zh_Hans/wificalling-gateway.po | 3 ++ .../libexec/wificalling-gateway/compiler.sh | 10 +++++++ .../libexec/wificalling-gateway/monitor.sh | 7 +++-- 6 files changed, 48 insertions(+), 6 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 1a52412e707e..0b32a7a626f8 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.8.17 +PKG_VERSION:=1.9.0 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index 16234d2a35c8..af92726a68e3 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -57,8 +57,20 @@ function parseUrl(uri, protocol) { out.public_key = (p.get('pbk') || p.get('publicKey') || '').replace(/ /g, '+'); out.short_id = (p.get('sid') || p.get('shortId') || '').replace(/ /g, '+'); out.fingerprint = p.get('fp') || p.get('fingerprint') || 'chrome'; - if (p.get('type') === 'ws') { - out.transport = 'ws'; out.path = p.get('path') || '/'; out.host = p.get('host') || ''; + var vless_type = p.get('type') || ''; + if (vless_type === 'xhttp') { + // XHTTP is a clash/mihomo transport; sing-box has no xhttp + // transport, so the node would never connect here. + throw new Error(_('xhttp transport is not supported by sing-box (use ws/grpc/httpupgrade)')); + } + if (vless_type === 'ws' || vless_type === 'grpc' || vless_type === 'httpupgrade') { + out.transport = vless_type; + out.host = p.get('host') || ''; + // grpc carries no path; its service_name goes in the path slot + // (the UCI/normalized.conf layout has no separate field). + out.path = (vless_type === 'grpc') + ? (p.get('serviceName') || p.get('service_name') || '/') + : (p.get('path') || '/'); } } else if (protocol === 'wireguard') { // wg://@:?private_key=…&local_address=…&reserved=…&mtu=… @@ -82,7 +94,18 @@ function parseVmess(uri) { sni: raw.sni || '', host: raw.host || '', path: raw.path || '', security: raw.tls === 'tls' ? 'tls' : '' }; - if (raw.net === 'ws') out.transport = 'ws'; + var raw_net = raw.net || ''; + if (raw_net === 'xhttp') { + // XHTTP is a clash/mihomo transport; sing-box has no xhttp + // transport, so the node would never connect here. + throw new Error(_('xhttp transport is not supported by sing-box (use ws/grpc/httpupgrade)')); + } + if (raw_net === 'ws' || raw_net === 'grpc' || raw_net === 'httpupgrade') { + out.transport = raw_net; + out.host = raw.host || ''; + // grpc: service_name goes in the path slot. + out.path = raw.path || '/'; + } return out; } diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index b89c3db72f56..ed2450c8e64e 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -502,3 +502,6 @@ msgstr "" msgid "sing-box: running" msgstr "" + +msgid "xhttp transport is not supported by sing-box (use ws/grpc/httpupgrade)" +msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 70ec94d1302f..f66422029e16 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -503,3 +503,6 @@ msgstr "sing-box:未运行" msgid "sing-box: running" msgstr "sing-box:运行中" + +msgid "xhttp transport is not supported by sing-box (use ws/grpc/httpupgrade)" +msgstr "sing-box 不支持 xhttp 传输(请使用 ws/grpc/httpupgrade)" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index a921e800b02d..d13033018e34 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -112,10 +112,20 @@ END { # back to the WS Host (f[19]) when sni (f[7]) is empty, so a plain # ws node with a Host header but no TLS never gains a tls block. else if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls((f[7]!=""?f[7]:f[19]),f[8],f[9],f[20]) + # VLESS/VMess transports: sing-box supports ws/grpc/httpupgrade. + # xhttp is a clash/mihomo transport and is rejected explicitly so a + # mis-imported node fails the compile with a readable reason. + if (f[17]=="xhttp") fail("xhttp transport is not supported by sing-box") + if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" + else if (f[17]=="grpc") s=s ",\"transport\":{\"type\":\"grpc\",\"service_name\":" q(f[18]!=""?f[18]:"/") "}" + else if (f[17]=="httpupgrade") s=s ",\"transport\":{\"type\":\"httpupgrade\",\"path\":" q(f[18]) ",\"host\":" q(f[19]) "}" } if (p=="vmess") { s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]~/^[0-9]+$/?f[10]:0) + if (f[17]=="xhttp") fail("xhttp transport is not supported by sing-box") if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" + else if (f[17]=="grpc") s=s ",\"transport\":{\"type\":\"grpc\",\"service_name\":" q(f[18]!=""?f[18]:"/") "}" + else if (f[17]=="httpupgrade") s=s ",\"transport\":{\"type\":\"httpupgrade\",\"path\":" q(f[18]) ",\"host\":" q(f[19]) "}" # Imported VMess links carry the TLS name in the WS Host (f[19]) when # sni (f[7]) is empty and the server is a bare IP; fall back to it so # certificate verification has a name to check. Plain ws nodes with diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh index dd9f1f5a7678..8ab667e9a99c 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -82,10 +82,13 @@ END { printf "\"epdg_ip\":%s,\"ike_seen\":%s,\"nat_t_seen\":%s,\"assured\":%s,", q(epdg[i]),(ike[i]?"true":"false"),(natt[i]?"true":"false"),(assured[i]?"true":"false") printf "\"sent_packets\":%d,\"reply_packets\":%d,\"delta_sent\":%d,\"delta_reply\":%d,\"last_activity\":%d,\"activity_evidence\":%s}", sent[i]+0,reply[i]+0,ds,dr,last,q(activity) if (log_enabled) { - if (handshake_success) { + if (handshake_success && now-old_event[i]>=15) { print now "|" label[i] "|" ip[i] "|handshake_success|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out old_event[i]=now; acc_sent=0; acc_reply=0 - } else if (handshake_failed) { + } else if (handshake_failed && now-old_event[i]>=15) { + # A flapping device state (registered <-> not_detected within a + # second) used to write a handshake event on every flip, flooding + # the log. Debounce to at most one handshake event per 15s. print now "|" label[i] "|" ip[i] "|handshake_failed|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out old_event[i]=now; acc_sent=0; acc_reply=0 } else if (sustained) { From 49256c0340eb8e14cd2192efc54753f1166dee0b Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 24 Aug 2026 21:08:11 +0800 Subject: [PATCH 41/56] luci-app-wificalling-gateway: apply relaxed low-memory sing-box profile Align the wloc 1.3 follow-up (relax lite memory profile): the sing-box process runs with GOMAXPROCS=1 and GOGC=75 (single worker + moderate GC) to reduce RSS on constrained routers, without the GOMEMLIMIT=24MiB hard cap that was dropped upstream after AX6S burst demand showed it too tight. Wi-Fi Calling only, no wloc domain code. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../root/etc/init.d/wificalling-gateway | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 0b32a7a626f8..c78cb12e1b8e 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.0 +PKG_VERSION:=1.9.1 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index c086c41fce91..cd276f69a6df 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -124,6 +124,11 @@ start_service() { /usr/bin/sing-box check -c "$RUNDIR/sing-box.json" || { logger -t "$APP" "sing-box rejected generated configuration"; return 1; } /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" || { logger -t "$APP" "firewall setup failed"; /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients"; return 1; } procd_open_instance sing-box + # Low-memory sing-box profile (aligned with wloc 1.3): one worker and a + # moderate GC target reduce RSS on constrained routers without imposing + # an artificial heap ceiling (the earlier GOMEMLIMIT=24MiB cap was + # dropped upstream after AX6S burst demand showed it was too tight). + procd_set_param env "GOMAXPROCS=1" "GOGC=75" procd_set_param command /usr/bin/sing-box run -c "$RUNDIR/sing-box.json" procd_set_param respawn 3600 5 5 procd_set_param limits nofile="65535 65535" From 5a1b8ab3acaa3fa82f3251cbfab5db8047ab7b2c Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 24 Aug 2026 23:55:08 +0800 Subject: [PATCH 42/56] luci-app-wificalling-gateway: add missing RFC1918 validation i18n The two source_ip validation messages added with the RFC1918 input check were never entered in the catalogs, so the zh-Hans UI showed them in English. Both files now at 170 msgids, pot/po parity, LC_ALL=C sorted, po2lmo compiles. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- .../po/templates/wificalling-gateway.pot | 6 ++++++ .../po/zh_Hans/wificalling-gateway.po | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index c78cb12e1b8e..4fe49afab31e 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.1 +PKG_VERSION:=1.9.2 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index ed2450c8e64e..0dd55fa94c38 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -161,6 +161,9 @@ msgstr "" msgid "Install tcping or nc to probe this node" msgstr "" +msgid "Invalid IPv4 address" +msgstr "" + msgid "Invalid WireGuard endpoint: " msgstr "" @@ -251,6 +254,9 @@ msgstr "" msgid "Online (static IP)" msgstr "" +msgid "Only private IPv4 addresses (RFC1918) are supported" +msgstr "" + msgid "Packet delta" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index f66422029e16..835eb9824256 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -162,6 +162,9 @@ msgstr "信息" msgid "Install tcping or nc to probe this node" msgstr "请安装 tcping 或 nc 以探测该节点" +msgid "Invalid IPv4 address" +msgstr "无效的 IPv4 地址" + msgid "Invalid WireGuard endpoint: " msgstr "无效的 WireGuard 端点:" @@ -252,6 +255,9 @@ msgstr "离线" msgid "Online (static IP)" msgstr "在线(静态 IP)" +msgid "Only private IPv4 addresses (RFC1918) are supported" +msgstr "仅支持私网 IPv4 地址(RFC1918)" + msgid "Packet delta" msgstr "数据包增量" From b3272e6d4b067b28932307c978a234ce55bcf351 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 25 Aug 2026 07:46:31 +0800 Subject: [PATCH 43/56] luci-app-wificalling-gateway: fix round 20 findings - source_ip validate accepts the empty DynamicList value (rmempty enforces at-least-one) so device policies save again - transport select declares grpc/httpupgrade so imported nodes are not reset to None on Save; path/host labels generalised - blank gRPC service_name stays blank (no ///Tun) in compiler and importer - handshake debounce uses its own clock so a drop-off right after sustained_traffic is still logged - follow_gateway devices return before the empty-IP check Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../view/wificalling-gateway/overview.js | 14 ++++++++--- .../wificalling-gateway/node-import.js | 23 ++++++++++--------- .../po/templates/wificalling-gateway.pot | 12 +++++----- .../po/zh_Hans/wificalling-gateway.po | 12 +++++----- .../root/etc/init.d/wificalling-gateway | 13 +++++++---- .../libexec/wificalling-gateway/compiler.sh | 6 +++-- .../libexec/wificalling-gateway/monitor.sh | 15 ++++++++---- 8 files changed, 58 insertions(+), 39 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 4fe49afab31e..722f9ae4b7cf 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.2 +PKG_VERSION:=1.9.3 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 1f7cfc70102e..28bf023c1121 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -356,10 +356,14 @@ return view.extend({ udpMode.modalonly = true; var transport = s.option(form.ListValue, 'transport', _('Transport')); transport.value('', _('None')); transport.value('ws', _('WebSocket')); + // Imported links can carry grpc/httpupgrade; the select must declare + // them or ui.Select falls back to None and the first Save silently + // drops the transport. Technical names stay English (no _()). + transport.value('grpc', 'gRPC'); transport.value('httpupgrade', 'HTTPUpgrade'); transport.modalonly = true; - var pathOpt = s.option(form.Value, 'path', _('WebSocket path')); + var pathOpt = s.option(form.Value, 'path', _('Transport path')); pathOpt.modalonly = true; - var hostOpt = s.option(form.Value, 'host', _('WebSocket Host')); + var hostOpt = s.option(form.Value, 'host', _('Transport host')); hostOpt.modalonly = true; var wgKey = s.option(form.Value, 'private_key', _('WireGuard private key')); wgKey.password = true; wgKey.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; @@ -409,7 +413,11 @@ return view.extend({ // Only RFC1918 can reach the nftables set and the generated config: // reject public/CGNAT addresses at input time with a readable hint. ips.validate = function(section_id, value) { - var m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\s*$/.exec(value || ''); + // The DynamicList container validator and the cleared add-item + // input both call this with ''; the "at least one address" rule + // is enforced by rmempty, so an empty value is not an error here. + if (!value) return true; + var m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\s*$/.exec(value); if (!m) return _('Invalid IPv4 address'); for (var i = 1; i <= 4; i++) { var octet = m[i]; diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index af92726a68e3..0907862107d1 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -63,15 +63,15 @@ function parseUrl(uri, protocol) { // transport, so the node would never connect here. throw new Error(_('xhttp transport is not supported by sing-box (use ws/grpc/httpupgrade)')); } - if (vless_type === 'ws' || vless_type === 'grpc' || vless_type === 'httpupgrade') { - out.transport = vless_type; - out.host = p.get('host') || ''; - // grpc carries no path; its service_name goes in the path slot - // (the UCI/normalized.conf layout has no separate field). - out.path = (vless_type === 'grpc') - ? (p.get('serviceName') || p.get('service_name') || '/') - : (p.get('path') || '/'); - } + if (vless_type === 'ws' || vless_type === 'grpc' || vless_type === 'httpupgrade') { + out.transport = vless_type; + out.host = p.get('host') || ''; + // grpc carries no path; its service_name goes in the path slot + // (the UCI/normalized.conf layout has no separate field). + out.path = (vless_type === 'grpc') + ? (p.get('serviceName') || p.get('service_name') || '') + : (p.get('path') || '/'); + } } else if (protocol === 'wireguard') { // wg://@:?private_key=…&local_address=…&reserved=…&mtu=… out.public_key = decodeURIComponent(url.username || ''); @@ -103,8 +103,9 @@ function parseVmess(uri) { if (raw_net === 'ws' || raw_net === 'grpc' || raw_net === 'httpupgrade') { out.transport = raw_net; out.host = raw.host || ''; - // grpc: service_name goes in the path slot. - out.path = raw.path || '/'; + // grpc: service_name goes in the path slot; a blank link stays + // blank instead of defaulting to "/" (which dials "///Tun"). + out.path = (raw_net === 'grpc') ? (raw.path || '') : (raw.path || '/'); } return out; } diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 0dd55fa94c38..828f16a74959 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -395,6 +395,12 @@ msgstr "" msgid "Transport" msgstr "" +msgid "Transport host" +msgstr "" + +msgid "Transport path" +msgstr "" + msgid "UDP 500/4500" msgstr "" @@ -434,12 +440,6 @@ msgstr "" msgid "WebSocket" msgstr "" -msgid "WebSocket Host" -msgstr "" - -msgid "WebSocket path" -msgstr "" - msgid "Wi-Fi Calling" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 835eb9824256..caa614fb7cab 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -396,6 +396,12 @@ msgstr "超时" msgid "Transport" msgstr "传输" +msgid "Transport host" +msgstr "传输主机" + +msgid "Transport path" +msgstr "传输路径" + msgid "UDP 500/4500" msgstr "UDP 500/4500" @@ -435,12 +441,6 @@ msgstr "警告" msgid "WebSocket" msgstr "WebSocket" -msgid "WebSocket Host" -msgstr "WebSocket 主机" - -msgid "WebSocket path" -msgstr "WebSocket 路径" - msgid "Wi-Fi Calling" msgstr "Wi-Fi Calling" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index cd276f69a6df..58eb5d804396 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -79,6 +79,10 @@ append_device() { config_get label "$s" label "$s"; config_get node "$s" node; config_get route_mode "$s" route_mode independent case "$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip + # A follow_gateway device is never intercepted and its source_ip values + # are unused, so return before the empty-IP check can log a misleading + # "skipping" line for it. + [ "$route_mode" = independent ] || return 0 if [ -z "$DEVICE_IPS" ]; then # Every source_ip was rejected by the validation: skip the device # (it falls back to direct routing) instead of failing the whole @@ -86,7 +90,6 @@ append_device() { logger -t "$APP" "device $s has no valid client IP, skipping" return 0 fi - [ "$route_mode" = independent ] || return 0 local safe_label safe_label=$(printf '%s' "$label" | tr '|' ' ') printf 'device|%s|%s|%s\n' "$safe_label" "$node" "$DEVICE_IPS" >> "$RUNDIR/normalized.conf" @@ -124,10 +127,10 @@ start_service() { /usr/bin/sing-box check -c "$RUNDIR/sing-box.json" || { logger -t "$APP" "sing-box rejected generated configuration"; return 1; } /usr/libexec/$APP/firewall.sh start "$RUNDIR/clients" || { logger -t "$APP" "firewall setup failed"; /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients"; return 1; } procd_open_instance sing-box - # Low-memory sing-box profile (aligned with wloc 1.3): one worker and a - # moderate GC target reduce RSS on constrained routers without imposing - # an artificial heap ceiling (the earlier GOMEMLIMIT=24MiB cap was - # dropped upstream after AX6S burst demand showed it was too tight). + # Low-memory profile: one worker plus a 75% GC target trade throughput + # for RSS on constrained routers. No GOMEMLIMIT — a hard heap cap + # stalls sing-box under burst load (e.g. a handshake probe while a + # call is active), so let the process size its heap freely. procd_set_param env "GOMAXPROCS=1" "GOGC=75" procd_set_param command /usr/bin/sing-box run -c "$RUNDIR/sing-box.json" procd_set_param respawn 3600 5 5 diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index d13033018e34..4baa35e46277 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -117,14 +117,16 @@ END { # mis-imported node fails the compile with a readable reason. if (f[17]=="xhttp") fail("xhttp transport is not supported by sing-box") if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" - else if (f[17]=="grpc") s=s ",\"transport\":{\"type\":\"grpc\",\"service_name\":" q(f[18]!=""?f[18]:"/") "}" + # service_name is the service half of the gRPC method path; keep a + # blank link blank instead of inventing "/" (which dials "///Tun"). + else if (f[17]=="grpc") s=s ",\"transport\":{\"type\":\"grpc\",\"service_name\":" q(f[18]) "}" else if (f[17]=="httpupgrade") s=s ",\"transport\":{\"type\":\"httpupgrade\",\"path\":" q(f[18]) ",\"host\":" q(f[19]) "}" } if (p=="vmess") { s=s ",\"uuid\":" q(f[6]) ",\"security\":\"auto\",\"alter_id\":" (f[10]~/^[0-9]+$/?f[10]:0) if (f[17]=="xhttp") fail("xhttp transport is not supported by sing-box") if (f[17]=="ws") s=s ",\"transport\":{\"type\":\"ws\",\"path\":" q(f[18]) ",\"headers\":{\"Host\":" q(f[19]) "}}" - else if (f[17]=="grpc") s=s ",\"transport\":{\"type\":\"grpc\",\"service_name\":" q(f[18]!=""?f[18]:"/") "}" + else if (f[17]=="grpc") s=s ",\"transport\":{\"type\":\"grpc\",\"service_name\":" q(f[18]) "}" else if (f[17]=="httpupgrade") s=s ",\"transport\":{\"type\":\"httpupgrade\",\"path\":" q(f[18]) ",\"host\":" q(f[19]) "}" # Imported VMess links carry the TLS name in the WS Host (f[19]) when # sni (f[7]) is empty and the server is a bare IP; fall back to it so diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh index 8ab667e9a99c..091679da92ca 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -35,6 +35,7 @@ FILENAME==state_file { old_wfc[i]=$3; old_sent[i]=$4+0; old_reply[i]=$5+0; old_last[i]=$6+0 old_event[i]=$7+0; old_streak[i]=$8+0; old_acc_sent[i]=$9+0; old_acc_reply[i]=$10+0 old_traffic_since[i]=($11!="" ? $11+0 : 0) + old_hs[i]=($12!="" ? $12+0 : 0) } next } @@ -82,15 +83,19 @@ END { printf "\"epdg_ip\":%s,\"ike_seen\":%s,\"nat_t_seen\":%s,\"assured\":%s,", q(epdg[i]),(ike[i]?"true":"false"),(natt[i]?"true":"false"),(assured[i]?"true":"false") printf "\"sent_packets\":%d,\"reply_packets\":%d,\"delta_sent\":%d,\"delta_reply\":%d,\"last_activity\":%d,\"activity_evidence\":%s}", sent[i]+0,reply[i]+0,ds,dr,last,q(activity) if (log_enabled) { - if (handshake_success && now-old_event[i]>=15) { + # Handshake events use their own debounce clock (old_hs): a + # sustained_traffic line must not suppress a genuine drop-off that + # follows the call within 15 s. Both timestamps are reset on a + # handshake event so sustained still waits for event_interval. + if (handshake_success && now-old_hs[i]>=15) { print now "|" label[i] "|" ip[i] "|handshake_success|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out - old_event[i]=now; acc_sent=0; acc_reply=0 - } else if (handshake_failed && now-old_event[i]>=15) { + old_hs[i]=now; old_event[i]=now; acc_sent=0; acc_reply=0 + } else if (handshake_failed && now-old_hs[i]>=15) { # A flapping device state (registered <-> not_detected within a # second) used to write a handshake event on every flip, flooding # the log. Debounce to at most one handshake event per 15s. print now "|" label[i] "|" ip[i] "|handshake_failed|" ds "|" dr "|call_or_sms_unknown|" wfc > event_out - old_event[i]=now; acc_sent=0; acc_reply=0 + old_hs[i]=now; old_event[i]=now; acc_sent=0; acc_reply=0 } else if (sustained) { # Sustained bidirectional traffic after registration is the # signature of a voice call (ringing or in-call RTP); the tunnel @@ -99,7 +104,7 @@ END { old_event[i]=now; acc_sent=0; acc_reply=0 } } - print label[i] "|" ip[i] "|" wfc "|" sent[i]+0 "|" reply[i]+0 "|" last "|" old_event[i]+0 "|" streak "|" acc_sent "|" acc_reply "|" traffic_since+0 > state_out + print label[i] "|" ip[i] "|" wfc "|" sent[i]+0 "|" reply[i]+0 "|" last "|" old_event[i]+0 "|" streak "|" acc_sent "|" acc_reply "|" traffic_since+0 "|" old_hs[i]+0 > state_out } print "]}" } From 64976939be0058fd2ba3da09519d04cfd797bea0 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 25 Aug 2026 18:51:36 +0800 Subject: [PATCH 44/56] luci-app-wificalling-gateway: fix round 21 nits - init.d: follow_gateway early return moves above source_ip parsing - node-import.js: restore sibling indentation (format only) - overview.js: Transport path description names the gRPC service-name meaning; Transport host offered only for ws/httpupgrade Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../view/wificalling-gateway/overview.js | 5 +++++ .../wificalling-gateway/node-import.js | 18 +++++++++--------- .../po/templates/wificalling-gateway.pot | 3 +++ .../po/zh_Hans/wificalling-gateway.po | 3 +++ .../root/etc/init.d/wificalling-gateway | 6 +++--- 6 files changed, 24 insertions(+), 13 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 722f9ae4b7cf..d67074818e1b 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.3 +PKG_VERSION:=1.9.4 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 28bf023c1121..3581ccfb7c05 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -362,8 +362,13 @@ return view.extend({ transport.value('grpc', 'gRPC'); transport.value('httpupgrade', 'HTTPUpgrade'); transport.modalonly = true; var pathOpt = s.option(form.Value, 'path', _('Transport path')); + pathOpt.description = _('Path for WebSocket/HTTPUpgrade, or gRPC service name for gRPC'); pathOpt.modalonly = true; var hostOpt = s.option(form.Value, 'host', _('Transport host')); + // host is only meaningful for the header-carrying transports; the + // gRPC compiler arm emits service_name and never reads host. + hostOpt.depends('transport', 'ws'); + hostOpt.depends('transport', 'httpupgrade'); hostOpt.modalonly = true; var wgKey = s.option(form.Value, 'private_key', _('WireGuard private key')); wgKey.password = true; wgKey.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index 0907862107d1..4e2fc2bcbf3e 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -63,15 +63,15 @@ function parseUrl(uri, protocol) { // transport, so the node would never connect here. throw new Error(_('xhttp transport is not supported by sing-box (use ws/grpc/httpupgrade)')); } - if (vless_type === 'ws' || vless_type === 'grpc' || vless_type === 'httpupgrade') { - out.transport = vless_type; - out.host = p.get('host') || ''; - // grpc carries no path; its service_name goes in the path slot - // (the UCI/normalized.conf layout has no separate field). - out.path = (vless_type === 'grpc') - ? (p.get('serviceName') || p.get('service_name') || '') - : (p.get('path') || '/'); - } + if (vless_type === 'ws' || vless_type === 'grpc' || vless_type === 'httpupgrade') { + out.transport = vless_type; + out.host = p.get('host') || ''; + // grpc carries no path; its service_name goes in the path slot + // (the UCI/normalized.conf layout has no separate field). + out.path = (vless_type === 'grpc') + ? (p.get('serviceName') || p.get('service_name') || '') + : (p.get('path') || '/'); + } } else if (protocol === 'wireguard') { // wg://@:?private_key=…&local_address=…&reserved=…&mtu=… out.public_key = decodeURIComponent(url.username || ''); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 828f16a74959..ede9081abe63 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -269,6 +269,9 @@ msgstr "" msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service." msgstr "" +msgid "Path for WebSocket/HTTPUpgrade, or gRPC service name for gRPC" +msgstr "" + msgid "Pick a device to fill its label and IP." msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index caa614fb7cab..4667e3336ede 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -270,6 +270,9 @@ msgstr "密码" msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service." msgstr "粘贴一个 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan 或 WireGuard (wg://) 链接。链接仅在本浏览器中本地解析,不会发送到外部服务。" +msgid "Path for WebSocket/HTTPUpgrade, or gRPC service name for gRPC" +msgstr "WebSocket/HTTPUpgrade 的路径,或 gRPC 的 service name" + msgid "Pick a device to fill its label and IP." msgstr "选择设备以自动填写名称和 IP。" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 58eb5d804396..f61c205c7997 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -78,11 +78,11 @@ append_device() { config_get_bool enabled "$s" enabled 1; [ "$enabled" -eq 1 ] || return 0 config_get label "$s" label "$s"; config_get node "$s" node; config_get route_mode "$s" route_mode independent case "$node" in *'|'*) logger -t "$APP" "invalid delimiter in device $s"; return 1;; esac - DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip # A follow_gateway device is never intercepted and its source_ip values - # are unused, so return before the empty-IP check can log a misleading - # "skipping" line for it. + # are unused, so return before they are parsed: neither append_ip's + # per-address warning nor the empty-IP "skipping" line applies to it. [ "$route_mode" = independent ] || return 0 + DEVICE_IPS=""; config_list_foreach "$s" source_ip append_ip if [ -z "$DEVICE_IPS" ]; then # Every source_ip was rejected by the validation: skip the device # (it falls back to direct routing) instead of failing the whole From c2be224e13794b7f0fdb55dcc48e0c2bef8a639d Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 26 Aug 2026 01:11:07 +0800 Subject: [PATCH 45/56] luci-app-wificalling-gateway: fix round 22 findings - overview.js: drop hostOpt.depends() - host doubles as the TLS server_name fallback for VLESS/VMess when sni is empty, and an inactive option is removed from UCI on save, so hiding it stripped imported nodes' certificate name on the first edit - node-import.js: re-indent the vless branch body to two tabs PKG_VERSION 1.9.4 -> 1.9.5 in this commit. Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../view/wificalling-gateway/overview.js | 7 +++---- .../wificalling-gateway/node-import.js | 18 +++++++++--------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index d67074818e1b..0651796558b0 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.4 +PKG_VERSION:=1.9.5 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 3581ccfb7c05..6f72118169ed 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -365,10 +365,9 @@ return view.extend({ pathOpt.description = _('Path for WebSocket/HTTPUpgrade, or gRPC service name for gRPC'); pathOpt.modalonly = true; var hostOpt = s.option(form.Value, 'host', _('Transport host')); - // host is only meaningful for the header-carrying transports; the - // gRPC compiler arm emits service_name and never reads host. - hostOpt.depends('transport', 'ws'); - hostOpt.depends('transport', 'httpupgrade'); + // host is also the TLS server_name fallback for VLESS/VMess when + // sni is empty, so it must stay active for every transport: hiding + // it via depends() would drop the value from UCI on save (round 22). hostOpt.modalonly = true; var wgKey = s.option(form.Value, 'private_key', _('WireGuard private key')); wgKey.password = true; wgKey.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js index 4e2fc2bcbf3e..3d9101ce328a 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -47,15 +47,15 @@ function parseUrl(uri, protocol) { out.congestion = p.get('congestion_control') || p.get('congestion') || 'bbr'; out.udp_mode = p.get('udp_relay_mode') || 'native'; } else if (protocol === 'vless') { - out.uuid = decodeURIComponent(url.username || ''); - out.flow = p.get('flow') || ''; - out.security = p.get('security') || ''; - out.sni = p.get('sni') || ''; - // pbk/sid are base64url in practice, but some generators emit - // standard base64, which URLSearchParams would corrupt the same - // way (see pinSHA256): restoring '+' is a no-op on base64url. - out.public_key = (p.get('pbk') || p.get('publicKey') || '').replace(/ /g, '+'); - out.short_id = (p.get('sid') || p.get('shortId') || '').replace(/ /g, '+'); + out.uuid = decodeURIComponent(url.username || ''); + out.flow = p.get('flow') || ''; + out.security = p.get('security') || ''; + out.sni = p.get('sni') || ''; + // pbk/sid are base64url in practice, but some generators emit + // standard base64, which URLSearchParams would corrupt the same + // way (see pinSHA256): restoring '+' is a no-op on base64url. + out.public_key = (p.get('pbk') || p.get('publicKey') || '').replace(/ /g, '+'); + out.short_id = (p.get('sid') || p.get('shortId') || '').replace(/ /g, '+'); out.fingerprint = p.get('fp') || p.get('fingerprint') || 'chrome'; var vless_type = p.get('type') || ''; if (vless_type === 'xhttp') { From 4ee5002876db814c7fd9704d8a77cec9a5aeb4e7 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 26 Aug 2026 13:45:46 +0800 Subject: [PATCH 46/56] luci-app-wificalling-gateway: drop review-round markers from comments Comment-only, no functional change, no version bump. Round numbers in comments refer to this PR's review iterations and are meaningless in-tree after the squash-merge (round 23 nit; node-health.sh had the same stale marker). Signed-off-by: Smth Dagg --- .../luci-static/resources/view/wificalling-gateway/overview.js | 2 +- .../root/usr/libexec/wificalling-gateway/node-health.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 6f72118169ed..f94263ad8d52 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -367,7 +367,7 @@ return view.extend({ var hostOpt = s.option(form.Value, 'host', _('Transport host')); // host is also the TLS server_name fallback for VLESS/VMess when // sni is empty, so it must stay active for every transport: hiding - // it via depends() would drop the value from UCI on save (round 22). + // it via depends() would drop the value from UCI on save. hostOpt.modalonly = true; var wgKey = s.option(form.Value, 'private_key', _('WireGuard private key')); wgKey.password = true; wgKey.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 49f2bc262634..1f1f08f289bc 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -165,7 +165,7 @@ wg_handshake_test() { elif [ $? -eq 2 ]; then # Contention: no probe was attempted, don't claim the # peer is down — report "testing" so the status page - # shows a neutral third state (review round 11). + # shows a neutral third state. state=testing; ping_json=null; reason_json=null else state=handshake_failed; ping_json=null From c8df7ef1dc2a96fa32c8b42cb329c4cbe58db11b Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 9 Sep 2026 17:58:17 +0800 Subject: [PATCH 47/56] luci-app-wificalling-gateway: fix unresolved review threads (1.9.6) Six review threads were still open on the current head: - Makefile: restore the Package/.../conffiles block for /etc/config/wificalling-gateway so opkg/apk upgrade keeps the user's nodes and device policies instead of replacing them with the shipped defaults; luci.mk supplies nothing here implicitly. - Makefile: include luci.mk relatively (../../luci.mk) like the other applications in this tree; the absolute $(TOPDIR)/feeds/luci path breaks when the feed is checked out under a different name in feeds.conf. - overview.js: route Save and Save & Apply through one commitAndApply() path with a handleSaveApply override - the inherited handler fired a second ui.changes.apply() while the first was still in flight (two apply_rollback posts, two overlapping modals). mode mirrors the stock ComboButton values ('0' = Save & Apply checked, '1' = Apply unchecked) and is passed to ui.changes.apply() as mode == '0'. - acl.d: allow the lease file at any location via the glob '/*/dhcp.leases' (rpcd matches with fnmatch, '*' crosses '/'); both the view and dhcp-sync.sh honour dhcp.@dnsmasq[0].leasefile, which rpcd previously denied for any non-default path. - dhcp-sync.sh: add a 'clear' mode that removes every plugin-managed wfc_ host; stop_service() calls it when the gateway is disabled or stopped (a plain restart keeps the bindings and re-syncs on start), so no dnsmasq side effects outlive the service. - init.d: fix the delimiter-guard comment - auxiliary is field 10, not field 8. Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 15 ++++++++-- .../view/wificalling-gateway/overview.js | 28 +++++++++++++------ .../root/etc/init.d/wificalling-gateway | 10 ++++++- .../libexec/wificalling-gateway/dhcp-sync.sh | 22 +++++++++++++-- .../acl.d/luci-app-wificalling-gateway.json | 3 +- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 0651796558b0..ac2d9db72e16 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.5 +PKG_VERSION:=1.9.6 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE @@ -17,4 +17,15 @@ LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway LUCI_DEPENDS:=+luci-base +sing-box +curl +nftables +kmod-nft-tproxy +kmod-nft-socket +ip-full LUCI_PKGARCH:=all -include $(TOPDIR)/feeds/luci/luci.mk +# Without this declaration the shipped /etc/config/wificalling-gateway is a +# plain payload file: opkg/apk upgrade replaces every node credential and +# device policy with the package defaults. luci.mk supplies nothing here +# implicitly; the other apps that ship a real /etc/config file declare it. +define Package/luci-app-wificalling-gateway/conffiles +/etc/config/wificalling-gateway +endef + +# Relative include, like the other applications in this tree: an absolute +# $(TOPDIR)/feeds/luci path breaks when the feed is checked out under a +# different name in feeds.conf. +include ../../luci.mk diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index f94263ad8d52..379ec84e12c7 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -495,18 +495,28 @@ return view.extend({ return E([], [importPanel, formNode]); }); }, - handleSave: function(ev) { - // On LuCI 24.10 Map.save() only stages a session-scoped UCI - // changeset; the changes are committed by ui.changes.apply() - // (upstream's own Save & Apply path), and the default handler's - // #maincontent .cbi-map lookup also fails under out-of-tree - // themes. Save through the form instance and commit+apply so the - // plain "Save" button persists. Older LuCI applies inside - // Map.save() and has no ui.changes, hence the guard. + // Single commit+apply path shared by both footer buttons. On LuCI + // 24.10 Map.save() only stages a session-scoped UCI changeset; the + // changes are committed by ui.changes.apply() (older LuCI applies + // inside Map.save() and has no ui.changes, hence the guard). The + // inherited handleSaveApply calls handleSave() and then applies again, + // so keeping an apply inside handleSave fired two overlapping + // apply_rollback posts and two modals — Save and Save & Apply both + // route through here, so exactly one apply runs per click. mode + // mirrors the upstream ComboButton values: '0' is Save & Apply + // (checked), '1' is Apply unchecked — pass mode == '0' to + // ui.changes.apply() exactly like the stock footer does. + commitAndApply: function(mode) { var m = this.mapInstance; if (!m) return Promise.resolve(); return m.save().then(function() { - if (ui.changes) return ui.changes.apply(true); + if (ui.changes) return ui.changes.apply(mode == '0'); }); + }, + handleSave: function(ev) { + return this.commitAndApply('0'); + }, + handleSaveApply: function(ev, mode) { + return this.commitAndApply(mode == null ? '0' : mode); } }); diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index f61c205c7997..c4b2c360d1ee 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -35,7 +35,7 @@ append_node() { # | -delimited normalized.conf record. label is deliberately excluded: # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and # labels never reach normalized.conf — the nodes file (health checks) - # gets a sanitized copy instead. credential/auxiliary (f[6]/f[8]) are + # gets a sanitized copy instead. credential/auxiliary (f[6]/f[10]) are # derived from password/uuid/flow/alter_id/private_key, all guarded # here, so they need no separate check. case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$alter_id$pre_shared_key" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac @@ -150,6 +150,14 @@ start_service() { stop_service() { /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients" + # Drop the plugin-managed static leases when the gateway is disabled or + # stopped for good, so no dnsmasq side effects outlive the service. On + # a plain restart the enabled check keeps the bindings in place and the + # start path re-syncs them instead — the clear branch only runs when + # the service is actually going away. + config_load "$APP" + config_get_bool enabled main enabled 0 + [ "$enabled" -eq 1 ] || /usr/libexec/$APP/dhcp-sync.sh clear || logger -t "$APP" "dhcp lease cleanup failed" # Also drop the stale snapshot when the gateway is stopped/disabled so # the status page does not keep rendering the last state indefinitely. : > "$RUNDIR/status.json" 2>/dev/null || true diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh index 6d89f32811ad..e7644a0d2b7b 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh @@ -11,6 +11,26 @@ set -eu # user-managed hosts are left alone. # # Usage: dhcp-sync.sh (clients: label|ip|node per line) +# dhcp-sync.sh clear (remove every plugin-managed wfc_ lease; +# run on service stop/disable — a restart +# re-creates them from the live lease table) + +# WFC_DNSMASQ overrides the dnsmasq init script (used by the test suite). +dnsmasq_init=${WFC_DNSMASQ:-/etc/init.d/dnsmasq} + +if [ "${1:-}" = clear ]; then + changed=0 + for host in $(uci show dhcp 2>/dev/null | sed -n 's/^dhcp\.\(wfc_[^=]*\)=host$/\1/p'); do + uci -q delete "dhcp.$host"; changed=1 + done + if [ "$changed" -eq 1 ]; then + uci commit dhcp + if ! "$dnsmasq_init" restart; then + logger -t wificalling-gateway "dhcp-sync: dnsmasq restart failed after lease cleanup; check the dhcp-host configuration" + fi + fi + exit 0 +fi clients=${1:?clients file required} # The dnsmasq lease file location is a UCI option; fall back to the @@ -18,8 +38,6 @@ clients=${1:?clients file required} leasefile=$(uci -q get dhcp.@dnsmasq[0].leasefile 2>/dev/null || true) [ -n "$leasefile" ] || leasefile=/tmp/dhcp.leases [ -f "$leasefile" ] || exit 0 -# WFC_DNSMASQ overrides the dnsmasq init script (used by the test suite). -dnsmasq_init=${WFC_DNSMASQ:-/etc/init.d/dnsmasq} valid_ip() { case "$1" in diff --git a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json index fb4944954ea4..806bec0ec18a 100644 --- a/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -14,7 +14,8 @@ "/tmp/run/wificalling-gateway/node-status.json": [ "read" ], "/tmp/run/wificalling-gateway/events.log": [ "read" ], "/tmp/dhcp.leases": [ "read" ], - "/etc/dhcp.leases": [ "read" ] + "/etc/dhcp.leases": [ "read" ], + "/*/dhcp.leases": [ "read" ] } }, "write": { From e1080ff737b09df414670db59f2fd604c8759074 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Thu, 10 Sep 2026 08:43:42 +0800 Subject: [PATCH 48/56] luci-app-wificalling-gateway: fix round-30, id whitelist (1.9.7) - overview.js: when the configured lease file cannot be read (rpcd only allows the basename dhcp.leases in any directory; a custom filename is refused), fall back to /tmp/dhcp.leases so the binding column keeps working on routers that point leasefile elsewhere. - node-health.sh/node-test.sh: whitelist the node id before it interpolates into /tmp filenames (defense in depth; ids are UCI section names from argv, never user input). - status.js: show the node label instead of the UCI section id in the Node column. - monitor.sh: require a monitor.state baseline before emitting handshake events, so a fresh install's first tick no longer logs a spurious handshake_success/handshake_failed. Signed-off-by: Smth Dagg --- .../resources/view/wificalling-gateway/overview.js | 9 ++++++++- .../resources/view/wificalling-gateway/status.js | 11 +++++++++-- .../root/usr/libexec/wificalling-gateway/monitor.sh | 7 +++++-- .../usr/libexec/wificalling-gateway/node-health.sh | 4 ++++ .../root/usr/libexec/wificalling-gateway/node-test.sh | 4 ++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index 379ec84e12c7..c443818b5ac6 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -24,10 +24,17 @@ return view.extend({ // contradicts the actual bindings on routers that move the // lease file (e.g. to persist across reboots). var leasefile = uci.get('dhcp', '@dnsmasq[0]', 'leasefile') || '/tmp/dhcp.leases'; + var leaseRead = L.resolveDefault(fs.read(leasefile), ''); + // rpcd only allows lease files whose basename is dhcp.leases + // (any directory); a custom filename is refused and resolved + // to ''. Fall back to the default file so the binding column + // keeps working on routers that point leasefile elsewhere. + if (leasefile !== '/tmp/dhcp.leases') + leaseRead = leaseRead.then(function(v) { return v || L.resolveDefault(fs.read('/tmp/dhcp.leases'), ''); }); return Promise.all([ L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), uci.load('wificalling-gateway'), - L.resolveDefault(fs.read(leasefile), ''), + leaseRead, L.resolveDefault(fs.read('/proc/net/arp'), '') ]); }); diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js index 842d4c74e5b7..c4f5190229c3 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js @@ -3,12 +3,14 @@ 'require fs'; 'require poll'; 'require dom'; +'require uci'; return view.extend({ load: function() { return Promise.all([ L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}'), - L.resolveDefault(fs.read('/var/run/wificalling-gateway/service-health.json'), '{}') + L.resolveDefault(fs.read('/var/run/wificalling-gateway/service-health.json'), '{}'), + uci.load('wificalling-gateway') ]); }, render: function(data) { @@ -72,9 +74,14 @@ return view.extend({ }, 30); /* ---------- Device tunnel status ---------- */ + function nodeLabel(id) { + // d.node is the UCI section id (e.g. cfgABCD); show the + // friendly label the user assigned instead. + return uci.get('wificalling-gateway', id, 'label') || id; + } function rows(source) { return (source.devices || []).map(function(d) { - var values = [d.label, d.ip, wfcLabel(d.wificalling || d.state), d.node || '-', d.epdg_ip || '-', + var values = [d.label, d.ip, wfcLabel(d.wificalling || d.state), nodeLabel(d.node), d.epdg_ip || '-', (d.ike_seen ? '500' : '-') + ' / ' + (d.nat_t_seen ? '4500' : '-'), d.assured ? _('Yes') : _('No'), d.sent_packets + ' ↑ / ' + d.reply_packets + ' ↓', when(d.last_activity)]; return E('tr', { class: 'tr' }, values.map(function(x) { return E('td', { class: 'td' }, String(x)); })); diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh index 091679da92ca..b3c9ba352fdb 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -75,8 +75,11 @@ END { streak=0; traffic_since=0 } acc_sent=old_acc_sent[i]+ds; acc_reply=old_acc_reply[i]+dr - handshake_success=(old_wfc[i]!="registered" && wfc=="registered") - handshake_failed=(wfc=="not_detected" && (old_wfc[i]=="registered" || old_wfc[i]=="connecting")) + # A pristine monitor.state (fresh install) has no baseline: emit + # neither a false handshake_success nor a false handshake_failed on + # the first tick - the state file is built from this run snapshot. + handshake_success=(old_wfc[i]!="" && old_wfc[i]!="registered" && wfc=="registered") + handshake_failed=(old_wfc[i]!="" && wfc=="not_detected" && (old_wfc[i]=="registered" || old_wfc[i]=="connecting")) sustained=(!handshake_success && wfc=="registered" && streak>=1 && traffic_since>0 && now-traffic_since>=3 && now-old_event[i]>=event_interval) printf "%s{", (i>1?",":"") printf "\"label\":%s,\"ip\":%s,\"node\":%s,\"state\":%s,\"wificalling\":%s,", q(label[i]),q(ip[i]),q(node[i]),q(legacy),q(wfc) diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 1f1f08f289bc..ba81d63b202e 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -28,6 +28,10 @@ json_escape() { # port would hand each other the wrong exit IP. wg_handshake_test() { local id=$1 server=$2 port=$3 cache cache_ts age lock lock_pid lock_age held priv pub local_addr psk mtu reserved lport cfg pid ip reason probe_url result + # The id interpolates into /tmp filenames below; only UCI section + # names (cfgNNNN / user letters) are legit. Reject anything else + # before any path is ever touched. + case "$id" in ''|*[!A-Za-z0-9_]*) return 1;; esac cache="/tmp/wg-health-$id" if [ -f "$cache" ]; then cache_ts=$(sed -n '1p' "$cache" 2>/dev/null || echo 0) diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh index fdf0845a38dc..c5963ee0b0e2 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh @@ -13,6 +13,10 @@ set -eu id=${1:?node id required} +# The id reaches /tmp filenames via wg_handshake_test and the cache +# delete below; only UCI section names are legit. Reject anything +# else before any path is touched (mirrors node-health.sh's guard). +case "$id" in ''|*[!A-Za-z0-9_]*) printf '{"state":"failed","reason":"invalid_id"}\n'; exit 0;; esac server=$(uci -q get "wificalling-gateway.$id.server") || true port=$(uci -q get "wificalling-gateway.$id.port") || true From e281ca39190f31b95d0bbdd37d4ab83e8bcba386 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Thu, 10 Sep 2026 21:26:26 +0000 Subject: [PATCH 49/56] luci-app-wificalling-gateway: fix round-31 findings (1.9.7) - status.js: the status file carries the sing-box outbound tag (node-
), not the section id, so the Node column rendered node-cfgXXXX and never the friendly label. Strip the prefix before the UCI lookup and restore the '-' fallback for a device with no node. - overview.js: the leasefile fallback now keys off the rejected read instead of a falsy value, so a configured lease file that is merely empty stays authoritative instead of being replaced by /tmp/dhcp.leases. - node-health.sh: validate the node id in the read loop, where it is interpolated into the /tmp/wg-health-* paths unvalidated, instead of only inside wg_handshake_test. An id outside the UCI section-name shape is skipped rather than reported as an ordinary unreachable node. - node-test.sh emits `invalid_id`, which had no arm in wgFailReason / wgFailDetail: both gain one plus the two zh_Hans entries, so the banner no longer renders the bare untranslated identifier. - init.d: stop_service drops the plugin-managed dnsmasq leases unconditionally. This hook cannot tell a restart from a stop (both arrive with enabled=1), so gating the clear on the enabled flag left `wfc_` host sections in /etc/config/dhcp behind on an explicit stop and on package removal, where nothing else would ever remove them. start_service re-syncs the bindings, so a restart pays one extra UCI commit and dnsmasq reload, and clear is a no-op when there is nothing to remove. - Makefile: PKG_VERSION 1.9.6 -> 1.9.7. e1080ff7 announced 1.9.7 in its subject and body without touching the Makefile; this commit makes the version real instead of skipping the number. Verified locally: 88/88 unit tests, sh -n / node --check / git diff --check clean, and all three platform packages install on the official rootfs via docker (24.10 ipk / 18.06 ipk / 25.12 apk). Signed-off-by: Smth Dagg --- .../luci-app-wificalling-gateway/Makefile | 2 +- .../view/wificalling-gateway/overview.js | 21 ++++++++++++------- .../view/wificalling-gateway/status.js | 9 ++++---- .../po/templates/wificalling-gateway.pot | 6 ++++++ .../po/zh_Hans/wificalling-gateway.po | 6 ++++++ .../root/etc/init.d/wificalling-gateway | 16 +++++++------- .../wificalling-gateway/node-health.sh | 6 +++++- 7 files changed, 45 insertions(+), 21 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index ac2d9db72e16..5152f5bad53e 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.6 +PKG_VERSION:=1.9.7 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index c443818b5ac6..e15b43adab6f 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -24,13 +24,17 @@ return view.extend({ // contradicts the actual bindings on routers that move the // lease file (e.g. to persist across reboots). var leasefile = uci.get('dhcp', '@dnsmasq[0]', 'leasefile') || '/tmp/dhcp.leases'; - var leaseRead = L.resolveDefault(fs.read(leasefile), ''); // rpcd only allows lease files whose basename is dhcp.leases - // (any directory); a custom filename is refused and resolved - // to ''. Fall back to the default file so the binding column - // keeps working on routers that point leasefile elsewhere. - if (leasefile !== '/tmp/dhcp.leases') - leaseRead = leaseRead.then(function(v) { return v || L.resolveDefault(fs.read('/tmp/dhcp.leases'), ''); }); + // (any directory); a custom filename is refused. Fall back to + // the default file so the binding column keeps working on + // routers that point leasefile elsewhere. The fallback keys + // off the rejection, not an empty read: a configured lease + // file that is simply empty must stay authoritative. + var leaseRead = fs.read(leasefile).catch(function() { + return leasefile !== '/tmp/dhcp.leases' + ? L.resolveDefault(fs.read('/tmp/dhcp.leases'), '') + : ''; + }); return Promise.all([ L.resolveDefault(fs.read('/var/run/wificalling-gateway/node-status.json'), '{}'), uci.load('wificalling-gateway'), @@ -71,7 +75,8 @@ return view.extend({ // Short reason label and full explanation for a failed node test. // Reasons come from node-health.sh's cache (config_missing / // timeout / unreachable) and from node-test.sh (no_server / - // no_health_script / no_tcp_probe / tcp_failed / busy). + // no_health_script / no_tcp_probe / tcp_failed / invalid_id / + // busy). function wgFailReason(reason) { if (reason === 'config_missing') return _('Missing config'); if (reason === 'no_server') return _('Missing server/port'); @@ -81,6 +86,7 @@ return view.extend({ if (reason === 'tcp_failed') return _('Unreachable'); if (reason === 'no_health_script') return _('Health check helper missing'); if (reason === 'no_tcp_probe') return _('No TCP probe available'); + if (reason === 'invalid_id') return _('Invalid node id'); return reason || ''; } function wgFailDetail(reason) { @@ -91,6 +97,7 @@ return view.extend({ if (reason === 'busy') return _('Another test is running right now'); if (reason === 'no_tcp_probe') return _('Install tcping or nc to probe this node'); if (reason === 'tcp_failed') return _('Server unreachable'); + if (reason === 'invalid_id') return _('Node id contains unsupported characters'); return ''; } // Banner-style notification with an optional detail suffix. diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js index c4f5190229c3..a86e1779034d 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js @@ -74,10 +74,11 @@ return view.extend({ }, 30); /* ---------- Device tunnel status ---------- */ - function nodeLabel(id) { - // d.node is the UCI section id (e.g. cfgABCD); show the - // friendly label the user assigned instead. - return uci.get('wificalling-gateway', id, 'label') || id; + function nodeLabel(tag) { + // The status file carries the sing-box outbound tag + // (node-
); show the friendly label instead. + var id = String(tag || '').replace(/^node-/, ''); + return (id ? uci.get('wificalling-gateway', id, 'label') : null) || id || '-'; } function rows(source) { return (source.devices || []).map(function(d) { diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index ede9081abe63..785e82c99c6f 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -167,6 +167,9 @@ msgstr "" msgid "Invalid WireGuard endpoint: " msgstr "" +msgid "Invalid node id" +msgstr "" + msgid "LAN IPv4 addresses" msgstr "" @@ -230,6 +233,9 @@ msgstr "" msgid "Node display name" msgstr "" +msgid "Node id contains unsupported characters" +msgstr "" + msgid "Node imported successfully. Reloading settings…" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 4667e3336ede..6bec2e6a8d38 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -168,6 +168,9 @@ msgstr "无效的 IPv4 地址" msgid "Invalid WireGuard endpoint: " msgstr "无效的 WireGuard 端点:" +msgid "Invalid node id" +msgstr "节点 ID 无效" + msgid "LAN IPv4 addresses" msgstr "局域网 IPv4 地址" @@ -231,6 +234,9 @@ msgstr "节点" msgid "Node display name" msgstr "节点显示名称" +msgid "Node id contains unsupported characters" +msgstr "节点 ID 含不支持的字符" + msgid "Node imported successfully. Reloading settings…" msgstr "节点导入成功。正在重新加载设置…" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index c4b2c360d1ee..1f2cb0c7df46 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -150,14 +150,14 @@ start_service() { stop_service() { /usr/libexec/$APP/firewall.sh stop "$RUNDIR/clients" - # Drop the plugin-managed static leases when the gateway is disabled or - # stopped for good, so no dnsmasq side effects outlive the service. On - # a plain restart the enabled check keeps the bindings in place and the - # start path re-syncs them instead — the clear branch only runs when - # the service is actually going away. - config_load "$APP" - config_get_bool enabled main enabled 0 - [ "$enabled" -eq 1 ] || /usr/libexec/$APP/dhcp-sync.sh clear || logger -t "$APP" "dhcp lease cleanup failed" + # Always drop the plugin-managed static leases. This hook cannot tell a + # restart from a stop (both arrive with enabled=1), so gating the clear on + # the enabled flag left `wfc_` host sections in /etc/config/dhcp behind on + # an explicit stop and on package removal, where nothing else would ever + # remove them. start_service re-syncs the bindings, so a restart only + # pays one extra UCI commit and dnsmasq reload; clear is a no-op when + # there is nothing to remove. + /usr/libexec/$APP/dhcp-sync.sh clear || logger -t "$APP" "dhcp lease cleanup failed" # Also drop the stale snapshot when the gateway is stopped/disabled so # the status page does not keep rendering the last state indefinitely. : > "$RUNDIR/status.json" 2>/dev/null || true diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index ba81d63b202e..632c9b165095 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -159,7 +159,11 @@ wg_handshake_test() { printf '{"generated_at":%s,"nodes":[' "$(date +%s)" first=1 while IFS='|' read -r id label protocol server port; do - [ -n "$id" ] || continue + # Ids are UCI section names; one outside that shape would land in + # the /tmp/wg-health-* paths below unvalidated, and would be + # reported as an ordinary handshake_failed/unreachable node rather + # than rejected. Skip it here so neither happens. + case "$id" in ''|*[!A-Za-z0-9_]*) continue;; esac state=no_icmp_reply; ping_json=null; measurement=icmp; reason_json=null # WireGuard nodes are validated by a real handshake, not ICMP. if [ "$protocol" = wireguard ]; then From 53fc13ba925d491eb1814e2c5490e06c6e658e9b Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sun, 13 Sep 2026 21:00:00 +0000 Subject: [PATCH 50/56] luci-app-wificalling-gateway: add buildroot scan signature to Makefile The SDK package scan (include/scan.mk) discovers packages by grepping Makefiles for 'call BuildPackage'; every LuCI app carries that marker as its last line. Ours did not, so the package never entered the feed index and Test Build failed with: WARNING: No feed for package 'luci-app-wificalling-gateway' found make: *** [package/luci-app-wificalling-gateway/download] Error 2 This was invisible to the local harness (build-ipk.sh and the unit suite never run the SDK scan). Also set LUCI_MAINTAINER, which luci.mk uses for the main package's Maintainer field (default: OpenWrt LuCI community). Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 5152f5bad53e..14c8ad4f814b 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -6,6 +6,9 @@ PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE PKG_MAINTAINER:=Smth Dagg +# luci.mk assigns the main package's Maintainer from LUCI_MAINTAINER +# (default: "OpenWrt LuCI community"); set it so the author is credited. +LUCI_MAINTAINER:=Smth Dagg LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway @@ -29,3 +32,5 @@ endef # $(TOPDIR)/feeds/luci path breaks when the feed is checked out under a # different name in feeds.conf. include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature From d093b35574cb119c0ef625945967bd0357137b5b Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 15 Sep 2026 01:35:00 +0000 Subject: [PATCH 51/56] luci-app-wificalling-gateway: strip narrative comments from Makefile No other applications/*/Makefile documents what luci.mk does with a variable (luci-app-adguardhome just sets LUCI_MAINTAINER bare), so the explanatory blocks above LUCI_MAINTAINER / LUCI_DEPENDS / conffiles and the relative include go; the buildroot scan signature stays. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 14c8ad4f814b..376e7a72a94d 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -6,31 +6,17 @@ PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE PKG_MAINTAINER:=Smth Dagg -# luci.mk assigns the main package's Maintainer from LUCI_MAINTAINER -# (default: "OpenWrt LuCI community"); set it so the author is credited. LUCI_MAINTAINER:=Smth Dagg LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway -# The gateway configures nftables itself (firewall.sh) and never talks to -# the firewall4 daemon, so the hard firewall4 dependency is what made opkg -# fail on 18.06-style feeds ("cannot find dependency firewall4"). Depend -# on the actual runtime needs instead; init.d preflights nft/sing-box with -# a readable message on firmwares that cannot run the gateway. LUCI_DEPENDS:=+luci-base +sing-box +curl +nftables +kmod-nft-tproxy +kmod-nft-socket +ip-full LUCI_PKGARCH:=all -# Without this declaration the shipped /etc/config/wificalling-gateway is a -# plain payload file: opkg/apk upgrade replaces every node credential and -# device policy with the package defaults. luci.mk supplies nothing here -# implicitly; the other apps that ship a real /etc/config file declare it. define Package/luci-app-wificalling-gateway/conffiles /etc/config/wificalling-gateway endef -# Relative include, like the other applications in this tree: an absolute -# $(TOPDIR)/feeds/luci path breaks when the feed is checked out under a -# different name in feeds.conf. include ../../luci.mk # call BuildPackage - OpenWrt buildroot signature From 55205b1d8dd19b5ccd8a2b51d4451da0d9c1bfcd Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 21 Sep 2026 10:30:00 +0000 Subject: [PATCH 52/56] luci-app-wificalling-gateway: sync the WLOC 1.4 wificalling component Ported from wificalling-location-gateway v1.4.0, keeping every standalone review fix (old_hs debounce, pristine-baseline guard, the WG handshake verification WLOC 1.4 dropped, and the acl additions): - monitor.sh: multi-tunnel channel tracking - dual-SIM/multi-ePDG phones hold several WFC tunnels at once, so the status payload gains channels[], channel_count and epdg_ips[] with per-channel packet counts; procd stop exits immediately on TERM; empty conntrack dst lines are skipped. - Shadowsocks protocol: init.d gains the method field (credential dispatch and the delimiter guard cover it), the compiler enforces the cipher whitelist with readable failures, the form adds a modal-only validated field, and the catalogs gain the two strings. - IPv6 follow-the-tunnel: when every device policy binds the same node the compiler emits wfc-tcp6/wfc-udp6 inbounds plus a v6 route rule; the firewall matches policy-device v6 by MAC (SLAAC privacy makes the v6 address dynamic), keeps link-local/ULA local and drops the rest. - node-health rotation: the monitor loop probes one node per sweep via a cursor file (10 s cadence); the rest report from their 60 s cache. - cherry-picks: dhcp-sync strict per-octet IPv4 validation, passwall-bypass command simplification. Verified locally: 90/90 unit tests, sh -n / node --check clean, catalog 174/174 with parity and ASCII sort, nft syntax verified in a container, and all three platform packages install on the official rootfs via docker. Signed-off-by: Smth Dagg --- .../view/wificalling-gateway/overview.js | 14 ++++- .../po/templates/wificalling-gateway.pot | 6 ++ .../po/zh_Hans/wificalling-gateway.po | 6 ++ .../root/etc/init.d/wificalling-gateway | 8 ++- .../libexec/wificalling-gateway/compiler.sh | 30 +++++++++- .../libexec/wificalling-gateway/dhcp-sync.sh | 4 +- .../libexec/wificalling-gateway/firewall.sh | 55 +++++++++++++++++-- .../wificalling-gateway/monitor-loop.sh | 23 +++++++- .../libexec/wificalling-gateway/monitor.sh | 44 +++++++++++++-- .../wificalling-gateway/node-health.sh | 23 ++++++++ .../wificalling-gateway/passwall-bypass.sh | 1 + 11 files changed, 196 insertions(+), 18 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index e15b43adab6f..f338147f65bd 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -316,7 +316,7 @@ return view.extend({ // title; showing the label field again would duplicate it. nodeLabel.modalonly = true; var p = s.option(form.ListValue, 'protocol', _('Protocol')); - ['anytls','hysteria2','tuic','vless','vmess','trojan','wireguard'].forEach(function(x) { p.value(x); }); + ['anytls','hysteria2','tuic','vless','vmess','trojan','wireguard','shadowsocks'].forEach(function(x) { p.value(x); }); s.option(form.Value, 'server', _('Server')).datatype = 'host'; s.option(form.Value, 'port', _('Port')).datatype = 'port'; var nodeStatus = s.option(form.DummyValue, '_node_status', _('Node status')); @@ -337,6 +337,18 @@ return view.extend({ var uuidField = s.option(form.Value, 'uuid', _('UUID')); uuidField.password = true; uuidField.textvalue = function(id) { return this.cfgvalue(id) ? _('Set') : _('Not set'); }; uuidField.modalonly = true; + // Shadowsocks cipher. The field is modal-only because the pinned + // cipher list is checked by the compiler; an unsupported cipher makes + // sing-box reject the whole config at load. + var methodOpt = s.option(form.Value, 'method', _('Shadowsocks method')); + methodOpt.placeholder = 'aes-256-gcm'; + methodOpt.modalonly = true; + var ssMethods = ['aes-128-gcm','aes-192-gcm','aes-256-gcm','chacha20-ietf-poly1305','xchacha20-ietf-poly1305','2022-blake3-aes-128-gcm','2022-blake3-aes-256-gcm','2022-blake3-chacha20-poly1305']; + methodOpt.validate = function(section_id, value) { + if (this.section.formvalue(section_id, 'protocol') != 'shadowsocks') return true; + if (!value) return true; + return (ssMethods.indexOf(value) >= 0) ? true : _('Unsupported Shadowsocks encryption method'); + }; var sniOpt = s.option(form.Value, 'sni', _('TLS server name')); sniOpt.modalonly = true; var securityOpt = s.option(form.ListValue, 'security', _('Security')); diff --git a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot index 785e82c99c6f..fafa0397aa9d 100644 --- a/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -359,6 +359,9 @@ msgstr "" msgid "Settings" msgstr "" +msgid "Shadowsocks method" +msgstr "" + msgid "Sustained activity log interval (seconds)" msgstr "" @@ -434,6 +437,9 @@ msgstr "" msgid "Unreachable" msgstr "" +msgid "Unsupported Shadowsocks encryption method" +msgstr "" + msgid "Unsupported node link format" msgstr "" diff --git a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po index 6bec2e6a8d38..3ac748b9f47b 100644 --- a/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -360,6 +360,9 @@ msgstr "已设置" msgid "Settings" msgstr "设置" +msgid "Shadowsocks method" +msgstr "Shadowsocks 加密方法" + msgid "Sustained activity log interval (seconds)" msgstr "持续活动日志间隔(秒)" @@ -435,6 +438,9 @@ msgstr "未知" msgid "Unreachable" msgstr "不可达" +msgid "Unsupported Shadowsocks encryption method" +msgstr "不支持的 Shadowsocks 加密方法" + msgid "Unsupported node link format" msgstr "不支持的节点链接格式" diff --git a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway index 1f2cb0c7df46..81615d397919 100755 --- a/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -19,7 +19,7 @@ wireguard_style() { } append_node() { - local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id credential auxiliary pin_sha256 private_key local_address reserved mtu pre_shared_key + local s="$1" enabled label protocol server port password sni insecure alpn uuid congestion udp_mode public_key short_id fingerprint security transport path host flow alter_id method credential auxiliary pin_sha256 private_key local_address reserved mtu pre_shared_key config_get_bool enabled "$s" enabled 1 [ "$enabled" -eq 1 ] || return 0 config_get label "$s" label "$s"; config_get protocol "$s" protocol @@ -30,21 +30,23 @@ append_node() { config_get security "$s" security; config_get transport "$s" transport; config_get path "$s" path; config_get host "$s" host config_get pin_sha256 "$s" pin_sha256 config_get flow "$s" flow; config_get alter_id "$s" alter_id 0 + config_get method "$s" method config_get private_key "$s" private_key; config_get local_address "$s" local_address; config_get reserved "$s" reserved; config_get mtu "$s" mtu; config_get pre_shared_key "$s" pre_shared_key # The delimiter guard covers every field interpolated into the # | -delimited normalized.conf record. label is deliberately excluded: # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and # labels never reach normalized.conf — the nodes file (health checks) # gets a sanitized copy instead. credential/auxiliary (f[6]/f[10]) are - # derived from password/uuid/flow/alter_id/private_key, all guarded + # derived from password/uuid/flow/alter_id/method/private_key, all guarded # here, so they need no separate check. - case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$alter_id$pre_shared_key" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac + case "$protocol$server$password$sni$uuid$public_key$short_id$host$flow$alpn$path$fingerprint$pin_sha256$security$transport$congestion$udp_mode$private_key$local_address$reserved$mtu$alter_id$method" in *'|'*) logger -t "$APP" "invalid delimiter in node $s"; return 1;; esac credential=$password; auxiliary=$uuid case "$protocol" in vless) credential=$uuid; auxiliary=$flow ;; vmess) credential=$uuid; auxiliary=$alter_id ;; trojan) credential=$password; auxiliary= ;; wireguard) credential=$private_key; auxiliary= ;; + shadowsocks) credential=$password; auxiliary=$method ;; esac printf 'node|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' "$s" "$protocol" "$server" "$port" "$credential" "$sni" "$insecure" "$alpn" "$auxiliary" "$congestion" "$udp_mode" "$public_key" "$short_id" "$fingerprint" "$security" "$transport" "$path" "$host" "$pin_sha256" "$private_key" "$local_address" "$reserved" "$mtu" "$pre_shared_key" >> "$RUNDIR/normalized.conf" local safe_label diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh index 4baa35e46277..acf3287b2634 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -35,7 +35,14 @@ $1=="global" { if ($2=="log_level") level=$3; if ($2=="wireguard_style") wg_styl $1=="node" { id=$2; proto=$3 if (id=="" || seen_node[id]++) fail("duplicate or empty node id: " id) - if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess" && proto!="trojan" && proto!="wireguard") fail("unsupported protocol: " proto) + if (proto!="anytls" && proto!="hysteria2" && proto!="tuic" && proto!="vless" && proto!="vmess" && proto!="trojan" && proto!="wireguard" && proto!="shadowsocks") fail("unsupported protocol: " proto) + # Shadowsocks carries no TLS/transport layer: the cipher (field 10) is the + # only extra field, and an unsupported one makes sing-box reject the whole + # config at load. + if (proto=="shadowsocks") { + if ($10=="") fail("shadowsocks node " id " is missing the encryption method") + if ($10 !~ /^(aes-128-gcm|aes-192-gcm|aes-256-gcm|chacha20-ietf-poly1305|xchacha20-ietf-poly1305|2022-blake3-aes-128-gcm|2022-blake3-aes-256-gcm|2022-blake3-chacha20-poly1305)$/) fail("shadowsocks node " id " uses an unsupported encryption method: " $10) + } if ($4=="" || $5 !~ /^[0-9]+$/ || $5<1 || $5>65535) fail("invalid server or port for node: " id) node[++nn]=$0; node_id[nn]=id; node_proto[id]=proto if (proto=="wireguard") wg_nodes[++nw]=nn @@ -90,7 +97,19 @@ END { print " ]," } print " \"log\":{\"level\":" q(level) ",\"timestamp\":true}," - print " \"inbounds\":[{\"type\":\"tproxy\",\"tag\":\"wfc-tcp\",\"listen\":\"0.0.0.0\",\"listen_port\":11441,\"network\":\"tcp\"},{\"type\":\"tproxy\",\"tag\":\"wfc-udp\",\"listen\":\"0.0.0.0\",\"listen_port\":11442,\"network\":\"udp\"}]," + # IPv6 tunnel mode: policy device IPv6 may follow the tunnel only when + # every device policy binds the same node (v6 tproxy cannot tell devices + # apart on a shared LAN prefix, so per-device v6 routing is impossible). + # Otherwise no v6 inbounds are emitted and firewall.sh drops policy IPv6. + v6_node="" + if (nd>=1) { + v6_same=1 + for(k2=2;k2<=nd;k2++) if (devnode[k2]!=devnode[1]) v6_same=0 + if (v6_same) v6_node=devnode[1] + } + inbounds="{\"type\":\"tproxy\",\"tag\":\"wfc-tcp\",\"listen\":\"0.0.0.0\",\"listen_port\":11441,\"network\":\"tcp\"},{\"type\":\"tproxy\",\"tag\":\"wfc-udp\",\"listen\":\"0.0.0.0\",\"listen_port\":11442,\"network\":\"udp\"}" + if (v6_node!="") inbounds=inbounds ",{\"type\":\"tproxy\",\"tag\":\"wfc-tcp6\",\"listen\":\"::\",\"listen_port\":11443,\"network\":\"tcp\"},{\"type\":\"tproxy\",\"tag\":\"wfc-udp6\",\"listen\":\"::\",\"listen_port\":11444,\"network\":\"udp\"}" + print " \"inbounds\":[" inbounds "]," print " \"outbounds\":[" for(k=1;k<=nn;k++) { split(node[k],f,"|"); id=f[2] @@ -135,6 +154,7 @@ END { if (f[16]=="tls"||f[7]!="") s=s ",\"tls\":" tls((f[7]!=""?f[7]:f[19]),f[8],f[9],f[20]) } if (p=="trojan") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + if (p=="shadowsocks") s=s ",\"password\":" q(f[6]) ",\"method\":" q(f[10]) if (p=="wireguard") { # Legacy wireguard outbound (sing-box < 1.11). The endpoint form above # is preferred; this branch uses the singular field name the old @@ -156,6 +176,12 @@ END { out=(node_proto[devnode[k]]=="wireguard" && wg_style=="endpoint") ? "wg-" devnode[k] : "node-" devnode[k] print " {\"source_ip_cidr\":[" list "],\"action\":\"route\",\"outbound\":" q(out) "}" (k "$tmp" || exit $? diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh index e7644a0d2b7b..b02fb5ece726 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh @@ -43,7 +43,9 @@ valid_ip() { case "$1" in ''|*[!0-9.]*|*..*|.*|*.) return 1;; esac - return 0 + awk -F. 'NF == 4 { for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; exit 0 } { exit 1 }' < device label (sanitized) from the clients file. diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh index cf41040df1ae..8086870d9f53 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh @@ -3,23 +3,70 @@ set -eu action=${1:-start}; clients=${2:-/var/run/wificalling-gateway/clients} table='inet wificalling_gateway' bypass_helper="${0%/*}/passwall-bypass.sh" -[ "$action" = stop ] && { "$bypass_helper" clear "$clients"; nft delete table $table 2>/dev/null || true; ip rule del fwmark 0x66 table 166 2>/dev/null || true; ip route flush table 166 2>/dev/null || true; exit 0; } +# Policy devices must never bypass the IPv4-first tunnel via native IPv6: +# their IPv6 is either pushed into the tunnel (v6 tproxy, available when the +# compiled config carries the wfc-tcp6/wfc-udp6 inbounds, i.e. every device +# policy binds the same node) or dropped outright. It is never left on the +# WAN - a node without IPv6 egress discards the traffic inside the tunnel. +v6_teardown() { + ip -6 rule del fwmark 0x66 table 166 2>/dev/null || true + ip -6 route flush table 166 2>/dev/null || true +} +# Resolve a client IP to a MAC (static DHCP lease first, then the neighbour +# table): IPv6 addresses are dynamic (SLAAC privacy extensions), so the +# device can only be matched by MAC at the firewall. +mac_for_ip() { + _fwc_mac=$(awk -v target="$1" '$3 == target { print $2; exit }' /tmp/dhcp.leases 2>/dev/null || true) + case "$_fwc_mac" in + ??:??:??:??:??:??) printf '%s' "$_fwc_mac"; return 0 ;; + esac + _fwc_mac=$(ip neigh show "$1" dev br-lan 2>/dev/null | awk '$2 == "lladdr" { print $3; exit }') + case "$_fwc_mac" in + ??:??:??:??:??:??) printf '%s' "$_fwc_mac" ;; + esac +} +[ "$action" = stop ] && { "$bypass_helper" clear "$clients" || true; v6_teardown; nft delete table $table 2>/dev/null || true; ip rule del fwmark 0x66 table 166 2>/dev/null || true; ip route flush table 166 2>/dev/null || true; exit 0; } +macs=$(for ip in $(awk -F '|' 'NF>=2 { print $2 }' "$clients" | sort -u); do mac_for_ip "$ip" || true; done | sort -u) ips=$(awk -F '|' 'NF>=2 { printf "%s%s", (n++?", ":""), $2 }' "$clients") -[ -n "$ips" ] || exit 0 +[ -n "$ips" ] || { + # Fail-open on an empty client set: a start with no clients must withdraw + # the rule/route/table installed for a previously present device, so + # deleting the last device never leaves a stale static route behind. + "$bypass_helper" clear "$clients" || true + v6_teardown + nft delete table $table 2>/dev/null || true + ip rule del fwmark 0x66 table 166 2>/dev/null || true + ip route flush table 166 2>/dev/null || true + exit 0 +} +# Link-local (NDP/RS) and ULA stay local (fc00::/7 covers the ULA range, so +# no per-LAN prefix element is needed); everything else from a policy device +# enters the v6 tunnel. nft delete table $table 2>/dev/null || true nft -f - </dev/null || true ip route replace local 0.0.0.0/0 dev lo table 166 +ip -6 rule add fwmark 0x66 table 166 2>/dev/null || true +ip -6 route replace local ::/0 dev lo table 166 "$bypass_helper" ensure "$clients" diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh index d1448c5ac641..189f9cd93c8c 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh @@ -1,11 +1,28 @@ #!/bin/sh clients=$1; output=$2; nodes=$3; node_output=$4; events=$5; state=$6; event_interval=${7:-60}; max_events=${8:-20}; log_enabled=${9:-1}; tick=0 +# Rotate node-health one node per sweep (cursor file remembers the last +# probed id): a big fleet no longer hammers every server on the same tick, +# and each node still gets a fresh probe every other sweep (10 s cadence). +cursor="${state}.node-health" +next_node() { + last=$(cat "$cursor" 2>/dev/null || true) + awk -F'|' -v last="$last" ' + $1 != "" { + if (after) { found = 1; print $1; exit } + if (!first) first = $1 + if ($1 == last) after = 1 + } + END { if (!found && first) print first } + ' "$nodes" +} while :; do /usr/libexec/wificalling-gateway/passwall-bypass.sh ensure "$clients" /usr/libexec/wificalling-gateway/monitor.sh "$clients" /proc/net/nf_conntrack "$output" "$state" "$events" "$event_interval" "$max_events" "$log_enabled" - if [ "$tick" -eq 0 ]; then - /usr/libexec/wificalling-gateway/node-health.sh "$nodes" "$node_output" + node=$(next_node) + if [ -n "$node" ]; then + /usr/libexec/wificalling-gateway/node-health.sh "$nodes" "$node_output" "$node" + printf '%s\n' "$node" > "$cursor" fi - tick=$(( (tick + 1) % 6 )) + tick=$(( (tick + 1) % 2 )) sleep 5 done diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh index b3c9ba352fdb..777052dc527a 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -15,7 +15,10 @@ tmp="${output}.tmp.$$" state_tmp="${state}.tmp.$$" event_tmp="${events}.tmp.$$" trim_tmp="${events}.trim.$$" -trap 'rm -f "$tmp" "$state_tmp" "$event_tmp" "$trim_tmp"' EXIT HUP INT TERM +trap 'rm -f "$tmp" "$state_tmp" "$event_tmp" "$trim_tmp"' EXIT +# procd stop sends TERM: clean up and leave immediately instead of resuming +# the sweep and lingering until the kill timeout. +trap 'rm -f "$tmp" "$state_tmp" "$event_tmp" "$trim_tmp"; exit 0' HUP INT TERM now=${WFC_NOW:-$(date +%s)} touch "$state" "$events" @@ -43,17 +46,34 @@ FILENAME==conntrack_file { line=$0 for (i=1;i<=n;i++) { if (line !~ ("src=" ip[i] " ")) continue + dst="" if (match(line,/dst=[0-9.]+/)) dst=substr(line,RSTART+4,RLENGTH-4) is500=(line ~ /dport=500 /); is4500=(line ~ /dport=4500 /) if (!is500 && !is4500) continue + if (dst=="") continue if (is500) ike[i]=1 if (is4500) natt[i]=1 if (is4500 && line ~ /\[ASSURED\]/) assured[i]=1 - epdg[i]=dst + # conntrack has no SIM/SA identifier; distinct ePDG IPs are the safe + # observable channel count (two SAs sharing one ePDG remain inseparable). + channel_key=i SUBSEP dst + if (is4500 && line ~ /\[ASSURED\]/) assured_ch[channel_key]=1 + if (is4500) nat_seen[channel_key]=1 + if (is500) ike_seen[channel_key]=1 + if (!channel_seen[channel_key]++) { + channel_count[i]++ + channel_dst[i,channel_count[i]]=dst + epdg_json[i]=epdg_json[i] (epdg_json[i]!="" ? "," : "") q(dst) + if (epdg[i]=="") epdg[i]=dst + } count=0; rest=line while (match(rest,/packets=[0-9]+/)) { val=substr(rest,RSTART+8,RLENGTH-8)+0; count++ - if (count==1) sent[i]=val; else if(count==2) reply[i]=val + if (count==1) { + if (is4500) nat_sent[channel_key]=val; else ike_sent[channel_key]=val + } else if (count==2) { + if (is4500) nat_reply[channel_key]=val; else ike_reply[channel_key]=val + } rest=substr(rest,RSTART+RLENGTH) } } @@ -62,6 +82,21 @@ FILENAME==conntrack_file { END { print "{\"generated_at\":" now ",\"disclaimer\":\"Encrypted IPsec evidence only; calls and SMS cannot be distinguished.\",\"devices\":[" for(i=1;i<=n;i++) { + # Per-channel detail: one phone (dual SIM, multi-ePDG selection) can + # hold several WFC tunnels at once; the UI renders each of them. + sent_total=0; reply_total=0 + channels_json="" + for (c=1;c<=channel_count[i];c++) { + channel_key=i SUBSEP channel_dst[i,c] + if (nat_seen[channel_key]) { + sent_total+=nat_sent[channel_key]; reply_total+=nat_reply[channel_key] + } else { + sent_total+=ike_sent[channel_key]; reply_total+=ike_reply[channel_key] + } + cstate=(assured_ch[channel_key]?"registered":nat_seen[channel_key]?"connecting":ike_seen[channel_key]?"negotiating":"no_session") + channels_json=channels_json (channels_json!=""?",":"") "{" q("epdg") ":" q(channel_dst[i,c]) ",\"state\":" q(cstate) ",\"ike_seen\":" (ike_seen[channel_key]?"true":"false") ",\"nat_t_seen\":" (nat_seen[channel_key]?"true":"false") ",\"assured\":" (assured_ch[channel_key]?"true":"false") ",\"sent_packets\":" (nat_seen[channel_key]?nat_sent[channel_key]:ike_sent[channel_key])+0 ",\"reply_packets\":" (nat_seen[channel_key]?nat_reply[channel_key]:ike_reply[channel_key])+0 "}" + } + sent[i]=sent_total; reply[i]=reply_total wfc=(assured[i]?"registered":natt[i]||ike[i]?"connecting":"not_detected") legacy=(assured[i] && sent[i]+reply[i]>=100?"active_traffic":assured[i]?"likely_registered":natt[i]?"nat_t_seen":ike[i]?"negotiating":"no_session") ds=(sent[i]>=old_sent[i]?sent[i]-old_sent[i]:sent[i]) @@ -83,7 +118,7 @@ END { sustained=(!handshake_success && wfc=="registered" && streak>=1 && traffic_since>0 && now-traffic_since>=3 && now-old_event[i]>=event_interval) printf "%s{", (i>1?",":"") printf "\"label\":%s,\"ip\":%s,\"node\":%s,\"state\":%s,\"wificalling\":%s,", q(label[i]),q(ip[i]),q(node[i]),q(legacy),q(wfc) - printf "\"epdg_ip\":%s,\"ike_seen\":%s,\"nat_t_seen\":%s,\"assured\":%s,", q(epdg[i]),(ike[i]?"true":"false"),(natt[i]?"true":"false"),(assured[i]?"true":"false") + printf "\"epdg_ip\":%s,\"epdg_ips\":[%s],\"channel_count\":%d,\"channels\":[%s],\"ike_seen\":%s,\"nat_t_seen\":%s,\"assured\":%s,", q(epdg[i]),epdg_json[i],channel_count[i]+0,channels_json,(ike[i]?"true":"false"),(natt[i]?"true":"false"),(assured[i]?"true":"false") printf "\"sent_packets\":%d,\"reply_packets\":%d,\"delta_sent\":%d,\"delta_reply\":%d,\"last_activity\":%d,\"activity_evidence\":%s}", sent[i]+0,reply[i]+0,ds,dr,last,q(activity) if (log_enabled) { # Handshake events use their own debounce clock (old_hs): a @@ -114,6 +149,7 @@ END { ' "$clients" "$state" "$conntrack" > "$tmp" cat "$event_tmp" >> "$events" +rm -f "$event_tmp" awk -F '|' -v limit="$max_events" ' FNR==NR { count[$2 FS $3]++; next } { key=$2 FS $3; seen[key]++; if (seen[key] > count[key]-limit) print } diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 632c9b165095..9fd1682b47f0 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -3,6 +3,10 @@ set -eu nodes=${1:?node list required} output=${2:-/var/run/wificalling-gateway/node-status.json} +# Optional third argument: only refresh this node's probe (the monitor loop +# rotates one node per tick so a big fleet does not hammer every server at +# once); with no target every node is refreshed. +target=${3:-} tmp="${output}.tmp.$$" trap 'rm -f "$tmp"' EXIT HUP INT TERM @@ -164,6 +168,25 @@ wg_handshake_test() { # reported as an ordinary handshake_failed/unreachable node rather # than rejected. Skip it here so neither happens. case "$id" in ''|*[!A-Za-z0-9_]*) continue;; esac + # Rotated probing: with a target only that node is refreshed; the + # rest report from their 60 s cache (or "not yet checked" on the + # first sweep) instead of being probed all at once. + if [ -n "$target" ] && [ "$id" != "$target" ]; then + if [ -f "/tmp/wg-health-$id" ] && [ "$(sed -n '2p' "/tmp/wg-health-$id" 2>/dev/null)" = ok ]; then + state=handshake_ok; ping_json="\"$(sed -n '3p' "/tmp/wg-health-$id")\""; measurement=wg_handshake + elif [ -f "/tmp/wg-health-$id" ]; then + state=handshake_failed; ping_json=null; measurement=wg_handshake + reason_json="\"$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null || echo unreachable)\"" + else + state=not_yet_checked; ping_json=null; measurement=none; reason_json='"not_yet_checked"' + fi + [ "$protocol" = wireguard ] || { state=$([ -f "/tmp/wg-health-$id" ] && echo reachable || echo not_yet_checked); measurement=$([ -f "/tmp/wg-health-$id" ] && echo icmp || echo none); ping_json=$([ -f "/tmp/wg-health-$id" ] && printf '"%s"' "$(sed -n '3p' "/tmp/wg-health-$id")" || printf 'null'); } + [ "$first" -eq 1 ] || printf ',' + first=0 + printf '{"id":"%s","state":"%s","measurement":"%s","ping_ms":%s,"reason":%s}' \ + "$(json_escape "$id")" "$state" "$measurement" "$ping_json" "${reason_json:-null}" + continue + fi state=no_icmp_reply; ping_json=null; measurement=icmp; reason_json=null # WireGuard nodes are validated by a real handshake, not ICMP. if [ "$protocol" = wireguard ]; then diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh index 124cccc4a965..b0531473d93d 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/passwall-bypass.sh @@ -28,6 +28,7 @@ ips=$(awk -F '|' 'NF>=2 { printf "%s%s", (n++?", ":""), $2 }' "$clients") [ -n "$ips" ] || { "$0" clear "$clients"; exit 0; } for chain in PSW_MANGLE PSW_NAT; do + nft list chain inet passwall "$chain" >/dev/null 2>&1 || continue if ! nft list chain inet passwall "$chain" 2>/dev/null | grep -q "$comment"; then nft insert rule inet passwall "$chain" ip saddr { $ips } counter return comment "$comment" fi From cdccf648bcd56c2333fa8516db37d4ff54975ad0 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Mon, 21 Sep 2026 11:05:00 +0000 Subject: [PATCH 53/56] luci-app-wificalling-gateway: update PKG_VERSION to 1.10.0 The package now carries the WLOC 1.4 wificalling component (Shadowsocks protocol, multi-tunnel channel tracking, IPv6 follow-the-tunnel), so the version follows the standalone release instead of staying behind at 1.9.7 while the code has moved on. Signed-off-by: Smth Dagg --- applications/luci-app-wificalling-gateway/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/luci-app-wificalling-gateway/Makefile b/applications/luci-app-wificalling-gateway/Makefile index 376e7a72a94d..70ab812d5e26 100644 --- a/applications/luci-app-wificalling-gateway/Makefile +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wificalling-gateway -PKG_VERSION:=1.9.7 +PKG_VERSION:=1.10.0 PKG_RELEASE:=1 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE From aecd37e6737bb355f035b5c0c71e2f69ab471c13 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Tue, 22 Sep 2026 01:33:24 +0000 Subject: [PATCH 54/56] luci-app-wificalling-gateway: fix MAC list rendering and rotated node reporting Round-36 findings from the automated review, plus one latent bug found against a stock OpenWrt neighbour table: - firewall.sh: mac_for_ip terminated each result without a newline, so two policy devices collapsed into a single token (aa:..:01aa:..:02) inside the nft element list and the whole ruleset failed under set -eu. Each MAC now prints on its own line and the list is comma-joined. - firewall.sh: with no policy device resolvable to a MAC (offline, no lease, non-br-lan bridge) the heredoc emitted elements = { }, which nft rejects and which aborted the start before any rule was installed, IPv4 TPROXY included. An empty set keeps the prerouting6 chain valid while matching nothing. - firewall.sh: the lease file and LAN device were hardcoded to /tmp/dhcp.leases and br-lan; both now follow the rest of the package (dhcp.@dnsmasq[0].leasefile, network.lan.device with an ifname and br-lan fallback). The neighbour-table lookup also matched lladdr at field 2, which stock OpenWrt output (IP dev DEV lladdr MAC STATE) never produces; the scan is position-independent now (verified against a live AX6S). - node-health.sh: /tmp/wg-health- was written only by the WireGuard handshake test, so with rotation on every non-WireGuard node reported "not yet checked" on each sweep it was not the target of. ICMP/TCP probes persist the same cache (verdict state and measurement kind on lines 4-5) and the skip branch serves it; shadowsocks joins the TCP probe fallback list. - monitor-loop.sh: drop the readerless tick counter and correct the rotation-cadence comment (a node is re-probed every N*5 s, which outgrows the 60 s cache lifetime past ~12 nodes). - overview.js: an empty Shadowsocks method passed the form but the compiler hard-fails on it at start; reject the empty value with the existing catalog string so no new msgid is needed. Also corrects the round-35 commit description: the passwall-bypass.sh hunk adds a chain-existence guard (a missing inet passwall chain is now skipped instead of failing), not a mere command simplification. Verified locally: 99/99 unit tests (5 new covering the MAC join, the empty-set fallback and the rotation cache), sh -n / node --check clean. Signed-off-by: Smth Dagg --- .../view/wificalling-gateway/overview.js | 7 ++-- .../libexec/wificalling-gateway/firewall.sh | 33 ++++++++++++--- .../wificalling-gateway/monitor-loop.sh | 9 ++-- .../wificalling-gateway/node-health.sh | 41 ++++++++++++++++++- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js index f338147f65bd..fa880e3b5457 100644 --- a/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -339,15 +339,16 @@ return view.extend({ uuidField.modalonly = true; // Shadowsocks cipher. The field is modal-only because the pinned // cipher list is checked by the compiler; an unsupported cipher makes - // sing-box reject the whole config at load. + // sing-box reject the whole config at load. The empty value is + // rejected with the same message: the compiler hard-fails on a + // missing method and would keep the whole gateway from starting. var methodOpt = s.option(form.Value, 'method', _('Shadowsocks method')); methodOpt.placeholder = 'aes-256-gcm'; methodOpt.modalonly = true; var ssMethods = ['aes-128-gcm','aes-192-gcm','aes-256-gcm','chacha20-ietf-poly1305','xchacha20-ietf-poly1305','2022-blake3-aes-128-gcm','2022-blake3-aes-256-gcm','2022-blake3-chacha20-poly1305']; methodOpt.validate = function(section_id, value) { if (this.section.formvalue(section_id, 'protocol') != 'shadowsocks') return true; - if (!value) return true; - return (ssMethods.indexOf(value) >= 0) ? true : _('Unsupported Shadowsocks encryption method'); + return (value && ssMethods.indexOf(value) >= 0) ? true : _('Unsupported Shadowsocks encryption method'); }; var sniOpt = s.option(form.Value, 'sni', _('TLS server name')); sniOpt.modalonly = true; diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh index 8086870d9f53..5b77eebce5e2 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh @@ -14,21 +14,42 @@ v6_teardown() { } # Resolve a client IP to a MAC (static DHCP lease first, then the neighbour # table): IPv6 addresses are dynamic (SLAAC privacy extensions), so the -# device can only be matched by MAC at the firewall. +# device can only be matched by MAC at the firewall. The lease file and LAN +# device follow the rest of the package: dhcp-sync.sh and the status view +# read dhcp.@dnsmasq[0].leasefile, and the LAN bridge is whatever +# network.lan.device names (br-lan on stock images). +leasefile=$(uci -q get dhcp.@dnsmasq[0].leasefile 2>/dev/null || true) +[ -n "$leasefile" ] || leasefile=/tmp/dhcp.leases +lan_dev=$(uci -q get network.lan.device 2>/dev/null || uci -q get network.lan.ifname 2>/dev/null || true) +[ -n "$lan_dev" ] || lan_dev=br-lan mac_for_ip() { - _fwc_mac=$(awk -v target="$1" '$3 == target { print $2; exit }' /tmp/dhcp.leases 2>/dev/null || true) + _fwc_mac=$(awk -v target="$1" '$3 == target { print $2; exit }' "$leasefile" 2>/dev/null || true) case "$_fwc_mac" in - ??:??:??:??:??:??) printf '%s' "$_fwc_mac"; return 0 ;; + ??:??:??:??:??:??) printf '%s\n' "$_fwc_mac"; return 0 ;; esac - _fwc_mac=$(ip neigh show "$1" dev br-lan 2>/dev/null | awk '$2 == "lladdr" { print $3; exit }') + # "lladdr" sits at field 4 on stock OpenWrt ("IP dev DEV lladdr MAC + # STATE"), so match by position-independent scan rather than a fixed + # field index. + _fwc_mac=$(ip neigh show "$1" dev "$lan_dev" 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "lladdr") { print $(i + 1); exit } }') case "$_fwc_mac" in - ??:??:??:??:??:??) printf '%s' "$_fwc_mac" ;; + ??:??:??:??:??:??) printf '%s\n' "$_fwc_mac" ;; esac } [ "$action" = stop ] && { "$bypass_helper" clear "$clients" || true; v6_teardown; nft delete table $table 2>/dev/null || true; ip rule del fwmark 0x66 table 166 2>/dev/null || true; ip route flush table 166 2>/dev/null || true; exit 0; } macs=$(for ip in $(awk -F '|' 'NF>=2 { print $2 }' "$clients" | sort -u); do mac_for_ip "$ip" || true; done | sort -u) ips=$(awk -F '|' 'NF>=2 { printf "%s%s", (n++?", ":""), $2 }' "$clients") +# mac_for_ip prints one MAC per line; nft wants a comma-joined element list. +# With no resolvable MAC at all (devices offline, foreign bridge, no lease) +# an empty "elements = { }" is an nft syntax error that aborts the whole +# ruleset under set -eu - fall back to an empty set, which keeps the +# prerouting6 rules valid while matching nothing. +mac_list=$(printf '%s\n' "$macs" | awk '{ printf "%s%s", (n++?", ":""), $1 }') +if [ -n "$macs" ]; then + mac_set="set macs { type ether_addr; elements = { $mac_list } }" +else + mac_set="set macs { type ether_addr; }" +fi [ -n "$ips" ] || { # Fail-open on an empty client set: a start with no clients must withdraw # the rule/route/table installed for a previously present device, so @@ -48,7 +69,7 @@ nft -f - </dev/null || true) @@ -23,6 +25,5 @@ while :; do /usr/libexec/wificalling-gateway/node-health.sh "$nodes" "$node_output" "$node" printf '%s\n' "$node" > "$cursor" fi - tick=$(( (tick + 1) % 2 )) sleep 5 done diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 9fd1682b47f0..4dea50ed8df0 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -180,7 +180,36 @@ wg_handshake_test() { else state=not_yet_checked; ping_json=null; measurement=none; reason_json='"not_yet_checked"' fi - [ "$protocol" = wireguard ] || { state=$([ -f "/tmp/wg-health-$id" ] && echo reachable || echo not_yet_checked); measurement=$([ -f "/tmp/wg-health-$id" ] && echo icmp || echo none); ping_json=$([ -f "/tmp/wg-health-$id" ] && printf '"%s"' "$(sed -n '3p' "/tmp/wg-health-$id")" || printf 'null'); } + # ICMP/TCP probes write the same cache (lines 4-5 carry the + # verdict state and measurement kind; WireGuard caches stay + # three lines), so the rotated skip branch serves the last + # reading instead of reporting "not yet checked" on every + # sweep the node is not the target of. + [ "$protocol" = wireguard ] || { + if [ -f "/tmp/wg-health-$id" ]; then + cache_state=$(sed -n '4p' "/tmp/wg-health-$id" 2>/dev/null) + kind=$(sed -n '5p' "/tmp/wg-health-$id" 2>/dev/null) + case "$kind" in icmp|tcp) ;; *) kind=icmp ;; esac + lat=$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null) + case "$lat" in *[!0-9.]*) lat= ;; esac + if [ "$cache_state" = reachable ] || [ "$cache_state" = tcp_reachable ]; then + if [ -n "$lat" ]; then + state=$cache_state; measurement=$kind; ping_json=$lat; reason_json=null + else + state=unreachable; measurement=$kind; ping_json=null; reason_json=null + fi + elif [ -n "$cache_state" ]; then + state=$cache_state; measurement=$kind; ping_json=null; reason_json=null + else + # A WireGuard-format cache (protocol switched in + # UCI) has no ICMP/TCP verdict lines: report the + # node as not yet checked until its next probe. + state=not_yet_checked; measurement=none; ping_json=null; reason_json='"not_yet_checked"' + fi + else + state=not_yet_checked; measurement=none; ping_json=null; reason_json='"not_yet_checked"' + fi + } [ "$first" -eq 1 ] || printf ',' first=0 printf '{"id":"%s","state":"%s","measurement":"%s","ping_ms":%s,"reason":%s}' \ @@ -209,7 +238,7 @@ wg_handshake_test() { state=reachable; ping_json=$latency else case "$protocol" in - anytls|vless|vmess|trojan) + anytls|vless|vmess|trojan|shadowsocks) if command -v tcping >/dev/null 2>&1; then measurement=tcp tcp_output=$(tcping -c 1 -t 1 -p "$port" "$server" 2>/dev/null || true) @@ -219,6 +248,14 @@ wg_handshake_test() { ;; esac fi + # Rotated sweeps read this cache for the non-target nodes: + # without it every non-WireGuard node would report "not yet + # checked" on every sweep it is not the target of. The first + # three lines mirror the WireGuard cache format; line 4 is the + # verdict state itself (preserves no_icmp_reply), line 5 the + # measurement kind (icmp/tcp). + if [ "$state" = reachable ] || [ "$state" = tcp_reachable ]; then cache_verdict=ok; else cache_verdict=failed; fi + printf '%s\n%s\n%s\n%s\n%s\n' "$(date +%s)" "$cache_verdict" "${latency:-}" "$state" "$measurement" > "/tmp/wg-health-$id" fi [ "$first" -eq 1 ] || printf ',' first=0 From f7cfca2ac848061c0349a6c6f5e413661b776ebe Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Wed, 23 Sep 2026 22:40:01 +0000 Subject: [PATCH 55/56] luci-app-wificalling-gateway: split the ICMP/TCP probe cache from the WG cache The rotated skip branch reused /tmp/wg-health- for the ICMP/TCP verdict, whose format collides with the WireGuard handshake cache: wg_handshake_test treats line 2 == ok as a completed handshake and line 3 as the verified exit IP. After switching a node's protocol to wireguard in UCI, the next 60 s reported handshake_ok with the cached latency rendered as the exit IP, with no handshake ever run. ICMP/TCP probes now persist a dedicated /tmp/wg-health-.probe (ts / verdict state / measurement kind / latency) and the skip branch reads only that path, so the two formats can never be confused. The 60 s lifetime is honoured by both the reader and the writer. Also corrects the round-36 commit description: the stock OpenWrt neighbour table (IP dev DEV lladdr MAC STATE) was verified against a live AX6S before the position-independent scan replaced the field-2 match, so the neighbour fallback resolves for the first time. Signed-off-by: Smth Dagg --- .../wificalling-gateway/node-health.sh | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 4dea50ed8df0..004984b44b31 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -180,30 +180,34 @@ wg_handshake_test() { else state=not_yet_checked; ping_json=null; measurement=none; reason_json='"not_yet_checked"' fi - # ICMP/TCP probes write the same cache (lines 4-5 carry the - # verdict state and measurement kind; WireGuard caches stay - # three lines), so the rotated skip branch serves the last - # reading instead of reporting "not yet checked" on every - # sweep the node is not the target of. + # ICMP/TCP probes persist a verdict cache on their own path + # (/tmp/wg-health-.probe): the WireGuard handshake cache + # treats line 2 == ok as a completed handshake and line 3 as + # the exit IP, so reusing one file would let a stale latency + # render as a verified exit IP after a protocol switch in UCI. [ "$protocol" = wireguard ] || { - if [ -f "/tmp/wg-health-$id" ]; then - cache_state=$(sed -n '4p' "/tmp/wg-health-$id" 2>/dev/null) - kind=$(sed -n '5p' "/tmp/wg-health-$id" 2>/dev/null) - case "$kind" in icmp|tcp) ;; *) kind=icmp ;; esac - lat=$(sed -n '3p' "/tmp/wg-health-$id" 2>/dev/null) - case "$lat" in *[!0-9.]*) lat= ;; esac - if [ "$cache_state" = reachable ] || [ "$cache_state" = tcp_reachable ]; then - if [ -n "$lat" ]; then - state=$cache_state; measurement=$kind; ping_json=$lat; reason_json=null + probe_cache="/tmp/wg-health-$id.probe" + if [ -f "$probe_cache" ]; then + cache_ts=$(sed -n '1p' "$probe_cache" 2>/dev/null || echo 0) + age=$(($(date +%s) - ${cache_ts:-0})) + if [ "$age" -lt 60 ] 2>/dev/null; then + cache_state=$(sed -n '2p' "$probe_cache" 2>/dev/null) + kind=$(sed -n '3p' "$probe_cache" 2>/dev/null) + case "$kind" in icmp|tcp) ;; *) kind=icmp ;; esac + lat=$(sed -n '4p' "$probe_cache" 2>/dev/null) + case "$lat" in *[!0-9.]*) lat= ;; esac + if [ "$cache_state" = reachable ] || [ "$cache_state" = tcp_reachable ]; then + if [ -n "$lat" ]; then + state=$cache_state; measurement=$kind; ping_json=$lat; reason_json=null + else + state=unreachable; measurement=$kind; ping_json=null; reason_json=null + fi + elif [ -n "$cache_state" ]; then + state=$cache_state; measurement=$kind; ping_json=null; reason_json=null else - state=unreachable; measurement=$kind; ping_json=null; reason_json=null + state=not_yet_checked; measurement=none; ping_json=null; reason_json='"not_yet_checked"' fi - elif [ -n "$cache_state" ]; then - state=$cache_state; measurement=$kind; ping_json=null; reason_json=null else - # A WireGuard-format cache (protocol switched in - # UCI) has no ICMP/TCP verdict lines: report the - # node as not yet checked until its next probe. state=not_yet_checked; measurement=none; ping_json=null; reason_json='"not_yet_checked"' fi else @@ -248,14 +252,14 @@ wg_handshake_test() { ;; esac fi - # Rotated sweeps read this cache for the non-target nodes: - # without it every non-WireGuard node would report "not yet - # checked" on every sweep it is not the target of. The first - # three lines mirror the WireGuard cache format; line 4 is the - # verdict state itself (preserves no_icmp_reply), line 5 the - # measurement kind (icmp/tcp). + # Rotated sweeps read this probe cache for the non-target + # nodes: without it every non-WireGuard node would report + # "not yet checked" on every sweep it is not the target of. + # The dedicated .probe path (ts / verdict state / measurement + # kind / latency) never collides with the WireGuard handshake + # cache format. if [ "$state" = reachable ] || [ "$state" = tcp_reachable ]; then cache_verdict=ok; else cache_verdict=failed; fi - printf '%s\n%s\n%s\n%s\n%s\n' "$(date +%s)" "$cache_verdict" "${latency:-}" "$state" "$measurement" > "/tmp/wg-health-$id" + printf '%s\n%s\n%s\n%s\n' "$(date +%s)" "$state" "$measurement" "${latency:-}" > "/tmp/wg-health-$id.probe" fi [ "$first" -eq 1 ] || printf ',' first=0 From 663a34a9e94791aeb6be8b9917dfa50edd1d58f3 Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Fri, 25 Sep 2026 01:07:59 +0000 Subject: [PATCH 56/56] luci-app-wificalling-gateway: serve the rotated probe cache without an age gate The .probe skip branch gated the cached reading at a fixed 60 s, but only the rotation target refreshes that cache: with more than ~12 nodes the rotation period (N*5 s) outgrows the gate and every non-WireGuard node flips to not_yet_checked for the tail of its cycle - the exact symptom the probe cache was added to fix, and the opposite of what the monitor-loop comment claimed. The WireGuard skip branch applies no age gate at all, so the two branches disagreed. Serve the last reading unconditionally now, matching the WireGuard branch; the record is simply the node's most recent verdict and the rotation guarantees it is refreshed every N sweeps. The monitor-loop comment is rewritten accordingly and the dead cache_verdict assignment is dropped (the record no longer carries an ok/failed line). Signed-off-by: Smth Dagg --- .../wificalling-gateway/monitor-loop.sh | 6 +-- .../wificalling-gateway/node-health.sh | 37 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh index b30a60a35a4b..0c8a72dedb96 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor-loop.sh @@ -2,9 +2,9 @@ clients=$1; output=$2; nodes=$3; node_output=$4; events=$5; state=$6; event_interval=${7:-60}; max_events=${8:-20}; log_enabled=${9:-1} # Rotate node-health one node per sweep (cursor file remembers the last # probed id): a big fleet no longer hammers every server on the same tick. -# A node is re-probed every N sweeps (N*5 s for N nodes), so past ~12 nodes -# the interval outgrows the 60 s cache lifetime and the sweeps in between -# serve the last cached reading. +# A node is re-probed every N sweeps (N*5 s for N nodes); the sweeps in +# between serve its last cached reading, whatever its age - the node-health +# skip branches apply no age gate. cursor="${state}.node-health" next_node() { last=$(cat "$cursor" 2>/dev/null || true) diff --git a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh index 004984b44b31..d8313e659c38 100755 --- a/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -188,25 +188,24 @@ wg_handshake_test() { [ "$protocol" = wireguard ] || { probe_cache="/tmp/wg-health-$id.probe" if [ -f "$probe_cache" ]; then - cache_ts=$(sed -n '1p' "$probe_cache" 2>/dev/null || echo 0) - age=$(($(date +%s) - ${cache_ts:-0})) - if [ "$age" -lt 60 ] 2>/dev/null; then - cache_state=$(sed -n '2p' "$probe_cache" 2>/dev/null) - kind=$(sed -n '3p' "$probe_cache" 2>/dev/null) - case "$kind" in icmp|tcp) ;; *) kind=icmp ;; esac - lat=$(sed -n '4p' "$probe_cache" 2>/dev/null) - case "$lat" in *[!0-9.]*) lat= ;; esac - if [ "$cache_state" = reachable ] || [ "$cache_state" = tcp_reachable ]; then - if [ -n "$lat" ]; then - state=$cache_state; measurement=$kind; ping_json=$lat; reason_json=null - else - state=unreachable; measurement=$kind; ping_json=null; reason_json=null - fi - elif [ -n "$cache_state" ]; then - state=$cache_state; measurement=$kind; ping_json=null; reason_json=null + # No age gate, matching the WireGuard skip branch: + # rotation refreshes this cache every N*5 s, so a + # fixed lifetime would flip big fleets to "not yet + # checked" for the tail of each cycle instead of + # serving the last reading. + cache_state=$(sed -n '2p' "$probe_cache" 2>/dev/null) + kind=$(sed -n '3p' "$probe_cache" 2>/dev/null) + case "$kind" in icmp|tcp) ;; *) kind=icmp ;; esac + lat=$(sed -n '4p' "$probe_cache" 2>/dev/null) + case "$lat" in *[!0-9.]*) lat= ;; esac + if [ "$cache_state" = reachable ] || [ "$cache_state" = tcp_reachable ]; then + if [ -n "$lat" ]; then + state=$cache_state; measurement=$kind; ping_json=$lat; reason_json=null else - state=not_yet_checked; measurement=none; ping_json=null; reason_json='"not_yet_checked"' + state=unreachable; measurement=$kind; ping_json=null; reason_json=null fi + elif [ -n "$cache_state" ]; then + state=$cache_state; measurement=$kind; ping_json=null; reason_json=null else state=not_yet_checked; measurement=none; ping_json=null; reason_json='"not_yet_checked"' fi @@ -257,8 +256,8 @@ wg_handshake_test() { # "not yet checked" on every sweep it is not the target of. # The dedicated .probe path (ts / verdict state / measurement # kind / latency) never collides with the WireGuard handshake - # cache format. - if [ "$state" = reachable ] || [ "$state" = tcp_reachable ]; then cache_verdict=ok; else cache_verdict=failed; fi + # cache format, and the skip branch applies no age gate — + # this record is simply the node's most recent verdict. printf '%s\n%s\n%s\n%s\n' "$(date +%s)" "$state" "$measurement" "${latency:-}" > "/tmp/wg-health-$id.probe" fi [ "$first" -eq 1 ] || printf ','