🦄 Sync 2026-10-02 14:52:05

This commit is contained in:
github-actions[bot] committed 2026-10-02 14:52:05 +08:00
1 parent 4c85793342
commit 0bfe84f1e8
133 files changed
+7983 -3225

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
-- ss_plugin_test.lua — shadowsocks SIP003 插件全链路回归测试
-- 用法:lua5.1 tests/ss_plugin_test.lua
--
-- 缺陷:`plugin` 能被解析、能通过 node.normalize 存活,但三个输出模块全都
-- 静默丢掉它 —— 带 obfs / v2ray-plugin 的 ss 节点导出后,客户端以明文 SS
-- 去连只接受带插件握手的服务端,必然失败且不报错。表单侧同样致命:
-- plugin 不在 node.PROTO_FIELDS.shadowsocks 里,用户在界面上编辑一次
-- 该节点,core.merge_form_node 就会把插件配置清空。
--
-- 上游依据(均已核对,非推测):
-- * SIP002 SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ]
-- plugin 的取值整体做百分号编码
-- * sing-box shadowsocks 出站:plugin(字符串,仅支持 obfs-local / v2ray-plugin)
-- 与 plugin_opts(SIP003 原始参数串,原样透传)
-- * mihomo shadowsocks 出站:plugin(obfs / v2ray-plugin / …)、
-- plugin-opts 是**映射**;obfs 的 mode 必须 ∈ {tls,http},
-- v2ray-plugin 的 mode 必须是 websocket,否则整个 outbound 构造失败,
-- mihomo 拒绝加载整份配置
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local node = require("substore.node")
local core = require("substore.core")
local parser = require("substore.parser")
local clash_meta = require("substore.output_clash_meta")
local singbox = require("substore.output_singbox")
local uri_mod = require("substore.output_uri")
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
local function line_with(text, pattern)
for l in text:gmatch("[^\n]+") do
if l:find(pattern) then return l end
end
return nil
end
-- SIP002 规范里的示例 URI
local SPEC_URI = "ss://YWVzLTI1Ni1nY206cGFzcw==@192.168.100.1:8888/"
.. "?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dwww.baidu.com#Example2"
local SPEC_PLUGIN = "obfs-local;obfs=http;obfs-host=www.baidu.com"
-- ---------- util.parse_sip003_plugin ----------
-- 取一个本地引用:修复前 util 里没有这个函数,直接调用会让整个文件在第 55 行
-- 崩掉,后面的断言一条都跑不到,反向验证就失去了意义。
local parse_plugin = util.parse_sip003_plugin or function() return nil end
local name, opts_str, opts_map = parse_plugin(SPEC_PLUGIN)
opts_map = opts_map or {}
check("parse plugin name", name == "obfs-local")
check("parse plugin opts string", opts_str == "obfs=http;obfs-host=www.baidu.com")
check("parse plugin opts map obfs", opts_map.obfs == "http")
check("parse plugin opts map obfs-host", opts_map["obfs-host"] == "www.baidu.com")
check("parse empty plugin returns nil", parse_plugin("") == nil)
check("parse nil plugin returns nil", parse_plugin(nil) == nil)
-- SIP003 规定分号、等号、反斜杠可用反斜杠转义
local esc_name, _, esc_map = parse_plugin("obfs-local;obfs=http\\;x")
esc_map = esc_map or {}
check("parse escaped semicolon name", esc_name == "obfs-local")
check("parse escaped semicolon kept", esc_map and esc_map.obfs == "http\\;x")
-- ---------- 解析:ss:// URI 的 plugin 必须落到模型 ----------
local parsed = parser.parse(SPEC_URI)
check("uri parsed", parsed ~= nil and #parsed.nodes == 1)
local n = parsed.nodes[1]
check("plugin parsed from uri", n.plugin == SPEC_PLUGIN)
check("plugin survives normalize", node.normalize(n).plugin == SPEC_PLUGIN)
-- ---------- mihomo(clashmeta) ----------
local d = node.normalize(n)
local yaml = clash_meta.generate({ d })
check("clashmeta emits plugin: obfs", yaml:find(" plugin: obfs\n", 1, true) ~= nil)
check("clashmeta emits plugin-opts", yaml:find(" plugin-opts:\n", 1, true) ~= nil)
check("clashmeta obfs mode", yaml:find(" mode: http\n", 1, true) ~= nil)
check("clashmeta obfs host", yaml:find(" host: www.baidu.com\n", 1, true) ~= nil)
-- plugin-opts 的子键必须缩进在它之下
local po = line_with(yaml, "plugin%-opts:")
local mode_line = line_with(yaml, "mode: http")
check("clashmeta plugin-opts children nested",
po ~= nil and mode_line ~= nil
and #mode_line:match("^(%s*)") > #po:match("^(%s*)"))
local function ss(plugin)
return node.normalize({ proto = "shadowsocks", name = "S", server = "1.2.3.4",
port = 8388, method = "aes-256-gcm", password = "p", plugin = plugin })
end
-- simple-obfs 是 obfs-local 的历史别名,mihomo 只认 obfs
local simple = clash_meta.generate({ ss("simple-obfs;obfs=tls;obfs-host=h.com") })
check("clashmeta simple-obfs mapped to obfs",
simple:find(" plugin: obfs\n", 1, true) ~= nil)
check("clashmeta simple-obfs mode tls", simple:find(" mode: tls\n", 1, true) ~= nil)
-- v2ray-plugin:mode 必须是 websocket
local v2 = clash_meta.generate({ ss("v2ray-plugin;mode=websocket;host=h.com;path=/ws;tls") })
check("clashmeta v2ray-plugin name",
v2:find(" plugin: v2ray-plugin\n", 1, true) ~= nil)
check("clashmeta v2ray-plugin mode",
v2:find(" mode: websocket\n", 1, true) ~= nil)
check("clashmeta v2ray-plugin host", v2:find(" host: h.com\n", 1, true) ~= nil)
check("clashmeta v2ray-plugin path", v2:find(" path: /ws\n", 1, true) ~= nil)
check("clashmeta v2ray-plugin tls", v2:find(" tls: true\n", 1, true) ~= nil)
-- 参数非法时必须**整个不输出**插件:mihomo 对这两类插件是强校验的,
-- 输出一个必然被拒绝的组合会让整份配置加载失败,一个坏节点废掉整个订阅。
local bad_obfs = clash_meta.generate({ ss("obfs-local") })
check("clashmeta obfs without mode omitted",
bad_obfs:find("plugin:", 1, true) == nil)
local bad_obfs2 = clash_meta.generate({ ss("obfs-local;obfs=bogus") })
check("clashmeta obfs invalid mode omitted",
bad_obfs2:find("plugin:", 1, true) == nil)
local bad_v2 = clash_meta.generate({ ss("v2ray-plugin;mode=quic") })
check("clashmeta v2ray-plugin invalid mode omitted",
bad_v2:find("plugin:", 1, true) == nil)
-- 未知插件名:mihomo 的 plugin 分支没有收尾 else,未知名字本来就静默跳过
local unknown = clash_meta.generate({ ss("kcptun;key=abc") })
check("clashmeta unknown plugin omitted",
unknown:find("plugin:", 1, true) == nil)
-- 无插件的普通 ss 节点不得凭空多出 plugin 字段
local plain = clash_meta.generate({ ss(nil) })
check("clashmeta plain ss has no plugin", plain:find("plugin", 1, true) == nil)
-- ---------- sing-box ----------
local sbox = singbox.generate({ d })
check("singbox emits plugin", sbox:find('"plugin":"obfs-local"', 1, true) ~= nil)
check("singbox plugin_opts is raw SIP003 string",
sbox:find('"plugin_opts":"obfs=http;obfs-host=www.baidu.com"', 1, true) ~= nil)
-- sing-box 只支持这两个插件名,其余会拒绝加载整份配置
check("singbox unknown plugin omitted",
singbox.generate({ ss("kcptun;key=abc") }):find('"plugin"', 1, true) == nil)
check("singbox v2ray-plugin kept",
singbox.generate({ ss("v2ray-plugin;mode=websocket") }):find('"plugin":"v2ray-plugin"', 1, true) ~= nil)
check("singbox plain ss has no plugin",
singbox.generate({ ss(nil) }):find('"plugin"', 1, true) == nil)
-- ---------- 分享链接回写(SIP002) ----------
local out_uri = uri_mod.to_share_uri(d)
check("uri keeps plugin query",
out_uri:find("/?plugin=", 1, true) ~= nil)
check("uri percent-encodes plugin",
out_uri:find("plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dwww.baidu.com", 1, true) ~= nil)
-- 往返:导出 → 再解析,插件配置必须一字不差
local back = parser.parse(out_uri)
check("uri round-trip re-parses", back ~= nil and #back.nodes == 1)
check("uri round-trip plugin preserved",
back.nodes[1].plugin == SPEC_PLUGIN)
-- 无插件时不得多出 `/?plugin=`
check("uri plain ss has no plugin query",
uri_mod.to_share_uri(ss(nil)):find("plugin", 1, true) == nil)
-- ---------- 表单:字段清单与合并 ----------
local ss_fields = node.PROTO_FIELDS.shadowsocks
local has_plugin = false
for _, k in ipairs(ss_fields) do if k == "plugin" then has_plugin = true end end
check("PROTO_FIELDS.shadowsocks has plugin", has_plugin)
-- 表单渲染 plugin 后,清空输入框必须能真的删掉它
local cleared = core.merge_form_node(d, { proto = "shadowsocks", name = "S",
server = "1.2.3.4", port = 8388, method = "aes-256-gcm", password = "p", plugin = "" })
check("merge_form_node clears plugin", cleared.plugin == "" or cleared.plugin == nil)
-- 表单提交了新值时必须生效
local updated = core.merge_form_node(d, { proto = "shadowsocks", name = "S",
server = "1.2.3.4", port = 8388, method = "aes-256-gcm", password = "p",
plugin = "v2ray-plugin;mode=websocket" })
check("merge_form_node applies plugin", updated.plugin == "v2ray-plugin;mode=websocket")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)