🌈 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

@@ -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 两者皆可