From 994dfffc895a9f7ec919ea46f4f6681fde0bed8b Mon Sep 17 00:00:00 2001 From: Smth Dagg Date: Sat, 8 Aug 2026 23:30:38 +0800 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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