-- 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)