mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 10:32:51 +08:00
333 lines
16 KiB
Lua
333 lines
16 KiB
Lua
-- protocol_coverage_test.lua — 协议覆盖一致性回归测试(2.6.0)
|
||
-- 覆盖本轮修复:
|
||
-- 1. hysteria(v1) / socks 的 URI 文本导入(此前只出不进,导出→导入回环丢节点)
|
||
-- 2. 简易 YAML 兜底解析不再把未知协议兜底成 vmess(静默数据损坏)
|
||
-- 3. socks5 -> socks 规范化,使节点页筛选 / 规则 proto_filter 能命中
|
||
-- 4. clashmeta hysteria(v1) 用 sni 而非 servername;不写 v1 非法的 obfs-password
|
||
-- 5. clashmeta socks5 不再重复输出 password;http 补 username
|
||
-- 6. singbox hysteria(v1) 用 auth_str(不是 password)、obfs 为字符串
|
||
-- 7. singbox / surge 的 socks、http 凭据不再丢
|
||
-- 8. core.merge_form_node 能清空 username
|
||
-- 用法:lua5.1 tests/protocol_coverage_test.lua
|
||
|
||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||
|
||
local parser = require("substore.parser")
|
||
local output = require("substore.output")
|
||
local output_uri = require("substore.output_uri")
|
||
local node = require("substore.node")
|
||
local core = require("substore.core")
|
||
local util = require("substore.util")
|
||
|
||
local passed, failed = 0, 0
|
||
|
||
local function check(name, cond)
|
||
if cond then
|
||
passed = passed + 1
|
||
print("PASS " .. name)
|
||
else
|
||
failed = failed + 1
|
||
print("FAIL " .. name)
|
||
end
|
||
end
|
||
|
||
-- ============================================================
|
||
-- 1. URI 文本导入:hysteria(v1)
|
||
-- ============================================================
|
||
local hy = parser.parse_uri("hysteria://pw@1.2.3.4:443/?sni=a.com&insecure=1&obfs=xxx#HY")
|
||
check("hysteria URI 可解析", hy ~= nil)
|
||
check("hysteria proto", hy and hy.proto == "hysteria")
|
||
check("hysteria server", hy and hy.server == "1.2.3.4")
|
||
check("hysteria port", hy and tonumber(hy.port) == 443)
|
||
check("hysteria password", hy and hy.password == "pw")
|
||
check("hysteria sni", hy and hy.sni == "a.com")
|
||
check("hysteria obfs", hy and hy.obfs == "xxx")
|
||
-- hysteria 系列在 mihomo / sing-box 里都是 TLS-only
|
||
check("hysteria security=tls", hy and hy.security == "tls")
|
||
-- obfs-password 是 hysteria2(salamander) 专属,v1 不应有
|
||
check("hysteria 无 obfs-password", hy and hy["obfs-password"] == nil)
|
||
check("hysteria insecure 透传", hy and hy.insecure == "1")
|
||
|
||
-- 不带查询参数的极简链接也要能解析
|
||
local hy_min = parser.parse_uri("hysteria://secret@9.9.9.9:8443#MIN")
|
||
check("hysteria 无参数可解析", hy_min ~= nil and hy_min.password == "secret")
|
||
|
||
-- 无法识别的查询参数忽略而不是报错(第三方链接常带 upmbps/downmbps 等)
|
||
local hy_extra = parser.parse_uri("hysteria://pw@1.2.3.4:443/?upmbps=100&downmbps=200&peer=x#E")
|
||
check("hysteria 未知参数忽略", hy_extra ~= nil and hy_extra.server == "1.2.3.4")
|
||
|
||
-- ============================================================
|
||
-- 2. URI 文本导入:socks / socks5
|
||
-- ============================================================
|
||
local sk = parser.parse_uri("socks5://user:pass@5.6.7.8:1080#SK")
|
||
check("socks5 URI 可解析", sk ~= nil)
|
||
-- socks5 与 socks 是同一协议,统一归一为规范名 socks
|
||
check("socks5 归一为 socks", sk and sk.proto == "socks")
|
||
check("socks server", sk and sk.server == "5.6.7.8")
|
||
check("socks port", sk and tonumber(sk.port) == 1080)
|
||
check("socks username", sk and sk.username == "user")
|
||
check("socks password", sk and sk.password == "pass")
|
||
|
||
local sk2 = parser.parse_uri("socks://5.6.7.8:1080#SK2")
|
||
check("socks:// 同义可解析", sk2 ~= nil and sk2.proto == "socks")
|
||
check("无认证 socks 无 username", sk2 and sk2.username == nil)
|
||
check("无认证 socks 无 password", sk2 and sk2.password == nil)
|
||
|
||
-- 密码含未编码的 @:必须取最后一个 @ 作分界(按第一个切会静默解析出错误密码)
|
||
local sk3 = parser.parse_uri("socks5://u:p@ss@5.6.7.8:1080#S3")
|
||
check("socks 密码含 @ 正确切分", sk3 ~= nil and sk3.username == "u"
|
||
and sk3.password == "p@ss" and sk3.server == "5.6.7.8")
|
||
|
||
-- 第三方链接可能带 ? 尾巴,不能污染 host:port
|
||
local sk4 = parser.parse_uri("socks5://5.6.7.8:1080/?foo=bar#S4")
|
||
check("socks 忽略查询尾巴", sk4 ~= nil and sk4.server == "5.6.7.8" and tonumber(sk4.port) == 1080)
|
||
|
||
-- ============================================================
|
||
-- 3. 导出 → 导入回环(修复前 hysteria / socks 只出不进)
|
||
-- ============================================================
|
||
local rt_cases = {
|
||
{ "hysteria", "hysteria://pw@1.2.3.4:443/?sni=a.com&insecure=1&obfs=xxx#HY" },
|
||
{ "hysteria2", "hysteria2://pw@1.2.3.4:443/?sni=a.com&obfs=salamander&obfs-password=op#H2" },
|
||
{ "socks5", "socks5://user:pass@5.6.7.8:1080#SK" },
|
||
{ "socks", "socks://5.6.7.8:1080#SK2" },
|
||
{ "tuic", "tuic://uuid-x:pw@1.2.3.4:443?sni=a.com#T" },
|
||
{ "vmess", "vmess://" .. util.base64_encode(util.json_encode({
|
||
v = "2", ps = "V", add = "1.2.3.4", port = "443", id = "u-1", aid = "0", net = "tcp", tls = "tls",
|
||
})) },
|
||
}
|
||
for _, c in ipairs(rt_cases) do
|
||
local n = parser.parse_uri(c[2])
|
||
check("回环 " .. c[1] .. " 首次解析", n ~= nil)
|
||
if n then
|
||
local back = output_uri.to_share_uri(n)
|
||
check("回环 " .. c[1] .. " 可再导出", back ~= nil)
|
||
local n2 = back and parser.parse_uri(back)
|
||
check("回环 " .. c[1] .. " 再导入成功", n2 ~= nil)
|
||
check("回环 " .. c[1] .. " proto 不变", n2 and n2.proto == n.proto)
|
||
check("回环 " .. c[1] .. " server 不变", n2 and n2.server == n.server)
|
||
check("回环 " .. c[1] .. " port 不变", n2 and tonumber(n2.port) == tonumber(n.port))
|
||
end
|
||
end
|
||
|
||
-- ============================================================
|
||
-- 4. 订阅文本中混有 hysteria / socks 行不再整行丢弃
|
||
-- ============================================================
|
||
local mixed = table.concat({
|
||
"hysteria://pw@1.1.1.1:443#A",
|
||
"socks5://u:p@2.2.2.2:1080#B",
|
||
"vmess://" .. util.base64_encode(util.json_encode({
|
||
v = "2", ps = "C", add = "3.3.3.3", port = "443", id = "u-2",
|
||
})),
|
||
}, "\n")
|
||
local mixed_res = parser.parse(mixed)
|
||
check("混合 URI 文本解析出 3 个节点", mixed_res and mixed_res.nodes and #mixed_res.nodes == 3)
|
||
if mixed_res and mixed_res.nodes then
|
||
local protos = {}
|
||
for _, n in ipairs(mixed_res.nodes) do protos[n.proto] = true end
|
||
check("混合文本含 hysteria", protos.hysteria == true)
|
||
check("混合文本含 socks", protos.socks == true)
|
||
check("混合文本含 vmess", protos.vmess == true)
|
||
end
|
||
|
||
-- ============================================================
|
||
-- 5. 简易 YAML 兜底解析:未知协议不得兜底成 vmess
|
||
-- ============================================================
|
||
local fb = parser.parse_yaml_content and parser.parse_yaml_content or nil
|
||
-- 走公开入口,确保兜底路径可达
|
||
local yaml_outbounds = [[
|
||
outbounds:
|
||
- type: hysteria2
|
||
server: 1.1.1.1
|
||
server_port: 443
|
||
password: p1
|
||
- type: hysteria
|
||
server: 1.1.1.2
|
||
server_port: 443
|
||
auth_str: p2
|
||
- type: tuic
|
||
server: 1.1.1.3
|
||
server_port: 443
|
||
uuid: u3
|
||
- type: wireguard
|
||
server: 1.1.1.4
|
||
server_port: 51820
|
||
- type: socks
|
||
server: 1.1.1.5
|
||
server_port: 1080
|
||
]]
|
||
local yres = parser.parse_yaml(yaml_outbounds)
|
||
if yres and #yres >0 then
|
||
local protos = {}
|
||
for _, n in ipairs(yres) do protos[n.proto] = (protos[n.proto] or 0) + 1 end
|
||
check("兜底解析 hysteria2 不被兜底成 vmess", protos.hysteria2 == 1)
|
||
check("兜底解析 hysteria 不被兜底成 vmess", protos.hysteria == 1)
|
||
check("兜底解析 tuic 不被兜底成 vmess", protos.tuic == 1)
|
||
check("兜底解析 wireguard 不被兜底成 vmess", protos.wireguard == 1)
|
||
check("兜底解析 socks 规范化", protos.socks == 1)
|
||
check("兜底解析无 vmess 假节点", protos.vmess == nil)
|
||
else
|
||
check("兜底解析 outbounds YAML", false)
|
||
end
|
||
|
||
-- ============================================================
|
||
-- 5b. sing-box YAML outbounds 的字段名别名(server_port / tag / auth_str / server_name / insecure)
|
||
-- 修复前:段名 `outbounds:` 被识别,但只读 Clash 的 port / name,
|
||
-- 所有 sing-box YAML 出站都卡在 `not port` 上被静默丢弃(解析出 0 个节点)。
|
||
-- ============================================================
|
||
local sb_yaml = [[
|
||
outbounds:
|
||
- type: hysteria2
|
||
tag: HY2
|
||
server: 1.1.1.1
|
||
server_port: 443
|
||
password: p1
|
||
tls:
|
||
server_name: sni.example.com
|
||
insecure: true
|
||
- type: hysteria
|
||
tag: HY1
|
||
server: 1.1.1.2
|
||
server_port: 8443
|
||
auth_str: p2
|
||
- type: vmess
|
||
tag: VM
|
||
server: 1.1.1.3
|
||
server_port: 443
|
||
uuid: u-3
|
||
security: aes-128-gcm
|
||
]]
|
||
local sb_res = parser.parse_yaml(sb_yaml)
|
||
check("sing-box YAML 出站不再被丢弃", sb_res and #sb_res == 3)
|
||
if sb_res and #sb_res == 3 then
|
||
check("sing-box YAML server_port 生效", tonumber(sb_res[1].port) == 443)
|
||
check("sing-box YAML tag 作为 name", sb_res[1].name == "HY2")
|
||
check("sing-box YAML tls.server_name -> sni", sb_res[1].sni == "sni.example.com")
|
||
check("sing-box YAML tls.insecure -> skip-cert-verify", sb_res[1]["skip-cert-verify"] == true)
|
||
check("sing-box YAML auth_str -> password", sb_res[2].password == "p2")
|
||
check("sing-box YAML hysteria 端口", tonumber(sb_res[2].port) == 8443)
|
||
-- sing-box 的 vmess.security 是加密方式,不是 TLS 层
|
||
check("sing-box YAML vmess.security -> cipher", sb_res[3].cipher == "aes-128-gcm")
|
||
check("sing-box YAML vmess security 不当作 TLS 层", sb_res[3].security ~= "aes-128-gcm")
|
||
end
|
||
|
||
-- insecure: false 不能被当成真值("false" 在 Lua 里是真值)
|
||
local sb_false = parser.parse_yaml([[
|
||
outbounds:
|
||
- type: hysteria2
|
||
server: 1.1.1.9
|
||
server_port: 443
|
||
password: p
|
||
tls:
|
||
insecure: false
|
||
]])
|
||
check("sing-box YAML insecure:false 不置 skip-cert-verify",
|
||
sb_false and sb_false[1] and sb_false[1]["skip-cert-verify"] ~= true)
|
||
|
||
-- ============================================================
|
||
-- 6. socks5 -> socks 规范化后,筛选 / 规则过滤能命中
|
||
-- ============================================================
|
||
local clash_socks = [[
|
||
proxies:
|
||
- name: SK
|
||
type: socks5
|
||
server: 1.1.1.1
|
||
port: 1080
|
||
username: u
|
||
password: p
|
||
]]
|
||
local cns = parser.parse_yaml(clash_socks)
|
||
check("clash socks5 导入成功", cns and #cns == 1)
|
||
check("clash socks5 归一为 socks", cns and cns[1] and cns[1].proto == "socks")
|
||
check("node.filter proto=socks 命中", #node.filter(cns, { proto = "socks" }) == 1)
|
||
check("apply_rules proto_filter=socks 命中", #node.apply_rules(cns, { proto_filter = "socks" }) == 1)
|
||
|
||
-- 直接对未归一节点调用 normalize 也应归一(所有真实节点都会经过 normalize)
|
||
local raw_norm = node.normalize({ proto = "socks5", server = "1.1.1.1", port = 1080 })
|
||
check("node.normalize 归一 socks5", raw_norm.proto == "socks")
|
||
|
||
-- ============================================================
|
||
-- 7. clashmeta 输出:hysteria(v1) 用 sni,不写 servername / obfs-password
|
||
-- ============================================================
|
||
local hy_node = { proto = "hysteria", name = "HY", server = "1.1.1.1", port = 443,
|
||
password = "pw", obfs = "obs", sni = "s.com" }
|
||
local cm_hy = output.generate({ hy_node }, "clashmeta")
|
||
check("clashmeta hysteria 有 sni", cm_hy and cm_hy:find(" sni: s.com", 1, true) ~= nil)
|
||
check("clashmeta hysteria 无 servername", cm_hy and cm_hy:find("servername", 1, true) == nil)
|
||
check("clashmeta hysteria 无 obfs-password", cm_hy and cm_hy:find("obfs-password", 1, true) == nil)
|
||
check("clashmeta hysteria 有 obfs", cm_hy and cm_hy:find(" obfs: obs", 1, true) ~= nil)
|
||
|
||
-- hysteria2 仍然用 sni + obfs-password
|
||
local h2_node = { proto = "hysteria2", name = "H2", server = "1.1.1.2", port = 443,
|
||
password = "p", obfs = "salamander", ["obfs-password"] = "op", sni = "s2.com" }
|
||
local cm_h2 = output.generate({ h2_node }, "clashmeta")
|
||
check("clashmeta hysteria2 有 sni", cm_h2 and cm_h2:find(" sni: s2.com", 1, true) ~= nil)
|
||
check("clashmeta hysteria2 有 obfs-password", cm_h2 and cm_h2:find(" obfs-password: op", 1, true) ~= nil)
|
||
|
||
-- vmess 仍然用 servername(不能被 hysteria 的改动误伤)
|
||
local vm_node = { proto = "vmess", name = "V", server = "1.1.1.3", port = 443,
|
||
uuid = "u", sni = "s3.com", security = "tls" }
|
||
local cm_vm = output.generate({ vm_node }, "clashmeta")
|
||
check("clashmeta vmess 仍用 servername", cm_vm and cm_vm:find(" servername: s3.com", 1, true) ~= nil)
|
||
|
||
-- ============================================================
|
||
-- 8. clashmeta 输出:socks password 不重复;http 补 username
|
||
-- ============================================================
|
||
local sk_node = { proto = "socks", name = "SK", server = "1.1.1.4", port = 1080,
|
||
username = "u", password = "p" }
|
||
local cm_sk = output.generate({ sk_node }, "clashmeta")
|
||
local _, pw_cnt = cm_sk:gsub(" password:", "")
|
||
check("clashmeta socks password 只出现一次", pw_cnt == 1)
|
||
check("clashmeta socks username 输出", cm_sk:find(" username: u", 1, true) ~= nil)
|
||
|
||
local http_node = { proto = "http", name = "HT", server = "1.1.1.5", port = 8080,
|
||
username = "hu", password = "hp" }
|
||
local cm_ht = output.generate({ http_node }, "clashmeta")
|
||
check("clashmeta http username 输出", cm_ht and cm_ht:find(" username: hu", 1, true) ~= nil)
|
||
local _, htpw = cm_ht:gsub(" password:", "")
|
||
check("clashmeta http password 只出现一次", htpw == 1)
|
||
|
||
-- ============================================================
|
||
-- 9. singbox 输出:hysteria(v1) 用 auth_str、obfs 为字符串
|
||
-- ============================================================
|
||
local sb_hy = util.json_decode(output.generate({ hy_node }, "singbox"))
|
||
check("singbox hysteria auth_str", sb_hy and sb_hy.outbounds[1] and sb_hy.outbounds[1].auth_str == "pw")
|
||
check("singbox hysteria 无 password 键", sb_hy and sb_hy.outbounds[1] and sb_hy.outbounds[1].password == nil)
|
||
check("singbox hysteria obfs 为字符串", sb_hy and sb_hy.outbounds[1] and sb_hy.outbounds[1].obfs == "obs")
|
||
|
||
local sb_h2 = util.json_decode(output.generate({ h2_node }, "singbox"))
|
||
check("singbox hysteria2 仍用 password", sb_h2 and sb_h2.outbounds[1] and sb_h2.outbounds[1].password == "p")
|
||
check("singbox hysteria2 obfs 为对象", sb_h2 and sb_h2.outbounds[1] and type(sb_h2.outbounds[1].obfs) == "table"
|
||
and sb_h2.outbounds[1].obfs.password == "op")
|
||
|
||
-- singbox socks / http 凭据
|
||
local sb_sk = util.json_decode(output.generate({ sk_node }, "singbox"))
|
||
check("singbox socks username", sb_sk and sb_sk.outbounds[1] and sb_sk.outbounds[1].username == "u")
|
||
check("singbox socks password", sb_sk and sb_sk.outbounds[1] and sb_sk.outbounds[1].password == "p")
|
||
local sb_ht = util.json_decode(output.generate({ http_node }, "singbox"))
|
||
check("singbox http username", sb_ht and sb_ht.outbounds[1] and sb_ht.outbounds[1].username == "hu")
|
||
check("singbox http password", sb_ht and sb_ht.outbounds[1] and sb_ht.outbounds[1].password == "hp")
|
||
|
||
-- ============================================================
|
||
-- 10. Surge 家族:socks / http 凭据
|
||
-- ============================================================
|
||
local sg_sk = output.generate({ sk_node }, "surge")
|
||
check("surge socks username", sg_sk and sg_sk:find("username=u", 1, true) ~= nil)
|
||
check("surge socks password", sg_sk and sg_sk:find("password=p", 1, true) ~= nil)
|
||
local sg_ht = output.generate({ http_node }, "surge")
|
||
check("surge http username", sg_ht and sg_ht:find("username=hu", 1, true) ~= nil)
|
||
check("surge http password", sg_ht and sg_ht:find("password=hp", 1, true) ~= nil)
|
||
|
||
-- ============================================================
|
||
-- 11. core.merge_form_node 能清空 username(FORM_KEYS 必须含 username)
|
||
-- ============================================================
|
||
local old_node = { proto = "socks", name = "N", server = "1.1.1.1", port = 1080,
|
||
username = "olduser", password = "oldpass" }
|
||
local merged = core.merge_form_node(old_node, { proto = "socks", name = "N", server = "1.1.1.1", port = 1080,
|
||
password = "newpass" })
|
||
check("merge_form_node 清空 username", merged and merged.username == nil)
|
||
check("merge_form_node 更新 password", merged and merged.password == "newpass")
|
||
|
||
-- ---------- 结果 ----------
|
||
print(string.format("\n%d passed, %d failed", passed, failed))
|
||
os.exit(failed == 0 and 0 or 1)
|