🌈 Sync 2026-10-01 13:09:37

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:09:37 +08:00
1 parent dceb6de0e4
commit 889951b6d0
116 files changed
+6376 -1093

No files matched your search

@@ -69,6 +69,14 @@ fi
# 不存在前导零,故可直接按字面比较。
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 ;;
'')
# 没有结果行 ≠「0 个失败」,而是 Lua 根本没跑到最后:模块加载失败、
# core.list() 抛异常(它在 pcall 之外)、解释器中途死掉等等。
# 旧实现把这种情况和成功归为一类(''|0) exit 0),于是一次彻底失败的
# 更新在 cron / 外部监控看来与成功无异 —— 故障永远不会被发现。
echo "substore cron: no result line, lua did not complete" >&2
exit 1
;;
0) exit 0 ;;
*) exit 1 ;;
esac
@@ -37,22 +37,50 @@ function index()
entry({"substore", "download"}, call("action_download"), nil)
end
-- 表单字段统一取字符串。
--
-- LuCI 的 formvalue **不保证返回字符串**:同名字段提交多次时返回的是 table。
-- 依据上游 luci/http.lua 的 urldecode_message_body:
-- elseif what == parser.VALUE and name then
-- local val = msg.params[name]
-- if type(val) == "table" then val[#val+1] = ...
-- elseif val ~= nil then msg.params[name] = { val, ... } -- ← 第二次出现变成 table
-- 而 formvalue 原样返回 msg.params[name];上游 luadoc 也写着
-- "@return HTTP input value or table of all input value"。
--
-- 本项目多处直接对返回值 :gsub / util.trim / urlencode,遇到 table 会抛
-- "attempt to index a table value" → HTTP 500。攻击面不限于已登录用户:
-- /substore/download 是**无需登录**的入口(供 Passwall / OpenClash 拉取),
-- 对它 POST 一个重复的 token 字段即可触发。统一收敛后这类输入不再崩溃。
--
-- 取值语义:table 取最后一个(与「同名参数后者覆盖前者」的直觉一致),
-- nil 保持 nil(post_ok 依赖这一点区分「无 token」与「空 token」)。
--
-- 注意:必须定义在 post_ok **之前** —— Lua 的 local function 只捕获定义时
-- 已可见的局部变量,写在后面会让 post_ok 里的 fv 落到全局(nil)。
local function fv(http, key)
local v = http.formvalue(key)
if type(v) == "table" then v = v[#v] end
if v == nil then return nil end
return tostring(v)
end
local function post_ok()
-- 拒绝缺失 CSRF token 的 POST
local http = require("luci.http")
return http.formvalue("token") ~= nil
return fv(http, "token") ~= nil
end
-- read cron fields from form, validate cron expression, return cron_enable cron_time
local function read_cron_fields()
local http = require("luci.http")
local core = require("substore.core")
local cron_enable = http.formvalue("cron_enable") or "0"
local m = http.formvalue("cron_min") or "0"
local h = http.formvalue("cron_hour") or "3"
local dom = http.formvalue("cron_dom") or "*"
local mon = http.formvalue("cron_mon") or "*"
local dow = http.formvalue("cron_dow") or "*"
local cron_enable = fv(http, "cron_enable") or "0"
local m = fv(http, "cron_min") or "0"
local h = fv(http, "cron_hour") or "3"
local dom = fv(http, "cron_dom") or "*"
local mon = fv(http, "cron_mon") or "*"
local dow = fv(http, "cron_dow") or "*"
local cron_time = table.concat({m,h,dom,mon,dow}, " ")
if not core.cron_time_valid(cron_time) then
cron_enable = "0"
@@ -65,19 +93,21 @@ end
-- read subscription level rules from form, return rules table
local function read_rules_fields()
local http = require("luci.http")
local core = require("substore.core")
local proto_list = {}
for _, p in ipairs({"vmess","vless","trojan","shadowsocks","ssr","hysteria2","tuic","hysteria","wireguard","socks"}) do
if http.formvalue("proto_filter_"..p) then proto_list[#proto_list+1]=p end
-- 协议清单来自 core.RULE_PROTOS,与表单模板共用一份(见该常量的说明)
for _, p in ipairs(core.RULE_PROTOS) do
if fv(http, "proto_filter_"..p) then proto_list[#proto_list+1]=p end
end
local rules_enable = http.formvalue("rules_enable") or "0"
local rules_enable = fv(http, "rules_enable") or "0"
if rules_enable ~= "1" then rules_enable = "0" end
return {
rules_enable = rules_enable,
proto_filter = table.concat(proto_list, ","),
keyword_include = http.formvalue("keyword_include") or "",
keyword_exclude = http.formvalue("keyword_exclude") or "",
dedup = http.formvalue("dedup") or "0",
rename_map = http.formvalue("rename_map") or "",
keyword_include = fv(http, "keyword_include") or "",
keyword_exclude = fv(http, "keyword_exclude") or "",
dedup = fv(http, "dedup") or "0",
rename_map = fv(http, "rename_map") or "",
}
end
@@ -85,11 +115,11 @@ function action_create()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = http.formvalue("proxy_enable") or "0"
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (fv(http, "url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = fv(http, "proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
end
@@ -112,12 +142,12 @@ function action_save()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (http.formvalue("url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = http.formvalue("proxy_enable") or "0"
local id = fv(http, "id") or ""
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local url = (fv(http, "url") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy_enable = fv(http, "proxy_enable") or "0"
if proxy_enable ~= "1" then proxy_enable = "0" end
local proxy = (http.formvalue("proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local proxy = (fv(http, "proxy") or ""):gsub("^%s+", ""):gsub("%s+$", "")
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" or url == "" then
return back_to_list("名称和 URL 不能为空")
@@ -141,9 +171,9 @@ function action_local_create()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = http.formvalue("content") or ""
local local_mode = http.formvalue("local_mode") or "text"
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = fv(http, "content") or ""
local local_mode = fv(http, "local_mode") or "text"
local rules = read_rules_fields()
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
if name == "" then return back_to_list("名称不能为空") end
@@ -164,10 +194,10 @@ function action_local_save()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = http.formvalue("content") or ""
local local_mode = http.formvalue("local_mode") or "text"
local id = fv(http, "id") or ""
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = fv(http, "content") or ""
local local_mode = fv(http, "local_mode") or "text"
local rules = read_rules_fields()
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" then return back_to_list("名称不能为空") end
@@ -194,7 +224,11 @@ function action_delete()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
core.remove(http.formvalue("id") or "")
-- §18:删除失败必须让用户看见。core.remove 对「非法 ID / 订阅不存在」
-- 返回 false(或 false, err),此前返回值被整个丢弃 —— 用户点了删除,
-- 页面正常跳回,订阅却还在,看起来像「删了但没生效」。
local ok, err = core.remove(fv(http, "id") or "")
if not ok then return back_to_list(err or "删除失败:订阅不存在") end
core.write_cron()
end
back_to_list()
@@ -204,10 +238,10 @@ end
-- 返回节点页。err 非空时把失败原因带到页面显示(§18:失败必须让用户看见,
-- 不能「失败了却看起来像成功」)。与 back_to_list 同一套约定。
local function back_to_nodes(http, err)
local id = http.formvalue("id") or ""
local id = fv(http, "id") or ""
local qs = "?id=" .. luci.util.urlencode(id)
for _, k in ipairs({ "proto", "keyword", "group", "sort", "desc" }) do
local v = http.formvalue(k)
local v = fv(http, k)
if v and v ~= "" then qs = qs .. "&" .. k .. "=" .. luci.util.urlencode(v) end
end
if err ~= nil and tostring(err) ~= "" then
@@ -222,9 +256,9 @@ function action_node_save()
local core = require("substore.core")
local parser = require("substore.parser")
if post_ok() then
local id = http.formvalue("id") or ""
local idx = tonumber(http.formvalue("idx") or "")
local content = http.formvalue("content") or ""
local id = fv(http, "id") or ""
local idx = tonumber(fv(http, "idx") or "")
local content = fv(http, "content") or ""
local nodes = core.read_nodes(id)
-- §18:任何一条失败路径都必须把原因带回页面。
-- 原来只在成功分支里做事,其余情况一律静默重定向,
@@ -258,14 +292,18 @@ function action_node_delete()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local idx_param = http.formvalue("idx") or ""
if type(idx_param) == "table" then idx_param = table.concat(idx_param, ",") end
local id = fv(http, "id") or ""
local idx_param = fv(http, "idx") or ""
local nodes = core.read_nodes(id)
local idxs = {}
for s in tostring(idx_param):gmatch("%d+") do
idxs[#idxs + 1] = tonumber(s)
end
-- §18:什么都没删掉时必须说清楚,不能静默跳回列表。
if #idxs == 0 then
back_to_nodes(http, "未指定要删除的节点")
return
end
-- 倒序删除,避免 table.remove 后下标偏移
table.sort(idxs, function(a, b) return a > b end)
local removed = false
@@ -275,10 +313,16 @@ function action_node_delete()
removed = true
end
end
if removed and core.write_nodes(id, nodes) then
core.save_meta(id, { node_count = #nodes })
core.refresh_combos(id)
if not removed then
back_to_nodes(http, "节点不存在或已被删除")
return
end
if not core.write_nodes(id, nodes) then
back_to_nodes(http, "写入节点数据失败")
return
end
core.save_meta(id, { node_count = #nodes })
core.refresh_combos(id)
end
back_to_nodes(http)
end
@@ -293,9 +337,9 @@ function action_node_set_group()
http.write(util.json_encode({ ok = false, err = "forbidden" }))
return
end
local id = http.formvalue("id") or ""
local idx = tonumber(http.formvalue("idx") or "")
local group = util.trim(http.formvalue("group") or "")
local id = fv(http, "id") or ""
local idx = tonumber(fv(http, "idx") or "")
local group = util.trim(fv(http, "group") or "")
local nodes = core.read_nodes(id)
if not idx or not nodes[idx] then
http.write(util.json_encode({ ok = false, err = "node not found" }))
@@ -315,27 +359,32 @@ function action_combo_save()
local core = require("substore.core")
local util = require("substore.util")
if post_ok() then
local id = util.trim(http.formvalue("id") or "")
local name = (http.formvalue("name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local id = util.trim(fv(http, "id") or "")
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local sources = {}
for _, it in ipairs(core.list()) do
if not it.combo and http.formvalue("src_" .. it.id) then
if not it.combo and fv(http, "src_" .. it.id) then
sources[#sources + 1] = it.id
end
end
if name ~= "" then
local rules = read_rules_fields()
local o = {
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
}
if id == "" then
core.add_combo(name, sources, o)
else
core.save_combo(id, name, sources, o)
end
-- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 ——
-- 用户填了表单却什么都没发生,页面上也没有任何提示。
if name == "" then return back_to_list("名称不能为空") end
local rules = read_rules_fields()
local o = {
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
dedup = rules.dedup,
}
-- 返回值此前被整个丢弃:add_combo / save_combo 的失败(非法 ID、
-- 未选来源、写入失败)一律静默。
local nid, err
if id == "" then
nid, err = core.add_combo(name, sources, o)
else
nid, err = core.save_combo(id, name, sources, o)
end
if not nid then return back_to_list(err or "保存组合订阅失败") end
end
back_to_list()
end
@@ -344,12 +393,15 @@ function action_update()
local http = require("luci.http")
local core = require("substore.core")
if post_ok() then
local id = http.formvalue("id") or ""
local id = fv(http, "id") or ""
local meta = core.get(id)
if meta and meta["local"] then
-- local subscription does not support auto update
return
end
-- §18:订阅不存在(ID 拼错 / 已被删除)时此前静默跳回列表,
-- 用户点了「更新」却看不到任何反馈。
if not meta then return back_to_list("订阅不存在") end
-- pcall 只保证「不抛异常」;core.sync 的失败是「返回 nil, err」而非抛错,
-- 因此必须同时检查两层结果,否则失败永远不会写回列表状态。
local ok, res, err = pcall(core.sync, id)
@@ -377,8 +429,8 @@ function action_probe()
http.write("invalid token")
return
end
local id = util.trim(http.formvalue("id") or "")
local mode = util.trim(http.formvalue("mode") or "")
local id = util.trim(fv(http, "id") or "")
local mode = util.trim(fv(http, "mode") or "")
if mode ~= "ping" and mode ~= "tcping" and mode ~= "url" then
http.status(400, "Bad Request")
http.prepare_content("text/plain; charset=utf-8")
@@ -394,8 +446,8 @@ function action_probe()
end
-- 与 nodes 页一致的过滤:按当前 proto / keyword 过滤后逐个探测
local nodes = core.read_nodes(id)
local proto = util.trim(http.formvalue("proto") or "")
local keyword = util.trim(http.formvalue("keyword") or "")
local proto = util.trim(fv(http, "proto") or "")
local keyword = util.trim(fv(http, "keyword") or "")
if proto ~= "" then nodes = node.filter(nodes, { proto = proto }) end
if keyword ~= "" then nodes = node.filter(nodes, { keyword = keyword }) end
nodes = node.sort(nodes, "name", false)
@@ -421,8 +473,8 @@ function action_download()
local http = require("luci.http")
local core = require("substore.core")
local util = require("substore.util")
local token = util.trim(http.formvalue("token") or "")
local target = util.trim(http.formvalue("target") or "ClashMeta")
local token = util.trim(fv(http, "token") or "")
local target = util.trim(fv(http, "target") or "ClashMeta")
local content, ct, filename, err = core.generate_link(token, target)
if not content then
@@ -9,6 +9,13 @@
local ki = (it and it.keyword_include) or ""
local ke = (it and it.keyword_exclude) or ""
local dedup = it and (it.dedup == "1" or it.dedup == true)
-- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单
-- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。
local proto_sel = {}
for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do
proto_sel[p:match("^%s*(.-)%s*$")] = true
end
local rm = (it and it.rename_map) or ""
local sel = {}
if it and type(it.sources) == "table" then
for _, s in ipairs(it.sources) do sel[s] = true end
@@ -63,7 +70,21 @@
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
<label style="display:inline-block;min-width:10em;vertical-align:top">协议筛选</label>
<% for _, p in ipairs(core.RULE_PROTOS) do %>
<label style="margin-right:0.75em;white-space:nowrap">
<input type="checkbox" name="proto_filter_<%=p%>" value="1" <%=proto_sel[p] and "checked" or ""%>/> <%=p%>
</label>
<% end %>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top">重命名</label>
<textarea name="rename_map" rows="3" style="width:60%"><%=luci.util.pcdata(rm)%></textarea>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。<br/>
协议筛选:全部不勾选 = 不筛选(保留所有协议)。<br/>
重命名每行一条:<code>旧名称=新名称</code>(精确匹配)、<code>模式 -> 替换</code>(正则替换)、<code>{server}_{port}_{proto}</code>(占位符模板)。</span>
</div>
</div>
<% if is_edit then %>
@@ -13,6 +13,13 @@
local ki = (it and it.keyword_include) or ""
local ke = (it and it.keyword_exclude) or ""
local dedup = it and (it.dedup == "1" or it.dedup == true)
-- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单
-- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。
local proto_sel = {}
for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do
proto_sel[p:match("^%s*(.-)%s*$")] = true
end
local rm = (it and it.rename_map) or ""
local proxy_enabled = it and (it.proxy_enable == true or it.proxy_enable == "1") or false
local util = require("substore.util")
-- 剩余流量 / 剩余时长(仅编辑页且有 subscription-userinfo 数据时显示,首页不显示)
@@ -89,7 +96,21 @@
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。</span>
<label style="display:inline-block;min-width:10em;vertical-align:top">协议筛选</label>
<% for _, p in ipairs(core.RULE_PROTOS) do %>
<label style="margin-right:0.75em;white-space:nowrap">
<input type="checkbox" name="proto_filter_<%=p%>" value="1" <%=proto_sel[p] and "checked" or ""%>/> <%=p%>
</label>
<% end %>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top">重命名</label>
<textarea name="rename_map" rows="3" style="width:60%"><%=luci.util.pcdata(rm)%></textarea>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080">多条关键词用逗号分隔(如:香港,美国,日本);包含 = 命中任一即保留,排除 = 命中任一即去除。<br/>
协议筛选:全部不勾选 = 不筛选(保留所有协议)。<br/>
重命名每行一条:<code>旧名称=新名称</code>(精确匹配)、<code>模式 -> 替换</code>(正则替换)、<code>{server}_{port}_{proto}</code>(占位符模板)。</span>
</div>
</div>
<% if is_edit and it.last_update then %>
@@ -9,6 +9,13 @@
local ki = (it and it.keyword_include) or ""
local ke = (it and it.keyword_exclude) or ""
local dedup = it and (it.dedup == "1" or it.dedup == true)
-- 协议筛选 / 重命名:控制器一直在读这两项(read_rules_fields),但此前三个表单
-- 都没有提交它们的控件 —— 字段永远写空,且每次保存会把 UCI 里已有的值重置为 ""。
local proto_sel = {}
for p in ((it and it.proto_filter) or ""):gmatch("[^,]+") do
proto_sel[p:match("^%s*(.-)%s*$")] = true
end
local rm = (it and it.rename_map) or ""
local local_mode = (it and it.local_mode) or "text"
local content = (it and it.raw_content) or ""
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
@@ -77,6 +84,22 @@
<label style="display:inline-block;min-width:10em"><%:Dedup%></label>
<input type="checkbox" name="dedup" value="1" <%=dedup and "checked" or ""%>/>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Protocol filter%></label>
<% for _, p in ipairs(core.RULE_PROTOS) do %>
<label style="margin-right:0.75em;white-space:nowrap">
<input type="checkbox" name="proto_filter_<%=p%>" value="1" <%=proto_sel[p] and "checked" or ""%>/> <%=p%>
</label>
<% end %>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<label style="display:inline-block;min-width:10em;vertical-align:top"><%:Rename%></label>
<textarea name="rename_map" rows="3" style="width:60%"><%=luci.util.pcdata(rm)%></textarea>
</div>
<div class="cbi-section-descr" style="margin:0.25em 0">
<span style="color:#808080"><%:Protocol filter: with none ticked no filtering is applied.%><br/>
<%:One rename rule per line: OLD=NEW (exact), PATTERN -> REPLACEMENT (regex), or {server}_{port}_{proto} (placeholder template).%></span>
</div>
</div>
<% if is_edit and it.last_update then %>
<div class="cbi-section-descr" style="margin:0.5em 0">
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
local M = {}
M.version = "2.6.0"
M.version = "2.6.8"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
@@ -16,6 +16,14 @@ M.CRON_FILE = "/etc/cron.d/substore"
M.MAX_SIZE = 10 * 1024 * 1024 -- 10MB
M.TIMEOUT = 20
-- 「协议筛选」的可选协议,用的是节点模型里的**规范**协议名(node.normalize 的产物)。
-- 控制器(read_rules_fields)与三个表单模板共用这一份:两边各写一份的话,
-- 一旦漂移,勾选框就会生成一个永远匹配不到任何节点的 proto_filter,
-- 而症状是「勾了没用」—— 不会报错,最难查。
-- 不含 socks5:解析阶段已把它归一成 socks(见 parser.lua 的说明)。
M.RULE_PROTOS = { "vmess", "vless", "trojan", "shadowsocks", "ssr",
"hysteria2", "tuic", "hysteria", "wireguard", "socks" }
local function id_is_valid(id)
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
end
@@ -42,6 +50,24 @@ local function load()
end
local seq = tonumber(data._seq) or 0
local items = type(data.items) == "table" and data.items or {}
-- items 的每个值都必须是订阅元数据表。出现别的类型说明文件不是本程序写的
-- (被手工编辑过 / 被截断后又被补全 / 磁盘错误)。此时不能原样返回:
-- * M.list / M.get 里的 pairs(meta) 会抛
-- "bad argument #1 to 'pairs' (table expected, got string)" ——
-- 订阅列表页直接 500;cron 路径更糟,core.list() 抛异常会让整轮同步
-- 在打印 "N ok, M failed" 之前中断,substore-cron.sh 据此判为成功。
-- * 静默丢弃坏条目再返回也不行:调用方会以为列表完好,下一次 save 就把
-- 它们永久抹掉(与 H8 的整体损坏同一个陷阱)。
-- 因此:返回能用的条目,同时带上损坏错误,让写路径(add / save_meta /
-- write_nodes …)拒绝落盘,与上面的整体损坏走同一条路。
local bad = false
local clean = {}
for id, meta in pairs(items) do
if type(meta) == "table" then clean[id] = meta else bad = true end
end
if bad then
return seq, clean, "订阅列表文件已损坏,存在非法条目:" .. M.LIST_FILE
end
return seq, items
end
@@ -50,7 +76,9 @@ local function save(seq, items)
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
end
-- 返回 arr, err。err 非空表示列表文件已损坏(此时 arr 为空)。
-- 返回 arr, err。err 非空表示列表文件已损坏。
-- 整体无法解析时 arr 为空;只有部分条目非法时 arr 仍包含能用的条目
-- (坏条目已被 load 过滤掉,否则下面的 pairs(meta) 会抛异常)。
-- 追加第二个返回值是向后兼容的:调用方普遍写成 ipairs(core.list()) 或
-- local items = core.list(),都只取第一个值。
function M.list()
@@ -157,7 +157,13 @@ function M.parse_url(url)
end
end
if not host or host == "" then return nil, "无效主机名" end
return { scheme = scheme, host = host, port = port }
-- 端口范围校验:`(%d+)` 只保证是数字,`http://host:99999/` 与 `:0` 都能通过,
-- 而这两个端口连不出去 —— 下载必然失败,却会先经过一轮 DNS/SSRF 检查,
-- 报出来的是「连接失败」这种指错方向的原因。parse_proxy 早已做了同样的校验,
-- 这里对齐。
local pn = tonumber(port)
if not pn or pn < 1 or pn > 65535 then return nil, "端口无效" end
return { scheme = scheme, host = host, port = tostring(pn) }
end
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
@@ -182,7 +188,8 @@ function M.parse_proxy(p)
return nil, "代理用户名/密码含非法字符"
end
local host, port
if hostport:sub(1, 1) == "[" then
local bracketed = hostport:sub(1, 1) == "["
if bracketed then
host = hostport:match("^%[([%w%.%-%:]+)%]")
port = hostport:match("^%[.*%]%:(%d+)$")
else
@@ -192,10 +199,22 @@ function M.parse_proxy(p)
if not host or host == "" or not host:match("^[%w%.%-%:]+$") then
return nil, "代理主机无效"
end
-- 未加方括号的 IPv6 字面量:RFC 3986 要求 IPv6 主机必须写成 [addr]。
-- 没有方括号时 `::1:1080` 既可能是「地址 ::1 + 端口 1080」,也可能就是
-- 地址 `::1:1080` —— 无法判定。与其猜一个再拼出一条 curl -x 解析不了的
-- 代理串(静默失效),不如明确报错让用户补上方括号。
if not bracketed and select(2, host:gsub(":", "")) > 1 then
return nil, "IPv6 代理地址必须加方括号,例如 http://[::1]:1080"
end
if port then
port = tonumber(port)
if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end
end
-- IPv6 字面量必须把方括号拼回去:上面为做主机校验把 [::1] 拆成了 ::1,
-- 直接拼会得到 `http://::1:1080` —— 冒号歧义,curl -x 与 http_proxy= 都
-- 解析不了(curl 会把 `::1:1080` 整个当成主机名),代理静默失效。
-- 单个冒号才是 host:port 的分隔符。
if host:find(":", 1, true) then host = "[" .. host .. "]" end
local r = scheme .. "://"
if userinfo ~= "" then r = r .. userinfo .. "@" end
r = r .. host
@@ -263,8 +282,19 @@ function M.check_public(host)
local ips = resolve(host)
if not ips then
-- 无 DNS 解析能力:放行,交由下载工具处理(尽力而为)
return true
-- 解析不出 IP 一律拒绝(fail-closed)。
--
-- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个
-- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
-- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时),
-- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1
-- 并连上去。检查的强度不能低于被检查者。
--
-- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio,
-- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下
-- curl 同样解析不了,下载本来就会失败,只是错误信息会变成
-- 「连接失败」这种指错方向的说法。
return false, "无法解析目标主机名"
end
for _, ip in ipairs(ips) do
if not is_private(ip) then return true end
@@ -369,7 +399,16 @@ local function fetch_curl(url, parsed, opts)
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)
-- 清理必须在**每一条**退出路径上执行。
-- 此前只在「本轮开始」和「2xx 成功」两处 os.remove:失败路径(curl 报错、
-- 超限、重定向无 Location / 目标不安全、HTTP 4xx-5xx、重定向次数过多)
-- 都把文件留在 /tmp。而 OpenWrt 的 /tmp 是 tmpfs —— 占的是内存;
-- 订阅更新失败(cron 定时重试)会一轮轮往内存里堆 .tmp/.hdr/.err,
-- 其中 .tmp 可能是部分下载的响应体,最大到 max_size。
local function cleanup()
os.remove(tmp); os.remove(hdr); os.remove(errf)
end
cleanup()
local cmd = string.format(
"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))
@@ -378,30 +417,46 @@ local function fetch_curl(url, parsed, opts)
if p then p:close() end
code = util.trim(code)
if code == "" or code == "000" then
return nil, util.trim(util.read_file(errf) or "下载失败")
local msg = util.trim(util.read_file(errf) or "下载失败")
cleanup()
return nil, msg
end
-- 只把 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
if size > max then
cleanup()
return nil, "响应超过大小限制 (" .. max .. " 字节)"
end
local content = util.read_file(tmp)
local headers = read_headers(hdr)
os.remove(tmp); os.remove(hdr); os.remove(errf)
cleanup()
if not content then return nil, "读取响应失败" end
return content, headers
end
if code:match("^3%d%d$") then
local loc = location_from_headers(hdr)
if not loc then return nil, "重定向无 Location" end
if not loc then
cleanup()
return nil, "重定向无 Location"
end
local next_url = resolve_url(cur, loc)
local np = M.parse_url(next_url)
if not np then return nil, "重定向目标无效" end
if not np then
cleanup()
return nil, "重定向目标无效"
end
local ok, re = M.check_public(np.host)
if not ok then return nil, "重定向目标不安全: " .. (re or "") end
if not ok then
cleanup()
return nil, "重定向目标不安全: " .. (re or "")
end
cleanup()
cur = next_url
else
cleanup()
return nil, "HTTP 错误 " .. code
end
end
@@ -167,17 +167,56 @@ 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),因此把公钥并入去重键。
-- 去重键的「身份」部分:同一 server:port 上的**不同账号是不同节点**。
--
-- 旧键只有 proto|server|port,于是「同一入口的多账号」被当成重复,静默删掉
-- 其中一个 —— 那是数据丢失而不是去重(与下面 WireGuard 公钥的说明同理)。
-- 这里取各协议里真正代表账号身份的字段;未知协议不猜,退回空身份。
local function identity_key(n)
local proto = (n.proto or ""):lower()
if proto == "ss" then proto = "shadowsocks" end
if proto == "wg" then proto = "wireguard" end
if proto == "socks5" then proto = "socks" end
-- WireGuard 的身份是 peer 公钥(同一个 endpoint 上不同公钥是不同节点)
if proto == "wireguard" then
return tostring(wg_public_key(n) or "")
end
if proto == "shadowsocks" then
return tostring(n.method or n.cipher or "") .. "\1" .. tostring(n.password or "")
end
if proto == "ssr" then
-- SSR 的加密方式、密码、协议插件、混淆方式是四段独立参数,任一不同
-- 都是不同的节点
return tostring(n.method or n.cipher or "") .. "\1" .. tostring(n.password or "")
.. "\1" .. tostring(n.protocol or "") .. "\1" .. tostring(n.obfs or "")
end
if proto == "vmess" or proto == "vless" then
return tostring(n.uuid or "")
end
if proto == "tuic" then
return tostring(n.uuid or "") .. "\1" .. tostring(n.password or "")
end
if proto == "socks" or proto == "http" then
return tostring(n.username or "") .. "\1" .. tostring(n.password or "")
end
if proto == "trojan" or proto == "hysteria" or proto == "hysteria2" then
return tostring(n.password or "")
end
return ""
end
-- 去重:按 proto+server+port+身份唯一。
-- proto 先归一:`wg` 与 `wireguard` 是同一协议,不归一的话同一条链路以两种
-- 写法出现时不会被去重。
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
local proto = (n.proto or ""):lower()
if proto == "wg" then proto = "wireguard" end
local key = proto .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "")
.. "|" .. identity_key(n)
if not seen[key] then
seen[key] = true
out[#out + 1] = n
@@ -569,6 +608,37 @@ local function split_alternatives(pat)
end
-- 按规则链式重命名节点(就地修改)
-- 把用户写的替换串翻译成 string.gsub 的替换串。
--
-- 两个坑:
-- * 捕获引用写的是 `$1`(正则风格),而 gsub 要的是 `%1`,必须转换。
-- * 其余字符必须按**字面**处理。用户写的 `%` 若原样传进 gsub,就落进了 gsub
-- 的替换串语义:`%` 后接非数字字符时被吞掉("50%off" → "50off"),
-- 结尾的 `%` 会注入一个 NUL 字节("100%" → "100\0")。这不是显示问题——
-- 节点名会写进节点文件并下发给所有客户端。
-- 原先只做了 `$` → `%` 的替换,没有转义字面 `%`,上述两种损坏都会发生。
local function build_replacement(rep)
rep = rep or ""
local out, i, n = {}, 1, #rep
while i <= n do
local c = rep:sub(i, i)
local nx = rep:sub(i + 1, i + 1)
if c == "$" and nx:match("%d") then
-- $1..$9 → %1..%9(gsub 的捕获引用)
out[#out + 1] = "%" .. nx
i = i + 2
elseif c == "%" then
-- 字面百分号:转义成 %%
out[#out + 1] = "%%"
i = i + 1
else
out[#out + 1] = c
i = i + 1
end
end
return table.concat(out)
end
function M.rename_with_rules(nodes, rules)
rules = rules or {}
for _, n in ipairs(nodes) do
@@ -581,7 +651,7 @@ function M.rename_with_rules(nodes, rules)
local name = n.name or ""
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1、HK-01 里的 - 转义成 %-
local pat = regex_to_lua(r.pattern or "")
local repl = (r.replacement or ""):gsub("%$", "%%")
local repl = build_replacement(r.replacement)
local ok, result = pcall(function()
-- 顶层 | 拆成多个候选依次替换(Lua pattern 没有「或」)
local s = name
@@ -12,8 +12,12 @@ local function esc_yaml(s)
-- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配
or s:find("[ :#{}%[%],&*?|>'\"%@`]") ~= nil
or s:match("^[-?]*:") ~= nil
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
-- 转义只在**加引号时**做:未加引号的 plain scalar 里反斜杠就是字面反斜杠,
-- 提前翻倍会让回读得到两个反斜杠(`pa\ss` → 输出 `pa\\ss` → 读回 `pa\\ss`,
-- 密码/路径直接错)。\ 本身不在上面的 need_quote 触发集里,所以含反斜杠的
-- 值恰恰是最容易走到这条路径的一类。
if need_quote then
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
return '"' .. s .. '"'
end
return s
@@ -31,14 +35,34 @@ local function yaml_list(items, level)
return table.concat(out, "\n")
end
-- 值可能是标量也可能是数组:数组输出为 YAML 列表,标量输出为单行
local function yaml_value(lines, key, v, level)
-- 把「数组或逗号分隔字符串」统一成数组。
-- mihomo 的 wireguard 出站里 allowed-ips / dns 是 []string、reserved 是 []uint8,
-- 三者都**只接受列表**。节点模型里这些字段的形态取决于来源:Clash YAML 的嵌套
-- 列表解析后是 table,表单导入 / URI 导入后是 "0.0.0.0/0, ::/0" 这样的字符串。
-- 字符串原样输出会得到 `allowed-ips: 0.0.0.0/0` 这个标量,mihomo 反序列化
-- 到 []string 失败,**整份配置拒绝加载**。
local function as_list(v)
local out = {}
if type(v) == "table" then
lines[#lines + 1] = indent(level) .. key .. ":"
lines[#lines + 1] = yaml_list(v, level + 1)
for _, x in ipairs(v) do
if x ~= nil and tostring(x) ~= "" then out[#out + 1] = x end
end
else
lines[#lines + 1] = indent(level) .. key .. ": " .. esc_yaml(v)
for x in tostring(v or ""):gmatch("[^,]+") do
x = x:match("^%s*(.-)%s*$")
if x ~= "" then out[#out + 1] = x end
end
end
return out
end
-- 值可能是数组也可能是逗号分隔字符串:一律输出为 YAML 列表。
-- 空列表直接不输出该键(`allowed-ips: []` 同样是非法值)。
local function yaml_value(lines, key, v, level)
local items = as_list(v)
if #items == 0 then return end
lines[#lines + 1] = indent(level) .. key .. ":"
lines[#lines + 1] = yaml_list(items, level + 1)
end
local PROTOCOL_TYPE_MAP = {
@@ -65,7 +89,9 @@ local function format_node(node)
lines[#lines + 1] = " - name: " .. esc_yaml(node.name or "")
lines[#lines + 1] = " type: " .. esc_yaml(ctype)
lines[#lines + 1] = " server: " .. esc_yaml(node.server or "")
lines[#lines + 1] = " port: " .. tostring(node.port or 0)
-- 端口必须是数字:`port: abc` / `port: 443/tcp` 这类非数字值会让 mihomo 拒绝
-- 加载整份配置。sing-box / v2ray 输出一直用 `tonumber() or 0` 兜底,此处对齐。
lines[#lines + 1] = " port: " .. tostring(tonumber(node.port) or 0)
-- 协议通用字段
if node.uuid then
@@ -97,6 +123,28 @@ local function format_node(node)
lines[#lines + 1] = " headers:"
lines[#lines + 1] = " Host: " .. esc_yaml(node.host)
end
elseif net == "grpc" then
-- 服务名写在 grpc-opts 里。只写 network: grpc 的话客户端用默认服务名
-- 去连,握手失败——与 ws 丢 path 是同一类问题(原来的实现只写了 network)。
-- 键名对照上游 transport 文档:grpc-service-name,带 grpc- 前缀。
-- 服务名取自 node.path,与 output_v2ray 的 serviceName、
-- output_singbox 的 service_name 同源。
if node.path then
lines[#lines + 1] = " grpc-opts:"
lines[#lines + 1] = " grpc-service-name: " .. esc_yaml(node.path)
end
elseif net == "h2" then
-- 上游 transport 文档:h2-opts.host 是**列表**,h2-opts.path 是标量。
if node.path or node.host then
lines[#lines + 1] = " h2-opts:"
if node.host then
lines[#lines + 1] = " host:"
lines[#lines + 1] = " - " .. esc_yaml(node.host)
end
if node.path then
lines[#lines + 1] = " path: " .. esc_yaml(node.path)
end
end
end
end
@@ -161,6 +209,16 @@ local function format_node(node)
end
end
if ctype == "vless" then
-- flow 是 XTLS Vision(xtls-rprx-vision)的必需参数。不写的话 mihomo 按
-- 普通 vless 处理,服务端要求 vision 时握手失败。surge / v2ray / URI 三个
-- 输出都写 flow,只有 clashmeta 漏了;parser_clash_yaml 也回读 flow,
-- 所以「Clash YAML → 导出 clashmeta」这条路径上 flow 会凭空消失。
if node.flow then
lines[#lines + 1] = " flow: " .. esc_yaml(node.flow)
end
end
if ctype == "hysteria2" then
if node.sni then
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
@@ -83,11 +83,22 @@ function M.surge_line(n)
return util.one_line(line)
end
-- 收集节点名列表
-- 收集可用于「逗号分隔成员列表」的节点名(Surge 家族 [Proxy Group] / QX [policy])。
--
-- 两类名字必须排除,否则生成的配置整体非法:
-- * 含逗号:这些格式的成员列表就是 `NAME = select, X, Y, DIRECT`,语法里没有
-- 引号 / 转义机制,名字里的逗号会被当成成员分隔符 —— `A,B` 被读成两个成员
-- `A` 与 `B`,两个都不存在,Surge / QX 会因引用不存在的代理而拒绝加载整份
-- 配置。节点定义本身仍留在 [Proxy] / [server_local] 中,只是不进成员列表。
-- * 换行:定义行经过 util.one_line(换行→空格),成员列表若用原始名就对不上
-- 定义行,同样成为悬空引用。这里统一先 one_line 再比对,保证两边一致。
local function names_of(nodes)
local out = {}
for _, n in ipairs(nodes or {}) do
if n.name then out[#out + 1] = n.name end
local nm = util.one_line(n.name)
if nm and nm ~= "" and not nm:find(",", 1, true) then
out[#out + 1] = nm
end
end
return out
end
@@ -177,6 +188,10 @@ end
function M.to_qx(nodes, options)
options = options or {}
local out = {}
-- 真正写出了 [server_local] 行的节点。QX 只支持下面这 4 类协议,其余节点
-- (hysteria2 / hysteria / tuic / socks / wireguard / ssr …)没有定义行;
-- [policy] 若把它们也列进去,就成了引用不存在服务器的悬空条目。
local emitted = {}
out[#out + 1] = "[server_local]"
for _, n in ipairs(nodes or {}) do
local proto = props(n)
@@ -213,10 +228,12 @@ function M.to_qx(nodes, options)
-- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行
-- 并伪造出一条新的 server_local 行
for i = before + 1, #out do out[i] = util.one_line(out[i]) end
-- 上面的 if/elseif 命中时必定追加一行,未命中时一行不加
if #out > before then emitted[#emitted + 1] = n end
end
out[#out + 1] = ""
out[#out + 1] = "[policy]"
local names = names_of(nodes)
local names = names_of(emitted)
local policy = "static=" .. (options.name or "PROXY") .. ", DIRECT"
for _, nm in ipairs(names) do policy = policy .. ", " .. nm end
out[#out + 1] = util.one_line(policy)
@@ -11,6 +11,38 @@ local function bool(v)
return true
end
-- 把「数组或逗号分隔字符串」统一成数组(空项丢弃)。
-- 节点模型里 wireguard 的 allowed-ips / reserved / dns 形态取决于来源:Clash YAML
-- 的嵌套列表解析后是 table,表单导入 / URI 导入后是 "0.0.0.0/0, ::/0" 这样的
-- 字符串。字符串原样写进 JSON 会得到 `"allowed_ips":"0.0.0.0/0"`,而 sing-box
-- 这几个字段是 []string / []uint8,反序列化失败 → 整份配置拒绝启动。
local function as_list(v)
local out = {}
if type(v) == "table" then
for _, x in ipairs(v) do
if x ~= nil and tostring(x) ~= "" then out[#out + 1] = x end
end
else
for x in tostring(v or ""):gmatch("[^,]+") do
x = x:match("^%s*(.-)%s*$")
if x ~= "" then out[#out + 1] = x end
end
end
return out
end
-- 同 as_list,但把每项转成数字(非数字项丢弃)。
-- reserved 在 sing-box 里是 []uint8:JSON 里写成 ["1","2","3"](字符串)同样
-- 反序列化失败,必须输出 [1,2,3]。
local function as_num_list(v)
local out = {}
for _, x in ipairs(as_list(v)) do
local num = tonumber(x)
if num then out[#out + 1] = num end
end
return out
end
-- sing-box type 映射
local TYPE_MAP = {
vmess = "vmess",
@@ -148,12 +180,17 @@ function M.to_outbound(n, tag)
if n.ipv6 then addr[#addr + 1] = n.ipv6 end
if #addr == 1 then o.local_address = addr[1]
elseif #addr > 1 then o.local_address = addr end
if n["allowed-ips"] then o.allowed_ips = n["allowed-ips"] end
if n.reserved then o.reserved = n.reserved end
-- 三个字段在 sing-box 里都是列表(allowed_ips/dns 是 []string,reserved 是
-- []uint8),字符串形态必须归一后再写,否则整份配置起不来
local aips = as_list(n["allowed-ips"])
if #aips > 0 then o.allowed_ips = aips end
local rsv = as_num_list(n.reserved)
if #rsv > 0 then o.reserved = rsv end
if n["persistent-keepalive"] then o.persistent_keepalive_interval = tonumber(n["persistent-keepalive"]) end
if n["listen-port"] then o.listen_port = tonumber(n["listen-port"]) end
if n.mtu then o.mtu = tonumber(n.mtu) end
if n.dns then o.dns = n.dns end
local dns = as_list(n.dns)
if #dns > 0 then o.dns = dns end
elseif stype == "socks" or stype == "http" then
-- socks 与 http 出站的认证字段相同(username / password)
if n.username then o.username = n.username end
@@ -14,6 +14,29 @@ local function url_encode(s)
end))
end
-- alpn 归一成逗号分隔字符串。
-- alpn 可能是数组:Clash YAML 的 alpn 列表与 sing-box JSON 的 tls.alpn 导入后
-- 就是 table。直接交给 url_encode 会 tostring 成 "table: 0x...",链接里出现
-- `alpn=table%3A%200x...`(客户端解析失败)。
local function alpn_str(v)
if type(v) == "table" then return table.concat(v, ",") end
return v
end
-- 是否跳过证书校验。模型里的权威字段是 skip-cert-verify(见 parser.lua 中
-- hysteria2/hysteria URI 解析处的说明);insecure 只是 URI 参数名,且**只有**
-- URI 解析器会写它 —— Clash YAML / sing-box JSON / 表单导入的节点只有
-- skip-cert-verify。只读 insecure 会让这些节点在导出链接时丢掉该项,
-- 客户端按严格校验握手直接失败。
local function insecure_flag(n)
local v = n["skip-cert-verify"]
if v == nil then v = n.skip_cert_verify end
if v == nil then v = n.insecure end
if v == nil then return nil end
if v == false or v == "false" or v == 0 or v == "0" then return "0" end
return "1"
end
-- 生成单节点分享链接;无法生成时返回 nil
function M.to_share_uri(n)
if type(n) ~= "table" or not n.server or not n.port then return nil end
@@ -59,10 +82,7 @@ function M.to_share_uri(n)
q[#q + 1] = "security=" .. url_encode(n.security or "none")
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
if n.alpn then
local alpn = type(n.alpn) == "table" and table.concat(n.alpn, ",") or n.alpn
q[#q + 1] = "alpn=" .. url_encode(alpn)
end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end
if n.path then q[#q + 1] = "path=" .. url_encode(n.path) end
if n.host then q[#q + 1] = "host=" .. url_encode(n.host) end
if n.flow then q[#q + 1] = "flow=" .. url_encode(n.flow) end
@@ -74,7 +94,7 @@ function M.to_share_uri(n)
local q = {}
q[#q + 1] = "security=" .. url_encode(n.security or "tls")
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
return "trojan://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
.. "?" .. table.concat(q, "&") .. "#" .. name
@@ -83,7 +103,8 @@ function M.to_share_uri(n)
if proto == "hysteria2" or proto == "hysteria" then
local q = {}
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
if n.insecure ~= nil then q[#q + 1] = "insecure=" .. tostring(n.insecure) end
local ins = insecure_flag(n)
if ins ~= nil then q[#q + 1] = "insecure=" .. ins end
-- 混淆:hysteria2 是 salamander,URI 参数为 obfs / obfs-password;
-- hysteria(v1) 的 obfs 只是普通字符串,**没有** obfs-password
-- (写到 v1 链接上是非法参数,且会误导下游客户端)
@@ -104,7 +125,7 @@ function M.to_share_uri(n)
local userinfo = (n.uuid or "") .. ":" .. url_encode(n.password or "")
local q = {}
if n.congestion_control then q[#q + 1] = "congestion_control=" .. url_encode(n.congestion_control) end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(n.alpn) end
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
return "tuic://" .. userinfo .. "@" .. server .. ":" .. tostring(port)
@@ -47,9 +47,25 @@ local function build_stream_settings(n)
return ss
end
-- 该协议能否落到 V2Ray / Xray outbound(SSR 无法表达,跳过)
-- 该协议能否落到 V2Ray / Xray outbound。
-- 白名单而不是「排除 ssr」:原来的 `proto ~= "ssr"` 会把 hysteria2 / hysteria /
-- tuic / wireguard / snell 一并放行,而 to_outbound 里没有它们的分支,全部落进
-- 末尾的 else —— 于是生成 `"protocol": "hysteria2"` 这种 Xray 根本不认识的
-- outbound,凭据还被塞进无意义的 users 字段。Xray 解析到未知 protocol 会拒绝
-- 整份配置,一个节点废掉整个订阅(与 parser 侧丢弃未知协议是同一个理由)。
local V2RAY_PROTOS = {
vmess = true,
vless = true,
trojan = true,
shadowsocks = true,
ss = true,
socks = true,
socks5 = true,
http = true,
}
local function supported(proto)
return (proto or "vmess") ~= "ssr"
return V2RAY_PROTOS[proto or "vmess"] == true
end
-- 单节点 → V2Ray outbound 表
@@ -29,6 +29,21 @@ local function split_lines(content)
return out
end
-- 节点必须有可用的 server 与 port,否则解析阶段就丢弃。
--
-- 依据:Clash / sing-box / v2ray 的输出都会把缺失值写成 `server: `(空字符串)
-- 或 `port: 0`。这两个值不是「降级」,而是**非法配置**——mihomo 与 sing-box 会
-- 拒绝加载整份文件,于是一个残缺节点废掉整个订阅的所有节点。
-- 与 H6(未知协议)同理:模型承载不了的东西不进模型,而不是带着非法值往下传。
--
-- port 来自 util.split_hostport,只会是 "%d+" 匹配到的数字串或 nil,
-- 因此这里只需判空与范围(0 与 >65535 都是非法端口)。
local function valid_hostport(host, port)
if not host or host == "" then return false end
local p = tonumber(port)
return p ~= nil and p >= 1 and p <= 65535
end
-- 检测订阅格式:uri / base64 / json / yaml / empty / unknown
function M.detect(content)
content = util.trim(content or "")
@@ -123,7 +138,7 @@ local function parse_ss(body)
end
local method, password = userinfo:match("^([^:]+):(.*)$")
local host, port = util.split_hostport(hostport)
if not (method and password and host) then return nil, "bad ss" end
if not (method and password and valid_hostport(host, port)) then return nil, "bad ss" end
local name = fragment ~= "" and fragment or (host .. ":" .. tostring(port or ""))
local out = node.normalize({
proto = "shadowsocks", name = name, server = host, port = tonumber(port),
@@ -159,7 +174,14 @@ local function parse_ssr(body)
rest = rest:sub(1, hash - 1)
end
if rest:find("%", 1, true) then rest = util.url_decode(rest) end
local decoded = util.base64_decode(rest)
-- 用 base64_url_decode 而不是 base64_decode:前者只是先把 -/_ 还原成 +//
-- 再走标准解码,对标准 base64 输入逐字节等价(标准字母表里没有 -/_),
-- 对 base64url 输入才是正确的。
-- 反过来的代价很大:base64_decode 会把 -/_ 当非法字符**直接剔除**
-- (util.lua 的 `s:gsub("[^%w%+/=]", "")`),于是外层用了 base64url 的
-- ssr:// 链接会少掉若干字符、整串解成乱码——server / port / 密码全错,
-- 而且不报错,用户只看到一个连不上的节点。
local decoded = util.base64_url_decode(rest)
if decoded == "" then return nil, "bad ssr" end
-- 以首个 '?' 切分主体与参数(密码 base64 可能含 '/',故不能按 '/' 切分)
@@ -169,8 +191,7 @@ local function parse_ssr(body)
local server, port, protocol, method, obfs, pwd_b64 = main:match("^([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):(.*)$")
if not server or server == "" then return nil, "bad ssr" end
local password = util.base64_url_decode(pwd_b64 or "")
if password == "" then password = pwd_b64 or "" end
local password = b64u_decode(pwd_b64 or "")
local params = {}
for k, v in (query or ""):gmatch("([^&=]+)=([^&]*)") do
@@ -215,6 +236,7 @@ local function parse_vless(uri, body)
if not at then return nil, "bad vless" end
local uuid = hp:sub(1, at - 1)
local host, port = util.split_hostport(hp:sub(at + 1))
if not valid_hostport(host, port) then return nil, "bad vless" end
local out = node.normalize({
proto = "vless", name = name, server = host, port = tonumber(port), uuid = uuid, raw = uri,
})
@@ -250,10 +272,16 @@ local function parse_trojan(uri, body)
query[k] = util.url_decode(v)
end
end
local at = hp:find("@", 1, true)
if not at then return nil, "bad trojan" end
local password = hp:sub(1, at - 1)
local host, port = util.split_hostport(hp:sub(at + 1))
-- 取**最后**一个 @:userinfo 里不允许裸 @,未编码的 @ 只可能出现在密码里。
-- 按第一个 @ 切会把 "p@ss" 切成密码 "p" + server "ss@1.2.3.4"(静默出错)。
-- 与 parse_socks / parse_hysteria2 / parse_tuic 保持同一策略。
local userinfo, hostport = hp:match("^(.*)@([^@]*)$")
if not userinfo then return nil, "bad trojan" end
-- 密码必须 url_decode:output_uri 写链接时会 url_encode,不解码就是双重编码
-- ("p@ss:w/rd" → 回读成 "p%40ss%3Aw%2Frd"),回环一次密码就错了。
local password = util.url_decode(userinfo)
local host, port = util.split_hostport(hostport)
if not valid_hostport(host, port) then return nil, "bad trojan" end
local out = node.normalize({
proto = "trojan", name = name, server = host, port = tonumber(port),
password = password, raw = uri,
@@ -291,6 +319,7 @@ local function parse_vmess(uri, body)
local at = hp:find("@", 1, true)
local uuid = hp:sub(1, at - 1)
local host, port = util.split_hostport(hp:sub(at + 1))
if not valid_hostport(host, port) then return nil, "bad vmess" end
local out = node.normalize({
proto = "vmess", name = name, server = host, port = tonumber(port),
uuid = uuid, raw = uri,
@@ -311,6 +340,7 @@ local function parse_vmess(uri, body)
if decoded == "" then return nil, "bad vmess b64" end
local j = util.json_decode(decoded)
if type(j) ~= "table" or not j.add then return nil, "bad vmess json" end
if not valid_hostport(j.add, j.port) then return nil, "bad vmess json" end
local out = node.normalize({
proto = "vmess",
name = j.ps or (j.add .. ":" .. tostring(j.port)),
@@ -323,6 +353,12 @@ local function parse_vmess(uri, body)
security = (j.tls == "tls" or j.tls == true) and "tls" or nil,
raw = uri,
})
-- host / path 是 ws(以及 http/h2)传输的必需参数:output_uri 会把它们写成
-- vmess://…?host=…&path=…,不回读就等于导出→导入丢字段。丢 path 的后果最严重——
-- Clash 输出得到 network: ws 却没有 ws-opts.path,客户端请求 "/" 而非真实路径。
-- vless 分支与 vmess 新版 URI 分支早已回读这两个字段,此处是对齐。
if j.host and j.host ~= "" then out.host = j.host end
if j.path and j.path ~= "" then out.path = j.path end
return out
end
@@ -343,10 +379,13 @@ local function parse_hysteria2(uri, body)
query[k] = util.url_decode(v)
end
end
local at = hp:find("@", 1, true)
if not at then return nil, "bad hysteria2" end
local password = util.url_decode(hp:sub(1, at - 1))
local host, port = util.split_hostport(hp:sub(at + 1))
-- 取最后一个 @(见 parse_socks 的说明):按第一个 @ 切会把密码里的裸 @ 当成
-- userinfo 分隔符,得到密码 "p" 和 server "ss@1.2.3.4"——静默解析出一个错节点。
local userinfo, hostport = hp:match("^(.*)@([^@]*)$")
if not userinfo then return nil, "bad hysteria2" end
local password = util.url_decode(userinfo)
local host, port = util.split_hostport(hostport)
if not valid_hostport(host, port) then return nil, "bad hysteria2" end
local out = node.normalize({
proto = "hysteria2", name = name, server = host, port = tonumber(port),
password = password, raw = uri,
@@ -390,11 +429,11 @@ local function parse_hysteria(uri, body)
query[k] = util.url_decode(v)
end
end
local at = hp:find("@", 1, true)
if not at then return nil, "bad hysteria" end
local password = util.url_decode(hp:sub(1, at - 1))
local host, port = util.split_hostport(hp:sub(at + 1))
if not host or host == "" then return nil, "bad hysteria" end
local userinfo, hostport = hp:match("^(.*)@([^@]*)$")
if not userinfo then return nil, "bad hysteria" end
local password = util.url_decode(userinfo)
local host, port = util.split_hostport(hostport)
if not valid_hostport(host, port) then return nil, "bad hysteria" end
local out = node.normalize({
proto = "hysteria", name = name, server = host, port = tonumber(port),
password = password, raw = uri,
@@ -468,16 +507,18 @@ local function parse_tuic(uri, body)
query[k] = util.url_decode(v)
end
end
local at = hp:find("@", 1, true)
if not at then return nil, "bad tuic" end
local userinfo = util.url_decode(hp:sub(1, at - 1))
-- 取最后一个 @(见 parse_socks 的说明)
local raw_userinfo, hostport = hp:match("^(.*)@([^@]*)$")
if not raw_userinfo then return nil, "bad tuic" end
local userinfo = util.url_decode(raw_userinfo)
local uuid, password = "", userinfo
local cpos = userinfo:find(":", 1, true)
if cpos then
uuid = userinfo:sub(1, cpos - 1)
password = userinfo:sub(cpos + 1)
end
local host, port = util.split_hostport(hp:sub(at + 1))
local host, port = util.split_hostport(hostport)
if not valid_hostport(host, port) then return nil, "bad tuic" end
local out = node.normalize({
proto = "tuic", name = name, server = host, port = tonumber(port),
uuid = uuid, password = password, raw = uri,
@@ -579,8 +620,15 @@ local function parse_wireguard_conf(content)
local section = nil
for line in content:gmatch("[^\r\n]+") do
local s = util.trim(line)
if s == "" or s:sub(1, 1) == "#" or s:sub(1, 1) == ";" then
-- 空行 / 注释
-- 行内注释:wg-quick 的 parse_options 用 `stripped="${line%%\#*}"`,即从
-- **第一个** # 起全部丢弃(不要求 # 前有空白)。此处对齐同一语义:否则
-- `Endpoint = 1.2.3.4:51820 # 备用` 会把 "# 备用" 当成值的一部分,
-- split_hostport 取不到端口,整份 .conf 因此作废。
-- wg-quick 不把 ; 当注释符(; 是本实现额外容忍的写法),故 ; 只按整行
-- 注释处理,不参与行内剥离。
s = util.trim((s:gsub("#.*$", "")))
if s == "" or s:sub(1, 1) == ";" then
-- 空行 / 纯注释行
elseif s:sub(1, 1) == "[" then
local name = s:match("^%[%s*(.-)%s*%]")
name = name and name:lower() or ""
@@ -655,41 +703,55 @@ local function parse_wireguard_conf(content)
end
end
-- 一层的表拷贝。common 里的 dns / reserved 是数组、awg 是子表,都必须按节点
-- 各拿一份:浅拷贝会让所有节点共享同一个表,之后任何一处改动(例如按节点编辑
-- DNS)会同时改到全部节点。
local function copy1(t)
local o = {}
for k, v in pairs(t) do o[k] = v end
return o
end
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
-- 单个 [Peer] 缺 Endpoint(或 Endpoint 解析不出端口)只跳过它自己:
-- 旧实现直接 return nil,一个坏段就让整份 .conf 作废,同文件里其它完好的
-- 对端全部丢失 —— 与「一个坏节点不该废掉整个订阅」同一原则。
if host and host ~= "" and port then
local out = {}
for k, v in pairs(common) do
if type(v) == "table" then out[k] = copy1(v) else out[k] = v end
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"])
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
-- AllowedIPs 拆分数组
local allowed = {}
for a in (p["allowedips"] or ""):gmatch("[^,]+") do
a = util.trim(a)
if a ~= "" then allowed[#allowed + 1] = a end
-- [Peer] 段的 AmneziaWG 参数(AdvancedSecurity)不在此处解析:
-- 它属于 [Peer] 段,而本项目的 amnezia-wg-option 只对应 [Interface] 段,
-- 合并进来会在导出时被写到 [Interface] 下(错误段落)。宁可不支持,
-- 也不要产出一份键位置错误的 .conf(§110)。
if next(awg) then out["amnezia-wg-option"] = copy1(awg) end
nodes[#nodes + 1] = node.normalize(out)
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
-- 一个可用 [Peer] 都没有时报错。此处 #peers > 0 已由上面保证,走到这里说明
-- 每个 [Peer] 都缺 Endpoint。不能返回空列表:空列表会被上层当成「解析成功但
-- 0 节点」,用户看到更新成功却一个节点都没有(H4/H5 的静默失败形态)。
if #nodes == 0 then return nil, "bad wireguard conf: no usable [Peer] endpoint" end
return nodes
end
@@ -746,15 +808,39 @@ local function parse_json_content(content)
if data[1] then list = data else list = { data } end
local nodes = {}
for _, o in ipairs(list) do
if type(o) == "table" and o.server and o.port then
nodes[#nodes + 1] = node.normalize({
proto = o.proto or "vmess",
name = o.name or (o.server .. ":" .. o.port),
server = o.server, port = tonumber(o.port),
uuid = o.uuid, password = o.password, method = o.method,
net = o.net, security = o.security, sni = o.sni,
raw = o.raw,
})
-- 协议名可能写在 proto 或 type 里:表单导入落盘的 JSON 用的就是 type
-- (见 M.parse_local 的 form 分支),同一份 JSON 走文本导入也必须解析出
-- 同一个协议,否则 `{"type":"vless",…}` 会被当成 vmess。
-- 两者都没有时保留旧的 vmess 兜底;type 存在但不在 TYPE_MAP 里,说明这是
-- 统一模型承载不了的协议,丢弃而不是兜底成 vmess(理由见 H6 的说明:
-- 凭空造一个字段全错的假节点比丢弃更糟)。
local proto, type_is_proto = o.proto, false
if proto == nil or proto == "" then
if o.type == nil or o.type == "" then
proto = "vmess"
else
proto = parser_clash_yaml.TYPE_MAP[o.type]
type_is_proto = true
end
end
if proto and type(o) == "table" and valid_hostport(o.server, o.port) then
-- 与 M.parse_local 的 form 分支同样「透传全部字段」:这里原来是硬编码
-- 白名单,只搬 proto/name/server/port/uuid/password/method/net/security/sni,
-- 于是 path / host / cipher / alterId / flow / fp / alpn / tls / obfs 等
-- 全被静默丢掉——ws 节点丢掉 path 就连不上。字段清单散落成多份必然漂移,
-- 统一由 node.normalize 收口。
local n = {}
for k, v in pairs(o) do n[k] = v end
n.proto = proto
-- 只有真的把 type 当作协议名消费掉时才清空它,避免误删 vmess 的
-- header type(output_uri 会读 n.type or n.headerType)
if type_is_proto then n.type = nil end
n.port = tonumber(n.port) or n.port
if n.alterId ~= nil then n.alterId = tonumber(n.alterId) or n.alterId end
if n.name == nil or n.name == "" then
n.name = (n.server or "") .. ":" .. tostring(n.port or "")
end
nodes[#nodes + 1] = node.normalize(n)
end
end
return nodes, nil
@@ -769,14 +855,95 @@ local function parse_yaml_content(content)
local in_proxies = false
local proxies_indent = 0
for i, line in ipairs(lines) do
-- 读取从 start 行开始、缩进大于 min_indent 的 `k: v` 映射,递归处理嵌套 map
-- (sing-box 的 tls: {enabled, server_name, utls: {fingerprint}} 就有两层)。
-- 返回 (map, 下一个未消费的行号)。
--
-- 单层收集是不够的:内层键会被摊平进外层,`utls.enabled` 会覆盖 `tls.enabled`,
-- `utls.fingerprint` 会变成 `tls.fingerprint`——取值看似还在,语义已经错了。
--
-- nlines 必须在这里就声明:read_map 是局部函数,闭包捕获的是此刻可见的
-- 变量;若 nlines 声明在后面,函数体里的 nlines 会被解析成全局变量。
local nlines = #lines
-- 读取从 start 行开始、缩进大于 min_indent 的 `- item` 序列。
-- 必须先于 read_map 声明:Lua 的局部函数只看得见「声明在它之前」的局部变量,
-- 顺序反了 read_seq 会解析成全局变量。
--
-- 序列必须先于嵌套 map 尝试:read_map 碰到 `- ` 开头的行会立刻 break,
-- 于是 `alpn:` 这种序列键会让整个子块从该行起被截断——不只 alpn 丢失,
-- 排在它后面的键(sing-box 的 utls.fingerprint 等)也一并消失。
local function read_seq(start, min_indent)
local seq, j = {}, start
while j <= nlines do
local l = lines[j]
local t = util.trim(l)
if t == "" then
j = j + 1
else
local ind = #(l:match("^%s*") or "")
if ind <= min_indent then break end
local item = t:match("^%-%s*(.*)$")
if not item then break end
seq[#seq + 1] = item:gsub('^["\'](.*)["\']$', '%1')
j = j + 1
end
end
return seq, j
end
local function read_map(start, min_indent)
local map, j = {}, start
while j <= nlines do
local l = lines[j]
local t = util.trim(l)
if t == "" then
j = j + 1
else
local ind = #(l:match("^%s*") or "")
if ind <= min_indent or t:match("^%-") then break end
local k, v = t:match("^([^:]+):%s*(.*)$")
if not k then break end
k, v = util.trim(k), util.trim(v)
if v ~= "" then
-- 去掉引号
map[k] = v:gsub('^["\'](.*)["\']$', '%1')
j = j + 1
else
-- 先试序列,再试嵌套 map(见 read_seq 的注释:顺序不能反)
local seq, sj = read_seq(j + 1, ind)
if next(seq) then
map[k] = seq
j = sj
else
local sub, nj = read_map(j + 1, ind)
if next(sub) then
map[k] = sub
j = nj
else
-- 空值且不是嵌套 map / 序列:保持旧行为(跳过该键)
j = j + 1
end
end
end
end
end
return map, j
end
-- 用 while 而不是 for:值为空的键要向后收集嵌套 map(sing-box 的
-- `tls:` 块),必须能一次吃掉多行。Lua 的数值 for 每轮都会重新赋值控制
-- 变量,循环体内改 i 不生效;Lua 5.1 也没有 goto。
local i = 1
while i <= nlines do
local line = lines[i]
local trimmed = util.trim(line)
if trimmed == "" or trimmed:sub(1, 1) == "#" then
-- 跳过空行和注释
i = i + 1
elseif trimmed:match("^proxies:%s*$") or trimmed:match("^outbounds:%s*$") then
in_proxies = true
proxies_indent = line:match("^%s*") and #line:match("^%s*") or 0
proxies_indent = #(line:match("^%s*") or "")
current_node = nil
i = i + 1
elseif in_proxies then
-- 检测列表项开始:- name: xxx 或单独的 -
local list_match = line:match("^%s*%-%s*(.*)$")
@@ -787,21 +954,44 @@ local function parse_yaml_content(content)
end
current_node = {}
-- 检查列表项同一行是否有字段
if list_match:match("^([^:]+):%s*(.+)$") then
local k, v = list_match:match("^([^:]+):%s*(.+)$")
current_node[k] = util.trim(v)
end
local k, v = list_match:match("^([^:]+):%s*(.+)$")
if k then current_node[util.trim(k)] = util.trim(v) end
i = i + 1
else
-- 解析缩进的字段
local indent = line:match("^%s*") and #line:match("^%s*") or 0
local indent = #(line:match("^%s*") or "")
if current_node and indent > proxies_indent then
local k, v = trimmed:match("^([^:]+):%s*(.+)$")
if k then
local k, v = trimmed:match("^([^:]+):%s*(.*)$")
if not k then
i = i + 1
else
k = util.trim(k)
v = util.trim(v)
-- 去掉引号
v = v:gsub('^["\'](.*)["\']$', '%1')
current_node[k] = v
if v ~= "" then
-- 去掉引号
current_node[k] = v:gsub('^["\'](.*)["\']$', '%1')
i = i + 1
else
-- 值为空:可能是嵌套 map(sing-box 的 tls: {enabled: true, …})。
-- 原先这类行直接被跳过,于是 security 永远读不到,TLS 整层丢失:
-- 导出的 sing-box 配置里 trojan/vmess/vless 静默退化成明文,
-- hysteria2/tuic 更让客户端以 C.ErrTLSRequired 拒绝启动。
-- 先试序列(alpn: 后面跟 - h2),再试嵌套 map(tls: {...})
local seq, sj = read_seq(i + 1, indent)
if next(seq) then
current_node[k] = seq
i = sj
else
local sub, nj = read_map(i + 1, indent)
if next(sub) then
current_node[k] = sub
i = nj
else
-- 空值且不是嵌套 map / 序列:保持旧行为(跳过该键)
i = i + 1
end
end
end
end
else
-- 遇到缩进减少,结束当前节点
@@ -810,8 +1000,11 @@ local function parse_yaml_content(content)
current_node = nil
end
in_proxies = false
i = i + 1
end
end
else
i = i + 1
end
end
@@ -860,10 +1053,49 @@ local function parse_yaml_content(content)
local skip_cert_verify = nil
if scv == true or scv == "true" or scv == "1" then skip_cert_verify = true
elseif scv == false or scv == "false" or scv == "0" then skip_cert_verify = false end
-- sing-box 的 tls 是一个嵌套 map(tls: {enabled, server_name, insecure, alpn}),
-- 上面收集成子表后在这里展开。字段名对照 parser_json_config.parse_singbox_json
-- (本项目读取 sing-box 出站的权威实现),不另立一套。
local tls_map = type(n.tls) == "table" and n.tls or nil
if tls_map then
-- enabled 是显式开关:sing-box 的 OutboundTLSOptions.Enabled 默认 false,
-- 只有 tls 块存在并不代表启用
local enabled = tls_map.enabled
if enabled == true or enabled == "true" or enabled == "1" then
if tls_map.reality and tls_map.reality ~= "" and tls_map.reality ~= false then
n.security = "reality"
else
n.security = "tls"
end
elseif tls_map.reality and tls_map.reality ~= "" and tls_map.reality ~= false then
n.security = "reality"
end
-- server_name / insecure 只在节点顶层没有对应字段时补
if (n.sni == nil or n.sni == "") and tls_map.server_name then n.sni = tls_map.server_name end
if scv == nil and tls_map.insecure ~= nil then
local ins = tls_map.insecure
if ins == true or ins == "true" or ins == "1" then skip_cert_verify = true
elseif ins == false or ins == "false" or ins == "0" then skip_cert_verify = false end
end
if n.alpn == nil and tls_map.alpn ~= nil then n.alpn = tls_map.alpn end
-- sing-box 的 utls 也是嵌套 map:{enabled, fingerprint}
local utls = tls_map.utls
if n.fp == nil and type(utls) == "table" then
local ue = utls.enabled
if (ue == true or ue == "true" or ue == "1") and utls.fingerprint ~= nil then
n.fp = utls.fingerprint
end
end
end
-- 注意不能用 `(cond) and nil or x` 写法:Lua 的 and/or 在 cond 为真时
-- 结果是 nil,会被后面的 or 继续兜底,等于没生效
local tls_security = n.security
if vmess_cipher ~= nil then tls_security = nil end
-- 同上:嵌套 map 已展开,标量 tls 才继续往下传。
-- 这里同样不能写 `(type(x)=="table") and nil or x` —— cond 为真时得到
-- `true and nil` = nil,再被 `or x` 兜回 x,等于没生效。
local tls_scalar = n.tls
if type(tls_scalar) == "table" then tls_scalar = nil end
local node_data = {
proto = proto,
name = n.name or n.Name or n.tag or (n.server .. ":" .. tostring(port)),
@@ -876,11 +1108,21 @@ local function parse_yaml_content(content)
-- Clash 的 cipher 是 vmess 加密方式;tls 才是 TLS 层,
-- 交由 node.normalize 归一到 security
cipher = vmess_cipher,
tls = n.tls,
-- 嵌套 map 已在上面展开成 security/sni/… ,这里只透传标量写法。
-- 传 table 下去会被下游的 `if n.tls then` 当成「已启用 TLS」
-- (Lua 里 table 恒为真值),tls.enabled=false 也会被判成启用。
tls = tls_scalar,
-- sing-box 的 vmess.security 已被上面消费为 cipher,不再当 TLS 层
security = tls_security,
-- Clash 写 sni / servername,sing-box 的 tls 子块写 server_name
sni = n.sni or n.servername or n.server_name,
-- 下面三项在 tls 子块里展开后挂在 n 上,必须显式带进 node_data,
-- 否则赋值后立刻被丢掉(n 只是个中间收集表,不参与最终结果)。
-- 字段名对照 parser_json_config.parse_singbox_json(本项目读取
-- sing-box 出站的权威实现):那边同样读 flow 与 tls.alpn。
flow = n.flow,
alpn = n.alpn,
fp = n.fp,
-- sing-box 的 tls.insecure 对应 skip-cert-verify。
-- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值,
-- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。
@@ -904,20 +1146,89 @@ end
-- ---------- 局域网订阅链接 ----------
-- 把 IPv6 字面量展开成 8 组 16 位数值(仅用于内网判定);无法解析时返回 nil。
-- 必须展开而不是比字符串前缀:`::1` / `0::1` / `0000::1` / `0:0:0:0:0:0:0:1`
-- 是同一个地址的不同写法,前缀比较会全部漏判(旧实现即如此,`[0::1]` 被判成公网)。
local function expand_v6(s)
s = s:match("^([^%%]*)") or s -- 丢弃 zone id(fe80::1%eth0)
local tail4
local head, a, b, c, d = s:match("^(.*):(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if head then
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
tail4 = { a * 256 + b, c * 256 + d }
s = head
end
local function collect(part)
local out = {}
for g in part:gmatch("[^:]+") do
if #g > 4 or not g:match("^%x+$") then return nil end
out[#out + 1] = tonumber(g, 16)
end
return out
end
local groups = {}
local left, right = s:match("^(.*)::(.*)$")
if left then
if left:find("::", 1, true) or right:find("::", 1, true) then return nil end
local lg, rg = collect(left), collect(right)
if not lg or not rg then return nil end
local zeros = 8 - #lg - #rg - (tail4 and 2 or 0)
if zeros < 1 then return nil end
for i = 1, #lg do groups[#groups + 1] = lg[i] end
for _ = 1, zeros do groups[#groups + 1] = 0 end
for i = 1, #rg do groups[#groups + 1] = rg[i] end
else
local flat = collect(s)
if not flat then return nil end
for i = 1, #flat do groups[#groups + 1] = flat[i] end
end
if tail4 then
groups[#groups + 1] = tail4[1]
groups[#groups + 1] = tail4[2]
end
if #groups ~= 8 then return nil end
return groups
end
-- 判断主机是否为内网 / 回环 / 链路本地地址(SSRF 防护)
local function is_private_host(host)
if not host then return false end
host = host:lower()
if host == "localhost" or host == "::" then return true end
if host == "localhost" then return true end
-- IPv6:去掉方括号
local v6 = host:match("^%[([^%]]+)%]$")
if v6 then host = v6 end
if host:find(":", 1, true) then
local h = host:gsub("^0*", "")
if h == "" or h == "1" then return true end -- :: 或 ::1
return host:match("^::") ~= nil
or host:match("^f[cd]") ~= nil
or host:match("^fe[89ab]") ~= nil
local g = expand_v6(host)
if not g then return false end
-- 未指定地址 :: 与回环 ::1(含 0::1 / 0000::1 / 0:0:0:0:0:0:0:1 等写法)
local all_zero = true
for i = 1, 7 do
if g[i] ~= 0 then
all_zero = false
break
end
end
if all_zero and g[8] <= 1 then return true end
-- IPv4 映射 / 兼容地址(::ffff:a.b.c.d、::a.b.c.d)按 IPv4 规则判定
local mapped = true
for i = 1, 5 do
if g[i] ~= 0 then
mapped = false
break
end
end
if mapped and (g[6] == 0 or g[6] == 0xffff) then
return is_private_host(string.format("%d.%d.%d.%d",
math.floor(g[7] / 256), g[7] % 256, math.floor(g[8] / 256), g[8] % 256))
end
-- ULA fc00::/7 与链路本地 fe80::/10
if g[1] >= 0xfc00 and g[1] <= 0xfdff then return true end
if g[1] >= 0xfe80 and g[1] <= 0xfebf then return true end
return false
end
local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return false end
@@ -960,9 +1271,17 @@ function M.parse_local_link(url)
if not scheme then return nil end
scheme = scheme:lower()
local authority, remainder = rest:match("^([^/]*)(.*)$")
-- authority 止于 / 或 ?:`http://host?target=x` 这种「没有路径、直接跟 query」
-- 的写法,旧模式 `[^/]*` 会把 "host?target=x" 整串当成 authority,
-- 于是 host 变成 "host?target=x"、query 全丢(target/name/uid 都没了)。
local authority, remainder = rest:match("^([^/?]*)(.*)$")
if authority == nil then authority, remainder = rest, "" end
-- 去掉 userinfo@(detect_local_link 一直这么做,此处此前漏了)
if authority:find("@", 1, true) then
authority = authority:match("^.*@(.*)$") or ""
end
-- 提取 host 与 port
local host, port
if authority:sub(1, 1) == "[" then
@@ -1006,7 +1325,13 @@ end
function M.parse(content)
if not content or content == "" then return { nodes = {}, format = "empty" } end
local format = M.detect(content)
if format == "uri" then
if format == "empty" then
-- 纯空白 / 只有 BOM 的内容:detect 会 trim 并去 BOM 后判为 empty,而上面的
-- `content == ""` 只挡住了完全空串。没有这个分支时,一份「全是空白」的订阅
-- 会一路掉到末尾报「无法识别的订阅格式」—— 对用户是误导,内容确实是空的,
-- 不是格式不认识。与空串保持同一返回形态(0 节点、格式 empty)。
return { nodes = {}, format = "empty" }
elseif format == "uri" then
return { nodes = parse_lines(split_lines(content)), format = "uri" }
elseif format == "base64" then
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
@@ -3,9 +3,23 @@
local util = require("substore.util")
local node = require("substore.node")
-- 协议白名单复用 Clash YAML 那张表:两边都是「外部格式的类型名 → 统一模型 proto」,
-- 各维护一份必然漂移。TYPE_MAP 的键就是这里要判定的原始类型名。
local TYPE_MAP = require("substore.parser_clash_yaml").TYPE_MAP
local M = {}
-- 把 Surge / QX 行里的类型名映射到统一模型 proto,未知类型返回 nil。
-- 与 parser_clash_yaml 的未知协议处理保持一致(见那边的长注释):snell / ssh /
-- shadowtls / mieru 这类在统一模型里没有对应字段,原样透传会在输出端变成
-- sing-box 的 type: "snell"、Xray 的 protocol: "snell" 这类非法取值,
-- 客户端会拒绝加载整份配置;兜底成 vmess 更糟(凭空造出字段全错的假节点)。
-- 两者都比丢弃差,所以丢弃。
local function map_proto(raw)
if type(raw) ~= "string" then return nil end
return TYPE_MAP[raw:lower()]
end
-- 解析 Surge 风格行:Name = proto, server, port, k=v, ...
local function parse_surge_line(name, rest)
local parts = {}
@@ -14,8 +28,8 @@ local function parse_surge_line(name, rest)
end
if #parts < 3 then return nil end
local proto = parts[1]:lower()
if proto == "ss" then proto = "shadowsocks" end
local proto = map_proto(parts[1])
if not proto then return nil end
local server = parts[2]
local port = tonumber(parts[3])
@@ -74,8 +88,8 @@ local function parse_qx_line(content)
end
local name = kv.tag or (host .. ":" .. tostring(port or ""))
proto = proto:lower()
if proto == "ss" then proto = "shadowsocks" end
local proto = map_proto(proto)
if not proto then return nil end
local data = { proto = proto, name = name, server = host, port = tonumber(port) }
if proto == "shadowsocks" then
@@ -120,12 +134,17 @@ function M.parse(content)
return nodes
end
-- 判断内容是否为客户端配置文件
-- 判断内容是否为客户端配置文件。
-- 段名大小写不敏感:Surge 官方配置写 `[Proxy]`,但 Loon / 第三方转换器常写
-- `[PROXY]` / `[General]` 这类全大写段名。原来的 find("%[Proxy%]") 只认大小写
-- 完全一致的写法,于是 detect 不认为这是 Surge 配置,继续往下走——
-- 而 `[` 开头的内容会被判成 JSON 数组,整份订阅报「JSON 解析失败」,
-- 一个节点都拿不到(M.parse 本身是按小写段名匹配的,本来就能解析)。
function M.is_config(content)
content = content or ""
return content:find("%[Proxy%]") ~= nil
or content:find("%[proxy%]") ~= nil
or content:find("%[server_local%]") ~= nil
local lower = content:lower()
return lower:find("[proxy]", 1, true) ~= nil
or lower:find("[server_local]", 1, true) ~= nil
end
return M
@@ -15,7 +15,14 @@ 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
if type(host) ~= "string" or host == "" then return false end
if not host:match("^[%w%.%-%:]+$") then return false end
-- 以 "-" 开头会被 busybox 的 getopt 当成**选项**而不是参数:
-- 命令是 `ping -c 1 -W 2 <host>`,host="--help" / "-c" 会改变 ping 的行为。
-- 引号(util.shq)挡不住这个 —— 引号由 shell 剥掉,getopt 看到的仍是 -x。
-- 合法主机名(RFC 1123 要求首字符为字母或数字)与 IP 都不会以 "-" 开头。
if host:sub(1, 1) == "-" then return false end
return true
end
-- 构造 http URL:IPv6 字面量必须写成 [addr]:port,否则 "2001:db8::1:443" 不是合法 URL,
@@ -157,6 +157,12 @@ function M.split_hostport(s)
else
local host, port = s:match("^([^:]+):(%d+)$")
if host then return host, port end
-- 没有可识别的端口,整串就是主机名 —— 但必须先剥掉**尾随**冒号:
-- `"example.com:"` 此前原样返回 host="example.com:",调用方拿这个带
-- 冒号的串当主机名,DNS 解析必然失败,而失败点离这里很远、很难定位。
-- 只剥结尾的冒号,`"::1"` 这类不带方括号的裸 IPv6 不受影响。
s = s:gsub(":$", "")
if s == "" then return nil, nil end
return s, nil
end
end
@@ -218,6 +224,12 @@ function M.json_encode(v)
elseif t == "table" then
if v == M.JSON_EMPTY_OBJECT then
return "{}"
elseif v == JSON_NULL then
-- 数组里的 null 解码时被换成 JSON_NULL 占位(见 json_decode),
-- 编码时必须还原成 null。此前没有这个分支,占位符落进下面的
-- is_array 判定(空表判为数组)被编成 [],于是 `[null,1]` 往返
-- 变成 `[[],1]` —— 结构被悄悄改写。
return "null"
elseif is_array(v) then
local a = {}
for i = 1, #v do a[#a + 1] = enc(v[i]) end
@@ -256,15 +268,21 @@ function M.json_decode(s)
i = i + 1
local obj = {}
skip_ws()
if s:sub(i, i) == "}" then i = i + 1 return obj end
-- 空对象必须返回 JSON_EMPTY_OBJECT 而不是裸 {}:Lua 的空表无法区分
-- {} 与 [],裸 {} 会被 is_array 判成数组、编码回 [],于是
-- `{"tls":{}}` 往返变成 `{"tls":[]}` —— sing-box / Xray 里
-- 要求是对象的字段(tls / settings)会因此被客户端拒绝。
if s:sub(i, i) == "}" then i = i + 1 return M.JSON_EMPTY_OBJECT end
while true do
skip_ws()
local k, ke = parse()
if ke then return nil, ke end
if not k or type(k) ~= "string" then return nil, "expected string key" end
skip_ws()
if s:sub(i, i) ~= ":" then return nil, "expected ':'" end
i = i + 1
local val = parse()
local val, verr = parse()
if verr then return nil, verr end
obj[k] = val
skip_ws()
local cc = s:sub(i, i)
@@ -284,7 +302,11 @@ function M.json_decode(s)
skip_ws()
if s:sub(i, i) == "]" then i = i + 1 return arr end
while true do
local val = parse()
local val, verr = parse()
-- 必须把内层错误透出去:此前无条件把 nil 当成 null 占位,
-- 于是 `[1,]` 这种畸形输入被静默接受(parse 报错后 i 未前进,
-- 下一轮读到 `]` 就当成数组结束),解出 `{1, JSON_NULL}`。
if verr then return nil, verr end
if val == nil then val = JSON_NULL end
arr[#arr + 1] = val
skip_ws()
@@ -358,7 +380,17 @@ function M.json_decode(s)
end
end
local v = parse()
local v, perr = parse()
-- 此前写成 `local v = parse(); return v` —— parse 的第二返回值(错误串)
-- 被直接丢弃,于是 json_decode **永远不返回错误**:所有调用方的
-- `local data, err = util.json_decode(...)` 里 err 判断都是死代码,
-- 畸形输入一律表现为「解出来是 nil」,与「内容就是 null」无法区分。
if perr then return nil, perr end
-- 解析出一个值还不够,必须确认**没有剩余内容**:parse 只消费一个值,
-- 于是 "1 2" / "[1] junk" / "1.2.3"(数字模式只吃 1.2)这类输入会被
-- 当成合法值静默接受,尾部的脏数据凭空消失。
skip_ws()
if i <= len then return nil, "trailing content at position " .. i end
return v
end