Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions applications/luci-app-wificalling-gateway/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions applications/luci-app-wificalling-gateway/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: MIT
#
# Copyright (C) 2026 Smth Dagg <smthdagg@gmail.com>

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 <smthdagg@gmail.com>

define Package/luci-app-wificalling-gateway/conffiles
/etc/config/wificalling-gateway
endef

include ../../luci.mk

# call BuildPackage - OpenWrt buildroot signature
10 changes: 10 additions & 0 deletions applications/luci-app-wificalling-gateway/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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);
}
});
Original file line number Diff line number Diff line change
@@ -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]); });
}
});
Original file line number Diff line number Diff line change
@@ -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
])])]);
}
});
Loading
Loading