🤞 Sync 2026-09-30 09:28:31

This commit is contained in:
github-actions[bot] committed 2026-09-30 09:28:31 +08:00
1 parent 0108cfa10a
commit 3d9edb1c37
78 files changed
+4594 -1815

No files matched your search

@@ -133,7 +133,8 @@ var FIELD_LABELS = {
"listen-port": "Listen Port",
"mtu": "MTU",
"dns": "DNS",
"amnezia-wg-option": "Amnezia WG Option"
"amnezia-wg-option": "Amnezia WG Option",
"username": "Username"
};
var NODEFORM_I18N = {
"node": "<%:Node%>",
@@ -76,7 +76,8 @@ var FIELD_LABELS = {
"listen-port": "Listen Port",
"mtu": "MTU",
"dns": "DNS",
"amnezia-wg-option": "Amnezia WG Option"
"amnezia-wg-option": "Amnezia WG Option",
"username": "Username"
};
var NODEFORM_I18N = {
"node": "<%:Node%>",
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
local M = {}
M.version = "2.5.1"
M.version = "2.6.0"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
@@ -25,12 +25,21 @@ function M.ensure_dirs()
util.ensure_dir(M.NODES_DIR)
end
-- 读取订阅列表,返回 seq, items, err。
--
-- 必须区分「文件不存在 / 为空」(合法的空列表)与「文件存在但解析失败」
-- (内容损坏、被截断、磁盘错误)。后者若按空列表处理,后果是灾难性的:
-- M.add / M.add_local / M.add_combo 会在这个空表上追加一条然后整表写回,
-- 把用户已有的全部订阅抹掉,并且 _seq 归零后重新发出 s00000001 这种
-- 已经用过的 ID。因此损坏时必须显式报错,由调用方拒绝写入。
local function load()
M.ensure_dirs()
local raw = util.read_file(M.LIST_FILE)
if not raw or raw == "" then return 0, {} end
local data = util.json_decode(raw)
if type(data) ~= "table" then return 0, {} end
if type(data) ~= "table" then
return 0, {}, "订阅列表文件已损坏,无法解析:" .. M.LIST_FILE
end
local seq = tonumber(data._seq) or 0
local items = type(data.items) == "table" and data.items or {}
return seq, items
@@ -41,8 +50,11 @@ local function save(seq, items)
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
end
-- 返回 arr, err。err 非空表示列表文件已损坏(此时 arr 为空)。
-- 追加第二个返回值是向后兼容的:调用方普遍写成 ipairs(core.list()) 或
-- local items = core.list(),都只取第一个值。
function M.list()
local _, items = load()
local _, items, lerr = load()
local arr = {}
for id, meta in pairs(items) do
local m = {}
@@ -51,7 +63,7 @@ function M.list()
arr[#arr + 1] = m
end
table.sort(arr, function(a, b) return (a.name or "") < (b.name or "") end)
return arr
return arr, lerr
end
function M.get(id)
@@ -70,7 +82,8 @@ function M.add(name, url, opts)
url = util.trim(url or "")
opts = opts or {}
if name == "" or url == "" then return nil, "名称/URL 不能为空" end
local seq, items = load()
local seq, items, lerr = load()
if lerr then return nil, lerr end
seq = seq + 1
local id = string.format("s%08x", seq)
local cron_time = util.trim(opts.cron_time or "")
@@ -103,7 +116,8 @@ function M.add_local(name, raw_content, local_mode, opts)
local_mode = local_mode or "text"
opts = opts or {}
if name == "" or raw_content == "" then return nil, "名称/内容 不能为空" end
local seq, items = load()
local seq, items, lerr = load()
if lerr then return nil, lerr end
seq = seq + 1
local id = string.format("s%08x", seq)
items[id] = {
@@ -131,7 +145,8 @@ end
-- 获取订阅的下载 token;若不存在则生成并持久化
function M.ensure_token(id)
if not id_is_valid(id) then return nil end
local seq, items = load()
local seq, items, lerr = load()
if lerr then return nil end
local meta = items[id]
if not meta then return nil end
if not meta.token or meta.token == "" then
@@ -178,7 +193,8 @@ M.CLEAR = setmetatable({}, { __tostring = function() return "substore.CLEAR" end
function M.save_meta(id, patch)
if not id_is_valid(id) then return false, "非法 ID" end
local seq, items = load()
local seq, items, lerr = load()
if lerr then return false, lerr end
local meta = items[id]
if not meta then return false, "订阅不存在" end
for k, v in pairs(patch or {}) do
@@ -189,7 +205,8 @@ end
function M.remove(id)
if not id_is_valid(id) then return false end
local seq, items = load()
local seq, items, lerr = load()
if lerr then return false, lerr end
if not items[id] then return false end
items[id] = nil
save(seq, items)
@@ -363,6 +380,10 @@ local FORM_KEYS = {
reserved = true, ["persistent-keepalive"] = true, persistent_keepalive = true,
["listen-port"] = true, listen_port = true,
mtu = true, dns = true, ["amnezia-wg-option"] = true,
-- socks / http 的用户名。必须在表里:下面的 merge_form_node 先按 FORM_KEYS
-- 清空原节点再套用提交值,不在表里的字段会保留旧值——用户在表单里清空用户名
-- 也删不掉(collectNodes 会略过空值),等于改不动。
username = true,
}
-- 合并表单节点到原节点:表单字段整体替换(可清空),非表单字段保留
@@ -441,7 +462,8 @@ function M.add_combo(name, sources, opts)
end
end
if #srcs == 0 then return nil, "请选择至少一个订阅" end
local seq, items = load()
local seq, items, lerr = load()
if lerr then return nil, lerr end
seq = seq + 1
local id = string.format("s%08x", seq)
items[id] = {
@@ -37,6 +37,11 @@ end
function M.normalize(node)
if type(node) ~= "table" then return node end
if node.proto == "ss" then node.proto = "shadowsocks" end
-- socks5 与 socks 是同一个协议,只是各客户端写法不同(Clash 写 socks5、
-- sing-box 写 socks、分享链接写 socks5://)。统一成 socks:
-- 各输出模块本来就同时认这两种写法,但 node.filter / rules.proto_filter
-- 是精确比较,两种写法会互相看不见——节点页按协议筛选和规则过滤都会漏。
if node.proto == "socks5" then node.proto = "socks" end
local d = DEFAULTS[node.proto] or {}
for k, v in pairs(d) do
if node[k] == nil then node[k] = v end
@@ -5,9 +5,15 @@ local M = {}
local function esc_yaml(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
-- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配
if s:find("[ :#{}%[%],&*?|>'\"%@`]") or s:match("^[-?]*:") then
-- 是否需要双引号必须在转义之前判定:转义之后的 \t / \r 只剩「反斜杠 + 字母」,
-- 落在 plain scalar 里会被 YAML 当成两个字面字符而不是制表符/回车。含任何控制
-- 字符(\n \r \t 等)时必须加引号 —— 未加引号的换行/回车会直接破坏文档结构。
local need_quote = s:find("%c") ~= nil
-- 注意:这里必须用 Lua 模式(不能传 plain=true),否则整串被当作字面量、永不匹配
or s:find("[ :#{}%[%],&*?|>'\"%@`]") ~= nil
or s:match("^[-?]*:") ~= nil
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
if need_quote then
return '"' .. s .. '"'
end
return s
@@ -106,11 +112,18 @@ local function format_node(node)
end
-- servername / sni
if node.sni then
lines[#lines + 1] = " servername: " .. esc_yaml(node.sni)
end
if node.servername then
lines[#lines + 1] = " servername: " .. esc_yaml(node.servername)
-- vmess / vless / trojan 在 mihomo 里用 servername;hysteria / hysteria2 用 sni
-- (已对照上游文档确认:hysteria 系列没有 servername 字段,写了会被忽略,
-- 结果 SNI 丢失 → 客户端拿 IP 校验证书直接握手失败)。hysteria 系列的 sni
-- 由下面的协议专属分支输出。
-- sni 与 servername 是同一个字段的两种写法,必须只输出一个键:Clash YAML 导入
-- 会同时填上两者(先 sni = p.sni or p.servername,随后的字段保留循环又原样复制了
-- servername),各写一行会让 YAML 出现重复键 —— 严格解析器直接报错,宽松解析器
-- 则取最后一个,行为不确定。
local is_hysteria = (ctype == "hysteria" or ctype == "hysteria2")
local tls_name = node.sni or node.servername
if tls_name and not is_hysteria then
lines[#lines + 1] = " servername: " .. esc_yaml(tls_name)
end
-- 特殊字段
@@ -152,7 +165,7 @@ local function format_node(node)
if node.sni then
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
end
-- 混淆(salamander)
-- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 两段
if node.obfs and node.obfs ~= "" and node.obfs ~= "plain" then
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs)
local opw = node["obfs-password"] or node.obfs_password
@@ -162,6 +175,17 @@ local function format_node(node)
end
end
if ctype == "hysteria" then
-- hysteria(v1):obfs 只是普通字符串,**没有** obfs-password
-- (obfs-password 是 hysteria2 的 salamander 专属字段,写到 v1 上是非法键)
if node.sni then
lines[#lines + 1] = " sni: " .. esc_yaml(node.sni)
end
if node.obfs and node.obfs ~= "" and node.obfs ~= "plain" then
lines[#lines + 1] = " obfs: " .. esc_yaml(node.obfs)
end
end
if ctype == "tuic" then
if node["udp-relay-mode"] then
lines[#lines + 1] = " udp-relay-mode: " .. esc_yaml(node["udp-relay-mode"])
@@ -214,13 +238,13 @@ local function format_node(node)
end
end
if ctype == "socks5" then
-- socks5 / http 的认证字段都是 username + password(已对照上游 mihomo 文档确认)。
-- password 已由上面的「协议通用字段」输出,这里只补 username——重复输出会让
-- YAML 里出现两个 password 键,属于非法/歧义配置。
if ctype == "socks5" or ctype == "http" then
if node.username then
lines[#lines + 1] = " username: " .. esc_yaml(node.username)
end
if node.password then
lines[#lines + 1] = " password: " .. esc_yaml(node.password)
end
end
if ctype == "ssr" then
@@ -57,8 +57,17 @@ function M.surge_line(n)
e[#e + 1] = "username=" .. (n.uuid or "")
if n.password then e[#e + 1] = "password=" .. n.password end
if n.sni then e[#e + 1] = "sni=" .. n.sni end
if n.alpn then e[#e + 1] = "alpn=" .. n.alpn end
elseif proto == "socks5" or proto == "socks" then
if n.alpn then
-- alpn 可能是数组:sing-box JSON 与 Clash YAML 的 alpn 列表导入后就是 table,
-- 直接拼接会 "attempt to concatenate a table value" 让整次导出失败
local alpn = n.alpn
if type(alpn) == "table" then alpn = table.concat(alpn, ",") end
if alpn ~= "" then e[#e + 1] = "alpn=" .. alpn end
end
elseif proto == "socks5" or proto == "socks" or proto == "http" then
-- socks5 / http 在 Surge 家族里都用 username= / password= 具名参数
-- (Surge 手册:Name = http, <host>, <port>[, <username>, <password>]
-- "may be given positionally after the port, or as named parameters")
if n.username then
e[#e + 1] = "username=" .. n.username
if n.password then e[#e + 1] = "password=" .. n.password end
@@ -70,7 +79,8 @@ function M.surge_line(n)
local line = (n.name or "") .. " = " .. head
if #e > 0 then line = line .. ", " .. table.concat(e, ", ") end
return line
-- 节点名与各参数值都来自订阅(不可信):含换行会截断本行并伪造出一条新的代理行
return util.one_line(line)
end
-- 收集节点名列表
@@ -112,7 +122,7 @@ local function surge_config(nodes, group_name, supports_ssr)
local select = group_name .. " = select"
for _, nm in ipairs(names) do select = select .. ", " .. nm end
select = select .. ", DIRECT"
out[#out + 1] = select
out[#out + 1] = util.one_line(select)
return table.concat(out, "\n")
end
@@ -172,6 +182,7 @@ function M.to_qx(nodes, options)
local proto = props(n)
local host = (n.server or "") .. ":" .. tostring(n.port or 0)
local tag = n.name or host
local before = #out
if proto == "shadowsocks" then
out[#out + 1] = string.format(
"shadowsocks=%s, method=%s, password=%s, tag=%s",
@@ -199,13 +210,16 @@ function M.to_qx(nodes, options)
host, n.password or "", tag)
if n.sni then out[#out] = out[#out] .. ", tls-host=" .. n.sni end
end
-- 节点名 / sni / path / host 全部来自订阅(不可信),含换行会截断本行
-- 并伪造出一条新的 server_local 行
for i = before + 1, #out do out[i] = util.one_line(out[i]) end
end
out[#out + 1] = ""
out[#out + 1] = "[policy]"
local names = names_of(nodes)
local policy = "static=" .. (options.name or "PROXY") .. ", DIRECT"
for _, nm in ipairs(names) do policy = policy .. ", " .. nm end
out[#out + 1] = policy
out[#out + 1] = util.one_line(policy)
out[#out + 1] = ""
out[#out + 1] = "[server_remote]"
return table.concat(out, "\n")
@@ -33,31 +33,32 @@ local function supported(proto)
end
-- 构建 TLS 字段
--
-- enabled 必须显式写 true:sing-box 的 OutboundTLSOptions.Enabled 是
-- `bool` + `json:"enabled,omitempty"`(option/tls.go),缺省值即 false。
-- 上游 common/tls/client.go 里 `if !options.Enabled { return dialer, nil }`
-- 会直接按明文拨号,hysteria2 / tuic 更会因为 `options.TLS == nil ||
-- !options.TLS.Enabled` 返回 C.ErrTLSRequired 而拒绝启动。只输出
-- server_name / insecure 而不带 enabled 等于没配 TLS。
local function build_tls(n)
local tls
if n.security and n.security ~= "none" then
tls = {}
if n.sni or n.servername then tls.server_name = n.sni or n.servername end
if n.alpn then
if type(n.alpn) == "string" then
local list = {}
for p in n.alpn:gmatch("[^,]+") do list[#list + 1] = p:match("^%s*(.-)%s*$") end
tls.alpn = list
else
tls.alpn = n.alpn
end
if not (n.security and n.security ~= "none") then return nil end
local tls = { enabled = true }
if n.sni or n.servername then tls.server_name = n.sni or n.servername end
if n.alpn then
if type(n.alpn) == "string" then
local list = {}
for p in n.alpn:gmatch("[^,]+") do list[#list + 1] = p:match("^%s*(.-)%s*$") end
tls.alpn = list
else
tls.alpn = n.alpn
end
if n["skip-cert-verify"] ~= nil then
tls.insecure = bool(n["skip-cert-verify"])
elseif n.skip_cert_verify ~= nil then
tls.insecure = bool(n.skip_cert_verify)
end
-- 空表会被 json_encode 编码成 [],而 tls 必须是对象;
-- security=tls 但无 sni/alpn/insecure 时就会走到这里
if next(tls) == nil then return util.JSON_EMPTY_OBJECT end
return tls
end
return nil
if n["skip-cert-verify"] ~= nil then
tls.insecure = bool(n["skip-cert-verify"])
elseif n.skip_cert_verify ~= nil then
tls.insecure = bool(n.skip_cert_verify)
end
return tls
end
-- 构建传输(transport)字段
@@ -112,12 +113,23 @@ function M.to_outbound(n, tag)
elseif stype == "shadowsocks" then
o.method = n.method or n.cipher or "aes-256-gcm"
o.password = n.password or ""
elseif stype == "hysteria2" or stype == "hysteria" then
elseif stype == "hysteria2" then
o.password = n.password or ""
-- 混淆(salamander)
-- 混淆(salamander):hysteria2 的 obfs 是 { type, password } 对象
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
o.obfs = { type = n.obfs, password = n["obfs-password"] or n.obfs_password or "" }
end
elseif stype == "hysteria" then
-- hysteria(v1) 与 hysteria2 在 sing-box 里的字段名并不相同(已对照上游文档确认):
-- * 认证字段是 auth_str,不是 password(parser_json_config 也是按
-- outbound.password or outbound.auth_str or outbound.auth 回读的)
-- * obfs 是普通字符串,不是 { type, password } 对象
-- 注意:sing-box 的 hysteria 出站还要求 up/down(带宽),本项目的节点模型
-- 不承载该字段,需用户在自己的配置里补上(见 CHANGELOG 已知限制)。
o.auth_str = n.password or ""
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
o.obfs = n.obfs
end
elseif stype == "tuic" then
o.uuid = n.uuid or ""
o.password = n.password or ""
@@ -142,7 +154,8 @@ function M.to_outbound(n, tag)
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
elseif stype == "socks" then
elseif stype == "socks" or stype == "http" then
-- socks 与 http 出站的认证字段相同(username / password)
if n.username then o.username = n.username end
if n.password then o.password = n.password end
end
@@ -84,11 +84,15 @@ function M.to_share_uri(n)
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
-- 混淆(salamander):hy2 URI 标准参数 obfs / obfs-password
-- 混淆:hysteria2 是 salamander,URI 参数为 obfs / obfs-password;
-- hysteria(v1) 的 obfs 只是普通字符串,**没有** obfs-password
-- (写到 v1 链接上是非法参数,且会误导下游客户端)
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
q[#q + 1] = "obfs=" .. url_encode(n.obfs)
local opw = n["obfs-password"] or n.obfs_password
if opw and opw ~= "" then q[#q + 1] = "obfs-password=" .. url_encode(opw) end
if proto == "hysteria2" then
local opw = n["obfs-password"] or n.obfs_password
if opw and opw ~= "" then q[#q + 1] = "obfs-password=" .. url_encode(opw) end
end
end
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
return (proto == "hysteria2" and "hysteria2://" or "hysteria://")
@@ -2,6 +2,8 @@
-- luci-app-substore
-- 与 parser.parse_wireguard_conf 互为逆操作:导入解析 [Interface] / [Peer],此处按同格式写回。
local util = require("substore.util")
local M = {}
-- 仅 wireguard 节点可用 .conf 表达;其余协议无法落到单行/单段,直接丢弃
@@ -156,7 +158,9 @@ function M.generate(nodes, options)
return nil, "该 WireGuard 节点没有私钥 (private-key),无法导出 .conf"
end
local lines = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
-- 注释行也必须压成单行:节点名来自订阅(不可信),含换行时会注入出真正的
-- 配置行(例如 "x\n[Interface]"),伪造出一段用户没写过的隧道配置
local lines = { "# " .. util.one_line(n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
for _, line in ipairs(build_section(n)) do lines[#lines + 1] = line end
return table.concat(lines, "\n") .. "\n"
end
@@ -10,9 +10,15 @@ local parser_surge = require("substore.parser_surge")
local M = {}
-- URI 文本导入支持的 scheme。
-- 必须覆盖 output_uri.to_share_uri 能生成的全部 scheme,否则「导出→导入」回环会
-- 丢节点:之前 hysteria / socks 只出不进(导出为 hysteria:// / socks5://,
-- 再导入却报 "unsupported proto"),含这两类节点的订阅文本也会被整行丢弃。
-- socks5 与 socks 都接受,统一归一为 proto="socks"(见 node.normalize)。
local SUPPORTED = {
vmess = true, vless = true, trojan = true, ss = true, ssr = true,
hysteria2 = true, tuic = true, wireguard = true,
hysteria = true, hysteria2 = true, tuic = true, wireguard = true,
socks = true, socks5 = true,
}
local function split_lines(content)
@@ -363,6 +369,88 @@ local function parse_hysteria2(uri, body)
return out
end
-- hysteria://password@host:port/?sni=..&insecure=..&obfs=..#name(v1)
-- 与 hysteria2 的区别(均已对照上游确认):
-- * v1 的 obfs 是普通字符串,没有 obfs-password(obfs-password 是 hysteria2 的 salamander)
-- * 两者在 mihomo / sing-box 里都是 TLS-only,因此同样补 security="tls"
-- 认不出的查询参数一律忽略(不报错),保证第三方生成的链接仍能解析出 server/port/password。
local function parse_hysteria(uri, body)
local name, rest = "", body
local hash = rest:find("#", 1, true)
if hash then
name = util.url_decode(rest:sub(hash + 1))
rest = rest:sub(1, hash - 1)
end
local query = {}
local qpos = rest:find("?", 1, true)
local hp = rest
if qpos then
hp = rest:sub(1, qpos - 1)
for k, v in rest:sub(qpos + 1):gmatch("([^&=]+)=([^&]*)") do
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 out = node.normalize({
proto = "hysteria", name = name, server = host, port = tonumber(port),
password = password, raw = uri,
})
if query.sni then out.sni = query.sni end
out.security = "tls"
if query.insecure ~= nil then
out.insecure = query.insecure
out["skip-cert-verify"] = not (query.insecure == "0" or query.insecure == "false")
end
if query.obfs and query.obfs ~= "" then out.obfs = query.obfs end
return out
end
-- socks5://user:pass@host:port#name(socks:// 同义)
-- 认证信息可选:没有 userinfo 时是无认证 socks。统一归一为 proto="socks"。
local function parse_socks(uri, body)
local name, rest = "", body
local hash = rest:find("#", 1, true)
if hash then
name = util.url_decode(rest:sub(hash + 1))
rest = rest:sub(1, hash - 1)
end
-- socks 没有标准查询参数,但第三方链接可能带 ? 尾巴,截掉以免污染 host:port
local qpos = rest:find("?", 1, true)
local hp = qpos and rest:sub(1, qpos - 1) or rest
local username, password
-- 取**最后**一个 @ 作为 userinfo 与 authority 的分界:未编码的 @ 只可能出现在
-- 密码里(userinfo 不允许裸 @),按第一个 @ 切会把 "p@ss" 这类密码切坏,
-- 而且失败是静默的(解析出一个错的密码)。
local userinfo, hostport = hp:match("^(.*)@([^@]*)$")
if not userinfo then
hostport = hp
else
userinfo = util.url_decode(userinfo)
local cpos = userinfo:find(":", 1, true)
if cpos then
username = userinfo:sub(1, cpos - 1)
password = userinfo:sub(cpos + 1)
else
username = userinfo
end
if username == "" then username = nil end
end
local host, port = util.split_hostport(hostport)
if not host or host == "" then return nil, "bad socks" end
local out = node.normalize({
proto = "socks", name = name, server = host, port = tonumber(port), raw = uri,
})
if username then out.username = username end
if password then out.password = password end
return out
end
-- tuic://uuid:password@host:port/?congestion_control=..&alpn=..&sni=..#name
local function parse_tuic(uri, body)
local name, rest = "", body
@@ -617,9 +705,11 @@ function M.parse_uri(uri)
if proto == "vless" then return parse_vless(uri, body) end
if proto == "trojan" then return parse_trojan(uri, body) end
if proto == "vmess" then return parse_vmess(uri, body) end
if proto == "hysteria" then return parse_hysteria(uri, body) end
if proto == "hysteria2" then return parse_hysteria2(uri, body) end
if proto == "tuic" then return parse_tuic(uri, body) end
if proto == "wireguard" then return parse_wireguard(uri, body) end
if proto == "socks" or proto == "socks5" then return parse_socks(uri, body) end
return nil, "unsupported"
end
@@ -730,38 +820,72 @@ local function parse_yaml_content(content)
raw_nodes[#raw_nodes + 1] = current_node
end
-- 协议映射:Clash type -> proto
local proto_map = {
vmess = "vmess",
vless = "vless",
trojan = "trojan",
ss = "shadowsocks",
ssr = "ssr",
http = "http",
socks5 = "socks5",
}
-- 协议映射:Clash type -> proto。
-- 复用 parser_clash_yaml 的权威表(单一事实来源),本处曾自维护一份,缺
-- hysteria2/hysteria/tuic/wireguard 且 socks5 未归一,后果是:
-- * 认不出的 type 被 `or "vmess"` 兜底成 vmess —— 一份 sing-box YAML 里的
-- hysteria2/tuic/wireguard 出站会变成 4 个没有 uuid 的假 vmess 节点(静默数据损坏)
-- * socks5 原样保留,而节点页筛选/规则 proto_filter 用的是规范名 socks,节点查不到
local proto_map = parser_clash_yaml.TYPE_MAP
-- 转换字段名并归一化
local result = {}
for _, n in ipairs(raw_nodes) do
if not n or not n.server or not n.port then
-- 跳过无效节点
local proto = proto_map[n.type or n.proto]
-- 上面的 in_proxies 分支同时接受 `proxies:`(Clash)与 `outbounds:`(sing-box)
-- 两种段名,但两者字段名并不相同:Clash 用 port / name,sing-box 用
-- server_port / tag。此前只读 port / name,导致 sing-box YAML 的 outbounds
-- 全部卡在下面的 `not port` 判断上被静默丢弃(识别了段名却一个节点都拿不到)。
-- 下列别名逐字对照 parser_json_config.parse_singbox_json 读取的键名,
-- 那是本项目读取 sing-box 出站的权威实现,不另立一套。
local port = n.port or n.server_port
if not n or not n.server or not port or not proto then
-- 跳过无效节点 / 未知协议。
-- 未知协议不能兜底成 vmess:那是凭空造出一个字段全错的节点,
-- 比丢弃更糟(用户看到"导入成功",实际拿到一批不可用的假节点)。
else
local proto = proto_map[n.type or n.proto] or "vmess"
-- vmess 的加密方式:Clash 写 cipher,sing-box 写 security。
-- security 在 node.normalize 里是 TLS 层,两者语义冲突,因此仅当取值
-- 落在 vmess 加密方式白名单内时才当作 cipher 消费掉,否则留给 TLS 层。
local vmess_cipher
if proto == "vmess" then
vmess_cipher = n.cipher
if vmess_cipher == nil and n.security ~= nil
and node.VMESS_CIPHERS[n.security] then
vmess_cipher = n.security
end
end
-- 同上的字符串真值问题:只认明确的真值写法
local scv = n["skip-cert-verify"] or n.insecure
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
-- 注意不能用 `(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
local node_data = {
proto = proto,
name = n.name or n.Name or (n.server .. ":" .. tostring(n.port or "")),
name = n.name or n.Name or n.tag or (n.server .. ":" .. tostring(port)),
server = n.server,
port = tonumber(n.port),
port = tonumber(port),
uuid = n.uuid or n.id,
password = n.password,
-- sing-box 的 hysteria/hysteria2 认证字段是 password / auth_str / auth
password = n.password or n.auth_str or n.auth,
method = n.cipher or n.method,
-- Clash 的 cipher 是 vmess 加密方式;tls 才是 TLS 层,
-- 交由 node.normalize 归一到 security
cipher = (proto == "vmess") and n.cipher or nil,
cipher = vmess_cipher,
tls = n.tls,
security = n.security,
sni = n.sni or n.servername,
-- 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,
-- sing-box 的 tls.insecure 对应 skip-cert-verify。
-- 行解析器读出来的是字符串,而 "false" / "0" 在 Lua 里也是真值,
-- 直接透传会让 skip-cert-verify=false 变成「跳过证书校验」,故显式判假。
["skip-cert-verify"] = skip_cert_verify,
net = n.net or n.network,
alterId = tonumber(n.alterId),
}
result[#result + 1] = node.normalize(node_data)
@@ -888,6 +1012,17 @@ function M.parse(content)
-- 兼容标准 base64 与 base64url(- _ 无 padding):base64_url_decode 两者皆可
local decoded = util.base64_url_decode(content)
if decoded == "" then return nil, "Base64 解码失败" end
-- 解出来的内容本身可能是 YAML / JSON:机场把整份 Clash 配置或 sing-box
-- 配置 base64 后直接下发是很常见的做法。原先一律按 URI 列表解析,这类
-- 订阅会得到 0 个节点且不报错(用户只看到「订阅为空」)。
-- 递归走一遍 detect/parse 复用既有分支;inner == "base64" 时不再递归,
-- 避免 base64 套 base64 时无限递归。外层容器格式仍是 base64。
local inner = M.detect(decoded)
if inner ~= "base64" and inner ~= "empty" and inner ~= "unknown" then
local res, err = M.parse(decoded)
if res then res.format = "base64" end
return res, err
end
return { nodes = parse_lines(split_lines(decoded)), format = "base64" }
elseif format == "json" then
local nodes, err = parse_json_content(content)
@@ -7,7 +7,10 @@ local node = require("substore.node")
local M = {}
-- Clash type → 统一 proto
-- Clash type → 统一 proto。
-- 这是**权威映射表**:parser_json_config 与 parser.lua 的简易 YAML 兜底解析都复用同一份,
-- 避免各自维护一份而漂移(曾出现兜底那份缺 hysteria2/hysteria/tuic/wireguard,
-- 且把 socks5 原样留下,导致这些节点被误当成 vmess 或被筛选器静默丢弃)。
local TYPE_MAP = {
vmess = "vmess",
vless = "vless",
@@ -24,6 +27,9 @@ local TYPE_MAP = {
http = "http",
}
-- 导出供 parser.lua 的简易 YAML 兜底解析复用(单一事实来源)
M.TYPE_MAP = TYPE_MAP
-- 标量值转换:布尔 / 数字 / 去引号 / 保留字符串
local function scalar(raw)
raw = raw or ""
@@ -47,6 +53,74 @@ local function scalar(raw)
return raw
end
-- 去掉行尾注释。YAML 规定内联注释的 # 前必须有空白,引号内的 # 不算注释。
-- 不处理的话 `proxies: # 说明` 会把注释文本当成值,proxies 变成字符串,
-- 整份配置被判定为「没有 proxies」→ 0 个节点且不报错。
local function strip_comment(v)
local in_s, in_d = false, false
for i = 1, #v do
local c = v:sub(i, i)
if in_s then
if c == "'" then in_s = false end
elseif in_d then
if c == '"' then in_d = false end
elseif c == "'" then in_s = true
elseif c == '"' then in_d = true
elseif c == "#" and (i == 1 or v:sub(i - 1, i - 1):match("%s")) then
return (v:sub(1, i - 1):gsub("%s*$", ""))
end
end
return v
end
-- 按顶层分隔符切分,忽略引号内以及 {} / [] 内部的同级分隔符
local function split_top(s, sep)
local parts, buf = {}, {}
local depth, in_s, in_d = 0, false, false
for i = 1, #s do
local c = s:sub(i, i)
if in_s then
if c == "'" then in_s = false end
buf[#buf + 1] = c
elseif in_d then
if c == '"' then in_d = false end
buf[#buf + 1] = c
elseif c == "'" then
in_s = true; buf[#buf + 1] = c
elseif c == '"' then
in_d = true; buf[#buf + 1] = c
elseif c == "{" or c == "[" then
depth = depth + 1; buf[#buf + 1] = c
elseif c == "}" or c == "]" then
depth = depth - 1; buf[#buf + 1] = c
elseif c == sep and depth == 0 then
parts[#parts + 1] = table.concat(buf); buf = {}
else
buf[#buf + 1] = c
end
end
parts[#parts + 1] = table.concat(buf)
return parts
end
-- 解析 YAML 流式映射 {...}。Clash 机场配置常用
-- `- {name: A, type: vmess, server: 1.1.1.1, port: 443}` 这种写法:它是合法 YAML,
-- 但不是合法 JSON(键没有加引号),util.json_decode 必然失败,节点被整条静默
-- 丢弃(0 个节点且无报错)。
local function parse_flow_map(s)
local body = s:match("^%s*{(.*)}%s*$")
if not body then return nil end
local map = {}
for _, part in ipairs(split_top(body, ",")) do
local k, v = part:match("^%s*([^:]+):%s*(.*)$")
if k then
map[k:gsub("%s*$", "")] = scalar(strip_comment(v))
end
end
if next(map) == nil then return nil end
return map
end
-- 预处理内容为 (indent, rest) 列表,跳过空行、注释、文档分隔符
local function preprocess(content)
local items = {}
@@ -79,6 +153,9 @@ local function parse_yaml(content)
pos = pos + 1
else
k = k:gsub("%s*$", "")
-- 行尾注释必须先去掉,否则 `proxies: # 说明` 会把注释文本当成值,
-- proxies 变成字符串,整份配置被判定为「没有 proxies」
v = strip_comment(v)
pos = pos + 1
if v == "" or v == "|" or v == ">" then
if pos <= n_items and items[pos].ind > it.ind then
@@ -108,12 +185,17 @@ local function parse_yaml(content)
if it.rest == "-" then dash = "" else dash = it.rest:match("^-%s*(.*)$") end
if dash == nil then break end
pos = pos + 1
-- 列表项也要去掉行尾注释:`- {a: 1} # 说明`、`- "::/0" # 说明`
-- 都会让注释文本混进值里(流式映射那条甚至会整项解析失败)
dash = strip_comment(dash)
local m = nil
-- 流式 JSON 对象:- {"name":"...","type":"vmess",...}(机场 clash 配置常见写法)
if dash:sub(1, 1) == "{" then
-- 先按 JSON 解(键带引号的流式写法),失败再按 YAML 流式映射解
local obj = util.json_decode(dash)
if type(obj) == "table" then m = obj end
if not m then m = parse_flow_map(dash) end
else
local k, v = dash:match("^([^:]+):%s*(.*)$")
-- 引号开头的列表项是标量而非映射:避免把 "- \"::/0\"" 里的冒号
@@ -152,7 +234,15 @@ local function map_clash_node(p)
if type(p) ~= "table" then return nil end
if not p.name or not p.server or not p.port then return nil end
local proto = TYPE_MAP[p.type] or p.type or "vmess"
-- 未知协议必须丢弃,不能原样透传、也不能兜底成 vmess:
-- * 原样透传:统一模型承载不了 snell / shadowtls / mieru / ssh 等类型(认证
-- 方式与字段都不同),透传出去会在输出端变成 sing-box 的 type: "snell"、
-- Xray 的 protocol: "snell" 这类非法取值,客户端会拒绝加载整份配置。
-- * 兜底成 vmess:那是凭空造出一个字段全错的节点,比丢弃更糟(用户看到
-- "导入成功",实际拿到一批不可用的假节点)。
-- parser.lua 的简易 YAML 兜底解析(复用同一张 TYPE_MAP)早已按此处理,此处对齐。
local proto = TYPE_MAP[p.type]
if not proto then return nil end
local n = {
proto = proto,
name = p.name,
@@ -102,7 +102,10 @@ function M.parse_singbox_json(content)
node_data.password = outbound.password
end
-- sing-box 用 tls 对象表达 TLS 层(enabled 缺省即 true)
-- sing-box 用 tls 对象表达 TLS 层。注意 enabled 的缺省值是 false
-- (option/tls.go: `Enabled bool` + omitempty),本输出模块因此总是
-- 显式写 enabled=true。读取侧这里放宽为「只要不是显式 false 就当作
-- 启用」,以便导入那些省略 enabled 的第三方配置时仍能保留 TLS 层。
if outbound.tls and type(outbound.tls) == "table" then
if outbound.tls.server_name then
node_data.sni = outbound.tls.server_name
@@ -77,12 +77,46 @@ function M.shq(s)
return "'" .. tostring(s or ""):gsub("'", "'\\''") .. "'"
end
-- 把值压成单行。Surge / Loon / Quantumult X / wg-quick .conf 都是行式格式:
-- 值里一旦含换行,就会截断当前行并伪造出额外的一行(节点名、sni、path 等
-- 全部来自订阅内容,属于不可信输入)。换行/回车压成空格,其余控制字符丢弃。
function M.one_line(s)
if s == nil then return nil end
return (tostring(s):gsub("[\r\n]+", " "):gsub("%c", ""))
end
-- 生成随机十六进制 token(用于下载链接访问控制)
--
-- 不能直接用 math.random 产出:Lua 5.1 的 math.random 是 31 位 LCG,而原实现
-- 每次调用都重新 randomseed,同一时钟刻度内的多次调用会给出完全相同的序列
-- (实测 20 万次调用只有约 18 万个不同值,约 9.7% 的 token 重复)。订阅下载
-- token 是访问控制的唯一凭据,重复即可被猜测。优先直接读内核熵源,
-- 只有在 /dev/urandom 不可用时才退回 PRNG(且只播种一次)。
local prng_seeded = false
local function seed_prng()
if prng_seeded then return end
prng_seeded = true
math.randomseed(os.time() + math.floor((os.clock() * 1000000) % 1000000))
end
function M.rnd_hex(len)
len = len or 16
local f = io.open("/dev/urandom", "rb")
if f then
local need = math.ceil(len / 2)
local bytes = f:read(need)
f:close()
if bytes and #bytes == need then
local out = {}
for i = 1, #bytes do
out[#out + 1] = string.format("%02x", bytes:byte(i))
end
return (table.concat(out):sub(1, len))
end
end
seed_prng()
local hex = "0123456789abcdef"
local out = {}
math.randomseed(os.time() + (os.clock() * 1000000 % 1000000) + math.random(0, 65535))
for _ = 1, len do
local idx = math.random(1, 16)
out[#out + 1] = hex:sub(idx, idx)
@@ -345,17 +379,33 @@ function M.file_size(path)
return #data
end
-- 原子写:先写临时文件再重命名,避免写一半损坏
-- 原子写:先写临时文件再重命名,避免写一半损坏。
--
-- 两处必须做对,否则「原子」只是名义上的:
-- 1) 临时文件名不能固定成 "<path>.tmp":两个进程同时写同一路径时会交错写进
-- 同一个临时文件,rename 上去的是两者内容的混合体,各自的原子性都失效。
-- 2) write / close 的返回值必须检查:磁盘写满或写入被截断时 f:write 会失败,
-- 但 os.rename 依然成功 —— 于是原子地换上一个残缺文件,调用方却以为成功,
-- 下一次读取才发现数据没了。
function M.atomic_write(path, content)
local tmp = path .. ".tmp"
local tmp = string.format("%s.tmp.%s", path, M.rnd_hex(8))
local f, e = io.open(tmp, "wb")
if not f then return false, e end
f:write(content)
f:close()
local ok, e2 = os.rename(tmp, path)
if not ok then
local wok, werr = f:write(content)
if not wok then
f:close()
os.remove(tmp)
return false, e2
return false, werr
end
local cok, cerr = f:close()
if not cok then
os.remove(tmp)
return false, cerr
end
local rok, rerr = os.rename(tmp, path)
if not rok then
os.remove(tmp)
return false, rerr
end
return true
end