mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 07:42:51 +08:00
🍕 Sync 2026-10-01 13:33:56
This commit is contained in:
1 parent
7b00e050c2
commit
290bcdcdf0
24 files changed
+731
-83
No files matched your search
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-passwall2
|
||||
PKG_VERSION:=26.10.1
|
||||
PKG_RELEASE:=132
|
||||
PKG_RELEASE:=133
|
||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||
|
||||
PKG_CONFIG_DEPENDS:= \
|
||||
|
||||
@@ -149,7 +149,7 @@ gen_nft_tables() {
|
||||
|
||||
insert_nftset() {
|
||||
local nftset_name="${1}"; shift
|
||||
local timeout_argument="${1}"; shift
|
||||
local timeout_argument="${1:--1}"; shift
|
||||
local default_timeout="365d"
|
||||
local suffix=""
|
||||
|
||||
@@ -692,13 +692,13 @@ update_wan_sets() {
|
||||
local WAN_IP=$(get_wan_ips ip4)
|
||||
[ -n "$WAN_IP" ] && {
|
||||
# nft flush set $NFTABLE_NAME $NFTSET_WAN
|
||||
echo "$WAN_IP" | insert_nftset $NFTSET_WAN
|
||||
echo "$WAN_IP" | insert_nftset $NFTSET_WAN "-1"
|
||||
}
|
||||
|
||||
local WAN6_IP=$(get_wan_ips ip6)
|
||||
[ -n "${WAN6_IP}" ] && {
|
||||
# nft flush set $NFTABLE_NAME $NFTSET_WAN6
|
||||
echo "$WAN6_IP" | insert_nftset $NFTSET_WAN6
|
||||
echo "$WAN6_IP" | insert_nftset $NFTSET_WAN6 "-1"
|
||||
}
|
||||
) 9>"${LOCK_PATH}/${CONFIG}_update_wan_sets.lock"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,127 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [2.6.9-r1] - P2 批次七:界面 / 探测 / 转换 / 权限(L1 / L5 / L6 / L8 / L9 / L23 / L24 / L25 / L26)
|
||||
|
||||
P2 最后一批,九项分布在界面、探测、转换与文件权限四处。共同点是**问题都不报错**:
|
||||
界面显示错语言、探测悄悄漏节点、模板把名字吃掉一个字符、正则写错却毫无提示 ——
|
||||
全部是「看起来正常,实际不对」的类型。
|
||||
|
||||
### L1 中文 msgid 在英文界面原样显示(`subscriptions.htm` / `nodes.htm` / `po/zh-cn/substore.po`)
|
||||
|
||||
三个界面串用了**中文当 msgid**:`<%:操作失败%>`、`<%:选择格式后生成订阅链接,格式可随时切换%>`。
|
||||
LuCI 的翻译方向是「msgid(英文)→ msgstr(当前语言)」,拿中文当 msgid 且 `.po` 里没有
|
||||
对应条目时,英文界面会**原样显示中文**。
|
||||
|
||||
现在 msgid 一律用英文,中文只作为 `po/zh-cn/substore.po` 里的 `msgstr`。
|
||||
新增条目:`Operation failed`、`Pick a format to generate the subscription link; you can
|
||||
switch formats at any time`,并补齐此前缺失的 `Type`。
|
||||
|
||||
### L5 只转义 `</` 挡不住 `<!--`(7 个视图模板)
|
||||
|
||||
注入到 `<script>` 里的 JSON 此前只做 `:gsub("</", "<\\/")`。这挡得住 `</script>` 提前闭合,
|
||||
但挡不住 `<!--`:HTML 词法阶段遇到 `<!--` 会进入 **script data escaped** 状态,
|
||||
其后的 `</script>` **不再结束脚本块**,页面剩下的部分全被当成脚本文本吞掉。
|
||||
|
||||
节点名来自订阅内容,一个叫 `<!--x` 的节点就能把整页搞坏(是页面破坏,不是 XSS)。
|
||||
现在改为 `:gsub("<", "\\u003c")` —— HTML 词法阶段再也看不到任何 `<`,两个坑一并堵上;
|
||||
而 `<` 在 JSON 与 JS 字符串字面量里都还原成 `<`,取值不受影响。
|
||||
|
||||
### L6 批量探测的进程/fd 无界占用(`probe.lua`)
|
||||
|
||||
`M.probe` 原来是「先把全部节点的 `io.popen` 起完,再统一读取」。并行度最高,但代价无界:
|
||||
每个节点占 1 个进程 + 1 个管道 fd,而节点数由订阅内容决定(上千个很常见)。
|
||||
路由器上 fd 与进程数都是硬上限,打满之后 `io.popen` 直接失败 —— 表现是一大片节点
|
||||
探测不出来,**而不是报错**。
|
||||
|
||||
改为分批:一批最多 `M.MAX_PARALLEL`(16)个进程,读完并关闭这一批再起下一批。
|
||||
并发度封顶,总耗时仍是「批数 × 单节点超时」。
|
||||
|
||||
### L8 空 format 绕过默认格式(`output.lua`)
|
||||
|
||||
`?target=` 会传进来空串 `""`,而 `""` 在 Lua 里是**真值**,`format or DEFAULT_FORMAT`
|
||||
兜不住它,于是一路落到 `return nil, "unsupported format: "` —— 冒号后面什么都没有。
|
||||
用户拿到的是一个说不出原因的错误页(控制器只在 nil 时兜底,覆盖不到空串)。
|
||||
|
||||
现在 `M.generate` 先归一:非字符串按 `""` 处理,再去掉首尾空白,为空则用默认格式。
|
||||
空白串(`" "`)同样归为「未指定」。
|
||||
|
||||
### L9 两行安装指令挂错块(`Makefile`)
|
||||
|
||||
`output.htm` / `combo.htm` 的 `INSTALL_DATA` 挂在 `/www/luci-static` 的 `INSTALL_DIR`
|
||||
块下面。**目标路径本身是对的**,但归错了块 —— 一旦有人调整块顺序就会被装到错误的目录。
|
||||
现已移入 view 块。
|
||||
|
||||
### L23 trojan → vmess/vless 生成的不是合法 UUID(`converter.lua` / `util.lua`)
|
||||
|
||||
原实现取 `base64(seed)` 的前 36 字符当 uuid。base64 只产出 24 个字符,
|
||||
`sub(1,36)` 是空操作,且结果里可能带 `+` `/` `=` —— 根本不是 UUID。
|
||||
客户端的 uuid 字段按 16 字节解析,格式不对时多数客户端**直接拒绝该节点**。
|
||||
|
||||
新增 `util.uuid()` 生成 RFC 4122 v4 UUID(8-4-4-4-12,第 13 位固定 `4`,
|
||||
第 17 位取 8/9/a/b),转换时用它。
|
||||
|
||||
### L24 `gsub` 替换串里的 `%` 未转义(`converter.lua` / `node.lua`)
|
||||
|
||||
`gsub` 的替换串里 `%` 有语义:`%1` 是捕获引用,裸 `%` 会被吞掉,结尾的 `%` 会注入 NUL 字节。
|
||||
节点名与服务器地址都来自订阅内容,直接当替换串用会把用户的内容改掉。
|
||||
|
||||
新增 `util.gsub_literal()` 把 `%` 转义成 `%%`,`converter.apply_template`(`{{name}}` 等四个
|
||||
占位符)与 `node.apply_rules` 的 `{server}` / `{port}` / `{uuid}` / `{name}` 模板全部改用它。
|
||||
`node.expand_template` 里原有的局部 `esc` 也统一到同一实现。
|
||||
|
||||
### L25 非法正则被静默吞掉(`node.lua` / 控制器)
|
||||
|
||||
`rename_with_rules` 用 `pcall` 包住 `gsub`,用户写出非法 pattern(未闭合的 `[` 等)时
|
||||
错误被吞掉,结果是「规则明明写了却完全不生效,页面上没有任何提示」。
|
||||
|
||||
新增 `M.validate_rename_map()`:在**保存时**逐条校验(含 `split_alternatives` 展开的每个
|
||||
备选分支),失败返回「重命名规则第 N 行:正则表达式无效(pattern)」。
|
||||
控制器在 `read_rules_fields` 这个唯一入口处调用,5 个保存动作全部回显该错误。
|
||||
`parse_rename_rules` 同时补记 `line_no`,让行号指向真正出错的那一行。
|
||||
|
||||
### L26 数据文件 0644 世界可读(`util.lua` / `core.lua`)
|
||||
|
||||
`io.open` 按 umask 创建文件(通常是 0644),而 `/etc/substore` 下的
|
||||
`subscriptions.json` 含订阅 URL 与公开下载 token,`nodes/*.json` 含 uuid / 密码 / 私钥。
|
||||
同机任何用户都能读到。
|
||||
|
||||
- `util.atomic_write(path, content, mode)` 新增可选 mode,在 `os.rename` **之后** chmod
|
||||
(先 chmod 再 rename 的话,临时文件名可猜,中间窗口里仍能读到)
|
||||
- `util.ensure_dir(path, mode)` 新增可选 mode,走 `mkdir -p -m`
|
||||
- `core.ensure_dirs` 建目录带 `700`,并对**已存在**的目录补一次 `chmod 700`
|
||||
(从旧版本升级上来的机器上目录已存在,`mkdir -m` 不生效);每进程只做一次,
|
||||
因为 `load()` 每次读列表都会调用它
|
||||
- 两个数据写入点传 `"600"`;cron 文件不传,权限语义保持不变
|
||||
|
||||
Lua 5.1 标准库没有 `os.chmod`(那是 nixio/posix 才有的),实现走 busybox `chmod`,
|
||||
路径经 `util.shq` 引用。
|
||||
|
||||
### 测试
|
||||
|
||||
新增 `tests/p2_batch7_test.lua`(56 项,全离线):
|
||||
|
||||
- L1 扫全部视图模板的 `<%:...%>` 与 `luci.i18n.translate()`,断言 msgid 不含 CJK
|
||||
(按 UTF-8 三字节区间判定),并断言 `.po` 里新条目齐全
|
||||
- L6 替换 `io.popen` 为记录桩,统计**同时存活**的句柄峰值,断言 `== MAX_PARALLEL`
|
||||
且 100 个节点全部关闭、顺序不变
|
||||
- L8 断言 `""` / `" "` / `"\t\n "` / `nil` 四种写法输出一致,非法名仍报错
|
||||
- L9 按行号断言两行 `INSTALL_DATA` 落在 view 块与 luci-static 块之间
|
||||
- L23/L24/L25 断言 UUID 形状、`%` 字面保留、非法正则报错并指出行号
|
||||
- L26 替换 `os.execute` 为记录桩,断言 chmod 600 出现在 rename 之后、
|
||||
不带 mode 时**不** chmod、`ensure_dirs` 只 chmod 一次
|
||||
|
||||
`tests/view_injection_test.lua` 的转义断言更新为「转义全部 `<`」,并新增两条反向对照:
|
||||
只转 `</` 时 `<!--` 仍然留存(证明放宽转义范围的理由成立),转全部 `<` 后
|
||||
`</script` 与 `<!--` 都不复存在。
|
||||
|
||||
`tests/controller_robustness_test.lua` 的 `substore.node` 桩补上 `validate_rename_map`
|
||||
(控制器新增的依赖;缺失会让整条保存路径 nil 调用 500 —— 是桩缺口,不是产品缺陷)。
|
||||
|
||||
**反向验证**:新断言在修复前的 `HEAD` 上 **34 条失败**
|
||||
(L1×5 / L6×3 / L8×4 / L9×2 / L23×5 / L24×6 / L25×5 / L26×4),
|
||||
修复后全绿;全量 43 个测试文件 + `cron_result_test.sh` 全部通过。
|
||||
|
||||
## [2.6.8-r1] - P2 批次六:网络安全与健壮性(L10 / L11 / L12 / L14)
|
||||
|
||||
P2 批次六,四项都围绕「**检查的强度不能低于被检查者**」这条线:
|
||||
|
||||
@@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk
|
||||
PKG_NAME:=luci-app-substore
|
||||
# 版本约定:每次提交 PKG_RELEASE +1(2.1.2-r1 ~ r9);
|
||||
# 达到 r10 时 PKG_VERSION 末位 +1(2.1.2 -> 2.1.3),PKG_RELEASE 重置为 1。
|
||||
PKG_VERSION:=2.6.8
|
||||
PKG_RELEASE:=13
|
||||
PKG_VERSION:=2.6.9
|
||||
PKG_RELEASE:=14
|
||||
|
||||
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
|
||||
|
||||
@@ -65,11 +65,11 @@ define Package/luci-app-substore/install
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/local_form.htm $(1)/usr/lib/lua/luci/view/substore/local_form.htm
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/nodes.htm $(1)/usr/lib/lua/luci/view/substore/nodes.htm
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/node_edit.htm $(1)/usr/lib/lua/luci/view/substore/node_edit.htm
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/output.htm $(1)/usr/lib/lua/luci/view/substore/output.htm
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/combo.htm $(1)/usr/lib/lua/luci/view/substore/combo.htm
|
||||
|
||||
$(INSTALL_DIR) $(1)/www/luci-static/resources/substore
|
||||
$(INSTALL_DATA) ./root/www/luci-static/resources/substore/nodeform.js $(1)/www/luci-static/resources/substore/nodeform.js
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/output.htm $(1)/usr/lib/lua/luci/view/substore/output.htm
|
||||
$(INSTALL_DATA) ./root/usr/lib/lua/luci/view/substore/combo.htm $(1)/usr/lib/lua/luci/view/substore/combo.htm
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
|
||||
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-substore.json $(1)/usr/share/luci/menu.d/luci-app-substore.json
|
||||
|
||||
@@ -132,18 +132,18 @@ group them, then re-emit them in a format your client can consume.
|
||||
## Installation
|
||||
|
||||
> The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the
|
||||
> [Makefile](Makefile) (currently `2.6.8-r1`).
|
||||
> [Makefile](Makefile) (currently `2.6.9-r1`).
|
||||
|
||||
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
|
||||
|
||||
```bash
|
||||
opkg install luci-app-substore-2.6.8-r1.ipk
|
||||
opkg install luci-app-substore-2.6.9-r1.ipk
|
||||
```
|
||||
|
||||
apk (OpenWrt / ImmortalWrt 25.12+):
|
||||
|
||||
```bash
|
||||
apk add --allow-untrusted luci-app-substore-2.6.8-r1.apk
|
||||
apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk
|
||||
```
|
||||
|
||||
Then open LuCI: **Services → Subscriptions**.
|
||||
@@ -229,7 +229,19 @@ timeout limits, download temp files removed on every exit path (`/tmp` is a tmpf
|
||||
command-injection defence (whitelisted parsing + shell quoting, plus probe targets
|
||||
starting with `-` rejected — busybox `getopt` would read them as options),
|
||||
token-based access control on the public download endpoint, and no credentials in
|
||||
logs. See [docs/SECURITY.md](docs/SECURITY.md).
|
||||
logs.
|
||||
|
||||
Data on disk: `/etc/substore` is `0700`, and `subscriptions.json` / `nodes/*.json`
|
||||
are `0600` — the former holds subscription URLs and public download tokens, the
|
||||
latter uuid / passwords / private keys. `io.open` creates files per the umask
|
||||
(typically 0644), readable by any local user, so the mode is tightened explicitly
|
||||
after each write.
|
||||
|
||||
Batch node probing is capped at 16 concurrent processes (`probe.MAX_PARALLEL`):
|
||||
the node count comes from subscription content, and unbounded concurrency exhausts
|
||||
the router's fd / process budget, after which `io.popen` fails silently.
|
||||
|
||||
See [docs/SECURITY.md](docs/SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -108,18 +108,18 @@
|
||||
## 安装
|
||||
|
||||
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
|
||||
> (当前 `2.6.8-r1`)。
|
||||
> (当前 `2.6.9-r1`)。
|
||||
|
||||
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
|
||||
|
||||
```bash
|
||||
opkg install luci-app-substore-2.6.8-r1.ipk
|
||||
opkg install luci-app-substore-2.6.9-r1.ipk
|
||||
```
|
||||
|
||||
apk(OpenWrt / ImmortalWrt 25.12+):
|
||||
|
||||
```bash
|
||||
apk add --allow-untrusted luci-app-substore-2.6.8-r1.apk
|
||||
apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk
|
||||
```
|
||||
|
||||
然后在 LuCI 菜单打开:**服务 → 订阅**。
|
||||
@@ -197,6 +197,14 @@ SSRF 防护(拒绝内网 / 保留 / 链路本地地址;**DNS 解析失败即
|
||||
响应体大小与超时限制、下载临时文件在每条退出路径上清理(`/tmp` 是 tmpfs)、
|
||||
命令注入防护(白名单解析 + shell 引用 + 探测目标拒绝以 `-` 开头的主机名)、
|
||||
公开下载端点基于 token 的访问控制、日志不含凭据。
|
||||
|
||||
数据落盘权限:`/etc/substore` 目录 `0700`,`subscriptions.json` 与 `nodes/*.json`
|
||||
`0600`(前者含订阅 URL 与公开下载 token,后者含 uuid / 密码 / 私钥)——
|
||||
`io.open` 按 umask 创建(通常 0644),同机任何用户都能读到,因此写入后显式收紧。
|
||||
|
||||
批量节点探测的并发上限为 16 个进程(`probe.MAX_PARALLEL`):节点数由订阅内容决定,
|
||||
不限并发会把路由器的 fd / 进程额度打满,之后 `io.popen` 静默失败。
|
||||
|
||||
详见 [docs/SECURITY.md](docs/SECURITY.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
> NOTE: stage-0 skeleton has not yet been installed/verified on a target device.
|
||||
> This document will be updated once installation is validated.
|
||||
>
|
||||
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.6.8-r1)。
|
||||
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.6.9-r1)。
|
||||
|
||||
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
|
||||
|
||||
```bash
|
||||
opkg install luci-app-substore-2.6.8-r1.ipk
|
||||
opkg install luci-app-substore-2.6.9-r1.ipk
|
||||
```
|
||||
|
||||
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
|
||||
|
||||
```bash
|
||||
apk add --allow-untrusted luci-app-substore-2.6.8-r1.apk
|
||||
apk add --allow-untrusted luci-app-substore-2.6.9-r1.apk
|
||||
```
|
||||
|
||||
Refresh LuCI:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|---|---|---|
|
||||
| P0 | 14 项高危(输出整份不可用 / 数据永久丢失 / 安全) | ✅ 已完成(4 次推送,见 CHANGELOG `[2.6.0-r2]`~`[2.6.0-r5]`) |
|
||||
| P1 | 13 项中危 + 审计中实测确认的 4 项新缺陷 | ✅ 已完成(`[2.6.1-r1]`) |
|
||||
| P2 | 其余 M / L 项 | 🔄 进行中(批次一 `[2.6.3-r1]`、批次二 `[2.6.4-r1]`、批次三 `[2.6.5-r1]`、批次四 `[2.6.6-r1]`、批次五 `[2.6.7-r1]`、批次六 `[2.6.8-r1]`) |
|
||||
| P2 | 其余 M / L 项 | 🔄 进行中(批次一 `[2.6.3-r1]`、批次二 `[2.6.4-r1]`、批次三 `[2.6.5-r1]`、批次四 `[2.6.6-r1]`、批次五 `[2.6.7-r1]`、批次六 `[2.6.8-r1]`、批次七 `[2.6.9-r1]`) |
|
||||
|
||||
**编写纪律**:只记录经**代码级审计或实测探针**确认的结论,不含推测。原始 70 项
|
||||
审计表(高危 14 / 中危 30 / 低危 26)未落盘,本文件自本次起作为「未修复项」的
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
- 下载临时文件(`.tmp` / `.tmp.hdr` / `.tmp.err`)在**每一条**退出路径上清理。
|
||||
OpenWrt 的 `/tmp` 是 tmpfs,占的是内存;失败路径不清理会让 cron 定时重试
|
||||
一轮轮往内存里堆文件,其中 `.tmp` 可能是最大到 `max-size` 的部分响应体
|
||||
- 批量节点探测并发上限 `probe.MAX_PARALLEL = 16`。节点数由订阅内容决定(可上千),
|
||||
每个探测占 1 个进程 + 1 个管道 fd;不限并发会打满路由器的 fd / 进程额度,
|
||||
之后 `io.popen` **静默失败** —— 症状是一大片节点探测不出来,而不是报错
|
||||
|
||||
## 输入校验
|
||||
- 订阅名/文件名白名单校验,禁止 `..`、`/`
|
||||
@@ -30,6 +33,15 @@
|
||||
- 订阅 URL 中的 token 不写入普通日志
|
||||
- 输出/下载接口使用**每订阅随机 16 位十六进制 token** 鉴权(`core.ensure_token`),不可猜测;`/substore/download` 无登录态
|
||||
- 日志使用 `logger -t luci-app-substore`,不记录敏感信息
|
||||
- 落盘权限:`/etc/substore` 目录 `0700`,`subscriptions.json` 与 `nodes/*.json` `0600`。
|
||||
`io.open` 按 umask 创建(通常 0644),同机任何用户都能读到订阅 URL、下载 token
|
||||
与节点凭据,因此 `util.atomic_write` 在 `os.rename` **之后**显式 chmod ——
|
||||
先 chmod 再 rename 的话,临时文件名可猜,中间窗口里仍能读到。
|
||||
目录侧由 `core.ensure_dirs` 建目录时带 `-m 700`,并对旧版本升级上来、
|
||||
已存在的目录补一次 `chmod 700`(每进程一次,避免 `load()` 每次都 fork)
|
||||
- 注入到页面 `<script>` 的 JSON 转义**全部** `<`(`<`):只转 `</` 挡得住
|
||||
`</script>` 提前闭合,却挡不住 `<!--` —— 后者让 HTML 词法阶段进入
|
||||
script data escaped 状态,其后的 `</script>` 不再结束脚本块,整页被吞进脚本
|
||||
|
||||
## 安全测试
|
||||
- 已验证 SSRF 私网拒绝
|
||||
|
||||
@@ -287,4 +287,16 @@ msgid "Delete selected nodes?"
|
||||
msgstr "确定删除选中的节点?"
|
||||
|
||||
msgid "Please select nodes first"
|
||||
msgstr "请先勾选节点"
|
||||
msgstr "请先勾选节点"
|
||||
|
||||
# 节点页 / 订阅页的失败提示(控制器经 ?err= 带回)
|
||||
msgid "Operation failed"
|
||||
msgstr "操作失败"
|
||||
|
||||
# 订阅页:导出格式下拉的说明
|
||||
msgid "Pick a format to generate the subscription link; you can switch formats at any time"
|
||||
msgstr "选择格式后生成订阅链接,格式可随时切换"
|
||||
|
||||
# 节点表头 / 节点编辑表单里的字段标签
|
||||
msgid "Type"
|
||||
msgstr "类型"
|
||||
@@ -90,10 +90,15 @@ local function read_cron_fields()
|
||||
return cron_enable, cron_time
|
||||
end
|
||||
|
||||
-- read subscription level rules from form, return rules table
|
||||
-- read subscription level rules from form, return rules table, err
|
||||
--
|
||||
-- 第二个返回值是**用户可见**的校验错误。目前只有重命名规则的正则需要校验:
|
||||
-- 非法 pattern 会被 node.rename_with_rules 里的 pcall 静默吞掉,用户看到的是
|
||||
-- 「规则写了但没生效」。在这里拦下来,调用方用 back_to_list(err) 回显。
|
||||
local function read_rules_fields()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local node = require("substore.node")
|
||||
local proto_list = {}
|
||||
-- 协议清单来自 core.RULE_PROTOS,与表单模板共用一份(见该常量的说明)
|
||||
for _, p in ipairs(core.RULE_PROTOS) do
|
||||
@@ -101,13 +106,16 @@ local function read_rules_fields()
|
||||
end
|
||||
local rules_enable = fv(http, "rules_enable") or "0"
|
||||
if rules_enable ~= "1" then rules_enable = "0" end
|
||||
local rename_map = fv(http, "rename_map") or ""
|
||||
local vok, verr = node.validate_rename_map(rename_map)
|
||||
if not vok then return nil, verr end
|
||||
return {
|
||||
rules_enable = rules_enable,
|
||||
proto_filter = table.concat(proto_list, ","),
|
||||
keyword_include = fv(http, "keyword_include") or "",
|
||||
keyword_exclude = fv(http, "keyword_exclude") or "",
|
||||
dedup = fv(http, "dedup") or "0",
|
||||
rename_map = fv(http, "rename_map") or "",
|
||||
rename_map = rename_map,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -124,7 +132,9 @@ function action_create()
|
||||
return back_to_list("名称和 URL 不能为空")
|
||||
end
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local id, err = core.add(name, url, {
|
||||
proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
@@ -153,7 +163,9 @@ function action_save()
|
||||
return back_to_list("名称和 URL 不能为空")
|
||||
end
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local ok, err = core.save_meta(id, {
|
||||
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
@@ -174,7 +186,9 @@ function action_local_create()
|
||||
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = fv(http, "content") or ""
|
||||
local local_mode = fv(http, "local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
|
||||
if name == "" then return back_to_list("名称不能为空") end
|
||||
if content == "" then return back_to_list("订阅内容不能为空") end
|
||||
@@ -198,7 +212,9 @@ function action_local_save()
|
||||
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = fv(http, "content") or ""
|
||||
local local_mode = fv(http, "local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
if id == "" then return back_to_list("缺少订阅 ID") end
|
||||
if name == "" then return back_to_list("名称不能为空") end
|
||||
if content == "" then return back_to_list("订阅内容不能为空") end
|
||||
@@ -370,7 +386,9 @@ function action_combo_save()
|
||||
-- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 ——
|
||||
-- 用户填了表单却什么都没发生,页面上也没有任何提示。
|
||||
if name == "" then return back_to_list("名称不能为空") end
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local o = {
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
|
||||
@@ -18,17 +18,18 @@
|
||||
local rm = (it and it.rename_map) or ""
|
||||
local local_mode = (it and it.local_mode) or "text"
|
||||
local content = (it and it.raw_content) or ""
|
||||
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
|
||||
-- 注入 JS 的初始内容:JSON 字符串字面量,把全部 "<" 转成 <,
|
||||
-- 同时挡住 "</script" 提前闭合与 "<!--" 吞掉其后整页(详见 nodes.htm 同名注释)
|
||||
local util = require("substore.util")
|
||||
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
|
||||
local js_content = (util.json_encode(content) or '""'):gsub("<", "\\u003c")
|
||||
-- local_mode 是用户可提交的字段,直接拼进 JS 字符串会被闭合引号注入;
|
||||
-- 归一为白名单取值后用 json_encode 输出,保证是合法的 JS 字符串字面量
|
||||
if local_mode ~= "form" then local_mode = "text" end
|
||||
local js_mode = util.json_encode(local_mode) or '"text"'
|
||||
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
|
||||
local node_mod = require("substore.node")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("<", "\\u003c")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("<", "\\u003c")
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = id ~= "" and core.read_nodes(id) or {}
|
||||
local n = nodes[idx]
|
||||
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
|
||||
local js_node = n and ((util.json_encode(n) or "null"):gsub("</", "<\\/")) or "null"
|
||||
-- 注入 JS 的节点对象:把全部 "<" 转成 <,同时挡住 "</script" 提前闭合
|
||||
-- 与 "<!--" 让脚本块吞掉其后整页(详见 nodes.htm 同名注释)
|
||||
local js_node = n and ((util.json_encode(n) or "null"):gsub("<", "\\u003c")) or "null"
|
||||
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
|
||||
local node_mod = require("substore.node")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("<", "\\u003c")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("<", "\\u003c")
|
||||
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
|
||||
%>
|
||||
<h2><%:Edit node%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
-- 但只有 JSON 编码还不够:json_encode 不转义 "<",id 直接来自查询串,
|
||||
-- 形如 "</script><script>alert(1)</script>" 的值会在 HTML 词法阶段就闭合
|
||||
-- script 元素(与 JS 字符串上下文无关),剩下的部分被当作 HTML 执行。
|
||||
-- 转义 "</" 即可:在 JS 字符串里 "\/" 就是 "/",而 HTML 不再看到 "</script"。
|
||||
local js_id = (util.json_encode(id) or '""'):gsub("</", "<\\/")
|
||||
-- 把**全部** "<" 转成 <:在 JS/JSON 字符串里这就是 "<",而 HTML 词法
|
||||
-- 阶段再也看不到 "<",于是 "</script"(提前闭合)与 "<!--"(进入 script
|
||||
-- data escaped 状态,后面的 </script> 不再结束脚本块,整页被吞进脚本)两个
|
||||
-- 坑一并堵上。只转 "</" 挡不住 "<!--"。
|
||||
local js_id = (util.json_encode(id) or '""'):gsub("<", "\\u003c")
|
||||
|
||||
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
|
||||
for i, nn in ipairs(nodes) do nn.__idx = i end
|
||||
@@ -47,7 +50,7 @@
|
||||
if flash_err and flash_err ~= "" then
|
||||
%>
|
||||
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
|
||||
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
<strong><%:Operation failed%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2><%:Nodes%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
if flash_err and flash_err ~= "" then
|
||||
%>
|
||||
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
|
||||
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
<strong><%:Operation failed%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value-description"><%:选择格式后生成订阅链接,格式可随时切换%></div>
|
||||
<div class="cbi-value-description"><%:Pick a format to generate the subscription link; you can switch formats at any time%></div>
|
||||
</td>
|
||||
<td>
|
||||
<% if it["local"] then %>
|
||||
|
||||
@@ -125,10 +125,12 @@ function M.convert_protocol(node, target_proto)
|
||||
new_node.port = node.port
|
||||
end
|
||||
|
||||
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码
|
||||
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码。
|
||||
-- 必须是真的 UUID:客户端的 uuid 字段会按 16 字节解析,格式不对时多数客户端
|
||||
-- 直接拒绝该节点。原来写的是 base64(seed) 截前 36 位 —— base64 只产出 24 个
|
||||
-- 字符(`sub(1,36)` 是空操作),里面还可能带 `+` `/` `=`,不是合法 UUID。
|
||||
if src == "trojan" and (tgt == "vmess" or tgt == "vless") then
|
||||
local seed = tostring(os.time()) .. tostring(math.random(100000, 999999))
|
||||
new_node.uuid = util.base64_encode(seed):sub(1, 36)
|
||||
new_node.uuid = util.uuid()
|
||||
end
|
||||
|
||||
-- 丢弃源协议专属字段(不带入转换后的协议)
|
||||
@@ -251,11 +253,14 @@ function M.apply_template(nodes, template)
|
||||
local cp = {}
|
||||
for k, v in pairs(n) do cp[k] = v end
|
||||
if cp.name and template ~= "" then
|
||||
-- 替换值一律按**字面**处理:gsub 的替换串里 `%` 有语义
|
||||
-- (`%1` 是捕获引用,`%%` 才是字面百分号,裸 `%` 会被吞掉或注入 NUL)。
|
||||
-- 节点名来自订阅内容,必须原样落地。
|
||||
local new_name = template
|
||||
new_name = new_name:gsub("{{name}}", cp.name or "")
|
||||
new_name = new_name:gsub("{{proto}}", cp.proto or "")
|
||||
new_name = new_name:gsub("{{server}}", cp.server or "")
|
||||
new_name = new_name:gsub("{{port}}", tostring(cp.port or ""))
|
||||
new_name = new_name:gsub("{{name}}", util.gsub_literal(cp.name))
|
||||
new_name = new_name:gsub("{{proto}}", util.gsub_literal(cp.proto))
|
||||
new_name = new_name:gsub("{{server}}", util.gsub_literal(cp.server))
|
||||
new_name = new_name:gsub("{{port}}", util.gsub_literal(cp.port))
|
||||
cp.name = new_name
|
||||
end
|
||||
out[#out + 1] = cp
|
||||
|
||||
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.6.8"
|
||||
M.version = "2.6.9"
|
||||
M.DATA_DIR = "/etc/substore"
|
||||
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
|
||||
M.NODES_DIR = M.DATA_DIR .. "/nodes"
|
||||
@@ -28,9 +28,20 @@ local function id_is_valid(id)
|
||||
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
-- 目录权限只收紧一次(每进程)。load() 每次读列表都会调用 ensure_dirs(),
|
||||
-- 无条件 chmod 会让每次读取都多 fork 两个子进程。
|
||||
local dirs_secured = false
|
||||
function M.ensure_dirs()
|
||||
util.ensure_dir(M.DATA_DIR)
|
||||
util.ensure_dir(M.NODES_DIR)
|
||||
util.ensure_dir(M.DATA_DIR, "700")
|
||||
util.ensure_dir(M.NODES_DIR, "700")
|
||||
if not dirs_secured then
|
||||
dirs_secured = true
|
||||
-- mkdir -m 只对**本次新建**的目录生效;从旧版本升级上来的机器上目录已存在,
|
||||
-- 权限仍是当初按 umask 建的(通常 0755),所以这里显式再 chmod 一次。
|
||||
-- 目录里是订阅 URL、公开下载 token 与节点凭据,0700 只留 root。
|
||||
util.chmod(M.DATA_DIR, "700")
|
||||
util.chmod(M.NODES_DIR, "700")
|
||||
end
|
||||
end
|
||||
|
||||
-- 读取订阅列表,返回 seq, items, err。
|
||||
@@ -73,7 +84,8 @@ end
|
||||
|
||||
local function save(seq, items)
|
||||
M.ensure_dirs()
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
|
||||
-- 0600:列表里有订阅 URL 与公开下载 token
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }), "600")
|
||||
end
|
||||
|
||||
-- 返回 arr, err。err 非空表示列表文件已损坏。
|
||||
@@ -248,7 +260,8 @@ end
|
||||
|
||||
function M.write_nodes(id, nodes)
|
||||
M.ensure_dirs()
|
||||
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes))
|
||||
-- 0600:节点文件里有 uuid / 密码 / 私钥等全部凭据
|
||||
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes), "600")
|
||||
end
|
||||
|
||||
function M.read_nodes(id)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
-- node.lua — 统一节点模型(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
-- util 是叶子模块(自身不 require 任何 substore.*),所以这里不会形成循环依赖。
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.PROTOS = {
|
||||
@@ -366,11 +369,13 @@ function M.apply_rules(nodes, rules)
|
||||
if rules.template_apply == true or rules.template_apply == "1" then
|
||||
for _,n in ipairs(nodes) do
|
||||
if n.template and n.template ~= "" and not n.url then
|
||||
-- 替换值按字面处理:gsub 替换串里的 `%` 有语义,裸 `%` 会被吞掉、
|
||||
-- 结尾的 `%` 会注入 NUL 字节(与 build_replacement 同一类问题)。
|
||||
local url = n.template
|
||||
url = url:gsub("{server}", n.server or "")
|
||||
url = url:gsub("{port}", tostring(n.port or ""))
|
||||
url = url:gsub("{uuid}", n.uuid or n.password or "")
|
||||
url = url:gsub("{name}", n.name or "")
|
||||
url = url:gsub("{server}", util.gsub_literal(n.server))
|
||||
url = url:gsub("{port}", util.gsub_literal(n.port))
|
||||
url = url:gsub("{uuid}", util.gsub_literal(n.uuid or n.password))
|
||||
url = url:gsub("{name}", util.gsub_literal(n.name))
|
||||
n.url = url
|
||||
end
|
||||
end
|
||||
@@ -485,21 +490,24 @@ end
|
||||
-- "旧名称=新名称"(精确匹配,type="exact")
|
||||
-- "pattern -> replacement"(正则替换,type="regex")
|
||||
-- "{server}_{port}_{proto}"(含 {var} 占位符,type="template")
|
||||
-- line_no 记录规则在原始文本里的行号(从 1 起),仅用于报错时定位到用户写的那一行
|
||||
function M.parse_rename_rules(rule_str)
|
||||
rule_str = rule_str or ""
|
||||
local rules = {}
|
||||
local no = 0
|
||||
for line in rule_str:gmatch("[^\r\n]+") do
|
||||
no = no + 1
|
||||
line = line:match("^%s*(.-)%s*$")
|
||||
if line ~= "" and not line:match("^#") then
|
||||
local pat, repl = line:match("^(.-)%s*%-%>%s*(.+)$")
|
||||
if pat then
|
||||
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl }
|
||||
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl, line_no = no }
|
||||
elseif line:find("{", 1, true) then
|
||||
rules[#rules + 1] = { type = "template", template = line }
|
||||
rules[#rules + 1] = { type = "template", template = line, line_no = no }
|
||||
else
|
||||
local k, v = line:match("^([^=]+)=(.*)$")
|
||||
if k and v then
|
||||
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$") }
|
||||
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$"), line_no = no }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -513,7 +521,7 @@ end
|
||||
-- 在结果里产生 NUL 字节并一路写进节点名、写盘、下发到各订阅文件。
|
||||
-- 所以替换前必须先把值里的 % 转义成 %%。
|
||||
local function expand_template(template, n)
|
||||
local function esc(v) return (tostring(v == nil and "" or v):gsub("%%", "%%%%")) end
|
||||
local esc = util.gsub_literal
|
||||
local out = template
|
||||
out = out:gsub("{server}", esc(n.server))
|
||||
out = out:gsub("{port}", esc(n.port))
|
||||
@@ -639,6 +647,32 @@ local function build_replacement(rep)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- 校验重命名规则串里的正则是否可用。返回 ok, err。
|
||||
--
|
||||
-- 用户写的 pattern 经 regex_to_lua 转换后交给 string.gsub;非法 pattern(未闭合的
|
||||
-- `[`、结尾的 `%` 等)会让 gsub 抛错。rename_with_rules 里那个 pcall 会把它吞掉,
|
||||
-- 结果是「规则明明写了却完全不生效,页面上没有任何提示」—— 用户只会觉得功能坏了。
|
||||
-- 在**保存时**校验并回显,用户才知道错在哪一行。
|
||||
function M.validate_rename_map(rule_str)
|
||||
for _, r in ipairs(M.parse_rename_rules(rule_str)) do
|
||||
if r.type == "regex" then
|
||||
local pat = regex_to_lua(r.pattern or "")
|
||||
local repl = build_replacement(r.replacement)
|
||||
local ok = pcall(function()
|
||||
-- 空串足以让 gsub 编译 pattern 与替换串;抛错即说明写法非法
|
||||
for _, alt in ipairs(split_alternatives(pat)) do
|
||||
(""):gsub(alt, repl)
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
return false, string.format("重命名规则第 %d 行:正则表达式无效(%s)",
|
||||
r.line_no or 0, r.pattern or "")
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function M.rename_with_rules(nodes, rules)
|
||||
rules = rules or {}
|
||||
for _, n in ipairs(nodes) do
|
||||
|
||||
@@ -129,8 +129,13 @@ M.FORMAT_OPTIONS = {
|
||||
|
||||
-- 统一分发:nodes → 目标格式字符串
|
||||
function M.generate(nodes, format, options)
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
-- 空串 / 纯空白等同于「未指定」。
|
||||
-- `?target=` 会传进来 "",而 "" 在 Lua 里是**真值**,所以 `format or DEFAULT_FORMAT`
|
||||
-- 兜不住它,会一路落到 "unsupported format: "(冒号后面什么都没有)——
|
||||
-- 用户拿到的是一个说不出原因的错误页。控制器只在 nil 时兜底,覆盖不到空串。
|
||||
if type(format) ~= "string" then format = "" end
|
||||
format = format:match("^%s*(.-)%s*$") or ""
|
||||
if format == "" then format = DEFAULT_FORMAT end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
if not norm then return nil, "unsupported format: " .. tostring(format) end
|
||||
|
||||
@@ -8,6 +8,11 @@ local M = {}
|
||||
|
||||
M.TIMEOUT = 2 -- 每个目标的超时(秒)
|
||||
|
||||
-- 批量探测时同时存在的子进程上限。
|
||||
-- 每个探测占 1 个进程 + 1 个管道 fd,节点数由订阅内容决定(可上千),
|
||||
-- 不限并发会把路由器的 fd/进程额度打满,之后 io.popen 静默失败。
|
||||
M.MAX_PARALLEL = 16
|
||||
|
||||
-- shell 引号:用单引号转义(util.shq),配合 safe_host 校验,双重防命令注入。
|
||||
-- 不能用 string.format("%q"):它生成双引号字符串,sh 在双引号内仍会做 $() / ``
|
||||
-- 命令替换,实测可注入。
|
||||
@@ -145,26 +150,39 @@ end
|
||||
-- 返回结果数组 { name=, server=, port=, latency=number|nil }
|
||||
function M.probe(nodes, mode)
|
||||
local jobs = {}
|
||||
-- 分批:一批最多 MAX_PARALLEL 个进程,读完并关闭这一批再起下一批。
|
||||
--
|
||||
-- 原来是「先把全部节点的 io.popen 起完,再统一读取」。并行度确实最高
|
||||
-- (总耗时≈单节点超时),但代价是无界的:每个节点占 1 个进程 + 1 个管道 fd,
|
||||
-- 而节点数由订阅内容决定(上千个很常见)。路由器上 fd 与进程数都是硬上限,
|
||||
-- 打满之后 io.popen 直接失败 —— 表现是一大片节点探测不出来,
|
||||
-- 而不是报错。分批后并发度封顶,总耗时仍是「批数 × 单节点超时」。
|
||||
local out = {}
|
||||
local batch = {}
|
||||
local function drain()
|
||||
for _, j in ipairs(batch) do
|
||||
local latency
|
||||
if j.f then
|
||||
local raw = j.f:read("*a") or ""
|
||||
j.f:close()
|
||||
latency = j.parse and j.parse(raw) or nil
|
||||
end
|
||||
out[#out + 1] = {
|
||||
name = j.name, server = j.server, port = j.port, latency = latency,
|
||||
}
|
||||
end
|
||||
batch = {}
|
||||
end
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
local cmd, parse = build_job(mode, n.server, n.port)
|
||||
local f = cmd and io.popen(cmd) or nil
|
||||
jobs[#jobs + 1] = {
|
||||
batch[#batch + 1] = {
|
||||
f = f, parse = parse,
|
||||
name = n.name or "", server = n.server or "", port = n.port or "",
|
||||
}
|
||||
if #batch >= M.MAX_PARALLEL then drain() end
|
||||
end
|
||||
local out = {}
|
||||
for _, j in ipairs(jobs) do
|
||||
local latency
|
||||
if j.f then
|
||||
local raw = j.f:read("*a") or ""
|
||||
j.f:close()
|
||||
latency = j.parse and j.parse(raw) or nil
|
||||
end
|
||||
out[#out + 1] = {
|
||||
name = j.name, server = j.server, port = j.port, latency = latency,
|
||||
}
|
||||
end
|
||||
drain()
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
@@ -124,6 +124,33 @@ function M.rnd_hex(len)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- RFC 4122 version 4 UUID(8-4-4-4-12 十六进制)。
|
||||
--
|
||||
-- 客户端的 uuid 字段要的是**合法 UUID**,不是「一串随机字符」:
|
||||
-- vmess/vless 的 uuid 会被解析成 16 字节,格式不对时多数客户端直接拒绝该节点。
|
||||
-- 所以这里按规范置版本位(第 13 位十六进制 = 4)与变体位(第 17 位 ∈ 8/9/a/b),
|
||||
-- 而不是随便取 36 个字符。随机源复用 rnd_hex(优先 /dev/urandom)。
|
||||
-- 32 个十六进制字符按 8-4-4-4-12 切分;第 13 位固定 '4'(版本),
|
||||
-- 第 17 位取 8/9/a/b(变体,即高两位为二进制的 10)。
|
||||
function M.uuid()
|
||||
local h = M.rnd_hex(32)
|
||||
if #h < 32 then return nil end
|
||||
local variant = tonumber(h:sub(17, 17), 16) % 4 + 8 -- 8..11 → 8/9/a/b
|
||||
return string.format("%s-%s-4%s-%x%s-%s",
|
||||
h:sub(1, 8), h:sub(9, 12), h:sub(14, 16),
|
||||
variant, h:sub(18, 20),
|
||||
h:sub(21, 32))
|
||||
end
|
||||
|
||||
-- 把任意字符串转成 string.gsub 的**字面**替换串。
|
||||
--
|
||||
-- gsub 的替换串有自己的语义:`%` 后接数字是捕获引用,`%%` 才是字面百分号,
|
||||
-- 而 `%` 后接其它字符会被吞掉、结尾的 `%` 会注入 NUL 字节。
|
||||
-- 用户提供的替换值(节点名、模板变量)必须原样落地,所以先转义。
|
||||
function M.gsub_literal(s)
|
||||
return (tostring(s or ""):gsub("%%", "%%%%"))
|
||||
end
|
||||
|
||||
-- ---------- URL ----------
|
||||
function M.url_decode(s)
|
||||
if type(s) ~= "string" then return "" end
|
||||
@@ -411,6 +438,12 @@ function M.file_size(path)
|
||||
return #data
|
||||
end
|
||||
|
||||
-- 修改文件权限。Lua 5.1 标准库**没有** os.chmod(那是 nixio/posix 才有的),
|
||||
-- 所以走 busybox 的 chmod,路径经 shq 引用。mode 传八进制字符串(如 "600")。
|
||||
function M.chmod(path, mode)
|
||||
return os.execute("chmod " .. mode .. " " .. M.shq(path) .. " >/dev/null 2>&1") == 0
|
||||
end
|
||||
|
||||
-- 原子写:先写临时文件再重命名,避免写一半损坏。
|
||||
--
|
||||
-- 两处必须做对,否则「原子」只是名义上的:
|
||||
@@ -419,7 +452,10 @@ end
|
||||
-- 2) write / close 的返回值必须检查:磁盘写满或写入被截断时 f:write 会失败,
|
||||
-- 但 os.rename 依然成功 —— 于是原子地换上一个残缺文件,调用方却以为成功,
|
||||
-- 下一次读取才发现数据没了。
|
||||
function M.atomic_write(path, content)
|
||||
--
|
||||
-- mode 可选:写盘后把文件权限收紧到该值。数据文件里是订阅 URL、公开下载 token、
|
||||
-- 节点凭据;io.open 按 umask 创建,默认通常是 0644 —— 同机任何用户都能读到。
|
||||
function M.atomic_write(path, content, mode)
|
||||
local tmp = string.format("%s.tmp.%s", path, M.rnd_hex(8))
|
||||
local f, e = io.open(tmp, "wb")
|
||||
if not f then return false, e end
|
||||
@@ -439,11 +475,21 @@ function M.atomic_write(path, content)
|
||||
os.remove(tmp)
|
||||
return false, rerr
|
||||
end
|
||||
-- rename 保留临时文件自身的权限,所以收紧必须在 rename **之后**做:
|
||||
-- 先 chmod 再 rename 的话,临时文件名是公开可猜的,中间窗口里别人仍能读到。
|
||||
if mode then M.chmod(path, mode) end
|
||||
return true
|
||||
end
|
||||
|
||||
function M.ensure_dir(path)
|
||||
os.execute("mkdir -p " .. M.shq(path))
|
||||
-- 建目录。mode 可选,仅在目录**由本次调用创建**时生效 ——
|
||||
-- 对已存在的目录 busybox mkdir 不会改权限(要改得显式 chmod),
|
||||
-- 调用方若需要「无论新建与否都收紧」,用 M.chmod 单独补一次。
|
||||
function M.ensure_dir(path, mode)
|
||||
if mode then
|
||||
os.execute("mkdir -p -m " .. mode .. " " .. M.shq(path) .. " >/dev/null 2>&1")
|
||||
else
|
||||
os.execute("mkdir -p " .. M.shq(path))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 人性化格式 ----------
|
||||
|
||||
@@ -65,10 +65,15 @@ package.loaded["luci.i18n"] = _G.luci.i18n
|
||||
-- ---------- substore 依赖 stub ----------
|
||||
local CORE = {}
|
||||
package.loaded["substore.core"] = CORE
|
||||
package.loaded["substore.node"] = {
|
||||
-- 控制器在 read_rules_fields 里会调用 node.validate_rename_map 校验重命名规则
|
||||
-- (非法正则要在保存时报错,而不是静默不生效)。stub 必须提供同名函数,
|
||||
-- 否则整条保存路径都会因 nil 调用而 500 —— 那是 stub 缺口,不是产品代码缺陷。
|
||||
local NODE = {
|
||||
filter = function(ns) return ns end,
|
||||
sort = function(ns) return ns end,
|
||||
validate_rename_map = function() return true end,
|
||||
}
|
||||
package.loaded["substore.node"] = NODE
|
||||
package.loaded["substore.probe"] = {
|
||||
probe = function() return {} end,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
-- p2_batch7_test.lua — P2 批次七回归测试(L1 / L6 / L8 / L9 / L23 / L24 / L25 / L26)
|
||||
-- 用法:lua5.1 tests/p2_batch7_test.lua
|
||||
--
|
||||
-- 全部离线:涉及子进程的地方一律替换 io.popen / os.execute 为记录桩。
|
||||
-- L5(视图 < 转义)的断言在 tests/view_injection_test.lua 里,不在此重复。
|
||||
|
||||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||||
|
||||
local util = require("substore.util")
|
||||
local node = require("substore.node")
|
||||
local output = require("substore.output")
|
||||
local probe = require("substore.probe")
|
||||
local conv = require("substore.converter")
|
||||
|
||||
local passed, failed = 0, 0
|
||||
local function check(name, cond)
|
||||
if cond then passed = passed + 1; print("PASS " .. name)
|
||||
else failed = failed + 1; print("FAIL " .. name) end
|
||||
end
|
||||
|
||||
local function read(p)
|
||||
return util.read_file(p) or ""
|
||||
end
|
||||
|
||||
-- ---------- L1:界面 msgid 一律用英文(中文只在 po 里做译文) ----------
|
||||
--
|
||||
-- 用中文当 msgid 且 po 里没有对应条目时,LuCI 在英文界面会**原样显示中文**。
|
||||
-- 这里直接扫模板:`<%:...%>` 与 `luci.i18n.translate("...")` 里的字符串不得含 CJK。
|
||||
local function has_cjk(s)
|
||||
-- UTF-8 里的 CJK 统一落在 U+4E00..U+9FFF(三字节 E4B8 80 .. E9BF BF)。
|
||||
-- 按字节扫:任意一个三字节序列落在该区间即判定为中文。
|
||||
for i = 1, #s - 2 do
|
||||
local b1, b2, b3 = s:byte(i), s:byte(i + 1), s:byte(i + 2)
|
||||
if b1 and b2 and b3 and b1 >= 0xE4 and b1 <= 0xE9
|
||||
and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local VIEW_DIR = "root/usr/lib/lua/luci/view/substore/"
|
||||
local view_files = {
|
||||
"subscriptions.htm", "nodes.htm", "node_edit.htm", "local_form.htm",
|
||||
"form.htm", "combo.htm", "output.htm",
|
||||
}
|
||||
local bad_msgids = {}
|
||||
for _, f in ipairs(view_files) do
|
||||
local src = read(VIEW_DIR .. f)
|
||||
for i, line in ipairs((function()
|
||||
local t = {}
|
||||
for l in (src .. "\n"):gmatch("([^\n]*)\n") do t[#t + 1] = l end
|
||||
return t
|
||||
end)()) do
|
||||
-- 跳过 Lua 注释行(模板里 <%% 之外的 -- 行),注释里可以写中文
|
||||
if not line:match("^%s*%-%-") then
|
||||
for msgid in line:gmatch("<%%:(.-)%%>") do
|
||||
if has_cjk(msgid) then
|
||||
bad_msgids[#bad_msgids + 1] = f .. ":" .. i .. " " .. msgid
|
||||
end
|
||||
end
|
||||
for msgid in line:gmatch('luci%.i18n%.translate%("(.-)"%)') do
|
||||
if has_cjk(msgid) then
|
||||
bad_msgids[#bad_msgids + 1] = f .. ":" .. i .. " " .. msgid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
check("no CJK msgid left in view templates", #bad_msgids == 0)
|
||||
if #bad_msgids > 0 then
|
||||
for _, b in ipairs(bad_msgids) do print(" " .. b) end
|
||||
end
|
||||
|
||||
-- 新英文 msgid 必须在 zh-cn 译文表里有条目,否则中文界面会显示英文
|
||||
local po = read("po/zh-cn/substore.po")
|
||||
check("po has Operation failed", po:find('msgid "Operation failed"', 1, true) ~= nil)
|
||||
check("po has Pick a format msgid",
|
||||
po:find("Pick a format to generate the subscription link", 1, true) ~= nil)
|
||||
check("po Operation failed translated", po:find('msgstr "操作失败"', 1, true) ~= nil)
|
||||
-- 反向对照:这两个 msgid 在改动前是中文的,确认它们确实来自本轮改动
|
||||
check("po still has Type", po:find('msgid "Type"', 1, true) ~= nil)
|
||||
|
||||
-- ---------- L6:批量探测必须限制并发 ----------
|
||||
--
|
||||
-- 原来「先起完全部 io.popen 再统一读取」,节点数上千时进程/fd 无界增长。
|
||||
-- 用记录桩统计**同时存活**的句柄峰值。
|
||||
check("MAX_PARALLEL defined", type(probe.MAX_PARALLEL) == "number" and probe.MAX_PARALLEL > 0)
|
||||
|
||||
local real_popen = io.popen
|
||||
local live, peak, opened = 0, 0, 0
|
||||
io.popen = function()
|
||||
live = live + 1
|
||||
opened = opened + 1
|
||||
if live > peak then peak = live end
|
||||
return {
|
||||
read = function() return "64 bytes from 127.0.0.1: time=1.5 ms" end,
|
||||
close = function() live = live - 1 end,
|
||||
}
|
||||
end
|
||||
local many = {}
|
||||
for i = 1, 100 do
|
||||
many[i] = { name = "n" .. i, server = "127.0.0.1", port = 80 }
|
||||
end
|
||||
local res = probe.probe(many, "ping")
|
||||
io.popen = real_popen
|
||||
|
||||
check("probe returns one result per node", #res == 100)
|
||||
check("probe spawned one process per node", opened == 100)
|
||||
-- 用局部变量兜住 nil:MAX_PARALLEL 不存在时(改动前)下面两条断言要能报 FAIL
|
||||
-- 而不是让整个脚本因「compare number with nil」中断,否则后面的断言全看不到。
|
||||
local cap = tonumber(probe.MAX_PARALLEL) or -1
|
||||
check("probe concurrency capped", peak <= cap)
|
||||
check("probe actually parallel (not serial)", peak == cap)
|
||||
check("probe closed every handle", live == 0)
|
||||
check("probe parsed latency", res[1].latency == 1.5)
|
||||
check("probe keeps order", res[1].name == "n1" and res[100].name == "n100")
|
||||
|
||||
-- ---------- L8:空 format 不能被当成合法格式名 ----------
|
||||
local tpl_nodes = { {
|
||||
name = "n", proto = "vmess", server = "1.1.1.1", port = 443,
|
||||
uuid = "11111111-1111-4111-8111-111111111111", alterId = 0,
|
||||
} }
|
||||
local def_body, def_err = output.generate(tpl_nodes, "clashmeta")
|
||||
check("baseline format works", type(def_body) == "string" and def_err == nil)
|
||||
|
||||
local empty_body, empty_err = output.generate(tpl_nodes, "")
|
||||
check("empty format does not error", empty_err == nil)
|
||||
check("empty format equals default", empty_body == def_body)
|
||||
check("whitespace format equals default", output.generate(tpl_nodes, " ") == def_body)
|
||||
check("tab/newline format equals default", output.generate(tpl_nodes, "\t\n ") == def_body)
|
||||
-- nil 与非法名仍按原语义走
|
||||
check("nil format equals default", output.generate(tpl_nodes, nil) == def_body)
|
||||
local _, bad_err = output.generate(tpl_nodes, "nosuchformat")
|
||||
check("unknown format still errors", type(bad_err) == "string" and bad_err:find("nosuchformat", 1, true) ~= nil)
|
||||
|
||||
-- ---------- L9:output.htm / combo.htm 的安装位置 ----------
|
||||
--
|
||||
-- 这两行原来挂在 /www/luci-static 的 INSTALL_DIR 块下面 —— 目标路径本身是对的,
|
||||
-- 但归错了块,一旦有人调整块顺序就会被装到错误的目录。
|
||||
local mk = read("Makefile")
|
||||
local view_dir_at = mk:find("$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/substore", 1, true)
|
||||
local static_dir_at = mk:find("$(INSTALL_DIR) $(1)/www/luci-static/resources/substore", 1, true)
|
||||
local out_htm_at = mk:find("view/substore/output.htm", 1, true)
|
||||
local combo_htm_at = mk:find("view/substore/combo.htm", 1, true)
|
||||
check("Makefile has view INSTALL_DIR", view_dir_at ~= nil)
|
||||
check("Makefile has luci-static INSTALL_DIR", static_dir_at ~= nil)
|
||||
check("output.htm installed in view block",
|
||||
out_htm_at ~= nil and out_htm_at > view_dir_at and out_htm_at < static_dir_at)
|
||||
check("combo.htm installed in view block",
|
||||
combo_htm_at ~= nil and combo_htm_at > view_dir_at and combo_htm_at < static_dir_at)
|
||||
|
||||
-- ---------- L23:trojan → vmess/vless 必须生成合法 UUID ----------
|
||||
local UUID_RE = "^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4%x%x%x%-[89ab]%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$"
|
||||
|
||||
-- 这几个函数是本轮新增的,在改动前的代码上并不存在。用局部兜底而不是直接调用:
|
||||
-- 否则整个脚本会在第一次 nil 调用处中断,后面所有断言都跑不到,
|
||||
-- 反向验证就只剩半份报告(看不清哪些新断言真的失败)。
|
||||
local uuid = util.uuid or function() return nil end
|
||||
local gsub_literal = util.gsub_literal or function() return nil end
|
||||
local validate_rename_map = node.validate_rename_map or function() return true end
|
||||
|
||||
local u = uuid()
|
||||
check("util.uuid shape", type(u) == "string" and u:match(UUID_RE) ~= nil)
|
||||
check("util.uuid unique-ish", uuid() ~= uuid())
|
||||
|
||||
local trojan = { name = "t", proto = "trojan", server = "a.example.com", port = 443, password = "secretpw" }
|
||||
local as_vmess = conv.convert_protocol(trojan, "vmess")
|
||||
check("trojan->vmess produced node", type(as_vmess) == "table")
|
||||
check("trojan->vmess uuid valid", as_vmess and as_vmess.uuid ~= nil and as_vmess.uuid:match(UUID_RE) ~= nil)
|
||||
check("trojan->vmess uuid not the password", as_vmess and as_vmess.uuid ~= "secretpw")
|
||||
local as_vless = conv.convert_protocol(trojan, "vless")
|
||||
check("trojan->vless uuid valid", as_vless and as_vless.uuid ~= nil and as_vless.uuid:match(UUID_RE) ~= nil)
|
||||
-- 反向对照:旧实现是 base64(seed) 的前 36 字符,base64 只产出 24 个字符,
|
||||
-- 所以长度必然不等于 36 且含非十六进制字符。确认新值确实不是那个形状。
|
||||
check("uuid is not base64-shaped",
|
||||
as_vmess and as_vmess.uuid:find("[+/=]", 1) == nil and #as_vmess.uuid == 36)
|
||||
|
||||
-- ---------- L24:gsub 替换串必须按字面处理 ----------
|
||||
check("gsub_literal escapes percent", gsub_literal("100%") == "100%%")
|
||||
check("gsub_literal empty", gsub_literal(nil) == "")
|
||||
|
||||
local tpl = { { name = "100%off", proto = "vmess", server = "s.example.com", port = 443 } }
|
||||
check("apply_template keeps percent",
|
||||
conv.apply_template(tpl, "[{{name}}]")[1].name == "[100%off]")
|
||||
check("apply_template keeps capture-like text",
|
||||
conv.apply_template({ { name = "a%1b", proto = "vmess", server = "s", port = 1 } }, "[{{name}}]")[1].name
|
||||
== "[a%1b]")
|
||||
check("apply_template keeps trailing percent",
|
||||
conv.apply_template({ { name = "x%", proto = "vmess", server = "s", port = 1 } }, "[{{name}}]")[1].name
|
||||
== "[x%]")
|
||||
|
||||
-- node.apply_rules 的 {server} 模板占位符走的是另一条路径,同样要按字面替换。
|
||||
-- 该分支由 rules.template_apply 开关控制,必须显式打开。
|
||||
local TMPL_ON = { template_apply = true }
|
||||
local tmpl_nodes = { {
|
||||
name = "tpl", proto = "vmess", server = "1.2.3.4", port = 443,
|
||||
template = "https://sub.example.com/{server}:{port}",
|
||||
} }
|
||||
local applied = node.apply_rules(tmpl_nodes, TMPL_ON)
|
||||
check("apply_rules template expands server",
|
||||
applied[1].url == "https://sub.example.com/1.2.3.4:443")
|
||||
local tmpl_nodes2 = { {
|
||||
name = "tpl", proto = "vmess", server = "100%host", port = 443,
|
||||
template = "https://x/{server}",
|
||||
} }
|
||||
check("apply_rules template keeps percent literal",
|
||||
node.apply_rules(tmpl_nodes2, TMPL_ON)[1].url == "https://x/100%host")
|
||||
-- 已有 url 的节点不得被模板覆盖(模板只是「补一个 url」,不是改写)
|
||||
local tmpl_nodes3 = { {
|
||||
name = "tpl", proto = "vmess", server = "1.2.3.4", port = 443,
|
||||
template = "https://x/{server}", url = "https://keep.me/",
|
||||
} }
|
||||
check("apply_rules template leaves existing url alone",
|
||||
node.apply_rules(tmpl_nodes3, TMPL_ON)[1].url == "https://keep.me/")
|
||||
|
||||
-- ---------- L25:非法重命名正则必须在保存时报错 ----------
|
||||
local ok_valid, err_valid = validate_rename_map("a -> b")
|
||||
check("valid regex accepted", ok_valid == true and err_valid == nil)
|
||||
check("empty rules accepted", validate_rename_map("") == true)
|
||||
check("exact rule accepted", validate_rename_map("OLD=NEW") == true)
|
||||
check("placeholder rule accepted", validate_rename_map("{server}_{port}_{proto}") == true)
|
||||
|
||||
local ok_bad, err_bad = validate_rename_map("[abc -> x")
|
||||
check("invalid regex rejected", ok_bad == false)
|
||||
check("invalid regex error names the line",
|
||||
type(err_bad) == "string" and err_bad:find("第 1 行", 1, true) ~= nil)
|
||||
check("invalid regex error quotes the pattern",
|
||||
type(err_bad) == "string" and err_bad:find("[abc", 1, true) ~= nil)
|
||||
|
||||
-- 行号要指向真正出错的那一行,而不是恒为 1
|
||||
local ok_multi, err_multi = validate_rename_map("good -> ok\n[bad -> x\nlast -> y")
|
||||
check("line number points at the bad rule",
|
||||
ok_multi == false and type(err_multi) == "string" and err_multi:find("第 2 行", 1, true) ~= nil)
|
||||
|
||||
-- 校验必须覆盖**每一条**备选分支(split_alternatives 展开后的每一项)
|
||||
check("invalid alternative rejected", validate_rename_map("[a|b -> x") == false)
|
||||
|
||||
-- ---------- L26:数据文件与目录权限 ----------
|
||||
--
|
||||
-- Lua 5.1 没有 os.chmod,实现走 busybox chmod;这里替换 os.execute 记录命令行。
|
||||
local real_execute = os.execute
|
||||
local exec_log = {}
|
||||
os.execute = function(cmd)
|
||||
exec_log[#exec_log + 1] = cmd
|
||||
return 0
|
||||
end
|
||||
|
||||
local tmpfile = "/tmp/substore_l26_probe.json"
|
||||
os.remove(tmpfile)
|
||||
local wok = util.atomic_write(tmpfile, "{}", "600")
|
||||
os.remove(tmpfile)
|
||||
check("atomic_write with mode succeeds", wok == true)
|
||||
|
||||
local chmod_after_rename = false
|
||||
for _, cmd in ipairs(exec_log) do
|
||||
if cmd:find("chmod 600", 1, true) and cmd:find(tmpfile, 1, true) then
|
||||
chmod_after_rename = true
|
||||
end
|
||||
end
|
||||
check("atomic_write chmods the target 600", chmod_after_rename)
|
||||
|
||||
-- 不带 mode 时不得额外 chmod(cron 文件走的就是这条路,权限语义要保持不变)
|
||||
exec_log = {}
|
||||
os.remove(tmpfile)
|
||||
util.atomic_write(tmpfile, "{}")
|
||||
os.remove(tmpfile)
|
||||
local stray = false
|
||||
for _, cmd in ipairs(exec_log) do
|
||||
if cmd:find("chmod", 1, true) then stray = true end
|
||||
end
|
||||
check("atomic_write without mode does not chmod", stray == false)
|
||||
|
||||
-- ensure_dir 的 mode 只在**新建**时由 mkdir -m 生效
|
||||
exec_log = {}
|
||||
util.ensure_dir("/tmp/substore_l26_dir", "700")
|
||||
local saw_mkdir_m = false
|
||||
for _, cmd in ipairs(exec_log) do
|
||||
if cmd:find("mkdir -p -m 700", 1, true) then saw_mkdir_m = true end
|
||||
end
|
||||
check("ensure_dir passes -m 700", saw_mkdir_m)
|
||||
|
||||
exec_log = {}
|
||||
util.ensure_dir("/tmp/substore_l26_dir")
|
||||
local saw_plain_mkdir = false
|
||||
for _, cmd in ipairs(exec_log) do
|
||||
if cmd:find("mkdir -p ", 1, true) and not cmd:find(" -m ", 1, true) then saw_plain_mkdir = true end
|
||||
end
|
||||
check("ensure_dir without mode stays plain", saw_plain_mkdir)
|
||||
|
||||
-- core.ensure_dirs:新建目录带 700,且对**已存在**的目录补一次 chmod 700
|
||||
package.loaded["substore.core"] = nil
|
||||
local core = require("substore.core")
|
||||
exec_log = {}
|
||||
core.ensure_dirs()
|
||||
local saw_data_chmod, saw_nodes_chmod = false, false
|
||||
for _, cmd in ipairs(exec_log) do
|
||||
if cmd:find("chmod 700", 1, true) then
|
||||
if cmd:find(core.DATA_DIR, 1, true) then saw_data_chmod = true end
|
||||
if cmd:find(core.NODES_DIR, 1, true) then saw_nodes_chmod = true end
|
||||
end
|
||||
end
|
||||
check("ensure_dirs chmods DATA_DIR 700", saw_data_chmod)
|
||||
check("ensure_dirs chmods NODES_DIR 700", saw_nodes_chmod)
|
||||
|
||||
-- 第二次调用不得重复 chmod(load() 每次读列表都会走到这里)
|
||||
local before = #exec_log
|
||||
core.ensure_dirs()
|
||||
local chmods_again = 0
|
||||
for i = before + 1, #exec_log do
|
||||
if exec_log[i]:find("chmod", 1, true) then chmods_again = chmods_again + 1 end
|
||||
end
|
||||
check("ensure_dirs chmods only once per process", chmods_again == 0)
|
||||
|
||||
os.execute = real_execute
|
||||
|
||||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||||
os.exit(failed == 0 and 0 or 1)
|
||||
@@ -37,9 +37,14 @@ local VIEWS = {
|
||||
"root/usr/lib/lua/luci/view/substore/output.htm",
|
||||
}
|
||||
|
||||
-- ---------- H2:JSON 编码注入到 JS 的位置必须同时转义 "</" ----------
|
||||
-- ---------- H2:JSON 编码注入到 JS 的位置必须转义**全部** "<" ----------
|
||||
--
|
||||
-- 逐个 json_encode 调用点检查:要么同一行/下一行出现 gsub("</", …),
|
||||
-- 只转 "</" 是不够的:HTML 词法阶段一旦看到 "<!--",就进入 script data escaped
|
||||
-- 状态,其后的 "</script>" 不再结束脚本块 —— 页面剩余部分全被当成脚本文本吞掉。
|
||||
-- 转义全部 "<" 为 \u003c 后,HTML 词法阶段看不到任何 "<",两个坑一并堵上;
|
||||
-- 而 \u003c 在 JSON 与 JS 字符串字面量里都还原成 "<",取值不受影响。
|
||||
--
|
||||
-- 逐个 json_encode 调用点检查:要么同一行/下一行出现 gsub("<", …),
|
||||
-- 要么该值是白名单归一化后的固定取值(注释里标注了「白名单」)。
|
||||
for _, path in ipairs(VIEWS) do
|
||||
local src = util.read_file(path)
|
||||
@@ -51,10 +56,11 @@ for _, path in ipairs(VIEWS) do
|
||||
if l:find("json_encode") and not l:match("^%s*%-%-") and not l:match("^%s*//") then
|
||||
-- 转义可能写在下一行,白名单说明通常写在紧邻的注释里(上一两行)
|
||||
local window = (lines[i - 2] or "") .. (lines[i - 1] or "") .. l .. (lines[i + 1] or "")
|
||||
local escaped = window:find('gsub("</"', 1, true) ~= nil
|
||||
local escaped = window:find('gsub("<"', 1, true) ~= nil
|
||||
and window:find("\\u003c", 1, true) ~= nil
|
||||
-- 白名单归一化的取值("text"/"form")不含 "<",无需转义
|
||||
local whitelisted = window:find("白名单", 1, true) ~= nil
|
||||
check(path:match("[^/]+$") .. ":" .. i .. " json_encode escapes </",
|
||||
check(path:match("[^/]+$") .. ":" .. i .. ' json_encode escapes <',
|
||||
escaped or whitelisted)
|
||||
end
|
||||
end
|
||||
@@ -67,6 +73,16 @@ check("json_encode does not escape <", util.json_encode(payload):find("</script"
|
||||
check("gsub(</) removes the closing tag",
|
||||
(util.json_encode(payload):gsub("</", "<\\/")):find("</script", 1, true) == nil)
|
||||
|
||||
-- 反向验证之二:只转 "</" 挡不住 "<!--" —— 这正是本轮把转义范围放宽到全部 "<" 的原因。
|
||||
-- 该断言在旧实现(gsub("</") )上必须成立,否则说明这条理由不成立。
|
||||
local comment_payload = "<!--x"
|
||||
check("gsub(</) alone still leaves <!-- (page-breaking)",
|
||||
(util.json_encode(comment_payload):gsub("</", "<\\/")):find("<!--", 1, true) ~= nil)
|
||||
check("gsub(<) escapes <!-- too",
|
||||
(util.json_encode(comment_payload):gsub("<", "\\u003c")):find("<!--", 1, true) == nil)
|
||||
check("gsub(<) still removes the closing tag",
|
||||
(util.json_encode(payload):gsub("<", "\\u003c")):find("</script", 1, true) == nil)
|
||||
|
||||
-- ---------- 字段清单:唯一来源在 Lua,页面负责注入 ----------
|
||||
local js = util.read_file("root/www/luci-static/resources/substore/nodeform.js")
|
||||
check("nodeform.js readable", js ~= nil)
|
||||
|
||||
Reference in new issue
Block a user