Files
op-packages/luci-app-substore/tests/protocol_coverage_test.lua
T

333 lines
16 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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)