mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-09 21:47:59 +08:00
294 lines
14 KiB
Lua
294 lines
14 KiB
Lua
-- parser_surge.lua — Surge 系 / Loon / QX 配置文件解析 → 统一节点模型(纯 Lua 5.1)
|
||
-- luci-app-substore
|
||
|
||
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
|
||
|
||
-- 剥掉 Loon 位置参数外面的双引号:`"password"` → `password`。
|
||
-- Loon 的节点行一律把凭据写成带引号的位置参数,Surge 家族的具名参数则不带引号;
|
||
-- 只剥一层(`""x""` → `"x"`),没有引号时原样返回。
|
||
local function unquote(v)
|
||
v = util.trim(v or "")
|
||
local inner = v:match('^"(.*)"$')
|
||
return inner or v
|
||
end
|
||
|
||
-- 按逗号切分字段,但**引号内的逗号不作为分隔符**。
|
||
--
|
||
-- Loon 把凭据写成双引号包裹的位置参数(nsloon.app/docs/Node/):
|
||
-- Trojan = Trojan,trojan.example.com,443,"password",transport=tcp,...
|
||
-- 密码里含逗号时,`rest:gmatch("[^,]+")` 会把它切成两段 ——
|
||
-- Trojan = Trojan,h,443,"pa,ss",sni=x
|
||
-- 的密码会静默变成 `"pa`(连引号一起、少掉后半段),用户拿到的不是他填的凭据,
|
||
-- 而且没有任何提示。生成端早就有「值里含逗号就整条丢弃」的防护(output_formats
|
||
-- 的 surge_line),解析端此前没有对应处理。
|
||
--
|
||
-- 两条边界:
|
||
-- * 引号个数为奇数时(订阅内容不可信,可能是落单的引号)**不做引号感知**,
|
||
-- 退回按逗号切分。否则那个引号会一直「开着」,把后面的 sni= / over-tls=
|
||
-- 全吞进同一个字段,比按逗号切更糟。
|
||
-- * 只跟踪引号开合,不做转义处理 —— Surge / Loon / QX 的行语法里都没有转义
|
||
-- 机制(Loon 文档只把引号列为位置参数的写法,未提及任何转义序列)。
|
||
--
|
||
-- 无引号的输入与旧的 `gmatch("[^,]+")` 逐字符等价:都按逗号切、都丢弃空字段、
|
||
-- 都对每段做 trim(旧的 `[^,]+` 会保留纯空白段,trim 后成空串,这里同样保留)。
|
||
local function split_fields(s)
|
||
local nquote = 0
|
||
for _ in s:gmatch('"') do nquote = nquote + 1 end
|
||
if nquote % 2 == 1 then
|
||
local out = {}
|
||
for part in s:gmatch("[^,]+") do out[#out + 1] = util.trim(part) end
|
||
return out
|
||
end
|
||
local out, buf, quoted = {}, {}, false
|
||
for i = 1, #s do
|
||
local c = s:sub(i, i)
|
||
if c == '"' then
|
||
quoted = not quoted
|
||
buf[#buf + 1] = c
|
||
elseif c == "," and not quoted then
|
||
if #buf > 0 then out[#out + 1] = util.trim(table.concat(buf)) end
|
||
buf = {}
|
||
else
|
||
buf[#buf + 1] = c
|
||
end
|
||
end
|
||
if #buf > 0 then out[#out + 1] = util.trim(table.concat(buf)) end
|
||
return out
|
||
end
|
||
|
||
-- 解析 Surge 风格行:Name = proto, server, port, k=v, ...
|
||
local function parse_surge_line(name, rest)
|
||
local parts = split_fields(rest)
|
||
if #parts < 3 then return nil end
|
||
|
||
local proto = map_proto(parts[1])
|
||
if not proto then return nil end
|
||
local server = parts[2]
|
||
local port = tonumber(parts[3])
|
||
|
||
-- 具名参数(Surge 家族:`password=pwd`)与位置参数(Loon:`,443,"pwd",`)并存。
|
||
-- Loon 的凭据一律写成端口之后的位置参数并用双引号包起来:
|
||
-- Trojan = Trojan,trojan.example.com,443,"password",transport=tcp,...
|
||
-- VLESS-Reality= VLESS,vless.example.com,443,"ae521383-...",transport=tcp,...
|
||
-- AnyTLS = AnyTLS,anytls.example.com,443,"password",sni=...
|
||
-- VMess = VMess,vmess.example.com,443,aes-128-gcm,"52396e06-...",transport=...
|
||
-- 此前只认具名参数,Loon 配置导入后 uuid / password 全丢,节点因缺凭据被
|
||
-- 下游静默丢弃。位置参数只收集「不含 = 的字段」,具名行完全不受影响。
|
||
-- 切分走 split_fields:引号内的逗号不再被当作分隔符,`"pa,ss"` 这种位置
|
||
-- 参数能原样保留(见该函数的说明)。
|
||
local kv, pos = {}, {}
|
||
for i = 4, #parts do
|
||
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
|
||
if k then
|
||
kv[k] = util.trim(v)
|
||
else
|
||
pos[#pos + 1] = unquote(parts[i])
|
||
end
|
||
end
|
||
|
||
local data = {
|
||
proto = proto, name = util.trim(name), server = server, port = port,
|
||
}
|
||
-- 凭据在两种写法里的落点,与 output_formats.surge_line 的分叉一一对应:
|
||
-- shadowsocks : Surge 家族 `encrypt-method=…,password=…`;Loon `加密方式,"密码"`
|
||
-- ssr : 同上,另有 protocol= / obfs= / obfs-param= / protocol-param=
|
||
-- vmess : Surge 家族 `username=…`;Loon `加密方式,"UUID"`
|
||
-- vless : Surge 家族 `username=…`;Loon `"UUID"`
|
||
-- trojan : Surge 家族 `password=…`;Loon `"密码"`
|
||
-- hysteria2 : Surge 家族 `password=…`;Loon `"密码"`
|
||
-- anytls : Surge 家族 `password=…`;Surfboard 裸位置参数;Loon `"密码"`
|
||
-- 位置参数(pos)只收集「不含 = 的字段」,具名写法完全不受影响,所以两种写法
|
||
-- 可以无条件并存 —— 本函数导入的既有 Surge / Loon 配置不会因此改变解析结果。
|
||
if proto == "shadowsocks" then
|
||
data.method = kv["encrypt-method"] or kv.cipher or kv.method or pos[1]
|
||
data.password = kv.password or pos[2]
|
||
elseif proto == "ssr" then
|
||
-- SSR 此前**没有**取字段的分支:不管哪种写法导入,加密方式、密码、协议插件、
|
||
-- 混淆方式全是 nil,节点带着空凭据进入统一模型,导出时再被静默丢掉。
|
||
data.method = kv["encrypt-method"] or kv.cipher or kv.method or pos[1]
|
||
data.password = kv.password or pos[2]
|
||
if kv.protocol then data.protocol = kv.protocol end
|
||
if kv.obfs then data.obfs = kv.obfs end
|
||
-- 两种拼写都写一份:PROTO_FIELDS / DEFAULTS 用的是带连字符的规范写法
|
||
-- (表单据此渲染),而 parser.lua 与各输出模块读的是下划线别名
|
||
-- (`n.obfs_param or n["obfs-param"]`)—— 与 parser.lua 同时写
|
||
-- method / cipher 是同一个理由,只写一份就会在某条输出路径上丢失。
|
||
if kv["obfs-param"] then
|
||
data["obfs-param"] = kv["obfs-param"]
|
||
data.obfs_param = kv["obfs-param"]
|
||
end
|
||
if kv["protocol-param"] then
|
||
data["protocol-param"] = kv["protocol-param"]
|
||
data.protocol_param = kv["protocol-param"]
|
||
end
|
||
elseif proto == "vmess" or proto == "vless" then
|
||
-- Loon 的 VMess 位置参数是「加密方式, UUID」两个字段;VLESS 只有一个 UUID
|
||
if proto == "vmess" and pos[2] then
|
||
data.cipher = pos[1]
|
||
data.uuid = kv.username or kv.uuid or pos[2]
|
||
else
|
||
data.uuid = kv.username or kv.uuid or pos[1]
|
||
end
|
||
if kv.flow then data.flow = kv.flow end
|
||
elseif proto == "trojan" then
|
||
data.password = kv.password or pos[1]
|
||
elseif proto == "anytls" then
|
||
data.password = kv.password or pos[1]
|
||
elseif proto == "hysteria2" or proto == "hysteria" then
|
||
-- Loon 的 Hysteria2 把密码写在端口之后的位置参数上(`Hysteria2,h,p,"密码"`)
|
||
data.password = kv.password or pos[1]
|
||
end
|
||
|
||
-- Reality 参数。Loon 的 VLESS / VMess / Trojan / AnyTLS Reality 行共用
|
||
-- public-key(Base64 公钥)与 short-id(十六进制)两个参数名,公钥取值带引号:
|
||
-- public-key="LgJ9bNTyUqBLFkDA12-QgEL7c1yQ1ztk-V1Q-3OLXSk",short-id=164168844958a16d
|
||
-- 原样带过去即可 —— node.normalize 会据 public-key 把 security 定为 reality。
|
||
if kv["public-key"] then data["public-key"] = unquote(kv["public-key"]) end
|
||
if kv["short-id"] then data["short-id"] = unquote(kv["short-id"]) end
|
||
|
||
-- 传输/TLS 通用字段
|
||
if kv.net or kv.network then data.net = kv.net or kv.network end
|
||
if kv["ws"] == "true" then data.net = "ws" end
|
||
if kv["ws-path"] then data.path = kv["ws-path"] end
|
||
if kv["ws-headers"] and kv["ws-headers"]:match("^Host:") then
|
||
data.host = kv["ws-headers"]:match("^Host:%s*(.*)$")
|
||
end
|
||
-- Loon 的传输写法是 transport=<tcp|ws|http> + path= + host=
|
||
-- (nsloon.app/docs/Node/ 的示例行),与 Surge 家族的 net= / ws=true /
|
||
-- ws-path / ws-headers=Host: 是同一组语义的两代写法 —— Loon 文档明说
|
||
-- 「旧参数 ws=true、ws-path、ws-headers=Host:域名 分别对应 transport=ws、
|
||
-- path、host」,即旧写法只是兼容别名。只认旧写法时,别人给的 Loon 配置
|
||
-- 导入后 net 保持默认 tcp、path / host 全丢,导出到任何格式都按 tcp 去连一个
|
||
-- 只开了 ws 的端口 —— 握手失败且不报错。
|
||
-- transport=http 按同一段文档「兼容配置中的 transport=http 会按 WebSocket
|
||
-- 处理」同样落成 ws。
|
||
-- 放在 ws=true / ws-path / ws-headers 之后:同一行两种写法都出现时以新写法为准。
|
||
if kv.transport then
|
||
data.net = (kv.transport == "http") and "ws" or kv.transport
|
||
end
|
||
if kv.path then data.path = kv.path end
|
||
if kv.host then data.host = kv.host end
|
||
if kv.sni then data.sni = kv.sni end
|
||
-- Surge 家族写 tls=true,Loon 写 over-tls=true,两种都是「启用 TLS」
|
||
if kv.tls and kv.tls ~= "false" and kv.tls ~= "none" then data.security = "tls" end
|
||
if kv["over-tls"] and kv["over-tls"] ~= "false" and kv["over-tls"] ~= "none" then
|
||
data.security = "tls"
|
||
end
|
||
if kv["skip-cert-verify"] and kv["skip-cert-verify"] ~= "0" and kv["skip-cert-verify"] ~= "false" then
|
||
data["skip-cert-verify"] = true
|
||
end
|
||
return node.normalize(data)
|
||
end
|
||
|
||
-- 解析 QX server_local 行:proto=server:port, k=v, ..., tag=Name
|
||
local function parse_qx_line(content)
|
||
local proto, rest = content:match("^([%w_]+)%s*=%s*(.+)$")
|
||
if not proto then return nil end
|
||
local parts = split_fields(rest)
|
||
if #parts < 1 then return nil end
|
||
|
||
local hostport = parts[1]
|
||
local host, port = util.split_hostport(hostport)
|
||
local kv = {}
|
||
for i = 2, #parts do
|
||
local k, v = parts[i]:match("^([%w%-]+)%s*=%s*(.*)$")
|
||
if k then kv[k] = v end
|
||
end
|
||
|
||
local name = kv.tag or (host .. ":" .. tostring(port or ""))
|
||
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
|
||
data.method = kv.method
|
||
data.password = kv.password
|
||
elseif proto == "vmess" or proto == "vless" then
|
||
data.uuid = kv.password or kv.uuid
|
||
elseif proto == "trojan" then
|
||
data.password = kv.password
|
||
elseif proto == "anytls" then
|
||
data.password = kv.password
|
||
end
|
||
-- QX 用 obfs= 表示传输 / TLS 模式(官方 sample.conf):over-tls 是「纯 TLS」、
|
||
-- wss 是「ws + TLS」、ws 是明文 ws。obfs-host 在 ws 下是 Host 头、在 over-tls
|
||
-- 下是 SNI —— 语义不同,必须分别落到 host / sni,否则 vless + over-tls 的节点
|
||
-- 导入后 SNI 被写进 host,Reality 那条更是连不上。
|
||
if kv["obfs"] == "over-tls" then
|
||
data.security = "tls"
|
||
if kv["obfs-host"] then data.sni = kv["obfs-host"] end
|
||
else
|
||
if kv["obfs"] == "ws" or kv["obfs"] == "wss" then data.net = "ws" end
|
||
if kv["obfs"] == "wss" then data.security = "tls" end
|
||
if kv["obfs-uri"] then data.path = kv["obfs-uri"] end
|
||
if kv["obfs-host"] then data.host = kv["obfs-host"] end
|
||
end
|
||
if kv["tls-host"] then data.sni = kv["tls-host"] end
|
||
if kv["tls-verification"] == "true" or kv["over-tls"] == "true" then data.security = "tls" end
|
||
-- flow:QX 的 vless 写 vless-flow=xtls-rprx-vision(sample.conf 的 Reality 条目)
|
||
if kv["vless-flow"] then data.flow = kv["vless-flow"] end
|
||
-- Reality:QX 的键名与分享链接 / Clash 都不同 —— Base64 公钥是
|
||
-- reality-base64-pubkey,十六进制 short id 是 reality-hex-shortid。
|
||
-- node.normalize 会据 public-key 把 security 定为 reality。
|
||
if kv["reality-base64-pubkey"] then data["public-key"] = kv["reality-base64-pubkey"] end
|
||
if kv["reality-hex-shortid"] then data["short-id"] = kv["reality-hex-shortid"] end
|
||
return node.normalize(data)
|
||
end
|
||
|
||
-- 解析客户端配置,返回节点列表(自动识别 Surge/Loon 与 QX 风格)
|
||
function M.parse(content)
|
||
if not content or content == "" then return {} end
|
||
local nodes = {}
|
||
local section = ""
|
||
for line in content:gmatch("[^\r\n]+") do
|
||
local t = util.trim(line)
|
||
if t == "" or t:sub(1, 1) == "#" or t:sub(1, 1) == ";" then
|
||
-- 跳过空行/注释
|
||
elseif t:match("^%[") then
|
||
section = t:match("^%[([^%]]+)%]") or ""
|
||
section = section:lower()
|
||
elseif section == "proxy" then
|
||
local name, rest = t:match("^([^=]-)%s*=%s*(.+)$")
|
||
if name and rest then
|
||
local n = parse_surge_line(name, rest)
|
||
if n then nodes[#nodes + 1] = n end
|
||
end
|
||
elseif section == "server_local" then
|
||
local n = parse_qx_line(t)
|
||
if n then nodes[#nodes + 1] = n end
|
||
end
|
||
end
|
||
return nodes
|
||
end
|
||
|
||
-- 判断内容是否为客户端配置文件。
|
||
-- 段名大小写不敏感:Surge 官方配置写 `[Proxy]`,但 Loon / 第三方转换器常写
|
||
-- `[PROXY]` / `[General]` 这类全大写段名。原来的 find("%[Proxy%]") 只认大小写
|
||
-- 完全一致的写法,于是 detect 不认为这是 Surge 配置,继续往下走——
|
||
-- 而 `[` 开头的内容会被判成 JSON 数组,整份订阅报「JSON 解析失败」,
|
||
-- 一个节点都拿不到(M.parse 本身是按小写段名匹配的,本来就能解析)。
|
||
function M.is_config(content)
|
||
content = content or ""
|
||
local lower = content:lower()
|
||
return lower:find("[proxy]", 1, true) ~= nil
|
||
or lower:find("[server_local]", 1, true) ~= nil
|
||
end
|
||
|
||
return M |