diff --git a/applications/luci-app-wificalling-gateway/LICENSE b/applications/luci-app-wificalling-gateway/LICENSE new file mode 100644 index 0000000000..9642b55503 --- /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 0000000000..d6aa14098d --- /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 +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 0000000000..422d39a6e4 --- /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, 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/events.js b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js new file mode 100644 index 0000000000..76ff2257b1 --- /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 0000000000..9e4a57812a --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -0,0 +1,163 @@ +'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, 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' }, [ + 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','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))); }; + 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'); }; + 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('', _('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)')); + 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')); + 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'); + 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 0000000000..2f5185fdb0 --- /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 0000000000..c0f7c98cac --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -0,0 +1,91 @@ +'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' || protocol === 'trojan') { + 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') || ''; + } + } 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; +} + +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 || '', + security: raw.tls === 'tls' ? 'tls' : '' + }; + 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 (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); +} + +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 0000000000..30e192d825 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -0,0 +1,357 @@ +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, 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" +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 "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 "" + +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/po/zh_Hans/wificalling-gateway.po b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po new file mode 100644 index 0000000000..de3a4ea43b --- /dev/null +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -0,0 +1,358 @@ +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, 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 / 延迟" + +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 "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 设置" + +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" 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 0000000000..7397e39f42 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway @@ -0,0 +1,9 @@ +config global 'main' + option enabled '0' + option log_level 'warn' + 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 0000000000..fbf14ab348 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -0,0 +1,96 @@ +#!/bin/sh /etc/rc.common +USE_PROCD=1 +START=99 +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 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 + 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 + 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|%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" +} + +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 + 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" + 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 + # 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" + 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 + 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" || { 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 + 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" + # 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 new file mode 100755 index 0000000000..b8707388bd --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -0,0 +1,125 @@ +#!/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" + 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 "}" +} +$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" && 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 + if (proto=="wireguard") wg_nodes[++nw]=nn + 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" + 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 "$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 0000000000..cf41040df1 --- /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 0 +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 0000000000..d1448c5ac6 --- /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 0000000000..8d52f4b3e9 --- /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 0000000000..d5086afcac --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -0,0 +1,45 @@ +#!/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|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 + [ "$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 0000000000..124cccc4a9 --- /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 0000000000..15e4b10d4a --- /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 0000000000..78f8007441 --- /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" ] + } + } + } +}