💐 Sync 2026-09-30 01:19:34

This commit is contained in:
github-actions[bot] committed 2026-09-30 01:19:34 +08:00
1 parent 67427d9030
commit a64d55f7c5
50 files changed
+2837 -589

No files matched your search

@@ -17,8 +17,10 @@ for c in lua5.1 lua; do
done
if [ -z "$LUA_BIN" ]; then
# 没有解释器 = 整个订阅更新链路不可用,属于必须暴露的故障,
# 不能静默 exit 0 让 cron 看起来一切正常。
logger -t luci-app-substore "cron: no lua interpreter found"
exit 0
exit 1
fi
[ -f "$LIST_FILE" ] || exit 0
@@ -61,4 +63,12 @@ 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
# 退出码反映本轮结果:有订阅更新失败就返回非 0,让 cron 的 MAILTO / 外部监控
# 能据此判断,而不是无论成败都 exit 0。日志行由上面 Lua 以 %d 格式打印,
# 不存在前导零,故可直接按字面比较。
FAILED=$(printf '%s\n' "$OUT" | sed -n 's/.*: [0-9][0-9]* ok, \([0-9][0-9]*\) failed.*/\1/p' | tail -1)
case "$FAILED" in
''|0) exit 0 ;;
*) exit 1 ;;
esac
@@ -201,13 +201,18 @@ function action_delete()
end
-- 节点页返回链接:保留当前筛选参数
local function back_to_nodes(http)
-- 返回节点页。err 非空时把失败原因带到页面显示(§18:失败必须让用户看见,
-- 不能「失败了却看起来像成功」)。与 back_to_list 同一套约定。
local function back_to_nodes(http, err)
local id = http.formvalue("id") or ""
local qs = "?id=" .. luci.util.urlencode(id)
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
local v = http.formvalue(k)
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
end
if err ~= nil and tostring(err) ~= "" then
qs = qs .. "&err=" .. luci.util.urlencode(tostring(err))
end
http.redirect(luci.dispatcher.build_url("admin", "services", "substore", "nodes") .. qs)
end
@@ -221,16 +226,29 @@ function action_node_save()
local idx = tonumber(http.formvalue("idx") or "")
local content = http.formvalue("content") or ""
local nodes = core.read_nodes(id)
if idx and nodes[idx] and content ~= "" then
local res = parser.parse_local(content, "form")
local newn = res and res.nodes and res.nodes[1]
if newn then
nodes[idx] = core.merge_form_node(nodes[idx], newn)
if core.write_nodes(id, nodes) then
core.refresh_combos(id)
end
end
-- §18:任何一条失败路径都必须把原因带回页面。
-- 原来只在成功分支里做事,其余情况一律静默重定向,
-- 用户提交了坏数据却看到「已保存」的样子。
if not idx or not nodes[idx] then
back_to_nodes(http, "节点不存在或下标无效")
return
end
if content == "" then
back_to_nodes(http, "提交内容为空")
return
end
local res, perr = parser.parse_local(content, "form")
local newn = res and res.nodes and res.nodes[1]
if not newn then
back_to_nodes(http, perr or "表单数据解析失败")
return
end
nodes[idx] = core.merge_form_node(nodes[idx], newn)
if not core.write_nodes(id, nodes) then
back_to_nodes(http, "写入节点数据失败")
return
end
core.refresh_combos(id)
end
back_to_nodes(http)
end
@@ -35,7 +35,7 @@
<% for _, x in ipairs(srcs) do %>
<div style="margin:0.25em 0">
<input type="checkbox" id="src_<%=x.id%>" name="src_<%=x.id%>" value="1" <%=sel[x.id] and "checked" or ""%>/>
<label for="src_<%=x.id%>"><%=x.name%>(<%=x.node_count or 0%>)</label>
<label for="src_<%=x.id%>"><%=luci.util.pcdata(x.name or "")%>(<%=x.node_count or 0%>)</label>
</div>
<% end %>
<% if #srcs == 0 then %>
@@ -36,6 +36,16 @@
nodes = node.sort(nodes, sort_by, sort_desc)
local node_edit_base = luci.dispatcher.build_url('admin/services/substore/node_edit')
%>
<%
-- §18:控制器把失败原因放在 ?err= 上带回来,这里渲染成可见的提示。
-- pcdata 转义:err 可能包含用户提交的内容,不能直接当 HTML 输出。
local flash_err = 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 %>
<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>
@@ -56,7 +66,7 @@
<select name="group">
<option value=""><%:All%></option>
<% for _, g in ipairs(group_list) do %>
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=g%></option>
<option value="<%=luci.util.pcdata(g)%>" <%=group==g and "selected" or ""%>><%=luci.util.pcdata(g)%></option>
<% end %>
</select>
</label>
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
local M = {}
M.version = "2.5.0"
M.version = "2.5.1"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
@@ -171,13 +171,18 @@ function M.generate_link(token, target, opts)
return content, ct, filename, nil
end
-- save_meta 补丁里的「清除」哨兵值。
-- Lua 的 pairs 永远不会产出值为 nil 的键,所以补丁表里写 `k = nil` 等于什么都没写,
-- 调用方无法表达「把这个字段删掉」。需要清除时传 M.CLEAR,save_meta 会还原成 nil。
M.CLEAR = setmetatable({}, { __tostring = function() return "substore.CLEAR" end })
function M.save_meta(id, patch)
if not id_is_valid(id) then return false, "非法 ID" end
local seq, items = load()
local meta = items[id]
if not meta then return false, "订阅不存在" end
for k, v in pairs(patch or {}) do
if v == nil then meta[k] = nil else meta[k] = v end
if v == nil or v == M.CLEAR then meta[k] = nil else meta[k] = v end
end
return save(seq, items)
end
@@ -227,7 +232,7 @@ end
-- 下载并解析订阅,写入节点文件并更新状态。成功返回 node_count,失败返回 nil, err
function M.sync(id)
local log = function(msg) os.execute("logger -t luci-app-substore " .. string.format("%q", msg)) end
local log = function(msg) os.execute("logger -t luci-app-substore " .. util.shq(msg)) end
log("Sync start id="..tostring(id))
local meta = M.get(id)
if not meta then log("Sync fail: subscription not found"); return nil, "订阅不存在" end
@@ -313,7 +318,12 @@ function M.sync(id)
local ok = M.save_meta(id, {
node_count = #nodes, format = res.format, error = "", last_update = os.time(),
upload = ui and ui.upload, download = ui and ui.download, total = ui and ui.total, expire = ui and ui.expire,
-- 机场不再下发 subscription-userinfo 时必须把旧数值清掉,
-- 否则列表页会一直显示早已过期的流量/到期时间。用 M.CLEAR 表达「清除」。
upload = (ui and ui.upload) or M.CLEAR,
download = (ui and ui.download) or M.CLEAR,
total = (ui and ui.total) or M.CLEAR,
expire = (ui and ui.expire) or M.CLEAR,
})
if not ok then return nil, "更新状态失败" end
-- 源订阅更新后,刷新引用它的组合订阅
@@ -10,6 +10,55 @@ M.DEFAULT_TIMEOUT = 20
M.MAX_REDIRECTS = 4
-- ---------- 私网 / 保留地址判断 ----------
-- 单段数值:十进制 / 八进制(前导 0)/ 十六进制(0x 前缀)
-- 返回数值;不是数值写法返回 nil
local function numeral(seg)
if seg:match("^0[xX]%x+$") then return tonumber(seg:sub(3), 16) end
if seg:match("^0%d+$") then return tonumber(seg:sub(2), 8) end
if seg:match("^%d+$") then return tonumber(seg, 10) end
return nil
end
-- 把 inet_aton 接受的各种数值型 IPv4 写法归一为点分十进制。
-- curl / wget 都按 inet_aton 语义解析主机名,因此 SSRF 检查必须用同一套语义,
-- 否则 "0177.0.0.1"(八进制 127.0.0.1)、"2130706433"(十进制)、"0x7f000001"(十六进制)、
-- "127.1"(短写)会被 is_private_ipv4 当成公网地址而放行,实测这几种写法都能连到 127.0.0.1。
-- 段数与点号数必须一致(拒绝 "1..2" 这类畸形写法)。
-- 返回 "a.b.c.d";不是数值型 IPv4 时返回 nil。
local function normalize_ipv4(host)
local dots = select(2, host:gsub("%.", ""))
local parts = {}
for seg in host:gmatch("[^%.]+") do parts[#parts + 1] = seg end
if #parts ~= dots + 1 or #parts > 4 then return nil end
local vals = {}
for i, seg in ipairs(parts) do
local v = numeral(seg)
if not v then return nil end
vals[i] = v
end
-- inet_aton:前 k-1 段各 8 位,最后一段吃掉剩余位宽
local k = #vals
for i = 1, k - 1 do
if vals[i] > 255 then return nil end
end
if vals[k] >= 2 ^ (32 - 8 * (k - 1)) then return nil end
local n = 0
for i, v in ipairs(vals) do
local shift = (i < k) and (32 - 8 * i) or 0
n = n + v * 2 ^ shift
end
local o1 = math.floor(n / 2 ^ 24) % 256
local o2 = math.floor(n / 2 ^ 16) % 256
local o3 = math.floor(n / 2 ^ 8) % 256
local o4 = n % 256
return string.format("%d.%d.%d.%d", o1, o2, o3, o4)
end
local function is_private_ipv4(ip)
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return false end
@@ -60,6 +109,18 @@ local function is_private(ip)
return is_private_ipv4(ip)
end
-- 粗校验:像不像一个 IPv6 字面量。
-- 带 ":" 的主机并不都是 IPv6 —— parse_url 对 "127.0.0.1:" 这类「有冒号但端口为空」
-- 的写法取不到端口,会把整个 "127.0.0.1:" 当主机名返回;它既不是合法域名,
-- is_private_ipv6 也识别不出,于是被当成公网放行。实测 curl 会把它连到 127.0.0.1:80。
-- 因此:含 ":" 但不像 IPv6 的一律拒绝(fail-closed)。
local function looks_like_ipv6(h)
if not h:match("^[%x:%.]+$") then return false end
if select(2, h:gsub(":", "")) < 2 then return false end
if h:find("::.*::") then return false end -- "::" 最多出现一次
return true
end
-- ---------- URL 解析 ----------
function M.parse_url(url)
url = util.trim(url or "")
@@ -67,7 +128,20 @@ function M.parse_url(url)
if not scheme then return nil, "无效 URL" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
local hostport = rest:gsub("/.*$", "")
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
-- 数值型 IPv4,DNS 解析必然失败,而解析失败是放行的(fail-open),
-- 于是绕过 SSRF 检查;curl 实际连的是 127.0.0.1(路由器上的 LuCI 就是 :80)。
local hostport = rest:match("^[^/%?#]*") or ""
-- 剥掉 userinfo(user:pass@):RFC 3986 里 userinfo 到**最后一个** @ 为止,
-- @ 之后才是主机。不剥的话 "http://evil@127.0.0.1/" 会把 "evil@127.0.0.1"
-- 当成主机名交给 check_public,DNS 解析失败即放行(fail-open),
-- 而 curl 实际连的是 127.0.0.1 —— 实测可复现。
local at = hostport:find("@", 1, true)
while at do
hostport = hostport:sub(at + 1)
at = hostport:find("@", 1, true)
end
local host, port
if hostport:sub(1, 1) == "[" then
local close = hostport:find("]", 1, true)
@@ -143,15 +217,50 @@ local function resolve(host)
return ips
end
-- 回环别名(/etc/hosts 常见写法),比较前会先去掉尾部点
local LOOPBACK_NAMES = {
["localhost"] = true,
["localhost.localdomain"] = true,
["localhost4"] = true,
["localhost4.localdomain4"] = true,
["localhost6"] = true,
["localhost6.localdomain6"] = true,
["ip6-localhost"] = true,
["ip6-loopback"] = true,
["ip6-localnet"] = true,
}
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
function M.check_public(host)
host = util.trim(host or "")
host = util.trim(host or ""):lower()
if host == "" then return false, "空主机名" end
if host:match("^%d+%.%d+%.%d+%.%d+$") or host:find(":", 1, true) then
if is_private(host) then return false, "目标为内网/保留地址" end
-- userinfo 应已被 parse_url 剥掉;这里再挡一次。
-- "evil@127.0.0.1" 不是合法主机名,DNS 解析必然失败,而解析失败是放行的
-- (fail-open),于是会绕过检查 —— 实测该写法确实能连到 127.0.0.1。
if host:find("@", 1, true) then return false, "主机名含非法字符 @" end
-- 空白 / 百分号编码:主机名里都不合法("127.0.0.1%00.example.com" 这类
-- 截断写法当前版本的 curl 会直接判 URL 非法,但不同版本行为不一,直接拒绝更稳)
if host:find("[%s%%]") then return false, "主机名含非法字符" end
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
host = host:gsub("%.$", "")
if host == "" then return false, "空主机名" end
if host:find(":", 1, true) then
if not looks_like_ipv6(host) then return false, "无效主机名" end
if is_private_ipv6(host) then return false, "目标为内网/保留地址" end
return true
end
if host:lower() == "localhost" then return false, "目标为 localhost" end
-- 数值型 IPv4(十进制 / 八进制 / 十六进制 / 短写)按 inet_aton 语义归一后再判私网
local norm = normalize_ipv4(host)
if norm then
if is_private_ipv4(norm) then return false, "目标为内网/保留地址" end
return true
end
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
local ips = resolve(host)
if not ips then
-- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为)
@@ -250,17 +359,20 @@ local function fetch_curl(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_arg = ""
if opts.proxy and opts.proxy ~= "" then
proxy_arg = string.format(" -x %q", opts.proxy)
proxy_arg = " -x " .. util.shq(opts.proxy)
end
-- 临时文件名带随机标记:固定路径会让两个并发下载(如两个订阅的 cron 同时触发,
-- 或手动更新撞上 cron)互相覆盖,A 订阅存下 B 的内容且都不报错。
local tag = util.rnd_hex(8)
local cur = url
for redirect = 0, M.MAX_REDIRECTS do
local tmp = "/tmp/substore_dl_" .. redirect .. ".tmp"
local tmp = "/tmp/substore_dl_" .. tag .. "_" .. redirect .. ".tmp"
local hdr = tmp .. ".hdr"
local errf = tmp .. ".err"
os.remove(tmp); os.remove(hdr); os.remove(errf)
local cmd = string.format(
"curl -sS -o %q --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %q -w \"%%{http_code}\"%s %q 2>%q",
tmp, t, math.min(t, 10), max, hdr, proxy_arg, cur, errf)
"curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code}\"%s %s 2>%s",
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf))
local p = io.popen(cmd)
local code = p and p:read("*a") or ""
if p then p:close() end
@@ -268,7 +380,10 @@ local function fetch_curl(url, parsed, opts)
if code == "" or code == "000" then
return nil, util.trim(util.read_file(errf) or "下载失败")
end
if code:match("^[23]%d%d$") then
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
-- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。
if code:match("^2%d%d$") then
local size = util.file_size(tmp)
if size > max then return nil, "响应超过大小限制 (" .. max .. " 字节)" end
local content = util.read_file(tmp)
@@ -304,20 +419,26 @@ function M.wget_proxy_env(proxy)
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
" 代理,请安装 curl 或改用 http 代理"
end
return string.format("http_proxy=%q https_proxy=%q ", proxy, proxy)
return "http_proxy=" .. util.shq(proxy) .. " https_proxy=" .. util.shq(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"
local tag = util.rnd_hex(8)
local tmp = "/tmp/substore_dl_wget_" .. tag .. ".tmp"
local log = tmp .. ".log"
os.remove(tmp); os.remove(log)
-- -S 打印响应头(含整条重定向链),据此逐跳复检 SSRF;
-- -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 cmd = proxy_env .. string.format("wget -S -q -T %d -O %s %s >%s 2>&1",
t, util.shq(tmp), util.shq(url), util.shq(log))
local rc = os.execute(cmd)
-- 退出码必须看:wget 失败时可能已经写下半截响应体,只判断 size == 0
-- 会把残缺内容当成下载成功。
-- Lua 5.1 的 os.execute 返回数字退出码,5.2+ 返回 true/nil, "exit", code,两者都兼容。
local exit_ok = (rc == true) or (type(rc) == "number" and rc == 0)
local logtext = util.read_file(log) or ""
os.remove(log)
local safe, reason = M.validate_redirect_chain(url, logtext)
@@ -327,6 +448,10 @@ local function fetch_wget(url, parsed, opts)
end
local size = util.file_size(tmp)
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
if not exit_ok then
os.remove(tmp)
return nil, "下载失败(wget 退出码非 0)"
end
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
local content = util.read_file(tmp)
os.remove(tmp)
@@ -142,9 +142,16 @@ function M.sort(nodes, by, desc)
by = by or "name"
desc = desc and true or false
table.sort(nodes, function(a, b)
local av = a[by] or ""
local bv = b[by] or ""
if by == "port" then av, bv = tonumber(av) or 0, tonumber(bv) or 0 end
local av, bv = a[by], b[by]
if by == "port" then
av, bv = tonumber(av) or 0, tonumber(bv) or 0
else
-- 字段值可能是数字(例如 Clash YAML 里 `- name: 123`),
-- 数字和字符串直接比较会抛 "attempt to compare number with string",
-- 整个节点列表页就崩了;统一转成字符串再比。
av = tostring(av == nil and "" or av)
bv = tostring(bv == nil and "" or bv)
end
if av == bv then return false end
if desc then return av > bv else return av < bv end
end)
@@ -159,6 +166,19 @@ function M.rename(node, new_name)
return node
end
-- 逗号分隔列表 → 数组,逐项去空白。
-- 与 split_keywords 的区别:不做小写化(group / template 名是大小写敏感的)。
-- 原来各过滤器直接 gmatch("[^,]+") 不去空白,"vmess, vless" 会把 " vless"
-- (带前导空格)当成一个协议名,于是只剩 1 个节点且不报错。
local function split_list(s)
local out = {}
for item in (s or ""):gmatch("[^,]+") do
item = item:match("^%s*(.-)%s*$")
if item ~= "" then out[#out + 1] = item end
end
return out
end
-- 将关键词串拆分为数组(英文逗号/中文逗号/空白分隔),忽略空项,并统一小写
local function split_keywords(s)
local out = {}
@@ -187,7 +207,7 @@ function M.apply_rules(nodes, rules)
-- 协议过滤
if rules.proto_filter and rules.proto_filter ~= "" then
local set = {}
for p in rules.proto_filter:gmatch("[^,]+") do set[p]=true end
for _, p in ipairs(split_list(rules.proto_filter)) do set[p] = true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.proto] then out[#out+1]=n end
@@ -197,7 +217,7 @@ function M.apply_rules(nodes, rules)
-- 分组过滤
if rules.group_filter and rules.group_filter ~= "" then
local set = {}
for g in rules.group_filter:gmatch("[^,]+") do set[g]=true end
for _, g in ipairs(split_list(rules.group_filter)) do set[g]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.group] then out[#out+1]=n end
@@ -207,7 +227,7 @@ function M.apply_rules(nodes, rules)
-- 标签包含
if rules.tags_include and rules.tags_include ~= "" then
local set = {}
for t in rules.tags_include:gmatch("[^,]+") do set[t]=true end
for _, t in ipairs(split_list(rules.tags_include)) do set[t]=true end
local out = {}
for _,n in ipairs(nodes) do
local match = false
@@ -221,7 +241,7 @@ function M.apply_rules(nodes, rules)
-- 模板过滤
if rules.template_filter and rules.template_filter ~= "" then
local set = {}
for tmpl in rules.template_filter:gmatch("[^,]+") do set[tmpl]=true end
for _, tmpl in ipairs(split_list(rules.template_filter)) do set[tmpl]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.template] then out[#out+1]=n end
@@ -400,18 +420,105 @@ function M.parse_rename_rules(rule_str)
end
-- 展开模板:替换 {var} 占位符
-- gsub 的**替换串**里 % 有特殊含义(%1 反向引用、%% 转义),而节点数据是不可信输入:
-- 名字里带 "%" 时会被静默吞掉("50% OFF" → "50 OFF"),更糟的是能拼出 %0,
-- 在结果里产生 NUL 字节并一路写进节点名、写盘、下发到各订阅文件。
-- 所以替换前必须先把值里的 % 转义成 %%。
local function expand_template(template, n)
local function esc(v) return (tostring(v == nil and "" or v):gsub("%%", "%%%%")) end
local out = template
out = out:gsub("{server}", n.server or "")
out = out:gsub("{port}", tostring(n.port or ""))
out = out:gsub("{proto}", n.proto or "")
out = out:gsub("{name}", n.name or "")
out = out:gsub("{uuid}", n.uuid or "")
out = out:gsub("{password}", n.password or "")
out = out:gsub("{group}", n.group or "")
out = out:gsub("{server}", esc(n.server))
out = out:gsub("{port}", esc(n.port))
out = out:gsub("{proto}", esc(n.proto))
out = out:gsub("{name}", esc(n.name))
out = out:gsub("{uuid}", esc(n.uuid))
out = out:gsub("{password}", esc(n.password))
out = out:gsub("{group}", esc(n.group))
return out
end
-- 正则风格 → Lua pattern 的转义映射。
-- Lua pattern 里转义符是 %,所以 \d 之类要改写;\d \D \w \W \s \S 与 Lua 的
-- %d %D %w %W %s %S 一一对应。
-- \b \B(词边界)在 Lua pattern 里**没有**对应写法:原来一律把 \ 换成 %,
-- 于是 \b 变成了 %b —— 那是「成对匹配」(%bxy 匹配 x…y),语义完全不同,
-- 而且经常直接抛错、被下面的 pcall 吞掉。这里按「无操作」处理。
local REGEX_ESCAPE = {
d = "%d", D = "%D", w = "%w", W = "%W", s = "%s", S = "%S",
b = "", B = "",
}
-- 把「正则里是普通字符、Lua pattern 里却是元字符」的字符转义。
-- 最要命的是 `-`:正则里在字符类外就是普通字符("HK-01"、"Node-42"),
-- Lua pattern 里却是「懒惰量词」,于是 `Node-(\d+)` 被解释成 Nod + e- + 数字,
-- 永远匹配不上,而且不报错(pcall 也吞不掉,因为根本没抛错)。
-- 字符类 [...] 内的 - 是范围([a-z]),两边语义一致,保持原样。
-- `\x` 按 REGEX_ESCAPE 映射(\d→%d,\b→空),其余转义字符按 Lua 写法 %x。
local function regex_to_lua(pat)
local out, i, in_class = {}, 1, false
local n = #pat
while i <= n do
local c = pat:sub(i, i)
if c == "\\" and i < n then
local nx = pat:sub(i + 1, i + 1)
local m = REGEX_ESCAPE[nx]
out[#out + 1] = (m ~= nil) and m or ("%" .. nx)
i = i + 2
elseif c == "[" then
in_class = true; out[#out + 1] = c; i = i + 1
elseif c == "]" then
in_class = false; out[#out + 1] = c; i = i + 1
elseif c == "-" and not in_class then
out[#out + 1] = "%-"; i = i + 1
else
out[#out + 1] = c; i = i + 1
end
end
return table.concat(out)
end
-- 把 `a|b` 拆成多个候选(**仅顶层** |)。
-- 正则的「或」在 Lua pattern 里不存在,原来整个规则会静默不匹配。
-- 只拆括号深度为 0、且不在字符类 [] 里的 |:
-- * `[...]` 里的 | 是字面字符(Lua 的 [a|b] 匹配 a、| 或 b),拆开会破坏字符类
-- * `(...)` 里的 | 属于分组内部。Lua pattern 的 () 只是捕获,没有「或」语义,
-- 拆开只会得到 `^(HK` / `US` / `JP)%-(.*)$` 这种残缺模式——比不拆更糟:
-- 每一段都匹配不上,还静默无提示。分组内的「或」明确不支持,按字面处理。
local function split_alternatives(pat)
local alts, buf = {}, {}
local depth, in_class, i = 0, false, 1
local n = #pat
-- 必须是 while 而不是 for:下面遇到 % 转义序列要一次吃掉两个字符,
-- Lua 的数值 for 会在每轮重新赋值控制变量,循环体内改 i 不生效。
-- 也不用 goto(Lua 5.1 没有)。
while i <= n do
local c = pat:sub(i, i)
if c == "%" then
-- Lua 转义序列整体保留(regex_to_lua 已产出 %d / %- 这类两字符序列)
buf[#buf + 1] = c .. pat:sub(i + 1, i + 1)
i = i + 2
elseif c == "|" and depth == 0 and not in_class then
alts[#alts + 1] = table.concat(buf)
buf = {}
i = i + 1
else
if in_class then
if c == "]" then in_class = false end
elseif c == "[" then
in_class = true
elseif c == "(" then
depth = depth + 1
elseif c == ")" and depth > 0 then
depth = depth - 1
end
buf[#buf + 1] = c
i = i + 1
end
end
alts[#alts + 1] = table.concat(buf)
return alts
end
-- 按规则链式重命名节点(就地修改)
function M.rename_with_rules(nodes, rules)
rules = rules or {}
@@ -423,11 +530,16 @@ function M.rename_with_rules(nodes, rules)
n.name = expand_template(r.template, n)
elseif r.type == "regex" then
local name = n.name or ""
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1
local pat = (r.pattern or ""):gsub("\\", "%%")
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1、HK-01 里的 - 转义成 %-
local pat = regex_to_lua(r.pattern or "")
local repl = (r.replacement or ""):gsub("%$", "%%")
local ok, result = pcall(function()
return string.gsub(name, pat, repl)
-- 顶层 | 拆成多个候选依次替换(Lua pattern 没有「或」)
local s = name
for _, alt in ipairs(split_alternatives(pat)) do
s = s:gsub(alt, repl)
end
return s
end)
if ok then
n.name = result
@@ -22,12 +22,37 @@ local AWG_CONF_KEY_MAP = {
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",
-- AWG 3.0 / 3.1(键名核对自 amneziawg-tools src/config.c)
["header-protection-key"] = "HeaderProtectionKey",
["content-padding-addition"] = "ContentPaddingAddition",
["rekey-after-time"] = "RekeyAfterTime",
["rekey-timeout"] = "RekeyTimeout",
["reject-after-time"] = "RejectAfterTime",
["keepalive-timeout"] = "KeepaliveTimeout",
["max-handshake-attempts"] = "MaxHandshakeAttempts",
["random-trailers"] = "RandomTrailers",
["disable-cookies"] = "DisableCookies",
["advanced-security"] = "AdvancedSecurity",
}
-- 布尔键在 .conf 里必须写成 on/off:amneziawg-tools 的 parse_bool 只接受
-- on/off(大小写不敏感)或十进制数,写 true/false 会被判为非法值。
local AWG_BOOL_CONF_KEYS = {
RandomTrailers = true, DisableCookies = true, AdvancedSecurity = true,
}
local function conf_key(k)
return AWG_CONF_KEY_MAP[k]
end
-- 值 → .conf 文本:布尔转 on/off,其余 tostring
local function conf_val(conf_key_name, v)
if AWG_BOOL_CONF_KEYS[conf_key_name] and type(v) == "boolean" then
return v and "on" or "off"
end
return tostring(v)
end
-- 值可能是标量或数组,统一转为 "a, b, c"
local function join_list(v)
if v == nil then return nil end
@@ -83,7 +108,7 @@ local function build_section(n)
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]))
put(kv[1], conf_val(kv[1], kv[2]))
end
end
@@ -122,6 +147,15 @@ function M.generate(nodes, options)
end
local n = wg[1]
-- PrivateKey 是 wg-quick / AmneziaWG 的必填项:缺了它导出的 .conf 连本项目的
-- parser.detect 都认不出来(detect 要求 [Interface] + PrivateKey 同时存在),
-- 客户端更是无法导入。宁可不导出,也不要产出这种「看起来成功」的残缺文件。
local priv = n["private-key"] or n.private_key
if priv == nil or priv == "" then
return nil, "该 WireGuard 节点没有私钥 (private-key),无法导出 .conf"
end
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"
@@ -45,6 +45,12 @@ function M.detect(content)
if lower:match("%[interface%]") and lower:match("privatekey%s*=") then
return "wireguard-conf"
end
-- Surge / Loon:必须排在下面的 URI 兜底判断之前。
-- Surge 配置头部的 "#!MANAGED-CONFIG https://…" 含 "://",排在后面会被判成 uri,
-- 于是整份订阅解析出 0 个节点,且因为 parse 返回的是合法表,同步还会报成功。
if parser_surge.is_config(content) then return "surge" end
-- JSON 数组(节点数组):必须排在 .conf 判断之后,否则 "[Interface]" 会被当成数组
if stripped:sub(1, 1) == "[" then return "json" end
if stripped:find("vmess://", 1, true) or stripped:find("vless://", 1, true)
or stripped:find("trojan://", 1, true) or stripped:find("ssr://", 1, true)
or stripped:find("ss://", 1, true) or content:find("://", 1, true) then
@@ -121,11 +127,17 @@ local function parse_ss(body)
return out
end
-- SSR 参数值可能为 base64url、标准 base64 或纯文本:优先 base64url 解码,失败回退 URL 解码
-- SSR 参数值可能为 base64url、标准 base64 或纯文本:优先 base64url 解码,失败回退 URL 解码。
-- util.base64_url_decode 永远不会「失败」——它把非 base64 字符直接剔掉再解码,
-- 所以纯文本也会解出乱码字节而不是空串,原来的 `if v ~= ""` 回退分支根本不可达,
-- 节点名 / obfsparam / group 会被解成乱码(并写盘、下发)。
-- 可靠的判据是往返一致性:合法 base64url 重新编码后与原文逐字符相同,纯文本不会。
local function b64u_decode(s)
s = s or ""
local v = util.base64_url_decode(s)
if v ~= "" then return v end
if v ~= "" and util.base64_url_encode(v) == (s:gsub("=+$", "")) then
return v
end
return util.url_decode(s)
end
@@ -206,6 +218,13 @@ local function parse_vless(uri, body)
if query.fp then out.fp = query.fp end
if query.alpn then out.alpn = query.alpn end
if query.headerType then out.headerType = query.headerType end
-- path / host / flow 以前没有回读:output_uri 会写出这三个参数,读不回来就造成
-- 往返丢字段。丢 path/host 的后果尤其严重——Clash 输出会得到 network: ws 却没有
-- ws-opts.path,客户端请求 "/" 而非真实路径,节点直接连不上。
-- flow 是 XTLS Vision(xtls-rprx-vision)的必需参数,丢了同样握手失败。
if query.path then out.path = query.path end
if query.host then out.host = query.host end
if query.flow then out.flow = query.flow end
return out
end
@@ -236,6 +255,12 @@ local function parse_trojan(uri, body)
if query.sni then out.sni = query.sni end
if query.security then out.security = query.security end
if query.alpn then out.alpn = query.alpn end
-- trojan 同样支持 ws/grpc 等传输:output_uri 会写出 type/path/host/fp,
-- 以前只回读 sni/security/alpn,造成往返丢字段
if query.type then out.net = query.type end
if query.path then out.path = query.path end
if query.host then out.host = query.host end
if query.fp then out.fp = query.fp end
return out
end
@@ -267,6 +292,12 @@ local function parse_vmess(uri, body)
if query.type then out.net = query.type end
if query.security then out.security = query.security end
if query.sni then out.sni = query.sni end
if query.fp then out.fp = query.fp end
if query.alpn then out.alpn = query.alpn end
-- 同上:vmess 新版 URI 也带 host / path / headerType
if query.host then out.host = query.host end
if query.path then out.path = query.path end
if query.headerType then out.headerType = query.headerType end
return out
end
-- 经典格式:vmess://base64(json)
@@ -278,7 +309,7 @@ local function parse_vmess(uri, body)
proto = "vmess",
name = j.ps or (j.add .. ":" .. tostring(j.port)),
server = j.add, port = tonumber(j.port),
uuid = j.id, aid = tonumber(j.aid),
uuid = j.id, alterId = tonumber(j.aid),
net = j.net, type = j.type,
-- scy 是 vmess 加密方式(cipher),不是 TLS 层;TLS 由 tls 字段表达
-- ("tls" 表示启用,"" 表示不启用)
@@ -315,7 +346,17 @@ local function parse_hysteria2(uri, body)
password = password, raw = uri,
})
if query.sni then out.sni = query.sni end
if query.insecure ~= nil then out.insecure = query.insecure end
-- hysteria2 / hysteria 在 sing-box 与 mihomo 里都是 TLS-only,而 hy2 URI 不带
-- security 参数。不补上的话 output_singbox.build_tls 直接返回 nil,
-- sni / insecure 全部丢失,生成的 outbound 不可用。
out.security = "tls"
-- insecure 是 URI 参数名,模型里的权威字段是 skip-cert-verify
-- (parser_json_config 也把 sing-box 的 tls.insecure 映射到它)。
-- 两个都保留:skip-cert-verify 供各输出消费,insecure 供 URI 回写。
if query.insecure ~= nil then
out.insecure = query.insecure
out["skip-cert-verify"] = not (query.insecure == "0" or query.insecure == "false")
end
-- 混淆参数回读,保证导出→导入回环不丢字段
if query.obfs then out.obfs = query.obfs end
if query["obfs-password"] then out["obfs-password"] = query["obfs-password"] end
@@ -392,19 +433,61 @@ local function parse_wireguard(uri, body)
return out
end
-- AmneziaWG 参数随版本演进,未知键直接丢弃:不猜语义,避免把上游新参数错误映射到 mihomo 字段。
-- 取值统一走 tonumber-or-raw:数值型(jc/s1/h1…)转数字;非数值载荷(i1..i5、h1 的 "a-b" 区间)保留字符串。
local AWG_CONF_KEYS = {
"jc", "jmin", "jmax", "s1", "s2", "s3", "s4",
"h1", "h2", "h3", "h4",
"i1", "i2", "i3", "i4", "i5",
"j1", "j2", "j3", "itime",
-- AmneziaWG 参数。字段名与类型逐字段核对自两份一手来源,不靠推断:
-- .conf 键名 → amneziawg-tools src/config.c(process_line,大小写不敏感)
-- 规范名/类型 → mihomo adapter/outbound/wireguard.go 的 AmneziaWGOption(proxy tag)
-- 内部规范名 = mihomo 的 proxy tag(小写连字符):Clash 输出直接透传这些键名,
-- 所以必须与 mihomo 一致;.conf 导出时再用显式映射换成 CamelCase。
-- 未列入的键一律丢弃(不猜语义,§110)。
local AWG_CONF_FIELDS = {
-- v1.0
jc = "jc", jmin = "jmin", jmax = "jmax",
s1 = "s1", s2 = "s2",
h1 = "h1", h2 = "h2", h3 = "h3", h4 = "h4",
-- v1.5
s3 = "s3", s4 = "s4",
i1 = "i1", i2 = "i2", i3 = "i3", i4 = "i4", i5 = "i5",
j1 = "j1", j2 = "j2", j3 = "j3", itime = "itime",
-- v3.0
headerprotectionkey = "header-protection-key",
contentpaddingaddition = "content-padding-addition",
rekeyaftertime = "rekey-after-time",
rekeytimeout = "rekey-timeout",
rejectaftertime = "reject-after-time",
keepalivetimeout = "keepalive-timeout",
maxhandshakeattempts = "max-handshake-attempts",
-- v3.1
randomtrailers = "random-trailers",
disablecookies = "disable-cookies",
}
-- 布尔字段(mihomo 侧类型为 bool)。amneziawg-tools 的 parse_bool 只认
-- on/off(大小写不敏感)或十进制数(0 假、非 0 真);true/false/yes/no 都是非法值。
-- 非法值一律丢弃:留着会让 mihomo 解析该字段时报错。
local AWG_BOOL_FIELDS = {
["random-trailers"] = true,
["disable-cookies"] = true,
}
-- on/off 或十进制数 → 布尔;其余返回 nil(非法值,丢弃)
local function awg_bool(raw)
local s = tostring(raw or ""):lower()
if s == "on" then return true end
if s == "off" then return false end
if s:match("^%d+$") then return tonumber(s) ~= 0 end
return nil
end
-- wg-quick / AmneziaWG .conf 解析([Interface] / [Peer] 分段,Key = Value)
-- AmneziaWG 客户端导出的 .conf 即标准 wg-quick 格式 + Jc/Jmin/Jmax/S1/S2/H1..H4 等键
-- 一个 .conf 可以含多个 [Peer](wg-quick 的多对端隧道)。本项目的节点模型是
-- 「一个节点 = 一个对端」,无法表达多对端隧道;旧实现把所有 [Peer] 写进同一个表,
-- 后一段覆盖前一段,结果既丢掉前面的对端,又可能把 A 段的 PresharedKey 和
-- B 段的 Endpoint 拼成一个并不存在的节点。这里改为每个 [Peer] 生成一个节点,
-- 共享同一份 [Interface] 设置。返回 nodes 数组;失败返回 nil, err。
local function parse_wireguard_conf(content)
local iface, peer = {}, {}
local iface = {}
local peers = {}
local section = nil
for line in content:gmatch("[^\r\n]+") do
local s = util.trim(line)
@@ -413,9 +496,15 @@ local function parse_wireguard_conf(content)
elseif s:sub(1, 1) == "[" then
local name = s:match("^%[%s*(.-)%s*%]")
name = name and name:lower() or ""
if name == "interface" then section = iface
elseif name == "peer" then section = peer
else section = nil end
if name == "interface" then
section = iface
elseif name == "peer" then
local p = {}
peers[#peers + 1] = p
section = p
else
section = nil
end
elseif section then
local k, v = s:match("^([^=]+)=(.*)$")
if k then
@@ -426,17 +515,12 @@ local function parse_wireguard_conf(content)
end
end
local host, port = util.split_hostport(peer["endpoint"] or "")
if not host or host == "" or not port then return nil, "bad wireguard conf: no endpoint" end
if #peers == 0 then return nil, "bad wireguard conf: no [Peer]" end
local out = {
-- [Interface] 由各节点共享
local common = {
proto = "wireguard",
server = host,
port = tonumber(port),
["private-key"] = iface["privatekey"],
["public-key"] = peer["publickey"],
["pre-shared-key"] = peer["presharedkey"],
["persistent-keepalive"] = tonumber(peer["persistentkeepalive"]),
["listen-port"] = tonumber(iface["listenport"]),
mtu = tonumber(iface["mtu"]),
}
@@ -446,48 +530,79 @@ local function parse_wireguard_conf(content)
a = util.trim(a)
if a ~= "" then
if a:find(":", 1, true) then
if out.ipv6 == nil then out.ipv6 = a end
if common.ipv6 == nil then common.ipv6 = a end
else
if out.ip == nil then out.ip = a end
if common.ip == nil then common.ip = a end
end
end
end
-- AllowedIPs 拆分数组
local allowed = {}
for a in (peer["allowedips"] or ""):gmatch("[^,]+") do
a = util.trim(a)
if a ~= "" then allowed[#allowed + 1] = a end
end
if #allowed > 0 then out["allowed-ips"] = allowed end
-- DNS 拆分:单值存字符串,多值存数组
local dns = {}
for a in (iface["dns"] or ""):gmatch("[^,]+") do
a = util.trim(a)
if a ~= "" then dns[#dns + 1] = a end
end
if #dns == 1 then out.dns = dns[1]
elseif #dns > 1 then out.dns = dns end
if #dns == 1 then common.dns = dns[1]
elseif #dns > 1 then common.dns = dns end
-- Reserved(部分客户端写法,逗号或空格分隔)
local reserved = {}
for a in (iface["reserved"] or ""):gmatch("[^,%s]+") do
reserved[#reserved + 1] = tonumber(a) or a
end
if #reserved > 0 then out.reserved = reserved end
if #reserved > 0 then common.reserved = reserved end
-- AmneziaWG 参数
-- AmneziaWG [Interface] 参数:按 AWG_CONF_FIELDS 映射成规范名
local awg = {}
for _, k in ipairs(AWG_CONF_KEYS) do
local raw = iface[k]
for ck, canon in pairs(AWG_CONF_FIELDS) do
local raw = iface[ck]
if raw ~= nil and raw ~= "" then
awg[k] = tonumber(raw) or raw
if AWG_BOOL_FIELDS[canon] then
local b = awg_bool(raw)
if b ~= nil then awg[canon] = b end
else
awg[canon] = tonumber(raw) or raw
end
end
end
if next(awg) then out["amnezia-wg-option"] = awg end
return node.normalize(out)
local nodes = {}
for _, p in ipairs(peers) do
local host, port = util.split_hostport(p["endpoint"] or "")
if not host or host == "" or not port then return nil, "bad wireguard conf: no endpoint" end
local out = {}
for k, v in pairs(common) do out[k] = v end
out.server = host
out.port = tonumber(port)
out["public-key"] = p["publickey"]
out["pre-shared-key"] = p["presharedkey"]
out["persistent-keepalive"] = tonumber(p["persistentkeepalive"])
-- AllowedIPs 拆分数组
local allowed = {}
for a in (p["allowedips"] or ""):gmatch("[^,]+") do
a = util.trim(a)
if a ~= "" then allowed[#allowed + 1] = a end
end
if #allowed > 0 then out["allowed-ips"] = allowed end
-- [Peer] 段的 AmneziaWG 参数(AdvancedSecurity)不在此处解析:
-- 它属于 [Peer] 段,而本项目的 amnezia-wg-option 只对应 [Interface] 段,
-- 合并进来会在导出时被写到 [Interface] 下(错误段落)。宁可不支持,
-- 也不要产出一份键位置错误的 .conf(§110)。
-- 每个节点各拿一份副本:共享同一个表会让后续修改一处影响全部节点。
if next(awg) then
local o = {}
for k, v in pairs(awg) do o[k] = v end
out["amnezia-wg-option"] = o
end
nodes[#nodes + 1] = node.normalize(out)
end
return nodes
end
-- 解析单条节点 URI,返回节点表或 nil, err
@@ -785,9 +900,9 @@ function M.parse(content)
local nodes = parser_surge.parse(content)
return { nodes = nodes, format = "surge" }
elseif format == "wireguard-conf" then
local n, err = parse_wireguard_conf(content)
if not n then return nil, err end
return { nodes = { n }, format = "wireguard-conf" }
local nodes, err = parse_wireguard_conf(content)
if not nodes then return nil, err end
return { nodes = nodes, format = "wireguard-conf" }
end
return nil, "无法识别的订阅格式"
end
@@ -828,6 +943,49 @@ function M.parse_local(content, mode)
-- SSR 参数别名
if n.obfs_param == nil and n["obfs-param"] ~= nil then n.obfs_param = n["obfs-param"] end
if n.protocol_param == nil and n["protocol-param"] ~= nil then n.protocol_param = n["protocol-param"] end
-- 表单里所有输入框的值都是字符串,但统一模型里这几个字段是数组
-- (见 parse_wireguard_conf:AllowedIPs/Reserved/DNS 都拆成数组)。
-- 不归一的话,从 UI 编辑 WireGuard 节点会把数组写成标量字符串,
-- 而 mihomo / sing-box 的对应字段是列表类型,导出结果非法。
for _, lk in ipairs({ "allowed-ips", "reserved", "dns" }) do
if type(n[lk]) == "string" then
local arr = {}
for piece in n[lk]:gmatch("[^,%s]+") do
if piece ~= "" then arr[#arr + 1] = piece end
end
-- reserved 是端口保留位,语义上是数字
if lk == "reserved" then
for i, piece in ipairs(arr) do arr[i] = tonumber(piece) or piece end
end
if #arr == 0 then
n[lk] = nil
elseif #arr == 1 and lk == "dns" then
n[lk] = arr[1] -- 单值 DNS 保持字符串,与 .conf 解析一致
else
n[lk] = arr
end
end
end
-- amnezia-wg-option 在表单里是一个 JSON 文本框,提交上来是字符串。
-- 下游(output_wireguard_conf / output_clash_meta / output_uri)
-- 一律要求它是 table,字符串会被静默忽略 —— 即从 UI 编辑
-- WireGuard 节点会丢掉全部 AmneziaWG 参数。这里统一解码;
-- 解不开就明确报错,而不是留个字符串让它在导出时无声消失。
if type(n["amnezia-wg-option"]) == "string" then
local s = util.trim(n["amnezia-wg-option"])
if s == "" then
n["amnezia-wg-option"] = nil
else
local opt = util.json_decode(s)
if type(opt) ~= "table" then
return nil, "AmneziaWG 参数必须是合法的 JSON 对象"
end
n["amnezia-wg-option"] = opt
end
end
if n.name == nil or n.name == "" then
n.name = (n.server or "") .. ":" .. tostring(n.port or "")
end
@@ -184,8 +184,12 @@ local function map_clash_node(p)
end
-- 保留其余字段(wireguard 的 private-key、peer-public-key 等)
-- 但 type 要排除:Clash 的 type 是协议判别字段(vmess/ss/trojan),
-- 已经在上面映射成 proto;原样拷进来会让节点多出一个 type,
-- 而 output_uri 把它当成 vmess 的 header type 写出("type":"vmess"),
-- 生成客户端无法识别的 vmess:// 链接。
for k, v in pairs(p) do
if n[k] == nil then n[k] = v end
if k ~= "type" and n[k] == nil then n[k] = v end
end
return node.normalize(n)
@@ -8,14 +8,25 @@ local M = {}
M.TIMEOUT = 2 -- 每个目标的超时(秒)
-- shell 引号(%q 双引号转义),配合 safe_host 校验,双重防命令注入
local function q(s) return string.format("%q", s) end
-- shell 引号:用单引号转义(util.shq),配合 safe_host 校验,双重防命令注入。
-- 不能用 string.format("%q"):它生成双引号字符串,sh 在双引号内仍会做 $() / ``
-- 命令替换,实测可注入。
local function q(s) return util.shq(s) end
-- 校验目标主机名/IP:仅允许 [A-Za-z0-9._-] 与冒号(IPv6),杜绝命令注入
local function safe_host(host)
return type(host) == "string" and host ~= "" and host:match("^[%w%.%-%:]+$") ~= nil
end
-- 构造 http URL:IPv6 字面量必须写成 [addr]:port,否则 "2001:db8::1:443" 不是合法 URL,
-- curl 直接报 URL rejected(code 000),导致所有 IPv6 节点的 URL 测试恒为失败。
local function http_url(server, port)
if server:find(":", 1, true) then
return "http://[" .. server .. "]:" .. port .. "/"
end
return "http://" .. server .. ":" .. port .. "/"
end
local function safe_port(port)
port = tonumber(port)
if port and port >= 1 and port <= 65535 then return port end
@@ -94,7 +105,7 @@ function M.url_test(server, port)
server = util.trim(server or "")
port = safe_port(port)
if not safe_host(server) or not port then return nil end
local url = "http://" .. server .. ":" .. port .. "/"
local url = http_url(server, port)
local cmd, parse = build_url_job(url)
local f = io.popen(cmd)
local out = f and f:read("*a") or ""
@@ -117,7 +128,7 @@ local function build_job(mode, server, port)
elseif mode == "url" then
port = safe_port(port)
if not port then return nil end
return build_url_job("http://" .. server .. ":" .. port .. "/")
return build_url_job(http_url(server, port))
end
return nil
end
@@ -69,6 +69,14 @@ function M.base64_url_decode(s)
return M.base64_decode(s)
end
-- shell 单引号转义。拼进 shell 命令行的**一切外部数据**都必须走这里。
-- 不能用 string.format("%q"):它生成的是双引号字符串,而 /bin/sh 在双引号内
-- 仍然会做 $() 和 `` 命令替换,所以 format("%q", "$(rm -rf /)") 会被真的执行。
-- 单引号内除 ' 之外所有字符都是字面量,' 本身写作 '\'' 即可闭合再转义。
function M.shq(s)
return "'" .. tostring(s or ""):gsub("'", "'\\''") .. "'"
end
-- 生成随机十六进制 token(用于下载链接访问控制)
function M.rnd_hex(len)
len = len or 16
@@ -353,7 +361,7 @@ function M.atomic_write(path, content)
end
function M.ensure_dir(path)
os.execute("mkdir -p " .. string.format("%q", path))
os.execute("mkdir -p " .. M.shq(path))
end
-- ---------- 人性化格式 ----------