From de207ac4ea67ed6d80d9a905f248316c046ebea8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Nov 2026 01:20:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=A4=20Sync=202026-11-15=2001:20:25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- filebrowser-q/Makefile | 2 +- filebrowser-q/files/filebrowser.config | 2 +- gately/Makefile | 4 +- luci-app-gately/Makefile | 4 +- luci-app-ipsec-vpnd/.gitignore | 2 + luci-app-ipsec-vpnd/Makefile | 4 +- luci-app-ipsec-vpnd/README.md | 176 ++++++++++++++---- .../luci-static/resources/view/ipsec-vpnd.js | 111 ++++++++--- .../po/templates/ipsec-vpnd.pot | 16 ++ luci-app-ipsec-vpnd/po/zh_Hans/ipsec-vpnd.po | 16 ++ .../root/etc/init.d/ipsec-vpnd | 117 ------------ .../root/etc/uci-defaults/luci-ipsec-vpnd | 27 ++- .../root/usr/lib/ipsec/luci-ipsec-vpnd-deps | 75 ++++++++ .../root/usr/lib/ipsec/luci-ipsec-vpnd-render | 137 ++++++++++++++ .../usr/lib/ipsec/luci-ipsec-vpnd-sanitize | 13 ++ .../root/usr/lib/ipsec/luci-ipsec-vpnd-updown | 17 +- .../root/usr/libexec/rpcd/ipsec-vpnd | 167 ++++++++++++++++- .../share/rpcd/acl.d/luci-app-ipsec-vpnd.json | 5 +- luci-app-ipsec-vpnd/tests/render-test.sh | 169 +++++++++++++++++ luci-app-ipsec-vpnd/tests/run-all.sh | 22 +++ luci-app-ipsec-vpnd/tests/sanitize-test.sh | 49 +++++ .../tests/sessions-parse-test.sh | 128 +++++++++++++ 22 files changed, 1068 insertions(+), 195 deletions(-) delete mode 100755 luci-app-ipsec-vpnd/root/etc/init.d/ipsec-vpnd create mode 100755 luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-deps create mode 100755 luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-render create mode 100755 luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-sanitize create mode 100644 luci-app-ipsec-vpnd/tests/render-test.sh create mode 100644 luci-app-ipsec-vpnd/tests/run-all.sh create mode 100644 luci-app-ipsec-vpnd/tests/sanitize-test.sh create mode 100644 luci-app-ipsec-vpnd/tests/sessions-parse-test.sh diff --git a/filebrowser-q/Makefile b/filebrowser-q/Makefile index eb932e6c4..d1eb0fc0f 100644 --- a/filebrowser-q/Makefile +++ b/filebrowser-q/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=filebrowser-q PKG_VERSION:=2.1.5-beta -PKG_RELEASE:=20 +PKG_RELEASE:=21 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)? diff --git a/filebrowser-q/files/filebrowser.config b/filebrowser-q/files/filebrowser.config index d8d51b57b..8e7d43f29 100644 --- a/filebrowser-q/files/filebrowser.config +++ b/filebrowser-q/files/filebrowser.config @@ -3,4 +3,4 @@ config filebrowser 'config' option listen_port '8787' option root_path '/' option cacheDir '/tmp' - option cacheDirMaxSizeMB '500' + option cacheDirMaxSizeMB '300' diff --git a/gately/Makefile b/gately/Makefile index 2478601e8..b5e674d89 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gately -PKG_VERSION:=0.6.6.2 -PKG_RELEASE:=35 +PKG_VERSION:=0.6.6.4 +PKG_RELEASE:=36 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)? diff --git a/luci-app-gately/Makefile b/luci-app-gately/Makefile index 4d428275b..d50dd866f 100644 --- a/luci-app-gately/Makefile +++ b/luci-app-gately/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-gately -PKG_VERSION:=0.6.6.2 -PKG_RELEASE:=5 +PKG_VERSION:=0.6.6.4 +PKG_RELEASE:=6 PKG_MAINTAINER:=wangwanjie PKG_LICENSE:=Apache-2.0 diff --git a/luci-app-ipsec-vpnd/.gitignore b/luci-app-ipsec-vpnd/.gitignore index d6e885d97..367c50ff0 100644 --- a/luci-app-ipsec-vpnd/.gitignore +++ b/luci-app-ipsec-vpnd/.gitignore @@ -1,2 +1,4 @@ *.apk *.ipk +.DS_Store +docs/ diff --git a/luci-app-ipsec-vpnd/Makefile b/luci-app-ipsec-vpnd/Makefile index 197c24459..534046165 100644 --- a/luci-app-ipsec-vpnd/Makefile +++ b/luci-app-ipsec-vpnd/Makefile @@ -6,12 +6,12 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for IPSec VPN Server (IKEv1 with PSK and Xauth) -LUCI_DEPENDS:=+strongswan +strongswan-minimal +strongswan-mod-xauth-generic +strongswan-mod-kernel-libipsec +kmod-tun +LUCI_DEPENDS:=+strongswan +strongswan-minimal +strongswan-mod-xauth-generic +strongswan-mod-kernel-libipsec +strongswan-mod-des +kmod-tun LUCI_PKGARCH:=all PKG_NAME:=luci-app-ipsec-vpnd PKG_VERSION:=27.906.25.12 -PKG_RELEASE:=2 +PKG_RELEASE:=3 define Package/luci-app-ipsec-vpnd/conffiles /etc/config/ipsec-vpnd diff --git a/luci-app-ipsec-vpnd/README.md b/luci-app-ipsec-vpnd/README.md index 55a6808a8..e66876be0 100644 --- a/luci-app-ipsec-vpnd/README.md +++ b/luci-app-ipsec-vpnd/README.md @@ -10,11 +10,12 @@ IKEv1 + PSK + Xauth(账号密码),目标用户是家用 / 小公司路由 ## 功能 -- **VPN 服务启停**(uhttpd → LuCI → procd 守护进程控制 strongSwan 的 `starter`) -- **账号 / 密码 / PSK 配置** 写在 `/etc/config/ipsec-vpnd`,运行时被 init 脚本翻译成 strongSwan 的 `ipsec.conf` + `ipsec.secrets` +- **VPN 服务启停**(uhttpd → LuCI → 控制 strongSwan 原生的 `ipsec` 服务,即 `procd` 托管的 `starter` + `charon`);「启用」是总开关,关掉时服务会停、conn 会从 `ipsec.conf` 里移除、凭据文件也会清掉 +- **账号 / 密码 / PSK 配置** 写在 `/etc/config/ipsec-vpnd`,由 `luci-ipsec-vpnd-render` 翻译成 strongSwan 的 `ipsec.conf` + `ipsec.secrets` - **首次安装自动初始化**:建 `network.VPN` 设备(绑 `ipsec0`)+ `firewall.ike/ipsec/ah/esp` 入站规则 + `firewall.VPN` zone + 端口转发;只跑一次,`/etc/config/ipsec-vpnd` 里的 `initialized` 标记防止升级时覆盖用户后续自定义 -- **和系统自带的 `ipsec` 服务冲突处理**:每次启动前都 `stop && disable` 系统那个,防止 charon 抢 UDP/500、4500 +- **不再自建服务实例**:本包不提供自己的 `ipsec-vpnd` 服务,charon 统一交给 strongSwan 自带的 `ipsec` 服务托管(`strongswan-ipsec` 包提供),从根本上避免两个服务抢 UDP/500、4500——抢端口时后启动的那个会**静默退出**,LuCI 上只显示「未运行」却查不到原因 - **菜单位置**:`VPN → IPSec VPN Server`(order 30) +- **依赖自检与诊断**:服务启动前检查 strongSwan 运行时是否完整,缺失时拒绝启动并写明确日志;LuCI 页面顶部直接列出缺哪一项、给出对应安装命令 ## 界面与配置项 @@ -22,56 +23,114 @@ UCI 路径 `ipsec-vpnd.@ipsec[0]`,字段: | 字段 | 默认 | 说明 | |---|---|---| -| `enabled` | `0` | 总开关 | +| `enabled` | `0` | 总开关。关掉 = 服务停 + 不加载 conn + 清除 `ipsec.secrets` | | `clientip` | `10.9.8.10/24` | 客户端地址池(CIDR),用 strongSwan 的 `rightsourceip` 推给拨号端 | | `clientdns` | `10.9.8.1` | 推给客户端的 DNS(即 VPN 网关 IP) | | `account` | `vpnuser` | Xauth 用户名 | | `password` | `changeme` | Xauth 密码 | | `secret` | `changeme` | IKE PSK 预共享密钥 | -VPN 客户端拨号后会拿到 `clientip` 池里一个地址,`clientdns` 指向 VPN 网关(路由器上的 `ipsec0` IP,初始化脚本里按 LAN 前两段、第三段 +1 自动算出来)。 +VPN 客户端拨号后会拿到 `clientip` 池里一个地址,`clientdns` 指向 VPN 网关(路由器上的 `ipsec0` IP,固定为 `10.9.8.1`)。 + +> `clientip` 直接传给 strongSwan 的 `rightsourceip`,格式是 `<起始地址>/<掩码>`,表示一个**地址池**。 +> 默认的 `10.9.8.10/24` 即 `10.9.8.10`–`10.9.8.254`(245 个地址,起点避开网关 `10.9.8.1`), +> 多个客户端可以同时在线,各自拿到池里的一个地址(实测两个客户端分别拿到 `10.9.8.10` / `10.9.8.11`)。 +> 注意 `account` 目前只支持**一个** XAuth 账号,不同用户名无法登录。 + +## 客户端配置示例(已实测通过) + +Linux 客户端(strongSwan 5.9/6.x): + +```conf +# /etc/ipsec.conf +config setup + uniqueids=never + +conn client + keyexchange=ikev1 + ike=aes128-sha1-modp2048,aes128-sha1-modp1024,3des-sha1-modp1024,3des-sha1-modp1536 + esp=aes128-sha1,3des-sha1 + right=192.168.1.139 # 服务端地址 + rightauth=psk + left=%defaultroute + leftid=testuser # IKE ID,同时作为 XAuth 用户名 + leftauth=psk + leftauth2=xauth # IKEv1 第二轮 XAuth 认证 + leftsourceip=%config # 向服务端申请虚拟地址 + auto=start +``` + +```conf +# /etc/ipsec.secrets(客户端侧也要写 XAuth 密码) +: PSK "testpsk" +testuser : XAUTH "testpass" +``` + +**关键点**:客户端必须用 `leftsourceip=%config`(或与服务端 `clientip` 完全一致的 TS)。 +写死 `leftsubnet=10.9.8.0/24` 或 `leftsourceip=<自己的地址>` 会与服务端下发的 +`10.9.8.10/32` 冲突,服务端日志出现 +`no matching CHILD_SA config found for 10.9.8.0/24 === <服务端IP>/32`, +最终报 `INVALID_ID_INFORMATION`,IKE 起来了但 CHILD SA 装不上。 ## 依赖 -`Makefile` 里 `LUCI_DEPENDS` 列的运行时依赖: +本包是纯 LuCI 前端 + 脚本,运行时依赖 strongSwan。**安装本包时会自动拉取下面这些依赖**(apk / ipk 的 `depends` 已声明): -``` -+strongswan-minimal -+strongswan-mod-xauth-generic -+strongswan-mod-kernel-libipsec -+strongswan-mod-des -+kmod-tun +| 包 | 作用 | +|---|---| +| `strongswan-minimal` | strongSwan 最小集,含 charon、starter(`/usr/lib/ipsec/starter`)、stroke 插件 | +| `strongswan-mod-xauth-generic` | XAuth 账号认证插件,缺它 XAuth 账号无法登录 | +| `strongswan-mod-kernel-libipsec` | 内核态 IPsec | +| `strongswan-mod-des` | 3DES 加密插件,缺它本包声明的 3DES 提案不可用(老版本 iOS/Android 客户端需要) | +| `kmod-tun` | TUN/TAP 设备 | + +> `strongswan-mod-stroke` 会连带装上 `strongswan-ipsec`(提供 `/usr/sbin/ipsec` 命令与系统自带的 `ipsec` 服务)。 +> 本包正是复用这个服务来托管 charon:LuCI 服务列表里跑起来的是 `ipsec`,本包不再有独立的 `ipsec-vpnd` 服务实例。 + +### 如果提示找不到依赖 + +先更新索引再装: + +```sh +apk update +apk add luci-app-ipsec-vpnd-*.apk ``` -**这是 IPsec VPN 服务能起来的前提。**安装本包前请确认这些包在固件里都有,否则 LuCI 界面能开,但服务起不来。 +索引缓存损坏时 `apk search` 会返回空,看起来像"源里没有 strongswan",`apk update` 即可恢复。 -### 关于 ImmortalWrt 25.12 系列(重要) +### 关于 ImmortalWrt 25.12 系列 -ImmortalWrt **25.12.0 / 25.12.1 的官方软件源在全架构上都没带 strongswan**(OpenWrt 上游 25.12 源有,ImmortalWrt 24.10 源也有,唯独 ImmortalWrt 25.12 系列缺)。如果你的固件就是 ImmortalWrt 25.12 系列且只挂着官方源,`opkg install luci-app-ipsec-vpnd` 会卡在依赖解析。 +实测 **ImmortalWrt 25.12.2 官方源已经带 strongswan**(`apk search strongswan*` 有 100+ 个包),直接装本包即可,不需要混用 OpenWrt 源。 -可行方案(任选): +25.12.0 / 25.12.1 是否同样带 strongswan **未验证**(这些源的镜像不提供历史索引,无法回溯确认)。如果你手上的版本确实报"找不到依赖",可以自行确认: -- **换 OpenWrt 上游源**:`/etc/opkg/distfeeds.conf` 里加 `src/gz openwrt_core https://downloads.openwrt.org/releases/25.12.1/targets/.../packages/` -- **换同版本但带 strongswan 的 ImmortalWrt fork 源** -- **用 szwjp/luci 这种社区源**,里头把 strongswan 也带上了 +```sh +apk update && apk search strongswan\* +``` + +若确实没有,可用**同版本**的 OpenWrt 源(注意内核与 libc 版本必须一致,否则会出现不可预期的依赖错配),或在固件里预装 strongswan 后再装本包。 ## 安装 ### 普通用户(装现成的 .apk) -从本仓库的 `Actions → Build → 最新一次成功 run → Artifacts → ipsec-vpnd-packages` 下载 `luci-app-ipsec-vpnd_*.apk` 和 `luci-i18n-ipsec-vpnd-zh-cn_*.apk`(中文界面,可选),用 scp / WinSCP 推到路由器: +从本仓库的 `Actions → Build → 最新一次成功 run → Artifacts → ipsec-vpnd-packages` 下载 `luci-app-ipsec-vpnd-*.apk` 和 `luci-i18n-ipsec-vpnd-zh-cn-*.apk`(中文界面,可选),用 scp / WinSCP 推到路由器: ```sh -opkg install luci-app-ipsec-vpnd_*.apk -opkg install luci-i18n-ipsec-vpnd-zh-cn_*.apk # 可选 +apk update +apk add --allow-untrusted luci-app-ipsec-vpnd-*.apk +apk add --allow-untrusted luci-i18n-ipsec-vpnd-zh-cn-*.apk # 可选 ``` -第一次安装会跑 `uci-defaults/luci-ipsec-vpnd` 一次性初始化(建 network/firewall/zone + 删系统 ipsec 服务),然后 LuCI 刷新一下就能在 `VPN → IPSec VPN Server` 看到。 +> 25.12 用 `apk`,更老的版本用 `opkg install`。 +> `--allow-untrusted` 是因为 CI 产出的包没有签名;正式分发建议自行签名。 -或者从 release 装(GitHub Actions 自动把每次 main 提交 publish 到 `IPSec VPND Latest Build` release): +第一次安装会跑 `uci-defaults/luci-ipsec-vpnd` 一次性初始化(建 network/firewall/zone + 启用系统 `ipsec` 服务 + 渲染初始 strongSwan 配置 + 拉起 VPN 网关接口),然后 LuCI 刷新一下就能在 `VPN → IPSec VPN Server` 看到。 + +或者从 release 装(GitHub Actions 自动把每次 main 提交 publish 到 `IPSec VPND Latest Build` release,注意该 release 每次推送会重建,文件名里的版本号会变): ```sh -opkg install https://github.com/szwjp/luci-app-ipsec-vpnd/releases/download/luci-app-ipsec-vpnd/luci-app-ipsec-vpnd_27.906.25.12-r1_all.apk +apk add --allow-untrusted https://github.com/szwjp/luci-app-ipsec-vpnd/releases/download/luci-app-ipsec-vpnd/luci-app-ipsec-vpnd-27.906.25.12-r12.apk ``` ### 自己构建(社区分发 / 自己做 firmware) @@ -83,13 +142,44 @@ opkg install https://github.com/szwjp/luci-app-ipsec-vpnd/releases/download/luci sudo apt-get install -y build-essential meson ninja-build fakeroot # 用仓库自带的 .github/build-pkg.sh -fakeroot bash .github/build-pkg.sh apk 1 # 出 luci-app-ipsec-vpnd_*.apk + luci-i18n-ipsec-vpnd-zh-cn-*.apk +fakeroot bash .github/build-pkg.sh apk 1 # 出 luci-app-ipsec-vpnd-*.apk + luci-i18n-ipsec-vpnd-zh-cn-*.apk fakeroot bash .github/build-pkg.sh ipk 1 # 出同名 .ipk ls -l *.apk *.ipk ``` +> `fakeroot` 不能省:apk v3 的 `mkpkg` 直接按文件属主写包,没有 fakeroot 就会把构建者的 uid 打进包里,装出来的文件属主不是 root。 +> macOS 上脚本本身能跑(已把 `mktemp -p` / `find -printf` / `sha256sum` 换成可移植写法),但产物属主仍是本地用户,仅适合调试,正式发布请走 CI。 +> +> **`po2lmo` 必须是真的那个**(CI 里从 luci 源码编译)。LuCI 的 `.lmo` 不是 GNU MO 格式, +> 用 `msgfmt` 转出来的 MO 文件能被 `msgunfmt` 读,但 LuCI 的加载器读不了 —— 装上去的表现是 +> **页面所有文案退回英文**。本地要构建的话: +> +> ```sh +> git clone --depth 1 --filter=blob:none --sparse https://github.com/openwrt/luci.git +> cd luci/modules/luci-base/src && make po2lmo # 产物在当前目录 +> export PATH="$PWD:$PATH" +> ``` + `build-pkg.sh` 用了仓库里那个**专为本包定制的最小工具链**(apk-tools 自编译 + po2lmo 自编译 + ipkg-build),不依赖完整 OpenWrt SDK 编译环境;这就是为什么 `build.yml` 跑得很快(~40s)。所有 actions 都用 Node 24 版(`actions/checkout@v5` / `actions/upload-artifact@v6` / `softprops/action-gh-release@v3`)。 +## 测试 + +```sh +sh tests/run-all.sh +``` + +三组离线测试,不依赖路由器: + +- `render-test.sh` —— UCI → `ipsec.conf`/`ipsec.secrets` 的映射、secrets 权限 0600、 + 停用时不留 conn、依赖缺失时不生成配置 +- `sessions-parse-test.sh` —— 客户端列表解析:控制字符清洗、旧版脏行丢弃、 + 在线但无地址时打 `vip_pending`、历史上限 200 条 +- `sanitize-test.sh` —— 控制字符清洗工具 + +脚本用的是 POSIX sh + awk,**在 OpenWrt 的 busybox 上也能跑**(CI 里跑的是 +GNU 环境,所以改完记得也在设备上跑一遍:busybox 的 `tr` 不认 `[:cntrl:]` +字符类,会把字面字符删掉——这个坑就是这么漏出去的)。 + ## CI `.github/workflows/` 下两个 workflow: @@ -102,23 +192,37 @@ ls -l *.apk *.ipk ``` . ├── Makefile # OpenWrt 包定义:LUCI_TITLE / LUCI_DEPENDS / PKG_* +├── tests/ # 离线回归测试(不依赖路由器,CI 里也会跑) +│ ├── run-all.sh # 跑全部测试 +│ ├── render-test.sh # UCI → ipsec.conf / ipsec.secrets 渲染 +│ ├── sessions-parse-test.sh # 客户端列表解析(控制字符、脏行、历史上限) +│ └── sanitize-test.sh # 控制字符清洗 ├── htdocs/luci-static/resources/view/ipsec-vpnd.js -│ # LuCI web UI(cbid 表格 + 启停按钮) +│ # LuCI web UI(配置表单 + 在线客户端表 + 依赖诊断) ├── po/ │ ├── templates/ipsec-vpnd.pot # gettext 模板 │ └── zh_Hans/ipsec-vpnd.po # 简体中文翻译 ├── root/ │ ├── etc/config/ipsec-vpnd # UCI 默认配置(服务默认 enabled=0) -│ ├── etc/init.d/ipsec-vpnd # procd init:写 ipsec.conf/secrets、启 starter +│ ├── usr/lib/ipsec/luci-ipsec-vpnd-render # UCI → ipsec.conf/ipsec.secrets 渲染器(含依赖自检) │ ├── etc/uci-defaults/luci-ipsec-vpnd │ │ # 首次安装一次性:建 network.VPN + firewall +│ ├── usr/lib/ipsec/luci-ipsec-vpnd-deps +│ │ # 依赖自检:init 启动拦截 + LuCI 诊断面板共用 +│ ├── usr/lib/ipsec/luci-ipsec-vpnd-updown +│ │ # strongSwan updown 钩子:记录客户端上下线 +│ ├── usr/lib/ipsec/luci-ipsec-vpnd-sanitize +│ │ # 剥掉用户名里的控制字符(busybox 的 tr 不认字符类) +│ ├── usr/libexec/rpcd/ipsec-vpnd # rpcd 后端:sessions / clear_history / deps / apply │ ├── usr/share/luci/menu.d/luci-app-ipsec-vpnd.json │ │ # LuCI 菜单注册(VPN → IPSec VPN Server) │ └── usr/share/rpcd/acl.d/luci-app-ipsec-vpnd.json │ # rpcd ACL:放行 luci-app-ipsec-vpnd 读写 /etc/config/ipsec-vpnd -├── .github/workflows/ -│ ├── build.yml -│ └── cleanup-old-workflow.yml +├── .github/ +│ ├── build-pkg.sh # 绕过 SDK 的 apk / ipk 直接打包脚本 +│ └── workflows/ +│ ├── build.yml +│ └── cleanup-old-workflow.yml ├── LICENSE # Apache-2.0 └── README.md # 本文件 ``` @@ -135,11 +239,13 @@ ls -l *.apk *.ipk | 现象 | 排查 | |---|---| | LuCI 菜单看不到 IPSec VPN Server | LuCI → 系统 → 软件包,确认 `luci-app-ipsec-vpnd` 已装;刷新 LuCI 缓存(`rm -f /tmp/luci-indexcache`) | -| 服务起不来,日志说 "starter: no such file" | `strongswan-minimal` 没装;装上 | +| 装完服务起不来,日志说 "starter: no such file" | strongSwan 运行时没装上。先 `apk update`(索引缓存坏了会搜不到包),再 `apk add strongswan-minimal strongswan-mod-xauth-generic strongswan-mod-kernel-libipsec strongswan-mod-des` | +| LuCI 页面顶部出现红色「缺少依赖」 | 页面会直接列出缺哪一项以及对应的安装命令,照着执行即可 | | 客户端拨不上,日志说 "no IKE config" | 看 `/etc/config/ipsec-vpnd` 的 `enabled` 是不是 `1`;看 init 日志(`logread \| grep ipsec`) | -| 系统自带 ipsec 又被启起来抢端口 | 升级 strongswan 包后可能要再跑一次 `uci-defaults/luci-ipsec-vpnd`——本包 init 脚本每次启动前都会重新 stop+disable 系统那个,但只在服务 enabled 时跑 | -| ImmortalWrt 25.12 装不上(缺依赖) | 见上面“关于 ImmortalWrt 25.12 系列” | -| GitHub Actions 出包失败 | 看 `Build` workflow 的 step 日志;常见坑在 `actions/cache` 没命中、host 工具链 apt 装失败这些 | +| 客户端拨不上,日志说 "no acceptable proposal" | 多半缺 3DES 插件:装 `strongswan-mod-des` | +| 服务显示「未运行」但 charon 明明在跑 | 说明有第二个服务实例抢占了 UDP/500、4500。本包不再自建服务,正常情况下只有系统 `ipsec` 一个实例;`ls /etc/rc.d/*ipsec*` 检查是否残留旧版 `ipsec-vpnd` 链接 | +| 装完 `ipsec0` 上没有 VPN 网关地址 | 执行 `ifup VPN`;本包在安装与服务启动时都会自动执行一次 | +| GitHub Actions 出包失败 | 看 `Build` workflow 的 step 日志;常见是 host 工具链 apt 装失败、或 apk-tools/po2lmo 编译报错 | ## 许可证 diff --git a/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js b/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js index f55b1294f..e884febe3 100644 --- a/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js +++ b/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js @@ -17,10 +17,10 @@ const callServiceList = rpc.declare({ expect: { '': {} } }); -const callServiceInit = rpc.declare({ - object: 'rc', - method: 'init', - params: ['name', 'action'] +const callIpsecApply = rpc.declare({ + object: 'ipsec-vpnd', + method: 'apply', + expect: { '': {} } }); const callIpsecSessions = rpc.declare({ @@ -35,25 +35,36 @@ const callClearHistory = rpc.declare({ expect: { '': {} } }); +const callIpsecDeps = rpc.declare({ + object: 'ipsec-vpnd', + method: 'deps', + expect: { '': {} } +}); + +const callIpsecStatus = rpc.declare({ + object: 'ipsec-vpnd', + method: 'status', + expect: { '': {} } +}); + function getServiceStatus() { - return L.resolveDefault(callServiceList('ipsec-vpnd'), {}).then(function(res) { - let isRunning = false; - try { - // procd_open_instance 未指定实例名时,procd 按 init 脚本 basename - // 命名实例为 'ipsec-vpnd';遍历全部实例,任一 running 即视为运行 - let instances = res['ipsec-vpnd']['instances'] || {}; - for (let name in instances) - if (instances[name]['running']) - isRunning = true; - } catch (e) { } - return isRunning; + // 后端直接报两件事:UCI 里的 enabled 总开关,以及系统 ipsec 服务是否在跑。 + // 只看服务状态会出现"开关是关的、服务却在跑"这种自相矛盾的情况。 + return L.resolveDefault(callIpsecStatus(), {}).then(function(res) { + return { + enabled: (res && res.enabled === '1'), + running: !!(res && res.running) + }; }); } -function renderStatus(isRunning) { +function renderStatus(st) { let spanTemp = '%s %s'; let renderHTML; - if (isRunning) + + if (!st.enabled) + renderHTML = spanTemp.format('orange', _('IPSec VPN'), _('DISABLED')); + else if (st.running) renderHTML = spanTemp.format('green', _('IPSec VPN'), _('RUNNING')); else renderHTML = spanTemp.format('red', _('IPSec VPN'), _('NOT RUNNING')); @@ -65,6 +76,15 @@ return view.extend({ render() { let m, s, o; + // 配置落盘成功后,渲染 ipsec.conf 并按 enabled 启停服务。 + // 挂在 "uci-applied" 上是为了保证读到的是**已经落盘**的配置。 + if (!document.ipsecVpndApplyHook) { + document.ipsecVpndApplyHook = true; + document.addEventListener('uci-applied', function() { + L.resolveDefault(callIpsecApply(), {}); + }); + } + m = new form.Map('ipsec-vpnd', _('IPSec VPN Server'), _('IPSec VPN connectivity using the native built-in VPN Client on iOS or Android (IKEv1 with PSK and Xauth)')); @@ -83,6 +103,43 @@ return view.extend({ ]); } + // 依赖诊断:strongSwan 运行时不全时,页面直接说明缺什么、怎么装。 + // 没有这块的话,用户只会看到 NOT RUNNING,无从判断是缺包还是配置问题。 + s = m.section(form.TypedSection); + s.anonymous = true; + s.render = function() { + let box = E('div', { class: 'cbi-section-error' }); + let wrap = E('div', { id: 'ipsec_vpnd_deps_wrap', style: 'display:none' }, [ box ]); + + let update = function() { + return L.resolveDefault(callIpsecDeps(), {}).then(function(res) { + let files = (res && Array.isArray(res.files)) ? res.files : []; + let missing = files.filter(function(d) { return d.status !== 'ok'; }); + if (missing.length === 0) { + wrap.style.display = 'none'; + return; + } + wrap.style.display = ''; + box.textContent = ''; + box.appendChild(E('h3', {}, _('Missing dependencies'))); + missing.forEach(function(d) { + box.appendChild(E('p', {}, d.detail || d.key)); + }); + box.appendChild(E('p', {}, _('Install the missing packages with:'))); + let mgr = (res && res.pkg_mgr === 'opkg') ? 'opkg' : 'apk'; + let cmd = (mgr === 'opkg') + ? 'opkg update && opkg install strongswan-minimal strongswan-mod-xauth-generic strongswan-mod-kernel-libipsec strongswan-mod-des' + : 'apk update && apk add strongswan-minimal strongswan-mod-xauth-generic strongswan-mod-kernel-libipsec strongswan-mod-des'; + box.appendChild(E('pre', {}, cmd)); + }); + }; + + poll.add(update, 15); + update(); + + return wrap; + } + s = m.section(form.NamedSection, 'ipsec', 'service'); o = s.option(form.Flag, 'enabled', _('Enable')); @@ -135,10 +192,15 @@ return view.extend({ let renderRows = function(sessions) { clientBody.textContent = ''; sessions.forEach(function(s) { + // 在线但还没拿到虚拟地址:CHILD_SA 尚未装好,或解析不到 /32 + // 选择器。给一句明确提示,别让用户对着 "-" 猜。 + let vipText = s.vip; + if (!vipText) + vipText = s.vip_pending ? _('Assigned IP pending') : '-'; clientBody.appendChild(E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td' }, s.user || '-'), E('td', { 'class': 'td' }, s.remote || '-'), - E('td', { 'class': 'td' }, s.vip || '-'), + E('td', { 'class': 'td' }, vipText), E('td', { 'class': 'td' }, s.age || '-'), E('td', { 'class': 'td' }, s.online ? _('Connected') : _('Disconnected')) ])); @@ -186,13 +248,14 @@ return view.extend({ ]); }; - // 保存后显式 restart 服务:LuCI 保存走 uci commit,不会触发 procd 的 - // config.change reload trigger(实测 commit/reload_config 均不重启服务), - // 必须显式 rc.init restart 才能让启用/停用即时生效。 + // 保存只负责把改动交给 LuCI 暂存;真正落盘(apply)由页面在所有 map + // 保存完之后统一触发,落盘成功时 LuCI 会在 document 上派发 "uci-applied"。 + // + // 渲染配置、启停服务必须放在那个事件里:早一步的话渲染器读到的是 + // 落盘前的旧值 —— 结果就是"启用"开关点了没反应(配置存下去了, + // ipsec.conf 还是按旧的 enabled 生成的)。 m.save = function(ev) { - return form.Map.prototype.save.call(this).then(function() { - return L.resolveDefault(callServiceInit('ipsec-vpnd', 'restart')); - }); + return form.Map.prototype.save.call(this); }; return m.render(); diff --git a/luci-app-ipsec-vpnd/po/templates/ipsec-vpnd.pot b/luci-app-ipsec-vpnd/po/templates/ipsec-vpnd.pot index 4067c94e4..24dc3d68c 100644 --- a/luci-app-ipsec-vpnd/po/templates/ipsec-vpnd.pot +++ b/luci-app-ipsec-vpnd/po/templates/ipsec-vpnd.pot @@ -9,6 +9,10 @@ msgstr "" msgid "Assigned IP" msgstr "" +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:186 +msgid "Assigned IP pending" +msgstr "" + #: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:162 msgid "Clear all client history records?" msgstr "" @@ -78,6 +82,10 @@ msgstr "" msgid "NOT RUNNING" msgstr "" +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:53 +msgid "DISABLED" +msgstr "" + #: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:99 msgid "Password" msgstr "" @@ -113,3 +121,11 @@ msgstr "" #: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:86 msgid "VPN Client IP" msgstr "" + +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:120 +msgid "Install the missing packages with:" +msgstr "" + +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:119 +msgid "Missing dependencies" +msgstr "" diff --git a/luci-app-ipsec-vpnd/po/zh_Hans/ipsec-vpnd.po b/luci-app-ipsec-vpnd/po/zh_Hans/ipsec-vpnd.po index fb0c044c0..a95d0da84 100644 --- a/luci-app-ipsec-vpnd/po/zh_Hans/ipsec-vpnd.po +++ b/luci-app-ipsec-vpnd/po/zh_Hans/ipsec-vpnd.po @@ -16,6 +16,10 @@ msgstr "账户" msgid "Assigned IP" msgstr "分配的 IP" +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:186 +msgid "Assigned IP pending" +msgstr "分配地址未就绪" + #: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:162 msgid "Clear all client history records?" msgstr "确定要清除所有客户端历史记录吗?" @@ -87,6 +91,10 @@ msgstr "当前没有 VPN 客户端在线" msgid "NOT RUNNING" msgstr "未运行" +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:53 +msgid "DISABLED" +msgstr "已停用" + #: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:99 msgid "Password" msgstr "密码" @@ -122,3 +130,11 @@ msgstr "VPN客户端DNS服务器" #: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:86 msgid "VPN Client IP" msgstr "VPN客户端地址段" + +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:120 +msgid "Install the missing packages with:" +msgstr "请先安装缺失的软件包:" + +#: applications/luci-app-ipsec-vpnd/htdocs/luci-static/resources/view/ipsec-vpnd.js:119 +msgid "Missing dependencies" +msgstr "缺少依赖" diff --git a/luci-app-ipsec-vpnd/root/etc/init.d/ipsec-vpnd b/luci-app-ipsec-vpnd/root/etc/init.d/ipsec-vpnd deleted file mode 100755 index 07c27425a..000000000 --- a/luci-app-ipsec-vpnd/root/etc/init.d/ipsec-vpnd +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/sh /etc/rc.common - -START=90 -STOP=10 - -USE_PROCD=1 -PROG=/usr/lib/ipsec/starter - -. $IPKG_INSTROOT/lib/functions.sh -. $IPKG_INSTROOT/lib/functions/network.sh - -IPSEC_SECRETS_FILE=/etc/ipsec.secrets -IPSEC_CONN_FILE=/etc/ipsec.conf -STRONGSWAN_CONF_FILE=/etc/strongswan.conf - -reload_service() { - config_load ipsec-vpnd - - local vt_enabled - config_get_bool vt_enabled "ipsec" "enabled" "0" - [ "$vt_enabled" -eq "0" ] && { - stop - return 1 - } - - # 配置变更时重启 procd 实例以重新加载。 - # 不要使用 strongswan 的 ipsec rereadall/reload 命令: - # 它们通过 starter pidfile 操作 charon,会误杀本服务 - # 自己管理的 starter/charon 实例 - stop - start -} - -service_triggers() { - procd_add_reload_trigger "ipsec-vpnd" -} - -start_service() { - config_load ipsec-vpnd - - local vt_enabled - config_get_bool vt_enabled "ipsec" "enabled" "0" - [ "$vt_enabled" -eq "0" ] && return 1 - - # 系统 strongswan 的 ipsec 服务与本服务共用 charon 及 UDP 500/4500, - # 必须停掉并禁用来避免抢端口。每次启动都重新执行,防止系统 ipsec - # 被升级或手动重新启用后再次抢占。 - [ -x /etc/init.d/ipsec ] && { - /etc/init.d/ipsec stop >/dev/null 2>&1 - /etc/init.d/ipsec disable >/dev/null 2>&1 - } - - local vt_clientip vt_clientdns vt_account vt_password vt_secret - config_get vt_clientip "ipsec" "clientip" "10.9.8.10/24" - config_get vt_clientdns "ipsec" "clientdns" "10.9.8.1" - config_get vt_account "ipsec" "account" "vpnuser" - config_get vt_password "ipsec" "password" "changeme" - config_get vt_secret "ipsec" "secret" "changeme" - - cat > /etc/ipsec.conf < /etc/ipsec.secrets </dev/null delete firewall.ipsecd @@ -94,5 +101,21 @@ EOF uci -q set ipsec-vpnd.ipsec.initialized='1' uci -q commit ipsec-vpnd +# UCI 配置里明文存着 XAuth 密码与 PSK,收紧到 0600 +chmod 600 /etc/config/ipsec-vpnd 2>/dev/null + +# 清理旧版本自建的 ipsec-vpnd 服务残留(升级路径)。新的版本不再提供服务实例, +# charon 统一由 strongSwan 的 ipsec 服务托管。 +/etc/init.d/ipsec-vpnd stop >/dev/null 2>&1 +/etc/init.d/ipsec-vpnd disable >/dev/null 2>&1 +rm -f /etc/rc.d/S*ipsec-vpnd /etc/rc.d/K*ipsec-vpnd + +# 生成初始 strongSwan 配置 +/usr/lib/ipsec/luci-ipsec-vpnd-render >/dev/null 2>&1 + +# 立即拉起 VPN 网关接口。ipsec0 由 charon 启动后才出现,netifd 会等设备 +# 就绪后重试,这样装完就能用,不必手动 ifup VPN。 +/sbin/ifup VPN >/dev/null 2>&1 + rm -f /tmp/luci-indexcache exit 0 diff --git a/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-deps b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-deps new file mode 100755 index 000000000..b39c751c0 --- /dev/null +++ b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-deps @@ -0,0 +1,75 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# +# luci-app-ipsec-vpnd 运行时依赖自检。 +# +# 由两处共用: +# - /etc/init.d/ipsec-vpnd:缺依赖时拒绝启动并写明确日志(避免只看到 NOT RUNNING) +# - /usr/libexec/rpcd/ipsec-vpnd:LuCI 页面的依赖诊断面板 +# +# 用法: +# luci-ipsec-vpnd-deps files 文件类依赖检查(starter / charon / 插件) +# luci-ipsec-vpnd-deps pkgs 软件包安装状态 +# +# 输出格式(每行一条,'|' 分隔): +# || +# status: ok | missing +# detail: ok 时为路径或版本号;missing 时为处理建议 +# +# 退出码:全部就绪 0;存在缺失 1。 + +PKGS="strongswan-minimal strongswan-charon strongswan-mod-stroke strongswan-mod-xauth-generic strongswan-mod-kernel-libipsec strongswan-mod-des kmod-tun" + +MISSING=0 + +check_file() { + # $1=key $2=路径 $3=缺失时的处理建议 + if [ -e "$2" ]; then + printf '%s|ok|%s\n' "$1" "$2" + else + MISSING=1 + printf '%s|missing|%s\n' "$1" "$3" + fi +} + +files() { + check_file starter /usr/lib/ipsec/starter \ + "缺少 strongSwan 启动器 /usr/lib/ipsec/starter(由 strongswan-mod-stroke 提供,随 strongswan-minimal 一并安装)" + check_file charon /usr/lib/ipsec/charon \ + "缺少 IKE 守护进程 /usr/lib/ipsec/charon(需安装 strongswan-charon)" + check_file xauth /etc/strongswan.d/charon/xauth-generic.conf \ + "缺少 xauth-generic 插件(需安装 strongswan-mod-xauth-generic,否则 XAuth 账号无法认证)" + check_file libipsec /etc/strongswan.d/charon/kernel-libipsec.conf \ + "缺少 kernel-libipsec 插件(需安装 strongswan-mod-kernel-libipsec)" + check_file des /etc/strongswan.d/charon/des.conf \ + "缺少 des 插件(需安装 strongswan-mod-des,否则本包声明的 3DES 提案不可用)" +} + +# 取已安装包的版本号;apk 与 opkg 两种包管理器都支持 +pkg_version() { + if command -v apk >/dev/null 2>&1; then + apk list --installed "$1" 2>/dev/null | awk 'NR==1{print $1}' + elif command -v opkg >/dev/null 2>&1; then + opkg list-installed "$1" 2>/dev/null | awk 'NR==1{print $3}' + fi +} + +pkgs() { + for p in $PKGS; do + v="$(pkg_version "$p")" + if [ -n "$v" ]; then + printf '%s|ok|%s\n' "$p" "$v" + else + MISSING=1 + printf '%s|missing|not installed\n' "$p" + fi + done +} + +case "${1:-files}" in + files) files ;; + pkgs) pkgs ;; + *) files ;; +esac + +exit $MISSING diff --git a/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-render b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-render new file mode 100755 index 000000000..73b7e369a --- /dev/null +++ b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-render @@ -0,0 +1,137 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# +# 从 /etc/config/ipsec-vpnd 渲染 strongSwan 的 ipsec.conf / ipsec.secrets。 +# +# 本包不自己启动 charon —— charon 由 strongSwan 原生的 `ipsec` 服务托管 +# (/etc/init.d/ipsec,来自 strongswan-ipsec 包),避免两个服务抢 UDP 500/4500 +# 导致其中一个无声退出。本脚本只负责把 UCI 配置翻译成 strongSwan 配置。 +# +# 调用方:rpcd 的 apply 方法、uci-defaults、post-install/post-upgrade。 +# 未启用服务(enabled=0)时不写文件,保留用户已有配置。 + +. "${IPKG_INSTROOT:-}/lib/functions.sh" + +# 依赖自检:strongSwan 运行时不全时不要生成一份"看着能起、实际连不上"的配置, +# 直接报错,由调用方(rpcd apply / postinst)把原因透出来。 +if [ -x /usr/lib/ipsec/luci-ipsec-vpnd-deps ]; then + deps_out="$(/usr/lib/ipsec/luci-ipsec-vpnd-deps files)" + if [ $? -ne 0 ]; then + oldifs="$IFS" + IFS=' +' + for line in $deps_out; do + case "$line" in + *'|missing|'*) logger -t ipsec-vpnd "依赖缺失: ${line#*|missing|}" ;; + esac + done + IFS="$oldifs" + logger -t ipsec-vpnd "strongSwan 运行时不完整,未生成 ipsec.conf。请先 apk update,再安装 strongswan-minimal strongswan-mod-xauth-generic strongswan-mod-kernel-libipsec strongswan-mod-des" + exit 1 + fi +fi + +config_load ipsec-vpnd + +vt_enabled=0 +config_get vt_enabled "ipsec" "enabled" "0" + +vt_clientip="10.9.8.10/24" +vt_clientdns="10.9.8.1" +vt_account="vpnuser" +vt_password="changeme" +vt_secret="changeme" +config_get vt_clientip "ipsec" "clientip" "$vt_clientip" +config_get vt_clientdns "ipsec" "clientdns" "$vt_clientdns" +config_get vt_account "ipsec" "account" "$vt_account" +config_get vt_password "ipsec" "password" "$vt_password" +config_get vt_secret "ipsec" "secret" "$vt_secret" + +# 先写到临时文件,再和现有文件比对:内容没变就不动文件。 +# 这样 rpcd 的 apply 才知道要不要重启服务 —— 别的页面触发一次 apply +# 不应该把正在跑的 VPN 连接踢掉。 +TMP_CONF="$(mktemp /tmp/.ipsec.conf.XXXXXX)" +TMP_SECRETS="$(mktemp /tmp/.ipsec.secrets.XXXXXX)" + +cat > "$TMP_CONF" <> "$TMP_CONF" < "$TMP_SECRETS" < "$TMP_SECRETS" +fi + +changed=0 +if ! cmp -s "$TMP_CONF" /etc/ipsec.conf; then + changed=1 +fi +if [ "$vt_enabled" = "1" ]; then + if ! cmp -s "$TMP_SECRETS" /etc/ipsec.secrets; then + changed=1 + fi +elif [ -e /etc/ipsec.secrets ]; then + # 停用状态下要把遗留的凭据文件清掉 + changed=1 +fi + +if [ "$changed" = "1" ]; then + cat "$TMP_CONF" > /etc/ipsec.conf + if [ "$vt_enabled" = "1" ]; then + cat "$TMP_SECRETS" > /etc/ipsec.secrets + chmod 600 /etc/ipsec.secrets + else + rm -f /etc/ipsec.secrets + fi + echo "changed" +else + echo "unchanged" +fi + +rm -f "$TMP_CONF" "$TMP_SECRETS" +exit 0 diff --git a/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-sanitize b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-sanitize new file mode 100755 index 000000000..e1b2af2fe --- /dev/null +++ b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-sanitize @@ -0,0 +1,13 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# +# 去掉输入里的所有控制字符(\t \n \r 等)。 +# +# 为什么需要:XAuth 用户名由客户端完全可控(任何能连到 UDP 500 的人都能影响它), +# 控制字符会把 TSV 记录撕开、字段错位并被持久化到历史文件里。 +# +# 实现说明:**不要用 `tr -d '[:cntrl:]'`** —— busybox 的 tr 不认字符类, +# 会把字面字符 : [ c n t r o l ] 全部删掉(实测 "testuser" 被删成 "esuse")。 +# busybox 的 sed / awk 认 POSIX 字符类,用 sed 最稳。 + +sed 's/[[:cntrl:]]//g' diff --git a/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-updown b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-updown index 933104494..754841d61 100755 --- a/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-updown +++ b/luci-app-ipsec-vpnd/root/usr/lib/ipsec/luci-ipsec-vpnd-updown @@ -47,6 +47,17 @@ now="$(date +%s)" user="${PLUTO_XAUTH_ID:-}" remote="${PLUTO_PEER:-}" +# 用户名与对端地址都来自网络侧(任何能连到 UDP 500 的人都能影响它们)。 +# 控制字符会把 TSV 行撕开,导致历史文件字段错位并被持久化,这里统一剔除。 +SANITIZE=/usr/lib/ipsec/luci-ipsec-vpnd-sanitize +if [ -x "$SANITIZE" ]; then + user="$(printf '%s' "$user" | "$SANITIZE")" + remote="$(printf '%s' "$remote" | "$SANITIZE")" +else + user="$(printf '%s' "$user" | sed 's/[[:cntrl:]]//g')" + remote="$(printf '%s' "$remote" | sed 's/[[:cntrl:]]//g')" +fi + # assigned client IP: the responder-sent virtual IP, then the /32 selector vip="${PLUTO_PEER_SOURCEIP%%/*}" [ -n "$vip" ] || vip="${PLUTO_MY_SOURCEIP%%/*}" @@ -58,7 +69,9 @@ vip="${PLUTO_PEER_SOURCEIP%%/*}" key="$user|$remote" -[ -f "$STATE_FILE" ] || : > "$STATE_FILE" +[ -f "$STATE_FILE" ] || ( umask 077; : > "$STATE_FILE" ) +# 状态文件里有客户端用户名与公网 IP,只允许 root 读 +chmod 600 "$STATE_FILE" 2>/dev/null # serialize read/merge/write with the rpcd backend flock_lock @@ -82,5 +95,7 @@ END { [ -s "$UP_TMP" ] && mv "$UP_TMP" "$STATE_FILE" rm -f "$UP_TMP" +# mv 会把临时文件的权限带过来,这里再收紧一次 +chmod 600 "$STATE_FILE" 2>/dev/null flock_unlock exit 0 diff --git a/luci-app-ipsec-vpnd/root/usr/libexec/rpcd/ipsec-vpnd b/luci-app-ipsec-vpnd/root/usr/libexec/rpcd/ipsec-vpnd index 957b19ae6..7de25bcf8 100755 --- a/luci-app-ipsec-vpnd/root/usr/libexec/rpcd/ipsec-vpnd +++ b/luci-app-ipsec-vpnd/root/usr/libexec/rpcd/ipsec-vpnd @@ -37,7 +37,7 @@ flock_unlock() { } list_methods() { - printf '%s\n' '{"sessions":{},"clear_history":{}}' + printf '%s\n' '{"sessions":{},"clear_history":{},"deps":{},"apply":{},"status":{}}' } json_err() { @@ -92,6 +92,18 @@ BEGIN { n = 0; last = "" } ip = rest } remote[sa] = ip + + # 远端 IKE ID:形如 192.168.1.100[testuser]。 + # strongSwan 只有在 IKE ID 与 XAuth 用户名不同时才打印 + # "Remote XAuth identity:",所以拿不到该行时可以用 IKE ID 兜底, + # 两者一致 —— 这样与 updown 钩子记录的 key 也能对上, + # 不会在客户端列表里出现同一客户端的两行。 + if (match(rest, /\[[^][]*\]/)) { + id = substr(rest, RSTART + 1, RLENGTH - 2) + gsub(/^[ \t"'"'"']+|[ \t"'"'"']+$/, "", id) + gsub(/[[:cntrl:]]/, "", id) + if (id != "") remoteid[sa] = id + } } next } @@ -101,6 +113,9 @@ BEGIN { n = 0; last = "" } if (sa != "" && (sa in est) && !(sa in user)) { v = substr(line, index(line, "identity:") + 9) gsub(/^[ \t"'"'"']+|[ \t"'"'"']+$/, "", v) + # 用户名完全由客户端可控(任何能连到 UDP 500 的人)。控制字符 + # 会把 TSV 行撕开、字段错位并被持久化到历史文件里,这里统一剔除。 + gsub(/[[:cntrl:]]/, "", v) if (v != "") user[sa] = v } next @@ -127,6 +142,7 @@ END { for (k = 1; k <= n; k++) { sa = order[k] u = user[sa]; r = remote[sa]; v = vip[sa]; a = age[sa] + if (u == "") u = remoteid[sa] if (u == "") u = "-" if (r == "") r = "-" printf "%s|%s\t%s\t%s\t%s\t%s\n", u, r, u, r, v, a @@ -139,7 +155,11 @@ sessions() { local online_tmp="/tmp/.luci-app-ipsec-vpnd-online.tsv" # ensure the history file exists so awk can always read it as first input - [ -f "$STATE_FILE" ] || : > "$STATE_FILE" + # 文件里有客户端用户名与公网 IP,一开始就建成 0600 + if [ ! -f "$STATE_FILE" ]; then + ( umask 077; : > "$STATE_FILE" ) + fi + chmod 600 "$STATE_FILE" 2>/dev/null # current online clients as TSV /usr/sbin/ipsec statusall 2>/dev/null | parse_statusall > "$online_tmp" @@ -147,7 +167,7 @@ sessions() { # serialize read/merge/write with the updown hook flock_lock - awk -F'\t' -v now="$now" -v state_tmp="$SESSIONS_TMP" -v state="$STATE_FILE" ' + awk -F'\t' -v now="$now" -v MAXROWS=200 -v state_tmp="$SESSIONS_TMP" -v state="$STATE_FILE" ' function jesc(s) { gsub(/\\/, "\\\\", s) gsub(/"/, "\\\"", s) @@ -156,19 +176,28 @@ function jesc(s) { BEGIN { n = 0 } FILENAME == state { # history rows: key user remote vip age last_seen + # 字段数不对的行是旧版本被恶意用户名污染过的脏行,直接丢掉 + if (NF != 6) next key = $1 if (key == "") next + gsub(/[[:cntrl:]]/, "", key) if (!(key in seen)) { seen[key] = 1; order[++n] = key } hu[key] = $2; hr[key] = $3; hv[key] = $4; ha[key] = $5; hl[key] = $6 + gsub(/[[:cntrl:]]/, "", hu[key]); gsub(/[[:cntrl:]]/, "", hr[key]) + gsub(/[[:cntrl:]]/, "", hv[key]); gsub(/[[:cntrl:]]/, "", ha[key]) next } { # online rows: key user remote vip age + if (NF != 5) next key = $1 if (key == "") next + gsub(/[[:cntrl:]]/, "", key) active[key] = 1 if (!(key in seen)) { seen[key] = 1; order[++n] = key } hu[key] = $2; hr[key] = $3; hv[key] = $4; ha[key] = $5; hl[key] = now + gsub(/[[:cntrl:]]/, "", hu[key]); gsub(/[[:cntrl:]]/, "", hr[key]) + gsub(/[[:cntrl:]]/, "", hv[key]); gsub(/[[:cntrl:]]/, "", ha[key]) } END { # build output order: online first (kept), then offline by last_seen desc @@ -185,6 +214,10 @@ END { } for (i = 1; i <= oc; i++) out[++nc] = off[i] + # 历史上限:在线的永远保留,离线的按最近使用排序后只留前 MAX 条。 + # 避免状态文件无限膨胀、每次轮询都把全部历史塞进 JSON。 + if (nc > MAXROWS) nc = MAXROWS + # JSON response printf "{\"code\":0,\"sessions\":[" first = 1 @@ -192,15 +225,21 @@ END { k = out[i] if (!first) printf "," first = 0 - printf "{\"user\":\"%s\",\"remote\":\"%s\",\"vip\":\"%s\",\"age\":\"%s\",\"online\":%s}", + printf "{\"user\":\"%s\",\"remote\":\"%s\",\"vip\":\"%s\",\"age\":\"%s\",\"online\":%s", jesc(hu[k]), jesc(hr[k]), jesc(hv[k]), jesc(ha[k]), (active[k] ? "true" : "false") + # 在线但没拿到地址:CHILD_SA 还没装好,或解析不到 /32 选择器。 + # 给前端一个明确标记,避免和"没有地址"混为一谈。 + if (active[k] && hv[k] == "") + printf ",\"vip_pending\":true" + printf "}" } printf "]}\n" # rewrite persisted history (drop online flag; recomputed each call) - for (i = 1; i <= n; i++) { - k = order[i] + # 只写保留下来的行,超出的旧记录就此淘汰。 + for (i = 1; i <= nc; i++) { + k = out[i] printf "%s\t%s\t%s\t%s\t%s\t%s\n", k, hu[k], hr[k], hv[k], ha[k], hl[k] > state_tmp } }' "$STATE_FILE" "$online_tmp" @@ -213,10 +252,123 @@ END { mv "$SESSIONS_TMP" "$STATE_FILE" fi rm -f "$SESSIONS_TMP" + # 状态文件里有客户端用户名与公网 IP,权限收紧到 0600 + chmod 600 "$STATE_FILE" 2>/dev/null flock_unlock return $rc } +apply() { + # 把 UCI 配置渲染成 strongSwan 配置,然后按 enabled 启停服务。 + # charon 由 strongSwan 原生的 `ipsec` 服务托管,本包不再自建服务实例。 + local render_out render_rc enabled + render_out="$(/usr/lib/ipsec/luci-ipsec-vpnd-render 2>&1)" + render_rc=$? + if [ "$render_rc" -ne 0 ]; then + json_err "failed to render ipsec.conf: $render_out" + return 1 + fi + + if [ ! -x /etc/init.d/ipsec ]; then + json_err "strongSwan 的 ipsec 服务不存在(缺 strongswan-ipsec 包)" + return 1 + fi + + # enabled 是总开关:关掉时连服务一起停,否则页面上开关是"关"、 + # 服务却还在跑,charon 也不会因为配置里没有 conn 就自己退出。 + enabled="$(uci -q get ipsec-vpnd.ipsec.enabled)" + if [ "$enabled" = "1" ]; then + /etc/init.d/ipsec enable >/dev/null 2>&1 + # 只有配置真的变了才重启服务:别的页面触发一次 apply 不应该 + # 把正在跑的 VPN 连接踢掉。 + if [ "$render_out" = "changed" ] || ! /etc/init.d/ipsec status >/dev/null 2>&1; then + /etc/init.d/ipsec restart >/dev/null 2>&1 + fi + else + /etc/init.d/ipsec disable >/dev/null 2>&1 + /etc/init.d/ipsec stop >/dev/null 2>&1 + fi + + json_init + json_add_int code 0 + json_add_string message "applied" + json_dump +} + +status() { + # 页面状态用:enabled 来自 UCI,running 来自系统 ipsec 服务。 + # 两者分开报,前端才能区分"没启用"和"启用了但没跑起来"。 + local enabled running=0 + enabled="$(uci -q get ipsec-vpnd.ipsec.enabled)" + [ "$enabled" = "1" ] || enabled="0" + + if [ -x /etc/init.d/ipsec ]; then + /etc/init.d/ipsec status >/dev/null 2>&1 && running=1 + fi + + json_init + json_add_int code 0 + json_add_string enabled "$enabled" + json_add_int running "$running" + json_dump +} + +deps() { + # 运行时依赖自检:文件类依赖 + 软件包安装状态。 + # 结果给 LuCI 的依赖诊断面板用;缺失时 init 脚本也会拒绝启动。 + local files_out files_rc pkgs_out pkgs_rc + files_out="$(/usr/lib/ipsec/luci-ipsec-vpnd-deps files)" + files_rc=$? + pkgs_out="$(/usr/lib/ipsec/luci-ipsec-vpnd-deps pkgs)" + pkgs_rc=$? + + local ok=1 + [ "$files_rc" -eq 0 ] || ok=0 + [ "$pkgs_rc" -eq 0 ] || ok=0 + + json_init + json_add_int code 0 + json_add_int ok "$ok" + + # 包管理器名:安装命令要用对应的那个(apk / opkg) + local pkg_mgr="apk" + if ! command -v apk >/dev/null 2>&1 && command -v opkg >/dev/null 2>&1; then + pkg_mgr="opkg" + fi + json_add_string pkg_mgr "$pkg_mgr" + + local oldifs="$IFS" + local line key rest st det + IFS=' +' + json_add_array files + for line in $files_out; do + [ -z "$line" ] && continue + key="${line%%|*}"; rest="${line#*|}"; st="${rest%%|*}"; det="${rest#*|}" + json_add_object "" + json_add_string key "$key" + json_add_string status "$st" + json_add_string detail "$det" + json_close_object + done + json_close_array + + json_add_array packages + for line in $pkgs_out; do + [ -z "$line" ] && continue + key="${line%%|*}"; rest="${line#*|}"; st="${rest%%|*}"; det="${rest#*|}" + json_add_object "" + json_add_string key "$key" + json_add_string status "$st" + json_add_string detail "$det" + json_close_object + done + json_close_array + IFS="$oldifs" + + json_dump +} + clear_history() { flock_lock rm -f "$STATE_FILE" "$SESSIONS_TMP" @@ -236,6 +388,9 @@ main() { case "$2" in sessions) sessions ;; clear_history) clear_history ;; + deps) deps ;; + apply) apply ;; + status) status ;; *) json_err "unknown method" ;; esac ;; diff --git a/luci-app-ipsec-vpnd/root/usr/share/rpcd/acl.d/luci-app-ipsec-vpnd.json b/luci-app-ipsec-vpnd/root/usr/share/rpcd/acl.d/luci-app-ipsec-vpnd.json index 193d5711f..a311a936f 100644 --- a/luci-app-ipsec-vpnd/root/usr/share/rpcd/acl.d/luci-app-ipsec-vpnd.json +++ b/luci-app-ipsec-vpnd/root/usr/share/rpcd/acl.d/luci-app-ipsec-vpnd.json @@ -3,7 +3,7 @@ "description": "Grant access to ipsec configuration", "read": { "ubus": { - "ipsec-vpnd": [ "sessions", "clear_history" ], + "ipsec-vpnd": [ "sessions", "deps", "status" ], "rc": [ "init" ], "service": [ "list" ] }, @@ -11,7 +11,8 @@ }, "write": { "ubus": { - "rc": [ "init" ] + "rc": [ "init" ], + "ipsec-vpnd": [ "apply", "clear_history" ] }, "uci": [ "ipsec-vpnd" ] } diff --git a/luci-app-ipsec-vpnd/tests/render-test.sh b/luci-app-ipsec-vpnd/tests/render-test.sh new file mode 100644 index 000000000..9ca0776b9 --- /dev/null +++ b/luci-app-ipsec-vpnd/tests/render-test.sh @@ -0,0 +1,169 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +# +# luci-ipsec-vpnd-render 的离线回归测试。 +# +# 渲染器把 /etc/config/ipsec-vpnd 翻译成 strongSwan 的 ipsec.conf / ipsec.secrets, +# 这里用一个临时根目录 + 假的 lib/functions.sh 桩件来跑它,不碰真实配置。 +# +# 覆盖: +# 1. enabled=1 时生成 conn,rightsourceip/rightdns 与 UCI 一致 +# 2. ipsec.secrets 权限 0600,且含 PSK 与 XAuth 两行 +# 3. enabled=0 时不生成 conn,并清掉遗留的 ipsec.secrets +# 4. 依赖自检不通过时不生成任何文件、退出码非 0 +# +# 用法:sh tests/render-test.sh [渲染器路径] + +set -u + +RENDER="${1:-root/usr/lib/ipsec/luci-ipsec-vpnd-render}" +# 在设备上跑时仓库路径不存在,退回安装后的路径 +[ -f "$RENDER" ] || RENDER="/usr/lib/ipsec/luci-ipsec-vpnd-render" +[ -f "$RENDER" ] || { echo "找不到渲染器: $RENDER"; exit 1; } + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/render-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +fail=0 +check() { + # check <名称> <期望> <实际> + if [ "$2" = "$3" ]; then + echo "ok - $1" + else + echo "FAIL - $1" + echo " 期望: [$2]" + echo " 实际: [$3]" + fail=1 + fi +} + +count_matches() { + # count_matches <正则> <文件>:匹配行数,文件不存在或无匹配都返回 0 + if [ -f "$2" ]; then + grep -c "$1" "$2" 2>/dev/null | head -1 + else + echo 0 + fi +} + +file_mode() { + # 跨平台取权限(macOS / Linux / busybox 的 stat 参数都不一样,统一走 ls -l) + ls -l "$1" 2>/dev/null | awk '{ + p = substr($1, 2, 9) + v = 0 + for (i = 1; i <= 3; i++) { + d = 0 + c = substr(p, i * 3 - 2, 1); if (c == "r") d += 4 + c = substr(p, i * 3 - 1, 1); if (c == "w") d += 2 + c = substr(p, i * 3, 1); if (c == "x") d += 1 + v = v * 10 + d + } + print v + }' +} + +# --- 桩件环境 --------------------------------------------------------------- +mkdir -p "$TMP/lib" "$TMP/etc" "$TMP/bin" "$TMP/usr/lib/ipsec" + +cat > "$TMP/lib/functions.sh" <<'EOF' +config_load() { :; } +config_get() { + # config_get