mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-06 03:14:12 +08:00
🌈 Sync 2026-10-01 13:09:37
This commit is contained in:
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user