🎈 Sync 2026-09-29 22:47:56

This commit is contained in:
github-actions[bot] committed 2026-09-29 22:47:57 +08:00
1 parent 97287119b0
commit 4c053b8bdb
29 files changed
+1015 -157

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy-pro
PKG_VERSION:=28.9.1.14
PKG_RELEASE:=4
PKG_RELEASE:=5
# Only files the package actually ships belong here. The previous list named
# four paths that exist nowhere in the tree - certs/ and ruleset/ are created at
@@ -193,7 +193,7 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
type: 'remote',
tag: 'geoip-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
url: 'https://gh-proxy.com/raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
@@ -201,7 +201,7 @@ function build_route_proxy(config, dm, ctx, direct_overrides) {
type: 'remote',
tag: 'geosite-cn',
format: 'binary',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
url: 'https://gh-proxy.com/raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs',
update_interval: '24h',
download_detour: 'main-out'
});
+1 -1
View File
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy
PKG_VERSION:=27.919.1.14
PKG_RELEASE:=19
PKG_RELEASE:=20
define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy
+1 -1
View File
@@ -7,7 +7,7 @@
**The modern ImmortalWrt proxy platform for ARM64 / AMD64**
基于 sing-box 1.14 内核的现代代理平台 — 简洁、高效、开箱即用。
本项目发版到R13后将不再频繁维护,除非存在重大修复。日常只推进“luci-app-homeproxy-pro”版本
---
@@ -1070,12 +1070,12 @@ if (!isEmpty(main_node)) {
{
tag: 'geoip-cn',
path: HP_DIR + '/resources/geoip_cn.srs',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs'
url: 'https://gh-proxy.com/raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs'
},
{
tag: 'geosite-cn',
path: HP_DIR + '/resources/geosite_cn.srs',
url: 'https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs'
url: 'https://gh-proxy.com/raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs'
}
];
+59
View File
@@ -2,6 +2,65 @@
All notable changes to this project will be documented in this file.
## [2.5.0-r1] - 订阅可靠性、错误反馈与 WireGuard 去重修复
- **修复严重缺陷(订阅更新可能「看起来成功其实失败」)**:`core.sync` 失败时是
「返回 `nil, err`」而**不是**抛异常,而调用方只判断了 `pcall` 的第一层返回值。
`pcall` 返回 `true` 只说明没有抛错,第二个返回值才是 `sync` 自身的结果——
于是「同步返回 `nil, err`」被统计成**成功**。
- `root/usr/bin/substore-cron.sh`:改为 `local ok, res, err = pcall(core.sync, id)`,
并要求 `ok and res` 才算成功;失败时打印真实原因。同时把脚本输出同时写 stdout 与
syslog(原先只进 syslog,cron 邮件里看不到)
- `root/usr/lib/lua/luci/controller/admin/substore.lua`:`action_update` 同样处理两层结果
- **修复严重缺陷(代理静默失效,属 silent fallback)**:
- `core.sync` 原先在「代理已启用但代理地址无效」时只记一条日志就**直连下载**,
用户会以为流量走了代理、实际暴露真实 IP。现改为**明确失败**并把原因写入订阅状态
- wget 后端原先在遇到它不支持的代理协议(`socks*`)时**丢掉代理继续直连**。
现改为明确报错并提示安装 curl 或改用 http 代理(busybox wget 只能通过
`http_proxy` / `https_proxy` 环境变量使用 http(s) 代理)
- **修复严重缺陷(wget 后端重定向绕过 SSRF 预检)**:curl 路径逐跳校验重定向目标,
wget 路径**完全不校验**,可被重定向到 `127.0.0.1` / 内网而绕过 `check_public`。
现 wget 改用 `-S` 输出响应头日志,由新增的 `http.validate_redirect_chain`
按出现顺序解析整条重定向链并逐跳复检,任一跳不安全即整体失败并**丢弃响应体**。
另修复协议相对地址(`//host/path`)被误当成同主机路径而漏检的问题
- **修复严重缺陷(界面失败无反馈)**:订阅的新建 / 保存 / 本地订阅新建 / 保存
失败时,控制器只静默跳回列表页,用户看到的是「什么都没发生」,与成功无法区分。
现失败原因经 `?err=` 回传到列表页并渲染为可见的错误提示
- **修复严重缺陷(WireGuard 节点被错误去重合并)**:去重键为 `proto+server+port`,
而同一 endpoint 上的**不同 peer 公钥是不同节点**,会被错误合并而丢节点。
现 WireGuard / `wg` 节点的去重键并入 peer 公钥(兼容 `public-key` / `public_key` /
`peer-public-key` / `peer_public_key` 四种写法);其余协议去重行为**完全不变**
- **修复严重缺陷(多个 WireGuard 节点被拼进一个 `.conf`)**:一个 `.conf` 文件只能包含
一个 `[Interface]`,拼接会产生客户端无法导入(或只取首段)的畸形配置。
现多于一个 WireGuard 节点时**明确报错**(错误信息含节点数与「一条隧道」提示),
而不是静默拼接
- **修复 AmneziaWG `.conf` 导出键名靠猜测**:原先用「首字母大写」由内部键名推导 `.conf`
键名,多词键会被写错(`header-protection-key` → `Header-protection-key`,
正确为 `HeaderProtectionKey`),未知键可能让客户端拒绝整份配置。
现改为显式映射表,只输出能**确定**键名的键(`Jc/Jmin/Jmax/S1–S4/H1–H4/I1–I5/J1–J3/Itime`),
映射不到的键一律不输出;已支持键集合零变化
- **修复模板注入(HTML / JS)**:7 个模板把用户可控数据(订阅名、URL、代理、节点字段、
解析错误信息)直接以 `<%= %>` 原样输出。现按上下文分别转义:
HTML 用 `luci.util.pcdata`,注入 JS 的字符串用 `json_encode`(并转义 `</` 防止提前
闭合 `script`),URL 用 `urlencode`
- **修复日志/错误信息泄漏凭据**:新增 `http.redact_proxy`(代理地址去凭据)与
`http.scrub_credentials`(抹掉 URL 中的 `//user:pass@`),用于日志与订阅错误信息
- **修复失败路径污染 `node_count`**:同步失败时不再把 `node_count` 清零——
磁盘上的旧节点仍在、订阅链接仍在下发,清零会让界面显示与实际不符
- 新增测试:`tests/controller_local_test.lua`(22 项)、`tests/template_escape_test.lua`(21 项)、
`tests/cron_result_test.sh`(5 项);`tests/wireguard_conf_test.lua` 扩至 93 项、
`tests/node_extended_test.lua` 扩至 28 项、`tests/http_proxy_test.lua` 扩充代理矩阵与
重定向链复检。全部测试共 1174 项断言通过
- 版本号 2.4.0-r2 → 2.5.0-r1;README.md / README.en.md / docs/INSTALL.md 同步
- 已知限制(本次**未**修改,另行处理):`check_public` 在无 DNS 解析能力时放行
(fail-open)且解析与下载之间存在 TOCTOU 窗口;wget 路径的重定向校验是事后的
(busybox wget 无 `--max-redirect`),响应体虽被丢弃但请求已经发出,且无法限制下载体积;
AmneziaWG 3.0 / 3.1 新增的 9 个字段(`HeaderProtectionKey`、`ContentPaddingAddition`、
`RekeyAfterTime`、`RekeyTimeout`、`RejectAfterTime`、`KeepaliveTimeout`、
`MaxHandshakeAttempts`、`RandomTrailers`、`DisableCookies`)**不被 `.conf` 解析器接受**
(白名单外,导入即丢弃),但会从 Clash / JSON / `wireguard://` 导入路径原样透传;
sing-box 本身不支持 AmneziaWG,其输出会丢弃 AWG 参数
## [2.4.0-r2] - 修复 vmess 加密方式与 TLS 层混淆
- **修复严重缺陷**:经典 vmess 分享链接 `vmess://base64(json)` 的 `scy` 是**加密方式**
+2 -2
View File
@@ -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.4.0
PKG_RELEASE:=8
PKG_VERSION:=2.5.0
PKG_RELEASE:=9
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
+3 -3
View File
@@ -93,18 +93,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.4.0-r2`).
> [Makefile](Makefile) (currently `2.5.0-r1`).
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
```bash
opkg install luci-app-substore-2.4.0-r2.ipk
opkg install luci-app-substore-2.5.0-r1.ipk
```
apk (OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.4.0-r2.apk
apk add --allow-untrusted luci-app-substore-2.5.0-r1.apk
```
Then open LuCI: **Services → Subscriptions**.
+3 -3
View File
@@ -80,18 +80,18 @@
## 安装
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
> (当前 `2.4.0-r2`)。
> (当前 `2.5.0-r1`)。
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
```bash
opkg install luci-app-substore-2.4.0-r2.ipk
opkg install luci-app-substore-2.5.0-r1.ipk
```
apk(OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.4.0-r2.apk
apk add --allow-untrusted luci-app-substore-2.5.0-r1.apk
```
然后在 LuCI 菜单打开:**服务 → 订阅**。
+3 -3
View File
@@ -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.4.0-r2)。
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.5.0-r1)。
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
```bash
opkg install luci-app-substore-2.4.0-r2.ipk
opkg install luci-app-substore-2.5.0-r1.ipk
```
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
```bash
apk add --allow-untrusted luci-app-substore-2.4.0-r2.apk
apk add --allow-untrusted luci-app-substore-2.5.0-r1.apk
```
Refresh LuCI:
@@ -3,7 +3,8 @@
# 用法:substore-cron.sh [订阅ID](不传则更新全部已启用订阅)
# 注意:显式设置 package.path,确保独立 cron 进程能找到 /usr/lib/lua 下的模块
DATA_DIR="/etc/substore"
# 数据目录可被环境变量覆盖,仅用于测试;生产环境仍为 /etc/substore
DATA_DIR="${SUBSTORE_DATA_DIR:-/etc/substore}"
LIST_FILE="$DATA_DIR/subscriptions.json"
SUB_ID="${1:-}"
LUA_BIN=""
@@ -23,19 +24,24 @@ fi
[ -f "$LIST_FILE" ] || exit 0
export SUB_ID
OUT=$(
LUA_PATH="/usr/lib/lua/?.lua;/usr/share/lua/?.lua;${LUA_PATH:-}" \
"$LUA_BIN" -e '
local core = require("substore.core")
local sub_id = os.getenv("SUB_ID")
local ok_count, fail_count = 0, 0
-- core.sync 失败时是「返回 nil, err」而非抛异常,因此 pcall 必须处理两层结果:
-- pcall 返回 true 只说明没有抛错,第二个返回值才是 sync 自身的结果。
-- 只判断第一层会把「sync 返回 nil, err」统计成成功。
local function sync_one(id)
local ok, err = pcall(function() return core.sync(id) end)
if ok then
local ok, res, err = pcall(core.sync, id)
if ok and res then
ok_count = ok_count + 1
else
fail_count = fail_count + 1
io.stderr:write("Failed sync " .. tostring(id) .. ": " .. tostring(err) .. "\n")
local reason = ok and err or res
io.stderr:write("Failed sync " .. tostring(id) .. ": " .. tostring(reason) .. "\n")
end
end
@@ -47,4 +53,12 @@ else
end
end
io.stdout:write(string.format("substore cron done: %d ok, %d failed\n", ok_count, fail_count))
' 2>&1 | logger -t luci-app-substore
' 2>&1
)
# 输出到 stdout(cron 会按需邮件/记录),同时写 syslog
printf '%s\n' "$OUT"
if command -v logger >/dev/null 2>&1; then
printf '%s\n' "$OUT" | logger -t luci-app-substore
fi
exit 0
@@ -2,9 +2,15 @@
module("luci.controller.admin.substore", package.seeall)
local function back_to_list()
-- 返回列表页。err 非空时把错误带到列表页显示(§18:失败必须让用户看见,
-- 不能「失败了却看起来像成功」)。沿用 LuCI 既有的 query + 模板渲染,不引入新 framework。
local function back_to_list(err)
local http = require("luci.http")
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "list"))
local url = luci.dispatcher.build_url("admin", "services", "substore", "list")
if err ~= nil and tostring(err) ~= "" then
url = url .. "?err=" .. luci.util.urlencode(tostring(err))
end
http.redirect(url)
end
function index()
@@ -84,18 +90,20 @@ function action_create()
local proxy_enable = http.formvalue("proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" and url ~= "" then
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
core.add(name, url, {
proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup, rename_map = rules.rename_map,
})
core.write_cron()
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
end
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
local id, err = core.add(name, url, {
proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup, rename_map = rules.rename_map,
})
if not id then return back_to_list(err or "创建订阅失败") end
core.write_cron()
end
back_to_list()
end
@@ -110,18 +118,21 @@ function action_save()
local proxy_enable = http.formvalue("proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" and url ~= "" then
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
core.save_meta(id, {
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup, rename_map = rules.rename_map,
})
core.write_cron()
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
end
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
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,
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup, rename_map = rules.rename_map,
})
if not ok then return back_to_list(err or "保存失败") end
core.write_cron()
end
back_to_list()
end
@@ -134,15 +145,17 @@ function action_local_create()
local content = http.formvalue("content") or ""
local local_mode = http.formvalue("local_mode") or "text"
local rules = read_rules_fields()
if name ~= "" and content ~= "" then
core.add_local(name, content, local_mode, {
rules_enable = rules.rules_enable,
proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include,
keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
})
end
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
if name == "" then return back_to_list("名称不能为空") end
if content == "" then return back_to_list("订阅内容不能为空") end
local id, err = core.add_local(name, content, local_mode, {
rules_enable = rules.rules_enable,
proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include,
keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
})
if not id then return back_to_list(err or "创建本地订阅失败") end
end
back_to_list()
end
@@ -156,19 +169,23 @@ function action_local_save()
local content = http.formvalue("content") or ""
local local_mode = http.formvalue("local_mode") or "text"
local rules = read_rules_fields()
if id ~= "" and name ~= "" and content ~= "" then
core.save_meta(id, {
name = name,
raw_content = content,
local_mode = local_mode,
rules_enable = rules.rules_enable,
proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include,
keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
})
core.sync(id)
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
local ok, err = core.save_meta(id, {
name = name,
raw_content = content,
local_mode = local_mode,
rules_enable = rules.rules_enable,
proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include,
keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
})
if not ok then return back_to_list(err or "保存失败") end
-- 解析失败会写进 meta.error(列表页 Status 列可见),此处再明确提示一次
local sok, serr = core.sync(id)
if not sok then return back_to_list(serr or "解析订阅内容失败") end
end
back_to_list()
end
@@ -315,12 +332,15 @@ function action_update()
-- local subscription does not support auto update
return
end
-- pcall fallback: parse or write error will not 500 the page, error saved to list status
local ok, err = pcall(function()
return core.sync(id)
end)
-- pcall 只保证「不抛异常」;core.sync 的失败是「返回 nil, err」而非抛错,
-- 因此必须同时检查两层结果,否则失败永远不会写回列表状态。
local ok, res, err = pcall(core.sync, id)
if not ok then
core.save_meta(id, { error = tostring(err), last_update = os.time() })
-- 抛异常:第二个返回值是错误信息
core.save_meta(id, { error = tostring(res), last_update = os.time() })
elseif not res then
-- 正常返回但失败:第三个返回值是错误信息
core.save_meta(id, { error = tostring(err or "更新失败"), last_update = os.time() })
end
end
back_to_list()
@@ -27,7 +27,7 @@
<div class="cbi-section">
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
<input type="text" id="name" name="name" value="<%=luci.util.pcdata(is_edit and it.name or "")%>"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Source subscriptions%></label>
@@ -52,11 +52,11 @@
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词包含</label>
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
<input type="text" name="keyword_include" value="<%=luci.util.pcdata(ki)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词排除</label>
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
<input type="text" name="keyword_exclude" value="<%=luci.util.pcdata(ke)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">去重</label>
@@ -38,11 +38,11 @@
<div class="cbi-section">
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
<input type="text" id="name" name="name" value="<%=luci.util.pcdata(is_edit and it.name or "")%>"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="url" style="display:inline-block;min-width:10em"><%:Subscription URL%></label>
<input type="text" id="url" name="url" value="<%=is_edit and it.url or ''%>" style="width:60%"/>
<input type="text" id="url" name="url" value="<%=luci.util.pcdata(is_edit and it.url or "")%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em">订阅代理</label>
@@ -51,7 +51,7 @@
</div>
<div id="proxy_row" class="cbi-section-descr" style="margin:0.5em 0;display:<%=proxy_enabled and "block" or "none"%>">
<label for="proxy" style="display:inline-block;min-width:10em">代理地址</label>
<input type="text" id="proxy" name="proxy" value="<%=is_edit and it.proxy or ''%>" style="width:60%"/>
<input type="text" id="proxy" name="proxy" value="<%=luci.util.pcdata(is_edit and it.proxy or "")%>" style="width:60%"/>
<div style="color:#808080;margin-top:0.25em">通过代理下载订阅(留空则不使用),支持 http:// 或 socks5:// 地址,如 socks5://192.168.5.1:1080</div>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
@@ -78,11 +78,11 @@
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词包含</label>
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
<input type="text" name="keyword_include" value="<%=luci.util.pcdata(ki)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">关键词排除</label>
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
<input type="text" name="keyword_exclude" value="<%=luci.util.pcdata(ke)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em">去重</label>
@@ -99,7 +99,7 @@
<% if traffic_str then %> | <%:Remaining traffic:%> <%=traffic_str%><% end %>
<% if time_str then %> | <%:Remaining time:%> <%=time_str%><% end %>
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=it.error%></span><% end %>
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(it.error)%></span><% end %>
</span>
</div>
<% end %>
@@ -14,6 +14,10 @@
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
local util = require("substore.util")
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
-- 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"'
%>
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
@@ -25,7 +29,7 @@
<div class="cbi-section">
<div class="cbi-section-descr" style="margin:0.5em 0">
<label for="name" style="display:inline-block;min-width:10em"><%:Name%></label>
<input type="text" id="name" name="name" value="<%=is_edit and it.name or ''%>"/>
<input type="text" id="name" name="name" value="<%=luci.util.pcdata(is_edit and it.name or "")%>"/>
</div>
<div class="cbi-section-descr" style="margin:0.5em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Subscription info%></label>
@@ -59,11 +63,11 @@
<div id="rules_row" style="margin:0.5em 0;display:<%=rules_enabled and "block" or "none"%>">
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em"><%:Keyword include%></label>
<input type="text" name="keyword_include" value="<%=ki%>" style="width:60%"/>
<input type="text" name="keyword_include" value="<%=luci.util.pcdata(ki)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em"><%:Keyword exclude%></label>
<input type="text" name="keyword_exclude" value="<%=ke%>" style="width:60%"/>
<input type="text" name="keyword_exclude" value="<%=luci.util.pcdata(ke)%>" style="width:60%"/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em"><%:Dedup%></label>
@@ -75,7 +79,7 @@
<span style="color:#808080">
<%:Nodes:%> <%=it.node_count or 0%>
| <%:Last updated:%> <%=os.date("%Y-%m-%d %H:%M", it.last_update)%>
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=it.error%></span><% end %>
<% if it.error and it.error ~= "" then %> | <span style="color:#cc0000"><%=luci.util.pcdata(it.error)%></span><% end %>
</span>
</div>
<% end %>
@@ -89,7 +93,7 @@
<script>
// 编辑时的初始内容(文本或 JSON),由服务端注入
var SUBSTORE_INIT_CONTENT = <%=js_content%>;
var SUBSTORE_INIT_MODE = "<%=local_mode%>";
var SUBSTORE_INIT_MODE = <%=js_mode%>;
// 字段标签与界面文本(nodeform.js 读取;翻译由服务端按系统语言渲染)
var FIELD_LABELS = {
@@ -9,10 +9,10 @@
local nodes = id ~= "" and core.read_nodes(id) or {}
local n = nodes[idx]
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
local js_node = n and (util.json_encode(n):gsub("</", "<\\/")) or "null"
local js_node = n and ((util.json_encode(n) or "null"):gsub("</", "<\\/")) or "null"
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
%>
<h2><%:Edit node%> — <%=meta and meta.name or ""%></h2>
<h2><%:Edit node%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
<% if not n then %>
<div class="cbi-section">
@@ -23,8 +23,8 @@
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_save')%>" class="cbi-map"
onsubmit="return node_edit_prepare()">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="idx" value="<%=idx%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<input type="hidden" name="idx" value="<%=luci.util.pcdata(tostring(idx or ""))%>"/>
<div class="cbi-section">
<div id="nodes_list"></div>
<input type="hidden" name="content" id="form_content" value=""/>
@@ -3,6 +3,7 @@
local http = require("luci.http")
local core = require("substore.core")
local node = require("substore.node")
local util = require("substore.util")
local ctx = luci.dispatcher.context or {}
local q = ctx.query or {}
local id = http.formvalue("id") or q.id or ""
@@ -13,6 +14,8 @@
local group = http.formvalue("group") or q.group or ""
local sort_by = http.formvalue("sort") or q.sort or "name"
local sort_desc = http.formvalue("desc") == "1" or q.desc == "1"
-- id 会进入 <script> 里的 JS 字符串字面量,用 JSON 编码而不是 HTML 转义
local js_id = util.json_encode(id) or '""'
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
for i, nn in ipairs(nodes) do nn.__idx = i end
@@ -33,13 +36,13 @@
nodes = node.sort(nodes, sort_by, sort_desc)
local node_edit_base = luci.dispatcher.build_url('admin/services/substore/node_edit')
%>
<h2><%:Nodes%> — <%=meta and meta.name or ""%></h2>
<h2><%:Nodes%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
<div style="margin:0.5em 0">
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/nodes')%>" class="cbi-map">
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<div style="display:flex;gap:0.5em;align-items:center;flex-wrap:wrap;margin:0.5em 0">
<label><%:Type%>:
<select name="proto">
@@ -57,7 +60,7 @@
<% end %>
</select>
</label>
<label><%:Keyword%>: <input type="text" name="keyword" size="12" value="<%=keyword%>"/></label>
<label><%:Keyword%>: <input type="text" name="keyword" size="12" value="<%=luci.util.pcdata(keyword)%>"/></label>
<label><%:Sort by%>:
<select name="sort">
<option value="name" <%=sort_by=="name" and "selected" or ""%>>name</option>
@@ -80,12 +83,12 @@
<!-- 勾选批量删除用的隐藏表单:JS 收集选中下标填入 idx(逗号分隔)后提交 -->
<form id="batch_delete_form" method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_delete')%>" style="display:none">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<input type="hidden" name="idx" value=""/>
<input type="hidden" name="proto" value="<%=proto%>"/>
<input type="hidden" name="keyword" value="<%=keyword%>"/>
<input type="hidden" name="group" value="<%=group%>"/>
<input type="hidden" name="sort" value="<%=sort_by%>"/>
<input type="hidden" name="proto" value="<%=luci.util.pcdata(proto)%>"/>
<input type="hidden" name="keyword" value="<%=luci.util.pcdata(keyword)%>"/>
<input type="hidden" name="group" value="<%=luci.util.pcdata(group)%>"/>
<input type="hidden" name="sort" value="<%=luci.util.pcdata(sort_by)%>"/>
<% if sort_desc then %><input type="hidden" name="desc" value="1"/><% end %>
</form>
@@ -101,28 +104,28 @@
<th><%:Actions%></th>
</tr>
<% for _, n in ipairs(nodes) do %>
<tr class="cbi-section-table-row" data-server="<%=n.server or ""%>" data-port="<%=n.port or ""%>">
<tr class="cbi-section-table-row" data-server="<%=luci.util.pcdata(n.server or "")%>" data-port="<%=luci.util.pcdata(tostring(n.port or ""))%>">
<td><input type="checkbox" class="node-check" value="<%=n.__idx%>"/></td>
<td><%=n.name or ""%></td>
<td><%=luci.util.pcdata(n.name or "")%></td>
<td>
<input type="text" class="group-input" style="width:8em" value="<%=luci.util.pcdata(n.group or "")%>"
data-idx="<%=n.__idx%>" onchange="substore_set_group(this)"/>
</td>
<td><%=n.proto or ""%></td>
<td><%=n.server or ""%></td>
<td><%=n.port or ""%></td>
<td><%=luci.util.pcdata(n.proto or "")%></td>
<td><%=luci.util.pcdata(n.server or "")%></td>
<td><%=luci.util.pcdata(tostring(n.port or ""))%></td>
<td class="node-latency">-</td>
<td style="white-space:nowrap">
<a class="cbi-button cbi-button-apply" href="<%=node_edit_base%>?id=<%=luci.util.urlencode(id)%>&idx=<%=n.__idx%>"><%:Edit%></a>
<form method="post" action="<%=luci.dispatcher.build_url('admin/services/substore/node_delete')%>" style="display:inline"
onsubmit="return confirm('<%=luci.util.pcdata(luci.i18n.translate('Delete this node?'))%>');">
<input type="hidden" name="token" value="<%=token%>"/>
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<input type="hidden" name="idx" value="<%=n.__idx%>"/>
<input type="hidden" name="proto" value="<%=proto%>"/>
<input type="hidden" name="keyword" value="<%=keyword%>"/>
<input type="hidden" name="group" value="<%=group%>"/>
<input type="hidden" name="sort" value="<%=sort_by%>"/>
<input type="hidden" name="proto" value="<%=luci.util.pcdata(proto)%>"/>
<input type="hidden" name="keyword" value="<%=luci.util.pcdata(keyword)%>"/>
<input type="hidden" name="group" value="<%=luci.util.pcdata(group)%>"/>
<input type="hidden" name="sort" value="<%=luci.util.pcdata(sort_by)%>"/>
<% if sort_desc then %><input type="hidden" name="desc" value="1"/><% end %>
<input type="submit" class="cbi-button cbi-button-reset" value="<%:Delete%>"/>
</form>
@@ -160,7 +163,7 @@ function substore_set_group(el) {
alert('<%:Failed to set group%>');
}
};
xhr.send('id=' + encodeURIComponent('<%=id%>')
xhr.send('id=' + encodeURIComponent(<%=js_id%>)
+ '&idx=' + encodeURIComponent(el.getAttribute('data-idx'))
+ '&group=' + encodeURIComponent(el.value)
+ '&token=' + encodeURIComponent('<%=token%>'));
@@ -23,13 +23,13 @@
end
local out, err = output.generate(nodes, fmt)
%>
<h2><%:Output%> — <%=meta and meta.name or luci.i18n.translate("Merged")%></h2>
<h2><%:Output%> — <%=luci.util.pcdata(meta and meta.name or luci.i18n.translate("Merged"))%></h2>
<div style="margin:0.5em 0">
<a class="cbi-button cbi-button-reset" href="<%=luci.dispatcher.build_url('admin/services/substore/list')%>"><%:Back%></a>
</div>
<form method="get" action="<%=luci.dispatcher.build_url('admin/services/substore/output')%>" class="cbi-map">
<input type="hidden" name="id" value="<%=id%>"/>
<input type="hidden" name="id" value="<%=luci.util.pcdata(id)%>"/>
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
<label><%:Format%>:
<select name="fmt">
@@ -41,7 +41,7 @@
</form>
<% if err then %>
<div style="color:#cc0000"><%=err%></div>
<div style="color:#cc0000"><%=luci.util.pcdata(err)%></div>
<% else %>
<textarea id="out_text" readonly style="width:100%;height:300px;font-family:monospace"><%=luci.util.pcdata(out)%></textarea>
<div style="margin-top:0.5em">
@@ -1,6 +1,17 @@
<%+header%>
<h2><%:Subscriptions%></h2>
<%
-- §18:控制器把失败原因放在 ?err= 上带回来,这里渲染成可见的提示。
-- pcdata 转义:err 可能包含用户提交的内容,不能直接当 HTML 输出。
local flash_err = require("luci.http").formvalue("err")
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)%>
</div>
<% end %>
<div class="cbi-section">
<a class="cbi-button cbi-button-add" href="<%=luci.dispatcher.build_url('admin/services/substore/form')%>">
<%:Add subscription link%>
@@ -53,9 +64,9 @@
<tr class="cbi-section-table-row">
<td>
<% if it.combo then %>
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=it.name%>
<span class="cbi-value-description" style="color:#0066aa">[<%:Combination%>]</span> <%=luci.util.pcdata(it.name or "")%>
<% else %>
<%=it.name%>
<%=luci.util.pcdata(it.name or "")%>
<% end %>
<% if it.cron_enable == true or it.cron_enable == "1" then %>
<div class="cbi-value-description">定时: <%=luci.util.pcdata(it.cron_time or "")%></div>
@@ -67,16 +78,16 @@
if type(it.sources) == "table" then
for _, sid in ipairs(it.sources) do names[#names + 1] = name_by_id[sid] or sid end
end
%><span style="color:#808080"><%=table.concat(names, " + ")%></span><%
%><span style="color:#808080"><%=luci.util.pcdata(table.concat(names, " + "))%></span><%
elseif it["local"] then
%><span style="color:#0066aa">[<%:Local%>]</span><%
else
%><%=it.url%><%
%><%=luci.util.pcdata(it.url or "")%><%
end
%></td>
<td><%=it.node_count or 0%></td>
<td><%=it.last_update and os.date("%Y-%m-%d %H:%M", it.last_update) or "-"%></td>
<td style="color:<%=status_color%>"><%=status_text%></td>
<td style="color:<%=status_color%>"><%=luci.util.pcdata(status_text or "")%></td>
<td>
<div style="display:flex;gap:0.25em;align-items:center">
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
local M = {}
M.version = "2.1.3"
M.version = "2.5.0"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
@@ -243,13 +243,14 @@ function M.sync(id)
log("Sync local subscription")
local content = meta.raw_content or ""
if content == "" then
M.save_meta(id, { error = "本地订阅内容为空", node_count = 0, last_update = os.time() })
-- 失败路径一律不改 node_count:磁盘上的旧节点仍在,订阅链接仍在下发(§35)
M.save_meta(id, { error = "本地订阅内容为空", last_update = os.time() })
return nil, "本地订阅内容为空"
end
local res, perr = parser.parse_local(content, meta.local_mode or "text")
if not res or not res.nodes then
log("Parse local fail: " .. tostring(perr))
M.save_meta(id, { error = perr or "本地解析失败", node_count = 0, last_update = os.time() })
M.save_meta(id, { error = perr or "本地解析失败", last_update = os.time() })
return nil, perr or "本地解析失败"
end
log("Parse local ok nodes="..#res.nodes)
@@ -267,23 +268,29 @@ function M.sync(id)
end
if not meta.url or meta.url == "" then log("Sync fail: no URL"); return nil, "无订阅 URL" end
-- 订阅代理:开启且代理地址有效时,通过代理下载订阅
-- 订阅代理:开启时代理地址必须有效。无效就明确失败——
-- 静默直连会让用户以为流量走了代理,属于必须避免的 silent fallback(§12)。
local proxy = ""
if meta.proxy_enable == true or meta.proxy_enable == "1" then
local p, perr = http.parse_proxy(meta.proxy or "")
if p and p ~= "" then
proxy = p
elseif perr then
log("Proxy ignored: " .. tostring(perr))
if not p or p == "" then
local msg = perr or "代理地址为空"
log("Proxy invalid: " .. tostring(msg))
M.save_meta(id, { error = "代理配置无效: " .. tostring(msg), last_update = os.time() })
return nil, "代理配置无效: " .. tostring(msg)
end
proxy = p
end
if proxy ~= "" then log("Using proxy " .. proxy) end
-- 日志不记录代理凭据(§39)
if proxy ~= "" then log("Using proxy " .. http.redact_proxy(proxy)) end
local content, headers, err = http.download(meta.url, { max_size = M.MAX_SIZE, timeout = M.TIMEOUT, proxy = proxy })
if not content then
log("Download fail: " .. tostring(err))
M.save_meta(id, { error = err, last_update = os.time() })
return nil, err
-- 下载工具的报错可能回显含凭据的 URL,写日志与入库前先抹掉(§39)
local safe_err = http.scrub_credentials(err or "下载失败")
log("Download fail: " .. safe_err)
M.save_meta(id, { error = safe_err, last_update = os.time() })
return nil, safe_err
end
log("Download ok size="..#content)
@@ -293,7 +300,7 @@ function M.sync(id)
local res, perr = parser.parse(content)
if not res or not res.nodes then
log("Parse fail: " .. tostring(perr))
M.save_meta(id, { error = perr or "解析失败", node_count = 0, last_update = os.time() })
M.save_meta(id, { error = perr or "解析失败", last_update = os.time() })
return nil, perr or "解析失败"
end
log("Parse ok nodes="..#res.nodes)
@@ -200,12 +200,52 @@ local function resolve_url(base, loc)
if loc:find("://", 1, true) then return loc end
local scheme, host = base:match("^([%w]+)://([^/]+)")
if not scheme then return loc end
-- 协议相对地址(//host/path):沿用 scheme,但主机取自 Location 本身,
-- 否则会被误当成同主机的路径而漏掉跨主机跳转
if loc:sub(1, 2) == "//" then return scheme .. ":" .. loc end
if loc:sub(1, 1) == "/" then return scheme .. "://" .. host .. loc end
local base_path = base:match("^[%w]+://[^/]+(.*)$") or "/"
local dir = base_path:match("^(.*)/[^/]*$") or ""
return scheme .. "://" .. host .. dir .. "/" .. loc
end
-- 从 wget -S 日志中按出现顺序提取重定向目标(Location)
local function locations_from_log(raw)
local locs = {}
for line in (raw or ""):gmatch("[^\r\n]+") do
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
if v then locs[#locs + 1] = util.trim(v) end
end
return locs
end
-- 复检重定向链:每一跳都必须通过 check_public。
-- busybox wget 没有 --max-redirect,无法在发出请求前拦住重定向,只能在 -S 日志里
-- 逐跳校验;任一跳不安全即整体失败并丢弃响应体(§14/§16)。
-- 纯函数,不触网,便于离线测试。返回 ok, err
function M.validate_redirect_chain(base_url, log)
for _, loc in ipairs(locations_from_log(log)) do
local next_url = resolve_url(base_url, loc)
local np = M.parse_url(next_url)
if not np then return false, "重定向目标无效: " .. tostring(loc) end
local ok, re = M.check_public(np.host)
if not ok then return false, "重定向目标不安全: " .. (re or "") end
end
return true
end
-- 代理地址去凭据,用于日志(§39:不记录代理密码)
function M.redact_proxy(p)
local scheme, rest = tostring(p or ""):match("^(%a[%w]*)://(.*)$")
if not scheme then return "***" end
return scheme .. "://" .. (rest:gsub("^[^@]*@", ""))
end
-- 抹掉文本中的 URL 凭据(//user:pass@),用于日志与错误信息(§39)
function M.scrub_credentials(text)
return (tostring(text or ""):gsub("//([^%s/@:]*)%:([^%s/@]*)@", "//***@"))
end
local function fetch_curl(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_arg = ""
@@ -253,17 +293,38 @@ local function fetch_curl(url, parsed, opts)
return nil, "重定向次数过多"
end
-- wget 后端的代理环境变量。busybox wget 只能通过 http_proxy/https_proxy 环境变量
-- 使用 http(s) 代理;遇到它不支持的协议(socks*)必须明确报错,
-- 不能丢掉代理静默直连(§12)。返回 env 或 nil, err
function M.wget_proxy_env(proxy)
if proxy == nil or proxy == "" then return "" end
local scheme = tostring(proxy):match("^(%a[%w]*)://")
scheme = scheme and scheme:lower() or ""
if scheme ~= "http" and scheme ~= "https" then
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
" 代理,请安装 curl 或改用 http 代理"
end
return string.format("http_proxy=%q https_proxy=%q ", proxy, proxy)
end
local function fetch_wget(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
if not proxy_env then return nil, perr end
local tmp = "/tmp/substore_dl_wget.tmp"
os.remove(tmp)
-- 仅 http/https 代理可用环境变量传递(busybox wget 不支持 socks 代理)
local proxy_env = ""
if opts.proxy and opts.proxy ~= "" and opts.proxy:match("^https?://") then
proxy_env = string.format("http_proxy=%q https_proxy=%q ", opts.proxy, opts.proxy)
end
local cmd = proxy_env .. string.format("wget -q -T %d -O %q %q 2>/dev/null", t, tmp, url)
local log = tmp .. ".log"
os.remove(tmp); os.remove(log)
-- -S 打印响应头(含整条重定向链),据此逐跳复检 SSRF;
-- 日志与响应体分流:体写 tmp,链写 log
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %q %q >%q 2>&1", t, tmp, url, log)
os.execute(cmd)
local logtext = util.read_file(log) or ""
os.remove(log)
local safe, reason = M.validate_redirect_chain(url, logtext)
if not safe then
os.remove(tmp)
return nil, reason
end
local size = util.file_size(tmp)
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
@@ -112,12 +112,23 @@ function M.filter(nodes, opts)
return out
end
-- 去重:按 proto+server+port 唯一
-- WireGuard peer 公钥。字段名以 parser 产出的 "public-key" 为准,
-- 其余为历史/导入别名(output_wireguard_conf 同样接受这几种写法)
local function wg_public_key(n)
return n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key
end
-- 去重:按 proto+server+port 唯一。
-- WireGuard/AmneziaWG 例外:同一个 endpoint 上不同 peer 公钥是**不同**的节点,
-- 只按 server+port 去重会把它们错误合并(§32/§43),因此把公钥并入去重键。
function M.dedup(nodes)
local seen = {}
local out = {}
for _, n in ipairs(nodes) do
local key = (n.proto or "") .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "")
if n.proto == "wireguard" or n.proto == "wg" then
key = key .. "|" .. tostring(wg_public_key(n) or "")
end
if not seen[key] then
seen[key] = true
out[#out + 1] = n
@@ -11,9 +11,21 @@ local function is_wireguard(n)
return p == "wireguard" or p == "wg"
end
-- amnezia-wg-option 键 → .conf 键:首字母大写(jc → Jc,jmin → Jmin,itime → Itime)
-- amnezia-wg-option 子键 → .conf 键。
-- 只输出能**确定**名字的键:多词键并不是把首字母大写就能得到 .conf 名
-- (header-protection-key 的正确写法是 HeaderProtectionKey,不是 Header-protection-key),
-- 靠规则猜出来的名字会被 AmneziaWG 客户端当成未知键,可能整份配置被拒。
-- 因此用显式映射,映射不到的键一律不输出(§110:不输出未经验证的 AWG 字段)。
local AWG_CONF_KEY_MAP = {
jc = "Jc", jmin = "Jmin", jmax = "Jmax",
s1 = "S1", s2 = "S2", s3 = "S3", s4 = "S4",
h1 = "H1", h2 = "H2", h3 = "H3", h4 = "H4",
i1 = "I1", i2 = "I2", i3 = "I3", i4 = "I4", i5 = "I5",
j1 = "J1", j2 = "J2", j3 = "J3", itime = "Itime",
}
local function conf_key(k)
return (k:gsub("^%l", string.upper))
return AWG_CONF_KEY_MAP[k]
end
-- 值可能是标量或数组,统一转为 "a, b, c"
@@ -60,14 +72,18 @@ local function build_section(n)
put("MTU", n.mtu)
put("DNS", join_list(n.dns))
-- AmneziaWG 参数:按 .conf 键名排序输出,保证可 diff
-- AmneziaWG 参数:按 .conf 键名排序输出,保证可 diff;
-- 无法确定 .conf 名的键直接跳过(见 AWG_CONF_KEY_MAP 说明)
local opt = n["amnezia-wg-option"]
if type(opt) == "table" then
local keys = {}
for k in pairs(opt) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do
put(conf_key(k), tostring(opt[k]))
local mapped = {}
for k, v in pairs(opt) do
local ck = conf_key(k)
if ck then mapped[#mapped + 1] = { ck, v } end
end
table.sort(mapped, function(x, y) return x[1] < y[1] end)
for _, kv in ipairs(mapped) do
put(kv[1], tostring(kv[2]))
end
end
@@ -85,23 +101,30 @@ local function build_section(n)
end
-- nodes → wg-quick .conf 文本
-- 注意:wg-quick / AmneziaWG 客户端一个文件导入一条隧道。单节点时输出即为标准单接口
-- .conf;多节点时各段以 "# <名称>" 注释分隔,便于查看与手工拆分。
-- §55/§56:一个 .conf 文件 = 一个 [Interface]。wg-quick / AmneziaWG 客户端按「单条隧道」
-- 导入,把多个 [Interface] 段拼进同一个文件会得到客户端无法导入(或只取首段)的畸形配置,
-- 因此多于一个 WireGuard 节点时明确报错,而不是静默拼接。
function M.generate(nodes, options)
local blocks = {}
local wg = {}
for _, n in ipairs(nodes or {}) do
if type(n) == "table" and is_wireguard(n) then
local sec = build_section(n)
local named = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
for _, line in ipairs(sec) do named[#named + 1] = line end
blocks[#blocks + 1] = table.concat(named, "\n")
wg[#wg + 1] = n
end
end
if #blocks == 0 then
if #wg == 0 then
-- 明确报错优于返回空文件:用户能知道是"没有 WireGuard 节点"而非订阅坏了
return nil, "没有可导出的 WireGuard 节点"
end
return table.concat(blocks, "\n\n") .. "\n"
if #wg > 1 then
return nil, string.format(
"WireGuard .conf 每个文件只能包含一条隧道,当前有 %d 个 WireGuard 节点;请只导出单个节点",
#wg)
end
local n = wg[1]
local lines = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
for _, line in ipairs(build_section(n)) do lines[#lines + 1] = line end
return table.concat(lines, "\n") .. "\n"
end
return M
@@ -0,0 +1,159 @@
-- controller_local_test.lua — 控制器错误反馈回归测试(§18/§19)
-- 用法:lua5.1 tests/controller_local_test.lua
--
-- 不需要真实 LuCI:用最小 stub 提供 luci.http / luci.dispatcher / luci.util,
-- 把 substore.core 换成可控的假实现,然后直接调用控制器的 action_* 函数,
-- 断言「失败必须体现在 redirect 的 ?err= 上」,而不是静默跳回列表页。
package.path = "./root/usr/share/?.lua;" .. package.path
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
-- ---------- luci stub ----------
local FORM = {} -- formvalue 数据
local LAST_REDIRECT = nil
local URLENC = {}
_G.luci = {
http = {
formvalue = function(k) return FORM[k] end,
redirect = function(url) LAST_REDIRECT = url end,
},
dispatcher = {
build_url = function(...)
local t = { ... }
return "/" .. table.concat(t, "/")
end,
},
util = {
urlencode = function(s)
s = tostring(s or "")
URLENC[#URLENC + 1] = s
return (s:gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end,
pcdata = function(s) return tostring(s or "") end,
},
i18n = { translate = function(s) return s end },
}
_G.luci.controller = { admin = {} }
-- 控制器内部用的是 require("luci.http") 而非全局,因此同时登记进 package.loaded
package.loaded["luci.http"] = _G.luci.http
package.loaded["luci.dispatcher"] = _G.luci.dispatcher
package.loaded["luci.util"] = _G.luci.util
package.loaded["luci.i18n"] = _G.luci.i18n
-- ---------- substore.core stub ----------
local CORE_RESULT = { add_local = { "s00000001" }, save_meta = { true }, sync = { true } }
local SYNC_CALLS = 0
package.loaded["substore.core"] = {
add_local = function() return CORE_RESULT.add_local[1], CORE_RESULT.add_local[2] end,
save_meta = function() return CORE_RESULT.save_meta[1], CORE_RESULT.save_meta[2] end,
sync = function() SYNC_CALLS = SYNC_CALLS + 1; return CORE_RESULT.sync[1], CORE_RESULT.sync[2] end,
write_cron = function() end,
cron_time_valid = function() return true end,
}
-- 载入控制器(module() 会在 package.loaded 里建表,不需要真实 luci 模块)
-- 路径可用 SUBSTORE_CONTROLLER 覆盖,便于对「修复前」的副本跑同一套断言
local CTL_PATH = os.getenv("SUBSTORE_CONTROLLER")
or "root/usr/lib/lua/luci/controller/admin/substore.lua"
local ok, err = pcall(dofile, CTL_PATH)
check("controller loads under stub", ok)
if not ok then print(" load error: " .. tostring(err)) end
local ctl = package.loaded["luci.controller.admin.substore"] or {}
local function reset()
FORM = {}
LAST_REDIRECT = nil
CORE_RESULT.add_local = { "s00000001" }
CORE_RESULT.save_meta = { true }
CORE_RESULT.sync = { true }
SYNC_CALLS = 0
end
local function has_err()
return type(LAST_REDIRECT) == "string" and LAST_REDIRECT:find("err=", 1, true) ~= nil
end
-- ---------- §19 空名称 / 空内容 ----------
reset()
FORM = { token = "t", name = "", content = "vmess://x" }
ctl.action_local_create()
check("local_create empty name redirects", type(LAST_REDIRECT) == "string")
check("local_create empty name reports error", has_err())
check("local_create empty name does not create", CORE_RESULT.add_local[1] == "s00000001" and SYNC_CALLS == 0)
reset()
FORM = { token = "t", name = "test", content = "" }
ctl.action_local_create()
check("local_create empty content reports error", has_err())
reset()
FORM = { token = "t", name = "", content = "" }
ctl.action_local_create()
check("local_create both empty reports error", has_err())
-- ---------- §18 core 失败必须传到 UI ----------
reset()
CORE_RESULT.add_local = { nil, "写入失败" }
FORM = { token = "t", name = "ok", content = "vmess://x" }
ctl.action_local_create()
check("local_create core failure redirects", type(LAST_REDIRECT) == "string")
check("local_create core failure reports error", has_err())
check("local_create core failure surfaces reason",
LAST_REDIRECT:find("err=", 1, true) ~= nil and URLENC[#URLENC] == "写入失败")
-- local_save: save_meta 失败
reset()
CORE_RESULT.save_meta = { nil, "订阅不存在" }
FORM = { token = "t", id = "s00000001", name = "ok", content = "vmess://x" }
ctl.action_local_save()
check("local_save save failure reports error", has_err())
check("local_save save failure surfaces reason", URLENC[#URLENC] == "订阅不存在")
check("local_save save failure skips sync", SYNC_CALLS == 0)
-- local_save: sync 失败(解析失败)
reset()
CORE_RESULT.sync = { nil, "无法识别的订阅格式" }
FORM = { token = "t", id = "s00000001", name = "ok", content = "garbage" }
ctl.action_local_save()
check("local_save sync failure reports error", has_err())
check("local_save sync failure surfaces reason", URLENC[#URLENC] == "无法识别的订阅格式")
check("local_save sync was attempted", SYNC_CALLS == 1)
-- local_save: 缺 id
reset()
FORM = { token = "t", id = "", name = "ok", content = "vmess://x" }
ctl.action_local_save()
check("local_save missing id reports error", has_err())
-- ---------- 成功路径:不应带 err ----------
reset()
FORM = { token = "t", name = "ok", content = "vmess://x" }
ctl.action_local_create()
check("local_create success redirects", type(LAST_REDIRECT) == "string")
check("local_create success has no err", not has_err())
reset()
FORM = { token = "t", id = "s00000001", name = "ok", content = "vmess://x" }
ctl.action_local_save()
check("local_save success has no err", not has_err())
check("local_save success ran sync", SYNC_CALLS == 1)
-- ---------- 无 CSRF token:不做任何事,也不报错 ----------
reset()
FORM = { name = "", content = "" }
ctl.action_local_create()
check("no token still redirects", type(LAST_REDIRECT) == "string")
check("no token no error banner", not has_err())
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# cron_result_test.sh — 回归测试:substore-cron.sh 必须正确区分三层结果
# (a) core.sync 抛异常
# (b) core.sync 返回 nil, err ← pcall 第一层仍为 true(修复前被统计成成功)
# (c) core.sync 返回节点数(成功,含 0)
#
# 通过桩 core 模块 + SUBSTORE_DATA_DIR 覆盖驱动真实脚本,不依赖公网。
# 用法:sh tests/cron_result_test.sh
set -u
REPO=$(cd "$(dirname "$0")/.." && pwd)
# 允许指向别的副本,用于验证本测试确实能捕获修复前的行为
SCRIPT="${SUBSTORE_CRON_SCRIPT:-$REPO/root/usr/bin/substore-cron.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/stub/substore" "$TMP/data"
: > "$TMP/data/subscriptions.json"
pass=0
fail=0
check() {
if [ "$2" = "$3" ]; then
pass=$((pass + 1))
echo "PASS $1"
else
fail=$((fail + 1))
echo "FAIL $1 (want '$3', got '$2')"
fi
}
contains() {
case "$2" in
*"$3"*) return 0 ;;
*) return 1 ;;
esac
}
run_case() {
# $1 = M.sync 的 Lua 实现体
cat >"$TMP/stub/substore/core.lua" <<EOF
local M = {}
M.list = function() return { { id = "s00000001", enabled = true } } end
M.sync = function(id) $1 end
return M
EOF
SUBSTORE_DATA_DIR="$TMP/data" LUA_PATH="$TMP/stub/?.lua" \
sh "$SCRIPT" 2>/dev/null
}
# (a) 抛异常 → 失败
OUT=$(run_case 'error("boom")' | tail -1)
check "exception counted as failure" "$OUT" "substore cron done: 0 ok, 1 failed"
# (b) 返回 nil, err → 失败(这是修复的核心:pcall 第一层为 true)
OUT=$(run_case 'return nil, "下载失败"' | tail -1)
check "nil,err counted as failure" "$OUT" "substore cron done: 0 ok, 1 failed"
# (b2) 失败原因必须被打印出来
OUT=$(run_case 'return nil, "下载失败"')
if contains "failure reason surfaced" "$OUT" "下载失败"; then
pass=$((pass + 1))
echo "PASS failure reason surfaced"
else
fail=$((fail + 1))
echo "FAIL failure reason surfaced"
fi
# (c) 成功返回节点数 → 成功
OUT=$(run_case 'return 3' | tail -1)
check "node count counted as success" "$OUT" "substore cron done: 1 ok, 0 failed"
# (c2) 成功但 0 节点 → 仍然是成功(0 在 Lua 中为真)
OUT=$(run_case 'return 0' | tail -1)
check "zero nodes still success" "$OUT" "substore cron done: 1 ok, 0 failed"
echo ""
echo "$pass passed, $fail failed"
[ "$fail" -eq 0 ] || exit 1
exit 0
+105
View File
@@ -31,5 +31,110 @@ check("injection userinfo", http.parse_proxy("http://user;ls@192.168.5.1:1080")
local _, e = http.parse_proxy("ftp://x:80")
check("err is string", type(e) == "string")
-- ---------- wget 后端代理能力(§12 禁止 silent fallback) ----------
-- busybox wget 只能通过 http_proxy/https_proxy 使用 http(s) 代理;
-- socks* 必须明确报错,而不是丢掉代理直连。
check("wget env empty proxy", http.wget_proxy_env("") == "")
check("wget env nil proxy", http.wget_proxy_env(nil) == "")
local env_http = http.wget_proxy_env("http://192.168.5.1:8080")
check("wget env http proxy", type(env_http) == "string" and env_http:find("http_proxy=", 1, true) ~= nil)
local env_https = http.wget_proxy_env("https://proxy.example.com:8443")
check("wget env https proxy", type(env_https) == "string" and env_https:find("http_proxy=", 1, true) ~= nil)
local e5, m5 = http.wget_proxy_env("socks5://192.168.5.1:1080")
check("wget rejects socks5 (no silent direct)", e5 == nil)
check("wget socks5 err mentions SOCKS5", type(m5) == "string" and m5:find("SOCKS5", 1, true) ~= nil)
local e4 = http.wget_proxy_env("socks4://192.168.5.1:1080")
check("wget rejects socks4", e4 == nil)
local eh = http.wget_proxy_env("socks5h://192.168.5.1:1080")
check("wget rejects socks5h", eh == nil)
-- ---------- 日志脱敏(§39) ----------
check("redact proxy strips credentials",
http.redact_proxy("http://user:pass@192.168.5.1:8080") == "http://192.168.5.1:8080")
check("redact proxy keeps credentials-free addr",
http.redact_proxy("socks5://192.168.5.1:1080") == "socks5://192.168.5.1:1080")
check("redact proxy handles garbage", http.redact_proxy("nonsense") == "***")
check("redact proxy handles nil", http.redact_proxy(nil) == "***")
local scrubbed = http.scrub_credentials("curl: (7) failed http://user:pw@example.com/sub?token=abc")
check("scrub removes url credentials", scrubbed:find("user:pw", 1, true) == nil)
check("scrub keeps the rest", scrubbed:find("example.com", 1, true) ~= nil)
check("scrub leaves clean text alone",
http.scrub_credentials("HTTP 错误 404") == "HTTP 错误 404")
-- ---------- 重定向链复检(§14/§16) ----------
-- 纯函数,不触网:日志文本按 wget -S 的真实输出格式构造
local function chain(...)
local lines = {}
for _, loc in ipairs({ ... }) do
lines[#lines + 1] = " HTTP/1.1 302 Found"
lines[#lines + 1] = " Location: " .. loc
end
lines[#lines + 1] = " HTTP/1.1 200 OK"
return table.concat(lines, "\n")
end
local BASE = "http://sub.example.com/api/sub"
check("chain public -> public allowed",
http.validate_redirect_chain(BASE, chain("http://cdn.example.com/a")) == true)
check("chain no redirect allowed",
http.validate_redirect_chain(BASE, " HTTP/1.1 200 OK") == true)
check("chain relative location allowed",
http.validate_redirect_chain(BASE, chain("/other/path")) == true)
-- 公网 → 内网:必须拒绝
local ok1, r1 = http.validate_redirect_chain(BASE, chain("http://127.0.0.1:8080/evil"))
check("chain to 127.0.0.1 rejected", ok1 == false)
check("chain 127.0.0.1 reason set", type(r1) == "string" and #r1 > 0)
check("chain to localhost rejected",
http.validate_redirect_chain(BASE, chain("http://localhost/evil")) == false)
check("chain to 192.168.1.1 rejected",
http.validate_redirect_chain(BASE, chain("http://192.168.1.1/evil")) == false)
check("chain to 10.0.0.1 rejected",
http.validate_redirect_chain(BASE, chain("http://10.0.0.1/evil")) == false)
check("chain to 172.16.0.1 rejected",
http.validate_redirect_chain(BASE, chain("http://172.16.0.1/evil")) == false)
check("chain to 169.254.169.254 rejected",
http.validate_redirect_chain(BASE, chain("http://169.254.169.254/latest/meta-data/")) == false)
check("chain to ipv6 loopback rejected",
http.validate_redirect_chain(BASE, chain("http://[::1]/evil")) == false)
check("chain to ipv6 link-local rejected",
http.validate_redirect_chain(BASE, chain("http://[fe80::1]/evil")) == false)
-- 协议相对地址(//host/path)主机必须取自 Location,不能被当成同主机路径
check("chain protocol-relative to private rejected",
http.validate_redirect_chain(BASE, chain("//127.0.0.1/evil")) == false)
check("chain protocol-relative to public allowed",
http.validate_redirect_chain(BASE, chain("//cdn.example.com/a")) == true)
-- 多跳:只要有一跳不安全就整体拒绝
check("chain multi-hop second hop private rejected",
http.validate_redirect_chain(BASE,
chain("http://cdn.example.com/a", "http://192.168.1.1/b")) == false)
check("chain multi-hop all public allowed",
http.validate_redirect_chain(BASE,
chain("http://cdn.example.com/a", "http://cdn2.example.com/b")) == true)
-- 非法 Location
check("chain invalid location rejected",
http.validate_redirect_chain(BASE, chain("ftp://example.com/x")) == false)
-- 用 busybox wget 1.37 `-S` 的真实输出(逐字节捕获)锁定日志格式,
-- 防止上游改变输出格式后校验静默失效
local REAL_LOG = [[Connecting to 127.0.0.1:18080 (127.0.0.1:18080)
HTTP/1.1 302 Found
Server: BaseHTTP/0.6 Python/3.14.4
Date: Tue, 29 Sep 2026 11:42:19 GMT
Location: http://127.0.0.1:18081/
Connecting to 127.0.0.1:18081 (127.0.0.1:18081)
HTTP/1.1 200 OK
Server: BaseHTTP/0.6 Python/3.14.4
Date: Tue, 29 Sep 2026 11:42:19 GMT
Content-Length: 11
saving to '/tmp/probe_out.bin'
probe_out.bin 100% |********************************| 11 0:00:00 ETA
]]
check("real wget -S log: private hop rejected",
http.validate_redirect_chain(BASE, REAL_LOG) == false)
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -84,5 +84,60 @@ node.add_tags(nodes5, {"vip"})
check("add_tags existing", nodes5[1].tags and #nodes5[1].tags == 2 and nodes5[1].tags[2] == "vip")
check("add_tags new", nodes5[2].tags and #nodes5[2].tags == 1 and nodes5[2].tags[1] == "vip")
-- ---------- dedup ----------
-- 非 WireGuard:行为不变,仍按 proto+server+port
local d0 = node.dedup({
{ proto = "vmess", server = "1.1.1.1", port = 443, uuid = "a" },
{ proto = "vmess", server = "1.1.1.1", port = 443, uuid = "b" },
{ proto = "vless", server = "1.1.1.1", port = 443, uuid = "c" },
})
check("dedup keeps first of identical vmess", #d0 == 2)
check("dedup treats different proto as distinct", d0[2].proto == "vless")
-- WireGuard(§32/§43):同 server+port 但 peer 公钥不同 → 不能合并
local d1 = node.dedup({
{ proto = "wireguard", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_A" },
{ proto = "wireguard", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_B" },
})
check("wg dedup keeps different public keys", #d1 == 2)
check("wg dedup first key kept", d1[1]["public-key"] == "KEY_A")
check("wg dedup second key kept", d1[2]["public-key"] == "KEY_B")
-- WireGuard:公钥相同 → 仍应去重
local d2 = node.dedup({
{ proto = "wireguard", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_A" },
{ proto = "wireguard", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_A" },
})
check("wg dedup removes identical peer", #d2 == 1)
-- WireGuard:公钥别名(导入/历史写法)同样参与去重键
local d3 = node.dedup({
{ proto = "wireguard", server = "wg.example.com", port = 51820, public_key = "KEY_A" },
{ proto = "wireguard", server = "wg.example.com", port = 51820, ["peer-public-key"] = "KEY_A" },
{ proto = "wireguard", server = "wg.example.com", port = 51820, peer_public_key = "KEY_B" },
})
check("wg dedup honours public_key aliases", #d3 == 2)
-- proto 别名 "wg"
local d4 = node.dedup({
{ proto = "wg", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_A" },
{ proto = "wg", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_B" },
})
check("wg alias proto keeps different peers", #d4 == 2)
-- 双方都没有公钥:退化为 server+port 去重(保持旧行为)
local d5 = node.dedup({
{ proto = "wireguard", server = "wg.example.com", port = 51820 },
{ proto = "wireguard", server = "wg.example.com", port = 51820 },
})
check("wg dedup without keys falls back to endpoint", #d5 == 1)
-- 不同端口仍是不同节点
local d6 = node.dedup({
{ proto = "wireguard", server = "wg.example.com", port = 51820, ["public-key"] = "KEY_A" },
{ proto = "wireguard", server = "wg.example.com", port = 51821, ["public-key"] = "KEY_A" },
})
check("wg dedup keeps different ports", #d6 == 2)
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -0,0 +1,157 @@
-- template_escape_test.lua — 模板转义回归测试(§38)
-- 用法:lua5.1 tests/template_escape_test.lua
--
-- 两件事:
-- 1) 语法:把模板按 LuCI 的方式重建成一个 Lua chunk 并 loadstring,
-- 确保 <% %> 里的代码没有语法错误(本机没有 LuCI,跑不了真实模板)。
-- 2) 转义:找出所有 <% %> 输出表达式,凡是包含「用户可控」变量的,
-- 必须经过 pcdata / urlencode / json_encode / build_url 之一,
-- 否则就是 §38 的注入点。
package.path = "./root/usr/share/?.lua;" .. package.path
local VIEW_DIR = os.getenv("SUBSTORE_VIEW_DIR") or "root/usr/lib/lua/luci/view/substore"
local TEMPLATES = {
"subscriptions.htm", "nodes.htm", "form.htm", "local_form.htm",
"combo.htm", "node_edit.htm", "output.htm",
}
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_file(p)
local f = io.open(p, "rb")
if not f then return nil end
local s = f:read("*a")
f:close()
return s
end
-- 用户可控(或源自订阅内容)的变量,出现在输出位置就必须转义
local TAINTED_NAMES = {
"it.name", "it.url", "it.proxy", "it.error",
"meta.name", "n.name", "n.server", "n.proto",
"keyword", "group", "sort_by", "proto",
"flash_err", "err", "content", "ki", "ke", "local_mode",
}
local SAFE_WRAPPERS = { "pcdata", "urlencode", "json_encode", "build_url", "tostring" }
-- 词边界匹配:避免 "ke" 命中 "token"、"keyword" 命中 "keyword_include"
local function taint_pattern(name)
local esc = name:gsub("[%p]", "%%%0")
return "%f[%w_]" .. esc .. "%f[^%w_]"
end
-- 去掉字符串字面量与比较表达式后再找污染变量:
-- `dedup and "checked" or ""`、`local_mode=="text" and "x" or ""` 这类
-- 只输出静态字符串的写法不是注入点。
-- 注意:Lua pattern 没有 | 交替,且必须先去掉比较、再去字面量,
-- 否则 `x=="lit"` 会先变成 `x==""` 而漏掉。
local function strip_static(e)
local VAR = "[%w_%.%[%]]+"
e = e:gsub(VAR .. '%s*[=~]=%s*"[^"]*"', " ")
e = e:gsub(VAR .. "%s*[=~]=%s*" .. VAR, " ")
e = e:gsub('"[^"]*"%s*[=~]=%s*' .. VAR, " ")
e = e:gsub('"[^"]*"', '""')
e = e:gsub("'[^']*'", "''")
return e
end
local function is_tainted(e)
e = strip_static(e)
for _, v in ipairs(TAINTED_NAMES) do
if e:find(taint_pattern(v)) then return true, v end
end
return false
end
-- 把模板重建成可 loadstring 的 Lua chunk(LuCI 的做法:文本变 __w,代码原样)
local function build_chunk(src)
local out = { "local __w = function() end" }
local i = 1
local n = #src
while i <= n do
local s = src:find("<%", i, true)
if not s then break end
local e = src:find("%>", s + 2, true)
if not e then return nil, "unclosed <% at " .. s end
local tag = src:sub(s + 2, e - 1)
local kind = tag:sub(1, 1)
local body = tag:sub(2)
if kind == "=" then
out[#out + 1] = "__w(" .. body .. ")"
elseif kind == ":" then
-- <%:文本%> 等价于 translate("文本"),内容是字符串字面量而非代码
out[#out + 1] = "__w(" .. string.format("%q", body) .. ")"
elseif kind == "+" then
out[#out + 1] = "__w()"
else
out[#out + 1] = tag
end
out[#out + 1] = "\n" -- 保留行数,便于报错定位
i = e + 2
end
return table.concat(out, "\n")
end
-- 收集所有输出表达式 <% %> 与 <%= %>
local function outputs(src)
local list = {}
local i = 1
while true do
local s = src:find("<%", i, true)
if not s then break end
local e = src:find("%>", s + 2, true)
if not e then break end
local tag = src:sub(s + 2, e - 1)
if tag:sub(1, 1) == "=" then
local line = 1
for _ in src:sub(1, s):gmatch("\n") do line = line + 1 end
list[#list + 1] = { expr = tag:sub(2), line = line }
end
i = e + 2
end
return list
end
for _, t in ipairs(TEMPLATES) do
local path = VIEW_DIR .. "/" .. t
local src = read_file(path)
check("read " .. t, src ~= nil)
if src then
-- 1) 语法
local chunk, cerr = build_chunk(src)
if not chunk then
check(t .. " template structure", false)
print(" " .. tostring(cerr))
else
local fn, lerr = loadstring(chunk, t)
check(t .. " compiles", fn ~= nil)
if not fn then print(" " .. tostring(lerr)) end
end
-- 2) 转义
local bad = {}
for _, o in ipairs(outputs(src)) do
local e = o.expr
local tainted, which = is_tainted(e)
if tainted then
local wrapped = false
for _, w in ipairs(SAFE_WRAPPERS) do
if e:find(w, 1, true) then wrapped = true; break end
end
if not wrapped then
bad[#bad + 1] = string.format("%s:%d <%%=%s%%> (tainted: %s)", t, o.line, e, which)
end
end
end
check(t .. " no unescaped user data", #bad == 0)
for _, b in ipairs(bad) do print(" UNESCAPED " .. b) end
end
end
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
@@ -184,6 +184,93 @@ if type(uri) == "string" then
check("uri roundtrip listen-port", r["listen-port"] == 51820)
end
-- ---------- 导出:wg-quick .conf 单接口约束(§55/§56) ----------
local wgconf = require("substore.output_wireguard_conf")
local c1 = wgconf.generate({ n })
check("wgconf single node returns text", type(c1) == "string")
check("wgconf single node has one Interface", select(2, c1:gsub("%[Interface%]", "")) == 1)
check("wgconf single node has one Peer", select(2, c1:gsub("%[Peer%]", "")) == 1)
-- 多节点:必须明确报错,绝不把多个 [Interface] 拼进一个文件
local multi = wgconf.generate({
{ proto = "wireguard", name = "w1", server = "a.example.com", port = 51820, ["public-key"] = "K1" },
{ proto = "wireguard", name = "w2", server = "b.example.com", port = 51820, ["public-key"] = "K2" },
})
check("wgconf multi node returns nil", multi == nil)
local _, multi_err = wgconf.generate({
{ proto = "wireguard", name = "w1", server = "a.example.com", port = 51820, ["public-key"] = "K1" },
{ proto = "wireguard", name = "w2", server = "b.example.com", port = 51820, ["public-key"] = "K2" },
})
check("wgconf multi node error is string", type(multi_err) == "string")
check("wgconf multi node error names the count", multi_err:find("2", 1, true) ~= nil)
check("wgconf multi node error mentions single tunnel", multi_err:find("一条隧道", 1, true) ~= nil)
-- 无 WireGuard 节点:仍是「没有可导出节点」而非拼接
local none, none_err = wgconf.generate({ { proto = "vmess", server = "x", port = 443 } })
check("wgconf no wg node returns nil", none == nil)
check("wgconf no wg node error", type(none_err) == "string" and none_err:find("没有可导出", 1, true) ~= nil)
local empty, empty_err = wgconf.generate({})
check("wgconf empty list returns nil", empty == nil)
check("wgconf empty list error", type(empty_err) == "string")
-- 单节点 .conf 必须能被自家解析器读回(往返一致)
local rt = parser.parse(c1)
check("wgconf roundtrip parses", rt ~= nil and rt.nodes and #rt.nodes == 1)
if rt and rt.nodes and rt.nodes[1] then
local r = rt.nodes[1]
check("wgconf roundtrip server", r.server == n.server)
check("wgconf roundtrip port", r.port == n.port)
check("wgconf roundtrip public-key", r["public-key"] == n["public-key"])
check("wgconf roundtrip ip", r.ip == n.ip)
end
-- ---------- 导出:AmneziaWG 参数写入 .conf 的键名(§110) ----------
-- 已知键必须写成客户端认得的 .conf 名;无法确定名字的键绝不能靠「首字母大写」
-- 猜一个出来(header-protection-key → Header-protection-key 会被客户端拒)
local awg_out = wgconf.generate({ {
proto = "wireguard", name = "awg", server = "wg.example.com", port = 51820,
["private-key"] = "PRIV", ["public-key"] = "PUB", ip = "10.0.0.1/32",
["allowed-ips"] = "0.0.0.0/0",
["amnezia-wg-option"] = {
jc = 5, jmin = 50, jmax = 1000,
s1 = 86, s2 = 574, h1 = "1000-2000", h4 = "7000-8000", itime = 30,
["header-protection-key"] = "c2VjcmV0",
["random-trailers"] = "on",
},
} })
check("awg conf export returns text", type(awg_out) == "string")
check("awg conf Jc", awg_out and awg_out:find("Jc = 5", 1, true) ~= nil)
check("awg conf Jmin", awg_out and awg_out:find("Jmin = 50", 1, true) ~= nil)
check("awg conf S1", awg_out and awg_out:find("S1 = 86", 1, true) ~= nil)
check("awg conf Itime", awg_out and awg_out:find("Itime = 30", 1, true) ~= nil)
-- range 必须原样保留(不能变成数字)
check("awg conf H1 range kept", awg_out and awg_out:find("H1 = 1000-2000", 1, true) ~= nil)
check("awg conf H4 range kept", awg_out and awg_out:find("H4 = 7000-8000", 1, true) ~= nil)
-- 多词键:宁可不输出,也不能输出错误键名
check("awg conf no mangled Header-protection-key",
awg_out and awg_out:find("Header-protection-key", 1, true) == nil)
check("awg conf no mangled Random-trailers",
awg_out and awg_out:find("Random-trailers", 1, true) == nil)
check("awg conf unmappable value not leaked", awg_out and awg_out:find("c2VjcmV0", 1, true) == nil)
-- 已知键按 .conf 名排序,保证可 diff
local iH1 = awg_out and awg_out:find("H1 = ", 1, true)
local iJc = awg_out and awg_out:find("Jc = ", 1, true)
local iS1 = awg_out and awg_out:find("S1 = ", 1, true)
check("awg conf sorted H1<Jc<S1", iH1 and iJc and iS1 and iH1 < iJc and iJc < iS1)
-- 导出的 .conf 必须能被自家解析器读回(AWG 参数往返一致)
local art = parser.parse(awg_out)
check("awg conf roundtrip parses", art ~= nil and art.nodes and #art.nodes == 1)
if art and art.nodes and art.nodes[1] then
local ao = art.nodes[1]["amnezia-wg-option"] or {}
check("awg conf roundtrip jc", ao.jc == 5)
check("awg conf roundtrip s1", ao.s1 == 86)
check("awg conf roundtrip h1 range is string",
ao.h1 == "1000-2000" and type(ao.h1) == "string")
check("awg conf roundtrip itime", ao.itime == 30)
end
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)