💐 Sync 2026-10-06 12:33:44

This commit is contained in:
github-actions[bot] committed 2026-10-06 12:33:44 +08:00
1 parent 8be03ce939
commit 7fe69dbb3a
246 files changed
+50330 -3414

No files matched your search

@@ -0,0 +1,296 @@
-- output_egern_test.lua — Egern 配置输出(output_egern.lua)的回归测试
-- 用法:lua5.1 tests/output_egern_test.lua
--
-- Egern 的配置是 YAML,不是 Surge 的逗号行(LEGACY_ISSUES 的 7.2)。它与本仓库
-- 其它输出端有几处**结构性**差异,每一处写错客户端都不报错、只是节点不可用,
-- 所以逐条锁住:
--
-- 1. proxies 是顶层列表,每项是**单键映射**,键名即小写协议名
-- (`- shadowsocks:`),字段名一律 snake_case(user_id / peer_public_key /
-- skip_tls_verify)—— 既不是 Clash 的 network:+ws-opts,也不是 Surge 的
-- 逗号行,两边的字段名都不能照抄;
-- 2. vmess / vless 的传输层是 transport 子映射,键名是传输类型本身
-- (tls / ws / wss / grpc / http2)。**TLS 也是其中一种**,没有顶层 tls
-- 开关 —— 「明文 tcp」就是完全不写 transport;
-- 3. Reality 的嵌套位置按协议分叉:vmess / vless 在 transport.<类型>.reality
-- 里,trojan / anytls 是节点顶层的 reality 对象。键名是 public_key /
-- short_id(不是统一模型的 public-key / short-id,也不是 reality-opts);
-- 4. 协议清单与 Surge 家族不同:有 VLESS 与 WireGuard,**没有** SSR 与
-- Hysteria v1 —— 后两者整条丢弃(EGERN_KEY);
-- 5. 节点名必须全局唯一(官方文档原文),与 mihomo 同一约束,复用
-- util.unique_tags。
--
-- 逐字段依据:egernapp.com/docs/configuration/example/ 与
-- .../configuration/proxies/(官方示例与协议字段表)。
package.path = "./root/usr/share/?.lua;" .. package.path
local node = require("substore.node")
local output = require("substore.output")
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
-- 输出是 table.concat(…, "\n"),**最后一行没有尾随换行**。断言行首/行尾时统一
-- 给正文补一个 "\n",这样「某行以 X 结尾」的断言对末行同样成立。
local function has(s, sub)
return type(s) == "string" and (s .. "\n"):find(sub, 1, true) ~= nil
end
local function N(t) return node.normalize(t) end
local function gen(nodes, opts)
return output.generate(nodes, "egern", opts or { name = "PROXY" })
end
-- ============ 1. 顶层结构 ============
local ss = N{ proto = "shadowsocks", name = "SS", server = "1.2.3.4", port = 8388,
method = "aes-256-gcm", password = "pw" }
local out = gen({ ss })
check("proxies is a top-level key", has(out, "proxies:"))
check("protocol is the mapping key (not a type field)",
has(out, " - shadowsocks:") and not has(out, "type: shadowsocks"))
check("fields are indented under the protocol key", has(out, "\n name: SS\n"))
check("policy_groups is emitted", has(out, "policy_groups:") and has(out, " - select:"))
check("group name and membership", has(out, " name: PROXY\n")
and has(out, " policies:\n - SS\n"))
check("snake_case field names", has(out, "password: pw") and not has(out, "Password"))
-- 统一模型的 name/server/port 三个字段在所有协议上共用
check("common fields server/port", has(out, "server: 1.2.3.4") and has(out, "port: 8388"))
-- 空节点表:proxies 必须是空**列表**(只写 `proxies:` 在 YAML 里是 null),
-- 且策略组要有合法成员
local empty = gen({})
check("empty proxies is an empty list", has(empty, "proxies: []"))
check("empty policies falls back to DIRECT", has(empty, " - DIRECT"))
-- ============ 2. 逐协议的字段名 ============
-- shadowsocks:method + password;udp_relay 只在支持它的协议上出现
check("ss method/password", has(gen({ ss }), "method: aes-256-gcm") and has(gen({ ss }), "password: pw"))
check("ss udp_relay emitted when set",
has(gen({ N{ proto = "shadowsocks", name = "S", server = "s", port = 1,
method = "aes-256-gcm", password = "p", udp = true } }), "udp_relay: true"))
-- SIP003 混淆 → obfs 三件套
local ss_obfs = N{ proto = "shadowsocks", name = "SSO", server = "1.2.3.4", port = 8388,
method = "aes-256-gcm", password = "pw", plugin = "obfs-local;obfs=http;obfs-host=bing.com" }
local o_obfs = gen({ ss_obfs })
check("ss obfs trio", has(o_obfs, "obfs: http") and has(o_obfs, "obfs_host: bing.com"))
-- v2ray-plugin 在 Egern 里没有对应字段:丢插件、保留节点(不是整条丢弃)
local ss_v2 = N{ proto = "shadowsocks", name = "SSV", server = "1.2.3.4", port = 8388,
method = "aes-256-gcm", password = "pw", plugin = "v2ray-plugin;mode=websocket;host=h.com" }
local o_v2 = gen({ ss_v2 })
check("ss v2ray-plugin dropped, node kept",
has(o_v2, "SSV") and not has(o_v2, "obfs") and not has(o_v2, "plugin"))
-- vmess:user_id + security(取值域与 node.VMESS_CIPHERS 一致,缺省回退 auto)
local vm = N{ proto = "vmess", name = "VM", server = "1.2.3.4", port = 443, uuid = "u-1" }
check("vmess user_id (not uuid)", has(gen({ vm }), "user_id: u-1") and not has(gen({ vm }), "uuid:"))
check("vmess security defaults to auto", has(gen({ vm }), "security: auto"))
check("vmess security keeps a whitelisted cipher",
has(gen({ N{ proto = "vmess", name = "V", server = "s", port = 1, uuid = "u",
cipher = "chacha20-poly1305" } }), "security: chacha20-poly1305"))
-- vless:user_id + flow
local vl = N{ proto = "vless", name = "VL", server = "1.2.3.4", port = 443, uuid = "u-1",
flow = "xtls-rprx-vision" }
check("vless user_id/flow", has(gen({ vl }), "user_id: u-1") and has(gen({ vl }), "flow: xtls-rprx-vision"))
-- trojan:password + sni + skip_tls_verify
local tr = N{ proto = "trojan", name = "TR", server = "1.2.3.4", port = 443, password = "p",
sni = "s.example.com", ["skip-cert-verify"] = true }
check("trojan password/sni/skip_tls_verify",
has(gen({ tr }), "password: p") and has(gen({ tr }), "sni: s.example.com")
and has(gen({ tr }), "skip_tls_verify: true"))
-- anytls:password(不是 auth)
local at = N{ proto = "anytls", name = "AT", server = "1.2.3.4", port = 443, password = "p" }
check("anytls password (not auth)", has(gen({ at }), "password: p") and not has(gen({ at }), "auth:"))
-- hysteria2:密码字段叫 auth,混淆叫 obfs_password
local hy = N{ proto = "hysteria2", name = "HY", server = "1.2.3.4", port = 443, password = "p",
obfs = "salamander", ["obfs-password"] = "op" }
local o_hy = gen({ hy })
-- 注意不能用 `not has(o_hy, "password:")`:混淆字段 obfs_password 里就含这个子串
check("hysteria2 password is auth", has(o_hy, "auth: p") and not has(o_hy, "\n password:"))
check("hysteria2 obfs/obfs_password", has(o_hy, "obfs: salamander") and has(o_hy, "obfs_password: op"))
-- obfs=plain 是「无混淆」,不能写出 `obfs: plain`
check("hysteria2 obfs=plain is omitted",
not has(gen({ N{ proto = "hysteria2", name = "H", server = "s", port = 1, password = "p",
obfs = "plain" } }), "obfs:"))
-- tuic:uuid + password + udp_relay_mode + alpn 列表
local tu = N{ proto = "tuic", name = "TU", server = "1.2.3.4", port = 443, uuid = "u-1",
password = "p", ["udp-relay-mode"] = "native", alpn = "h3,h2" }
local o_tu = gen({ tu })
check("tuic uuid/password", has(o_tu, "uuid: u-1") and has(o_tu, "password: p"))
check("tuic udp_relay_mode (snake_case, not udp_relay)",
has(o_tu, "udp_relay_mode: native") and not has(o_tu, "udp_relay: "))
check("tuic alpn is a block list", has(o_tu, "alpn:\n - h3\n - h2"))
-- socks5:协议键是 socks5(统一模型里叫 socks)
local so = N{ proto = "socks", name = "SO", server = "1.2.3.4", port = 1080,
username = "usr", password = "pw" }
local o_so = gen({ so })
check("socks maps to the socks5 key", has(o_so, " - socks5:"))
check("socks5 username/password", has(o_so, "username: usr") and has(o_so, "password: pw"))
-- wireguard:private_key + peer_public_key + local_ipv4/6(Egern 的必填组合)
local wg = N{ proto = "wireguard", name = "WG", server = "1.2.3.4", port = 51820,
["private-key"] = "priv", ["public-key"] = "pub", ["pre-shared-key"] = "psk",
ip = "10.0.0.2/32", reserved = "1,2,3", dns = "1.1.1.1", mtu = 1420,
["persistent-keepalive"] = 25 }
local o_wg = gen({ wg })
check("wireguard private_key/peer_public_key",
has(o_wg, "private_key: priv") and has(o_wg, "peer_public_key: pub"))
check("wireguard preshared_key", has(o_wg, "preshared_key: psk"))
check("wireguard local_ipv4 from ip", has(o_wg, "local_ipv4: 10.0.0.2/32"))
check("wireguard reserved/dns_servers are block lists",
has(o_wg, "reserved:\n - 1\n - 2\n - 3")
and has(o_wg, "dns_servers:\n - 1.1.1.1"))
check("wireguard mtu/keepalive", has(o_wg, "mtu: 1420") and has(o_wg, "keepalive: 25"))
-- wireguard 的 public-key 是**对端公钥**,绝不能被当成 Reality
check("wireguard has no reality block", not has(o_wg, "reality:"))
-- ============ 3. transport 嵌套(vmess / vless) ============
-- 明文 tcp:完全不写 transport(Egern 没有顶层 tls 开关)
check("plain tcp has no transport", not has(gen({ vm }), "transport:"))
-- ws 无 TLS → ws;ws + TLS → wss(官方把两者列为两个传输类型)
local vm_ws = N{ proto = "vmess", name = "VMW", server = "1.2.3.4", port = 443, uuid = "u-1",
net = "ws", path = "/ws", host = "h.com" }
local o_ws = gen({ vm_ws })
check("ws transport", has(o_ws, " transport:\n ws:\n path: /ws"))
check("ws headers Host", has(o_ws, " headers:\n Host: h.com"))
local vm_wss = N{ proto = "vmess", name = "VMWS", server = "1.2.3.4", port = 443, uuid = "u-1",
net = "ws", security = "tls", path = "/ws", sni = "s.example.com", ["skip-cert-verify"] = true }
local o_wss = gen({ vm_wss })
check("ws + tls becomes wss", has(o_wss, " wss:") and not has(o_wss, " ws:\n"))
check("wss carries sni/skip_tls_verify",
has(o_wss, "sni: s.example.com") and has(o_wss, "skip_tls_verify: true"))
-- grpc:服务名在 Egern 里叫 service_name(Clash 叫 grpc-service-name)
local vm_grpc = N{ proto = "vmess", name = "VMG", server = "1.2.3.4", port = 443, uuid = "u-1",
net = "grpc", path = "gsvc", sni = "s.example.com" }
local o_grpc = gen({ vm_grpc })
check("grpc uses service_name", has(o_grpc, " grpc:\n service_name: gsvc"))
check("grpc has no grpc-service-name", not has(o_grpc, "grpc-service-name"))
-- http2
local vl_h2 = N{ proto = "vless", name = "VLH", server = "1.2.3.4", port = 443, uuid = "u-1",
net = "h2", path = "/h2", host = "h.com", security = "tls" }
local o_h2 = gen({ vl_h2 })
check("h2 maps to the http2 transport", has(o_h2, " http2:\n path: /h2"))
-- ============ 4. Reality 的嵌套位置 ============
-- vmess / vless:在 transport.<类型>.reality 里(tcp+tls 时就是 transport.tls.reality)
local vmr = N{ proto = "vmess", name = "VMR", server = "1.2.3.4", port = 443, uuid = "u-1",
["public-key"] = "PBK", ["short-id"] = "SID", sni = "s.example.com" }
local o_vmr = gen({ vmr })
check("vmess reality nested under transport", has(o_vmr, " transport:\n tls:\n")
and has(o_vmr, " reality:\n public_key: PBK\n short_id: SID"))
check("vmess reality uses snake_case keys, not reality-opts",
not has(o_vmr, "reality-opts") and not has(o_vmr, "public-key:"))
-- vmess + ws + reality:reality 挂在 wss 子映射里
local vmwr = N{ proto = "vmess", name = "VMWR", server = "1.2.3.4", port = 443, uuid = "u-1",
net = "ws", path = "/w", ["public-key"] = "PBK", ["short-id"] = "SID" }
check("vmess ws+reality nests reality under wss",
has(gen({ vmwr }), " wss:\n path: /w\n reality:\n public_key: PBK"))
-- trojan / anytls:节点**顶层**的 reality 对象(不在 transport 里)
local trr = N{ proto = "trojan", name = "TRR", server = "1.2.3.4", port = 443, password = "p",
["public-key"] = "PBK", ["short-id"] = "SID" }
check("trojan reality is top-level",
has(gen({ trr }), "\n reality:\n public_key: PBK\n short_id: SID"))
check("trojan reality is not inside a transport", not has(gen({ trr }), "transport:"))
local atr = N{ proto = "anytls", name = "ATR", server = "1.2.3.4", port = 443, password = "p",
["public-key"] = "PBK", ["short-id"] = "SID" }
check("anytls reality is top-level",
has(gen({ atr }), "\n reality:\n public_key: PBK\n short_id: SID"))
-- 没有 short-id 时只写 public_key(short_id 是可选的)
local vmr2 = N{ proto = "vmess", name = "VMR2", server = "1.2.3.4", port = 443, uuid = "u-1",
["public-key"] = "PBK" }
check("reality without short_id omits the key",
has(gen({ vmr2 }), "public_key: PBK") and not has(gen({ vmr2 }), "short_id:"))
-- ============ 5. 协议清单:SSR 与 Hysteria v1 整条丢弃 ============
local ssr = N{ proto = "ssr", name = "SSR", server = "1.2.3.4", port = 8388,
method = "aes-128-cfb", password = "p", protocol = "origin", obfs = "plain" }
local o_ssr = gen({ ssr })
check("ssr node is dropped entirely", not has(o_ssr, "SSR") and not has(o_ssr, "ssr:"))
check("dropping ssr leaves an empty proxies list", has(o_ssr, "proxies: []"))
-- hysteria v1 的字段结构与 v2 不同(v1 的 obfs 是普通字符串、没有 obfs_password),
-- 拿 hysteria2 的键去顶会让客户端按错误的协议去连
local hy1 = N{ proto = "hysteria", name = "HY1", server = "1.2.3.4", port = 443, password = "p" }
local o_hy1 = gen({ hy1 })
check("hysteria v1 node is dropped entirely",
not has(o_hy1, "HY1") and not has(o_hy1, "hysteria:"))
-- 混合:可表达的节点保留,不可表达的丢弃,策略组只列保留的
local mixed = gen({ ss, ssr, hy1 })
check("mixed input keeps only expressible nodes",
has(mixed, "SS") and not has(mixed, "SSR") and not has(mixed, "HY1"))
check("policy group lists only kept nodes", has(mixed, " - SS\n")
and not has(mixed, " - SSR"))
-- ============ 6. 名字唯一性 ============
-- 官方要求节点名全局唯一;重名追加 " #2",组名与内置策略一并占位
local dup = gen({
N{ proto = "vmess", name = "D", server = "1.1.1.1", port = 1, uuid = "a" },
N{ proto = "vmess", name = "D", server = "1.1.1.2", port = 2, uuid = "b" },
N{ proto = "vmess", name = "PROXY", server = "1.1.1.3", port = 3, uuid = "c" },
})
check("duplicate names get a #N suffix",
has(dup, ' name: D\n') and has(dup, ' name: "D #2"\n'))
check("a node named like the group is renamed",
has(dup, ' name: "PROXY #2"\n'))
check("policies reference the renamed tags",
has(dup, " - D\n - \"D #2\"\n - \"PROXY #2\"\n"))
-- ============ 7. YAML 转义与端口 ============
-- 含 YAML c-indicator 的值必须加引号(未加引号的 `name: a: b` 会让客户端
-- 拒绝整份配置)—— 转义实现与 Clash.Meta 输出共用一份
local weird = gen({ N{ proto = "vmess", name = "a: b", server = "1.2.3.4", port = 443, uuid = "u" } })
check("names needing quotes are quoted", has(weird, ' name: "a: b"\n'))
check("quoted names are quoted in policies too", has(weird, ' - "a: b"\n'))
local pct = gen({ N{ proto = "shadowsocks", name = "P", server = "s", port = 1,
method = "aes-256-gcm", password = "%secret" } })
check("passwords starting with % are quoted", has(pct, 'password: "%secret"'))
-- port 必须是整数:非数字值会被客户端拒绝,与 clash_meta 的兜底一致
local bad_port = gen({ N{ proto = "vmess", name = "BP", server = "s", port = "abc", uuid = "u" } })
check("non-numeric port falls back to 0", has(bad_port, "port: 0"))
-- ============ 8. 格式注册 ============
-- Egern 的内容是 YAML,后缀必须与内容一致(.conf 会被下游当成 Surge 的逗号行)
check("egern extension is yaml", output.extension_for("egern") == "yaml")
check("egern content-type is set", output.content_type_for("egern") ~= nil)
check("egern dispatches to the YAML generator", type(output.generate({ ss }, "egern", {})) == "string")
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)