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..abb5ad080509 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/Makefile @@ -0,0 +1,20 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-wificalling-gateway +PKG_VERSION:=1.8.11 +PKG_RELEASE:=1 +PKG_LICENSE:=MIT +PKG_LICENSE_FILES:=LICENSE +PKG_MAINTAINER:=Smth Dagg + +LUCI_TITLE:=LuCI support for per-device Wi-Fi Calling gateway +LUCI_URL:=https://github.com/smthdagg/luci-app-wificalling-gateway +# The gateway configures nftables itself (firewall.sh) and never talks to +# the firewall4 daemon, so the hard firewall4 dependency is what made opkg +# fail on 18.06-style feeds ("cannot find dependency firewall4"). Depend +# on the actual runtime needs instead; init.d preflights nft/sing-box with +# a readable message on firmwares that cannot run the gateway. +LUCI_DEPENDS:=+luci-base +sing-box +curl +nftables +kmod-nft-tproxy +kmod-nft-socket +ip-full +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk diff --git a/applications/luci-app-wificalling-gateway/README.md b/applications/luci-app-wificalling-gateway/README.md new file mode 100644 index 000000000000..bc78e0153245 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/README.md @@ -0,0 +1,171 @@ +# Wi-Fi Calling Gateway + +[English](README_EN.md) · [安装](docs/zh-CN/INSTALL.md) · [配置](docs/zh-CN/CONFIGURATION.md) · [排错](docs/zh-CN/TROUBLESHOOTING.md) · [开发与维护](DEVELOPER.md) + +面向 OpenWrt / ImmortalWrt 的独立 LuCI 插件。它把指定局域网设备通过指定的 sing-box 节点转发,同时让其他设备继续走路由器默认路由,并观察 Wi‑Fi Calling 常用的 ePDG/IPsec UDP 500、4500 会话证据。 + +### 设置 + +![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://) 分享链接,以及标准 WireGuard `[Interface]/[Peer]` 配置块;WireGuard 支持预共享密钥(PSK)。 +- **WireGuard 节点真实握手健康检查**:不用 ICMP 猜,临时起 sing-box endpoint 验证隧道握手并显示验证通过的出口 IP(60 秒缓存);握手失败区分原因(配置缺失/超时/不可达),节点表格悬停可见。 +- **节点即时测试**:节点表格每行有「测试」按钮——WireGuard 节点立即重做一次握手测试(绕过缓存),其他协议做 TCP 探测,结果横幅显示(含出口 IP 与失败原因)。 +- **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 透明转发。 +- 节点 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(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 用户态模拟) | +| 已实机验证 | 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`、`nftables`、`kmod-nft-tproxy`、`kmod-nft-socket`、`ip-full`。(插件直接配置 nftables,不依赖 firewall4 守护进程;1.7.1 及更早依赖里写死的 `firewall4` 正是 18.06/Lede 上安装失败的根因,1.7.2 起已移除。) + +## 快速安装 + +从 [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.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.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.11-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.8.11-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.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.11-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 生效前提)** +> +> 运营商要求设备定位与 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 new file mode 100644 index 000000000000..bae4e6b9ead7 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/events.js @@ -0,0 +1,67 @@ +'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 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') + ' ↓', meaningLabel(f[6])].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. 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.'))); + 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..dce06297c095 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/overview.js @@ -0,0 +1,480 @@ +'use strict'; +'require view'; +'require form'; +'require fs'; +'require poll'; +'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() { + // 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), ''), + L.resolveDefault(fs.read('/proc/net/arp'), '') + ]); + }); + }, + 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 === 'handshake_ok') return _('Good'); + if (n.state === 'handshake_failed') return _('Offline'); + if (n.state === 'unreachable') return _('Offline'); + // 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 === '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'); + } + // 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 === '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. + function testNotify(message, kind, detail) { + var p = E('p', {}, message); + if (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; + 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 if (r && r.state === 'failed') { + // 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'); + } + }).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 + // 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 = {}, leaseHost = {}; + (data[2] || '').split('\n').forEach(function(line) { + var p = line.split(/\s+/); + 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) { + 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'); + // 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) { + L.toArray(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 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 = /^\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]); }); + 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.'); + // 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) { + 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'); }; + 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'); + 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.section.formvalue(section_id, 'protocol') == 'vmess') + return _('Reality security is not available for VMess nodes'); + return true; + }; + 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')); + 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'); }; + 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'); }; + 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) { + 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); } + }, _('Test')); + 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'); + 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 = lanSubnetHint(); + var devicePicker = s.option(form.DummyValue, '_device_picker', _('From connected devices')); + devicePicker.rmempty = true; + devicePicker.modalonly = true; + 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; }); + // 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 + ')'); + })); + 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 + // "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'); + 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); }; + 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) { + 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); + this.mapInstance = m; + return m.render().then(function(formNode) { + return E([], [importPanel, formNode]); + }); + }, + handleSave: function(ev) { + // On LuCI 24.10 Map.save() only stages a session-scoped UCI + // changeset; the changes are committed by ui.changes.apply() + // (upstream's own Save & Apply path), and the default handler's + // #maincontent .cbi-map lookup also fails under out-of-tree + // themes. Save through the form instance and commit+apply so the + // plain "Save" button persists. Older LuCI applies inside + // Map.save() and has no ui.changes, hence the guard. + 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/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..842d4c74e5b7 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/view/wificalling-gateway/status.js @@ -0,0 +1,91 @@ +'use strict'; +'require view'; +'require fs'; +'require poll'; +'require dom'; + +return view.extend({ + load: function() { + return Promise.all([ + L.resolveDefault(fs.read('/var/run/wificalling-gateway/status.json'), '{}'), + L.resolveDefault(fs.read('/var/run/wificalling-gateway/service-health.json'), '{}') + ]); + }, + 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) { + 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 || '-'; + } + } + + /* ---------- Service status ---------- */ + 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); + + /* ---------- 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 || '-', + (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(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/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..16234d2a35c8 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/htdocs/luci-static/resources/wificalling-gateway/node-import.js @@ -0,0 +1,99 @@ +'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') || ''; + // 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') { + 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') || ''; + // 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') || ''; + } + } else if (protocol === 'wireguard') { + // wg://@:?private_key=…&local_address=…&reserved=…&mtu=… + out.public_key = decodeURIComponent(url.username || ''); + // 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') || ''; + } + 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 000000000000..4c6d45b15a1e --- /dev/null +++ b/applications/luci-app-wificalling-gateway/po/templates/wificalling-gateway.pot @@ -0,0 +1,498 @@ +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 "Another test is running right now" +msgstr "" + +msgid "Bound" +msgstr "" + +msgid "Call in progress (inferred from sustained encrypted traffic)" +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 "DHCP binding" +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 "Following gateway" +msgstr "" + +msgid "From connected devices" +msgstr "" + +msgid "General" +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 "Health check helper missing" +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 "Install tcping or nc to probe this node" +msgstr "" + +msgid "Invalid WireGuard endpoint: " +msgstr "" + +msgid "LAN IPv4 addresses" +msgstr "" + +msgid "Last activity" +msgstr "" + +msgid "Likely registered" +msgstr "" + +msgid "Log level" +msgstr "" + +msgid "MAC changed, rebind on reconnect" +msgstr "" + +msgid "Maximum records per device" +msgstr "" + +msgid "Meaning" +msgstr "" + +msgid "Missing config" +msgstr "" + +msgid "Missing key/address" +msgstr "" + +msgid "Missing server/port" +msgstr "" + +msgid "NAT-T seen" +msgstr "" + +msgid "Native" +msgstr "" + +msgid "Negotiating" +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 "" + +msgid "Node" +msgstr "" + +msgid "Node display name" +msgstr "" + +msgid "Node imported successfully. Reloading settings…" +msgstr "" + +msgid "Node status" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Not bound yet" +msgstr "" + +msgid "Not detected" +msgstr "" + +msgid "Not set" +msgstr "" + +msgid "Offline" +msgstr "" + +msgid "Online (static IP)" +msgstr "" + +msgid "Packet delta" +msgstr "" + +msgid "Packets" +msgstr "" + +msgid "Password" +msgstr "" + +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service." +msgstr "" + +msgid "Pick a device to fill its label and IP." +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 security is not available for VMess nodes" +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. 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 "" + +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 "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 "" + +msgid "Server" +msgstr "" + +msgid "Server and port are required" +msgstr "" + +msgid "Server unreachable" +msgstr "" + +msgid "Service status" +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 "Test" +msgstr "" + +msgid "Test in progress" +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 "" + +msgid "The generated sing-box config failed validation." +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 "Timeout" +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 "Unable to test node: " +msgstr "" + +msgid "Unknown" +msgstr "" + +msgid "Unreachable" +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 "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 "" + +msgid "WireGuard reserved (comma-separated)" +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 new file mode 100644 index 000000000000..c4feb0caf84b --- /dev/null +++ b/applications/luci-app-wificalling-gateway/po/zh_Hans/wificalling-gateway.po @@ -0,0 +1,499 @@ +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 "Another test is running right now" +msgstr "另一个测试正在进行中" + +msgid "Bound" +msgstr "已绑定" + +msgid "Call in progress (inferred from sustained encrypted traffic)" +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 "DHCP binding" +msgstr "DHCP 绑定" + +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 "Following gateway" +msgstr "跟随网关" + +msgid "From connected devices" +msgstr "从已连接设备选择" + +msgid "General" +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 "Health check helper missing" +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 "Install tcping or nc to probe this node" +msgstr "请安装 tcping 或 nc 以探测该节点" + +msgid "Invalid WireGuard endpoint: " +msgstr "无效的 WireGuard 端点:" + +msgid "LAN IPv4 addresses" +msgstr "局域网 IPv4 地址" + +msgid "Last activity" +msgstr "最后活动" + +msgid "Likely registered" +msgstr "可能已注册" + +msgid "Log level" +msgstr "日志级别" + +msgid "MAC changed, rebind on reconnect" +msgstr "MAC 已变化,重连后自动重绑" + +msgid "Maximum records per device" +msgstr "每台设备最大记录数" + +msgid "Meaning" +msgstr "含义" + +msgid "Missing config" +msgstr "缺少配置" + +msgid "Missing key/address" +msgstr "缺少密钥/地址" + +msgid "Missing server/port" +msgstr "缺少服务器/端口" + +msgid "NAT-T seen" +msgstr "已发现 NAT-T" + +msgid "Native" +msgstr "原生" + +msgid "Negotiating" +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 "无会话" + +msgid "Node" +msgstr "节点" + +msgid "Node display name" +msgstr "节点显示名称" + +msgid "Node imported successfully. Reloading settings…" +msgstr "节点导入成功。正在重新加载设置…" + +msgid "Node status" +msgstr "节点状态" + +msgid "None" +msgstr "无" + +msgid "Not bound yet" +msgstr "待绑定" + +msgid "Not detected" +msgstr "未检测到" + +msgid "Not set" +msgstr "未设置" + +msgid "Offline" +msgstr "离线" + +msgid "Online (static IP)" +msgstr "在线(静态 IP)" + +msgid "Packet delta" +msgstr "数据包增量" + +msgid "Packets" +msgstr "数据包" + +msgid "Password" +msgstr "密码" + +msgid "Paste one AnyTLS, Hysteria2/Hy2, TUIC, VLESS, VMess, Trojan, or WireGuard link (wg:// or an [Interface]/[Peer] config block). It is parsed locally in this browser and is not sent to an external service." +msgstr "粘贴一个 AnyTLS、Hysteria2/Hy2、TUIC、VLESS、VMess、Trojan 或 WireGuard (wg://) 链接。链接仅在本浏览器中本地解析,不会发送到外部服务。" + +msgid "Pick a device to fill its label and IP." +msgstr "选择设备以自动填写名称和 IP。" + +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 security is not available for VMess nodes" +msgstr "VMess 节点不支持 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. 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 "记录数:" + +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 "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 "请先保存节点,再刷新本页以便为设备选择该节点。" + +msgid "Server" +msgstr "服务器" + +msgid "Server and port are required" +msgstr "服务器和端口为必填项" + +msgid "Server unreachable" +msgstr "服务器不可达" + +msgid "Service status" +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 "Test" +msgstr "测试" + +msgid "Test in progress" +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 "配置已修改但网关未重启:sing-box 仍在运行旧配置。请重启服务使配置生效。" + +msgid "The generated sing-box config failed validation." +msgstr "生成的 sing-box 配置未通过校验。" + +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 "Timeout" +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 "Unable to test node: " +msgstr "无法测试节点:" + +msgid "Unknown" +msgstr "未知" + +msgid "Unreachable" +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 "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 私钥" + +msgid "WireGuard reserved (comma-separated)" +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/config/wificalling-gateway b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway new file mode 100644 index 000000000000..38f46df04a8a --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/etc/config/wificalling-gateway @@ -0,0 +1,12 @@ +config global 'main' + option enabled '0' + option log_level 'warn' + 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 new file mode 100755 index 000000000000..5507a5d678b2 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/etc/init.d/wificalling-gateway @@ -0,0 +1,124 @@ +#!/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() { + 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 + 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 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 + 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; config_get pre_shared_key "$s" pre_shared_key + # The delimiter guard covers every field interpolated into the + # | -delimited normalized.conf record. label is deliberately excluded: + # subscription labels routinely contain '|' (e.g. "HK01|BGP|CMCU") and + # labels never reach normalized.conf — the nodes file (health checks) + # gets a sanitized copy instead. credential/auxiliary (f[6]/f[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$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 ;; + 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|%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" +} + +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 "$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 +} + +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. + 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 + # 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; } + 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 + 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 +} + +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/rpcd/luci.wificalling-gateway b/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway new file mode 100755 index 000000000000..b3183113f12b --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/rpcd/luci.wificalling-gateway @@ -0,0 +1,41 @@ +#!/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_add_string 'id' 'id' + 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/compiler.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh new file mode 100755 index 000000000000..a921e800b02d --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/compiler.sh @@ -0,0 +1,152 @@ +#!/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) +} +# 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) + 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" { + # 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++) { + 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; used[$3]=1; 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\":[" + 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 + 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] + # 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 " ]," + } + 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] + # 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 + if (p=="wireguard") wg_check(f, id) + 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") "}}" + # 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]) "}}" + # 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]) + } + if (p=="trojan") s=s ",\"password\":" q(f[6]) ",\"tls\":" tls(f[7],f[8],f[9],f[20]) + if (p=="wireguard") { + # Legacy wireguard outbound (sing-box < 1.11). The endpoint form above + # is preferred; this branch uses the singular field name the old + # outbound actually parsed (local_address, not local_addresses). + s=s ",\"private_key\":" q(f[21]) ",\"peer_public_key\":" q(f[13]) ",\"local_address\":[" q(f[22]) "]" + 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[24]!="") s=s ",\"mtu\":" f[24] + if (f[25]!="") s=s ",\"pre_shared_key\":" q(f[25]) + } + 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") + out=(node_proto[devnode[k]]=="wireguard" && wg_style=="endpoint") ? "wg-" devnode[k] : "node-" devnode[k] + print " {\"source_ip_cidr\":[" list "],\"action\":\"route\",\"outbound\":" q(out) "}" (k "$tmp" || exit $? +chmod 600 "$tmp" +mv "$tmp" "$output" +trap - EXIT HUP INT TERM 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..6d89f32811ad --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/dhcp-sync.sh @@ -0,0 +1,100 @@ +#!/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} +# 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} + +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 + # 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" + +# 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 < "$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); +# 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 + # 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/libexec/wificalling-gateway/firewall.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/firewall.sh new file mode 100755 index 000000000000..cf41040df1ae --- /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 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..dd9f1f5a7678 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/monitor.sh @@ -0,0 +1,115 @@ +#!/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) { + # 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 + } + } + 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..49f2bc262634 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-health.sh @@ -0,0 +1,201 @@ +#!/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 + +sing_box=${WFC_SING_BOX:-/usr/bin/sing-box} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +# Real WireGuard handshake validation: run a temporary sing-box endpoint +# for the node and ask an echo service through it. ICMP reachability says +# nothing about the WG tunnel, and a dead UDP path is exactly what the +# 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() { + 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) + 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 + # 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 + # 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 + 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" + release_lock "$lock" + return 1 + fi + psk=$(uci -q get "wificalling-gateway.$id.pre_shared_key") || true + mtu=$(uci -q get "wificalling-gateway.$id.mtu") || true + 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. 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"]' \ + "\"$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"; ) + # Verify the tunnel with an echo service through the probe. The URL is + # 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" + release_lock "$lock" + printf '%s' "$ip" + return 0 + fi + printf '%s\nfailed\n%s\n' "$(date +%s)" "${reason:-unreachable}" > "$cache" + release_lock "$lock" + return 1 +} + +{ + printf '{"generated_at":%s,"nodes":[' "$(date +%s)" + first=1 + while IFS='|' read -r id label protocol server port; do + [ -n "$id" ] || continue + 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 + 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)\"" + fi + else + 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","state":"%s","measurement":"%s","ping_ms":%s,"reason":%s}' \ + "$(json_escape "$id")" "$state" "$measurement" "$ping_json" "${reason_json:-null}" + 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/node-test.sh b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh new file mode 100755 index 000000000000..fdf0845a38dc --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/node-test.sh @@ -0,0 +1,87 @@ +#!/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":"no_server"}\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 40 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" + 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" + 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/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/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..c92212bf0a8a --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/libexec/wificalling-gateway/service-health.sh @@ -0,0 +1,98 @@ +#!/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:-/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.$$" +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 +# 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 +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/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..fb4944954ea4 --- /dev/null +++ b/applications/luci-app-wificalling-gateway/root/usr/share/rpcd/acl.d/luci-app-wificalling-gateway.json @@ -0,0 +1,32 @@ +{ + "luci-app-wificalling-gateway": { + "description": "Manage Wi-Fi Calling Gateway", + "read": { + "uci": [ "wificalling-gateway", "dhcp" ], + "ubus": { "file": [ "read" ] }, + "file": { + "/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" ], + "/tmp/run/wificalling-gateway/events.log": [ "read" ], + "/tmp/dhcp.leases": [ "read" ], + "/etc/dhcp.leases": [ "read" ] + } + }, + "write": { + "uci": [ "wificalling-gateway" ], + "ubus": { + "file": [ "write" ], + "luci.wificalling-gateway": [ "node_test" ] + }, + "file": { + "/var/run/wificalling-gateway/events.log": [ "write" ], + "/tmp/run/wificalling-gateway/events.log": [ "write" ] + } + } + } +}