diff --git a/lib/backend/proxy/__tests__/complex-protocol.test.ts b/lib/backend/proxy/__tests__/complex-protocol.test.ts new file mode 100644 index 00000000..37a743f3 --- /dev/null +++ b/lib/backend/proxy/__tests__/complex-protocol.test.ts @@ -0,0 +1,889 @@ +/** + * Complex Protocol Test Suite + * 验证各种复杂协议组合(VLESS+Reality+httpupgrade、SS+plugin、多传输层等) + * 解析后转换到各平台,确保输出字段正确 + */ +import { describe, expect, it } from 'vitest'; + +import { convert, parse } from '../index'; +import type { ProxyNode } from '../types'; + +// ============================================================ +// 测试 URI 定义 —— 每个都是真实场景中的复杂协议组合 +// ============================================================ + +const TEST_URIS = { + // 1. VLESS + Reality + httpupgrade (最复杂组合) + vlessRealityHttpupgrade: + 'vless://uuid-1234@1.2.3.4:443?type=httpupgrade&host=cdn.example.com&path=%2Fws%3Fed%3D2048&security=reality&sni=www.google.com&fp=chrome&pbk=abc123def456&sid=abcdef01&spx=%2Fspider#VLESS-Reality-Httpupgrade', + + // 2. VLESS + Reality + WebSocket + vlessRealityWs: + 'vless://uuid-5678@2.3.4.5:443?type=ws&path=%2Fws&host=ws.example.com&security=reality&sni=www.google.com&fp=chrome&pbk=xyz789&sid=12345678&spx=%2F#VLESS-Reality-WS', + + // 3. VLESS + Reality + gRPC + vlessRealityGrpc: + 'vless://uuid-abcd@3.4.5.6:443?type=grpc&serviceName=grpc-service&security=reality&sni=grpc.example.com&fp=chrome&pbk=key123&sid=aabbccdd&spx=%2F#VLESS-Reality-gRPC', + + // 4. VLESS + ECH + WebSocket + vlessEchWs: + 'vless://uuid-ech1@5.6.7.8:443?type=ws&host=ech.example.com&path=%2Fech&security=tls&sni=ech.example.com&fp=firefox&ech=1#VLESS-ECH-WS', + + // 5. VMess + WebSocket + TLS + vmessWsTls: + 'vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MtV1MtVExTIiwiYWRkIjoiNi43LjguOSIsInBvcnQiOiI0NDMiLCJpZCI6IjExMTExMTExLTExMTExLTExMTEtMTExMTExMTExMTExMSIsImFpZCI6IjEiLCJuZXQiOiJ3cyIsInR5cGUiOiJub25lIiwiaG9zdCI6IndzLmV4YW1wbGUuY29tIiwicGF0aCI6Ii93cyIsInRscyI6InRscyIsInNuaSI6IndzLmV4YW1wbGUuY29tIn0#VMess-WS-TLS', + + // 6. VMess + gRPC + vmessGrpc: + 'vmess://eyJ2IjoiMiIsInBzIjoiVk1lc3MtZ1JwYyIsImFkZCI6IjcuOC45LjAiLCJwb3J0IjoiNDQzIiwiaWQiOiIyMjIyMjIyMi0yMjIyLTIyMjItMjIyMi0yMjIyMjIyMjIyMjIiLCJhaWQiOiIxIiwibmV0IjoiZ3JwYyIsInR5cGUiOiJub25lIiwiaG9zdCI6ImdycGNfY2hhbm5lbCIsInBhdGgiOiIiLCJ0bHMiOiJ0bHMifQ#VMess-gRPC', + + // 7. Trojan + WebSocket + trojanWs: + 'trojan://pass123@8.9.10.11:443?type=ws&host=ws.trojan.com&path=%2Ftrojan&security=tls&sni=trojan.example.com#Trojan-WS', + + // 8. SS + v2ray-plugin + base64 params + ssV2rayPlugin: + 'ss://Y2hhY2hhMjAtcG9seTEzMDU6dGVzdHBhc3NAMTIzLjQ1LjY3Ljg5OjQ0Mw%3D%3D?v2ray-plugin=eyJ0eXBlIjoid2Vic29ja2V0Iiwib2JmcyI6Imh0dHAiLCJob3N0IjoiZXhhbXBsZS5jb20ifQ#SS-V2ray-Plugin', + + // 9. SS + obfs-plugin (plugin name must be obfs-local or simple-obfs per SS standard) + ssObfs: + 'ss://YWVzLTI1Ni1nY206dGVzdHBhc3NAMTIzLjQ1LjY3Ljg5OjQ0Mw%3D%3D?plugin=obfs-local%3Bmode%3Dhttp%3Bobfs-host%3Dexample.com#SS-Obfs', + + // 10. Hysteria2 + port hopping + hysteria2: + 'hysteria2://hy2pass@10.11.12.13:443?obfs=salamander&obfs-password=obfspass&sni=hy2.example.com&insecure=1&ports=443-8443-4&hop-interval=3#Hysteria2-PortHopping', + + // 11. TUIC v5 + tuicV5: + 'tuic://uuid-tuic:tuicpass@14.15.16.17:443?sni=tuic.example.com&alpn=h3&congestion_control=bbr&udp_relay_mode=native&reduce_rtt=1#TUIC-v5', + + // 12. AnyTLS + anytls: + 'anytls://atp@18.19.20.21:443?sni=at.example.com&insecure=1#AnyTLS', + + // 13. Naive + naive: + 'naive+https://naiveuser:naivepass@22.23.24.25:443?sni=naive.example.com#Naive', + + // 14. WireGuard + wireguard: + 'wireguard://testkey@26.27.28.29:51820?publickey=pubkey123&ip=10.0.0.2&dns=1.1.1.1&mtu=1280&reserved=123,45,678&keepalive=25#WireGuard', + + // 15. SS + shadow-tls (plugin string format) + ssShadowTls: + 'ss://Y2hhY2hhMjAtcG9seTEzMDU6dGVzdEB2MC5zaGFkb3d0bHMudG86NDQz?plugin=shadow-tls%3Bhost%3Dshadow.example.com%3Bpassword%3Dshadowpass%3Bversion%3D1.3#SS-ShadowTLS', + + // 16. Trojan + Reality + trojanReality: + 'trojan://trpass@30.31.32.33:443?security=reality&sni=tr.example.com&fp=chrome&pbk=trpbk123&sid=trsid456&spx=%2Ftr#Trojan-Reality', +} as const; + +// ============================================================ +// 测试:解析验证 +// ============================================================ + +describe('Complex Protocol URI Parsing', () => { + it('should parse VLESS+Reality+httpupgrade', () => { + const nodes = parse(TEST_URIS.vlessRealityHttpupgrade); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('vless'); + expect(node.uuid).toBe('uuid-1234'); + expect(node.server).toBe('1.2.3.4'); + expect(node.network).toBe('ws'); // httpupgrade → ws + expect(node.tls).toBe(true); + expect(node.sni).toBe('www.google.com'); + expect(node['reality-opts']).toBeDefined(); + expect(node['reality-opts']!['public-key']).toBe('abc123def456'); + expect(node['reality-opts']!['short-id']).toBe('abcdef01'); + // httpupgrade 标记在 ws-opts 中 + expect(node['ws-opts']?.['v2ray-http-upgrade']).toBe(true); + expect(node['xhttp-opts']?.mode).toBe('stream-one'); + }); + + it('should parse VLESS+Reality+gRPC', () => { + const nodes = parse(TEST_URIS.vlessRealityGrpc); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('vless'); + expect(node.network).toBe('grpc'); + expect(node['grpc-opts']?.['service-name']).toBe('grpc-service'); + expect(node['reality-opts']).toBeDefined(); + }); + + it('should parse VLESS+ECH+WS', () => { + const nodes = parse(TEST_URIS.vlessEchWs); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('vless'); + // URI 格式中 ech=1 只存储 _ech 标志,ech-opts 需要从 Clash YAML/base64 获取 + expect((node as any)._ech).toBe('1'); + expect(node.network).toBe('ws'); + }); + + it('should parse Hysteria2 with port hopping', () => { + const nodes = parse(TEST_URIS.hysteria2); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('hysteria2'); + expect(node.obfs).toBe('salamander'); + expect(node['obfs-password']).toBe('obfspass'); + expect(node.ports).toBeDefined(); + }); + + it('should parse TUIC v5', () => { + const nodes = parse(TEST_URIS.tuicV5); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('tuic'); + expect(node.uuid).toBe('uuid-tuic'); + expect(node['congestion-controller']).toBe('bbr'); + expect(node['reduce-rtt']).toBeTruthy(); + }); + + it('should parse AnyTLS', () => { + const nodes = parse(TEST_URIS.anytls); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('anytls'); + expect(node.sni).toBe('at.example.com'); + }); + + it('should parse Naive', () => { + const nodes = parse(TEST_URIS.naive); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('naive'); + expect(node.username).toBe('naiveuser'); + }); + + it('should parse WireGuard', () => { + const nodes = parse(TEST_URIS.wireguard); + expect(nodes.length).toBeGreaterThanOrEqual(1); + const node = nodes[0]; + expect(node.type).toBe('wireguard'); + expect(node.ip).toBe('10.0.0.2'); + expect(node.mtu).toBe(1280); + }); +}); + +// ============================================================ +// 测试:VLESS+Reality+httpupgrade 转换(最复杂场景) +// ============================================================ + +describe('VLESS+Reality+httpupgrade Conversion', () => { + const parseNode = () => parse(TEST_URIS.vlessRealityHttpupgrade)[0]; + + it('ClashMeta: should output httpupgrade cleaned, fingerprint, reality-opts, no tls', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'clashmeta'); + + // httpupgrade 内部标记字段必须被清理 + expect(result).not.toContain('v2ray-http-upgrade'); + expect(result).not.toContain('v2ray-http-upgrade-fast-open'); + + // 应有 fingerprint (非 tls-fingerprint) + expect(result).toContain('fingerprint: chrome'); + + // reality-opts 正确 + expect(result).toContain('reality-opts:'); + expect(result).toContain('public-key: abc123def456'); + expect(result).toContain('short-id: abcdef01'); + + // ws-opts 路径不含 early-data (已提取) + expect(result).toContain('early-data-header-name:'); + expect(result).toContain('max-early-data: 2048'); + }); + + it('Surge: should return empty (VLESS not supported)', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'surge'); + expect(result).toBe(''); + }); + + it('Loon: should output correct VLESS with Reality', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'loon'); + + expect(result).toContain('vless,1.2.3.4,443'); + expect(result).toContain('over-tls=true'); + expect(result).toContain('sni=www.google.com'); + expect(result).toContain('tls-profile=chrome'); + }); + + it('Shadowrocket: should output ClashYAML with fingerprint and reality', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'shadowrocket'); + + expect(result).toContain('fingerprint: chrome'); + expect(result).toContain('public-key: abc123def456'); + expect(result).not.toContain('tls-fingerprint:'); + }); + + it('Singbox: should output utls+reality in TLS', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + + expect(out.type).toBe('vless'); + expect(out.tls.reality.enabled).toBe(true); + expect(out.tls.reality.public_key).toBe('abc123def456'); + expect(out.tls.utls.fingerprint).toBe('chrome'); + expect(out.transport.type).toBe('ws'); + }); + + it('URI: should output vless:// with reality params', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'uri'); + + expect(result).toContain('vless://uuid-1234@1.2.3.4:443'); + expect(result).toContain('security=reality'); + expect(result).toContain('pbk=abc123def456'); + expect(result).toContain('sid=abcdef01'); + // httpupgrade 在 URI 中映射为 type=ws + expect(result).toContain('type=ws'); + expect(result).toContain('mode=stream-one'); + }); + + it('QX: should output vless with reality params', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'qx'); + + expect(result).toContain('vless='); + expect(result).toContain('reality-base64-pubkey='); + expect(result).toContain('reality-hex-shortid='); + }); +}); + +// ============================================================ +// 测试:SS + Plugin 组合转换 +// ============================================================ + +describe('SS Plugin Conversions', () => { + it('ClashMeta: SS+obfs should output plugin and plugin-opts', async () => { + const nodes = parse(TEST_URIS.ssObfs); + const result = await convert(nodes, 'clashmeta'); + + expect(result).toContain('plugin: obfs'); + expect(result).toContain('plugin-opts:'); + expect(result).toContain('host: example.com'); + }); + + it('Loon: SS+obfs should output obfs-name and obfs-host', async () => { + const nodes = parse(TEST_URIS.ssObfs); + const result = await convert(nodes, 'loon'); + + expect(result).toContain('obfs-name=http'); + expect(result).toContain('obfs-host=example.com'); + }); + + it('Surge: SS+obfs should output obfs', async () => { + const nodes = parse(TEST_URIS.ssObfs); + const result = await convert(nodes, 'surge'); + + expect(result).toContain('obfs=http'); + expect(result).toContain('obfs-host=example.com'); + }); + + it('Singbox: SS+obfs should output obfs plugin', async () => { + const nodes = parse(TEST_URIS.ssObfs); + const result = await convert(nodes, 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + + expect(out.plugin).toBe('obfs'); + }); +}); + +// ============================================================ +// 测试:Hysteria2 + port hopping 转换 +// ============================================================ + +describe('Hysteria2 Complex Conversion', () => { + const parseNode = () => parse(TEST_URIS.hysteria2)[0]; + + it('ClashMeta: should output hysteria2 with obfs and salamander', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'clashmeta'); + + expect(result).toContain('type: hysteria2'); + expect(result).toContain('obfs: salamander'); + expect(result).toContain('obfs-password: obfspass'); + // 隐式 TLS,不需要 tls 字段 + expect(result).not.toMatch(/tls:/); + }); + + it('Singbox: should output server_ports and hop_interval', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + + expect(out.type).toBe('hysteria2'); + expect(out.server_ports).toBeDefined(); + expect(out.hop_interval).toBe('3s'); + expect(out.obfs.type).toBe('salamander'); + expect(out.obfs.password).toBe('obfspass'); + }); + + it('Loon: should output Hysteria2 with salamander', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'loon'); + + expect(result).toContain('Hysteria2'); + expect(result).toContain('salamander-password=obfspass'); + }); + + it('Surge: should output hysteria2 with port-hopping', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'surge'); + + expect(result).toContain('hysteria2'); + expect(result).toContain('port-hopping='); + }); +}); + +// ============================================================ +// 测试:TUIC v5 转换 +// ============================================================ + +describe('TUIC v5 Conversion', () => { + const parseNode = () => parse(TEST_URIS.tuicV5)[0]; + + it('ClashMeta: should output tuic with congestion-controller and alpn', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'clashmeta'); + + expect(result).toContain('type: tuic'); + expect(result).toContain('congestion-controller: bbr'); + expect(result).toContain('udp-relay-mode: native'); + expect(result).toContain('alpn:'); + // 隐式 TLS + expect(result).not.toMatch(/tls:/); + }); + + it('Surge: should output tuic-v5 (no token)', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'surge'); + + expect(result).toContain('tuic-v5'); + expect(result).toContain('uuid=uuid-tuic'); + expect(result).toContain('password="tuicpass"'); + }); + + it('Singbox: should output zero_rtt_handshake and congestion_control', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + + expect(out.type).toBe('tuic'); + expect(out.congestion_control).toBe('bbr'); + expect(out.zero_rtt_handshake).toBe(true); + expect(out.tls.enabled).toBe(true); + }); + + it('URI: should output tuic:// with all params', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'uri'); + + expect(result).toContain('tuic://uuid-tuic:tuicpass@14.15.16.17:443'); + expect(result).toContain('congestion_control=bbr'); + expect(result).toContain('reduce_rtt=1'); + }); +}); + +// ============================================================ +// 测试:Naive + Singbox 转换 +// ============================================================ + +describe('Naive Conversion', () => { + const parseNode = () => parse(TEST_URIS.naive)[0]; + + it('Singbox: should output http type with TLS (no insecure)', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + + expect(out.type).toBe('http'); + expect(out.username).toBe('naiveuser'); + expect(out.password).toBe('naivepass'); + expect(out.tls.enabled).toBe(true); + // insecure should be false (not true) for naive + expect(out.tls.insecure).toBe(false); + }); + + it('URI: should output naive+https:// with auth', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'uri'); + + expect(result).toContain('naive+https://'); + expect(result).toContain('naiveuser'); + expect(result).toContain('naive.example.com'); + }); +}); + +// ============================================================ +// 测试:AnyTLS 全平台转换 +// ============================================================ + +describe('AnyTLS Full Conversion', () => { + const parseNode = () => parse(TEST_URIS.anytls)[0]; + + it('ClashMeta: should output anytls without tls', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'clashmeta'); + + expect(result).toContain('type: anytls'); + // 隐式 TLS + expect(result).not.toMatch(/tls:/); + }); + + it('Loon: should output anytls with tls-name', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'loon'); + + expect(result).toContain('anytls,18.19.20.21,443'); + expect(result).toContain('tls-name=at.example.com'); + }); + + it('Surge: should output anytls', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'surge'); + + expect(result).toContain('anytls'); + expect(result).toContain('sni=at.example.com'); + }); + + it('QX: should output anytls with over-tls', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'qx'); + + expect(result).toContain('anytls=18.19.20.21:443'); + expect(result).toContain('over-tls=true'); + }); + + it('Singbox: should output anytls with TLS', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + + expect(out.type).toBe('anytls'); + expect(out.tls.enabled).toBe(true); + }); + + it('URI: should output anytls://', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'uri'); + + expect(result).toContain('anytls://atp@18.19.20.21:443'); + expect(result).toContain('sni=at.example.com'); + }); +}); + +// ============================================================ +// 测试:VLESS + ECH + WS 转换 +// ============================================================ + +describe('VLESS+ECH Conversion', () => { + it('ClashMeta: should output ech-opts when ech-opts present', async () => { + // ECH config must come from Clash YAML / base64 format, not simple URI + // Test with a node that has ech-opts set manually + const nodes = parse(TEST_URIS.vlessEchWs); + // URI format only stores _ech flag, not ech-opts config + expect(nodes[0]['ech-opts']).toBeUndefined(); + expect((nodes[0] as any)._ech).toBe('1'); + }); + + it('Singbox: should handle ech field when present', async () => { + // ECH in Singbox requires ech config, not just flag + const nodes = parse(TEST_URIS.vlessEchWs); + const result = await convert(nodes, 'singbox'); + const config = JSON.parse(result); + const out = config.outbounds[0]; + // Without ech-opts config, singbox won't have ech field + expect(out.type).toBe('vless'); + }); +}); + +// ============================================================ +// 测试:VLESS Reality gRPC 转换 — grpc 内部字段清理 +// ============================================================ + +describe('VLESS+Reality+gRPC grpc-opts cleanup', () => { + const parseNode = () => parse(TEST_URIS.vlessRealityGrpc)[0]; + + it('ClashMeta: should clean grpc internal fields', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'clashmeta'); + + expect(result).not.toContain('_grpc-type'); + expect(result).not.toContain('_grpc-authority'); + expect(result).toContain('service-name: grpc-service'); + }); + + it('Shadowrocket: should clean grpc internal fields', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'shadowrocket'); + + expect(result).not.toContain('_grpc-type'); + expect(result).not.toContain('_grpc-authority'); + }); + + it('URI: should preserve grpc internal fields for round-trip', async () => { + const nodes = parseNode(); + const result = await convert([nodes], 'uri'); + + expect(result).toContain('serviceName=grpc-service'); + }); +}); + +// ============================================================ +// 测试:TCP 节点不应有传输层字段泄漏 +// ============================================================ + +describe('TCP node transport cleanup', () => { + it('AnyTLS should not have network=tcp', async () => { + const nodes = parse(TEST_URIS.anytls); + // AnyTLS 默认无 network 字段 + expect(nodes[0].network).toBeUndefined(); + }); + + it('Hysteria2 should not have network field', async () => { + const nodes = parse(TEST_URIS.hysteria2); + // Hysteria2 使用 QUIC,不需要 network + expect(nodes[0].network).toBeUndefined(); + }); +}); + +// ============================================================ +// 测试:Round-trip —— URI → Parse → Convert → URI 保留核心字段 +// ============================================================ + +describe('URI Round-trip preservation', () => { + it('VLESS Reality spider-x should survive parse→convert→uri', async () => { + const nodes = parse(TEST_URIS.vlessRealityGrpc); + const uriResult = await convert([nodes[0]], 'uri'); + + // spider-x 必须保留 + expect(uriResult).toContain('spx=%2F'); + }); + + it('VLESS Reality short-id should survive round-trip', async () => { + const nodes = parse(TEST_URIS.vlessRealityWs); + const uriResult = await convert([nodes[0]], 'uri'); + + expect(uriResult).toContain('sid=12345678'); + }); + + it('TUIC reduce-rtt should survive round-trip', async () => { + const nodes = parse(TEST_URIS.tuicV5); + const uriResult = await convert([nodes[0]], 'uri'); + + expect(uriResult).toContain('reduce_rtt=1'); + expect(uriResult).toContain('congestion_control=bbr'); + }); + + it('WireGuard reserved should survive round-trip', async () => { + const nodes = parse(TEST_URIS.wireguard); + const uriResult = await convert([nodes[0]], 'uri'); + + // URL-encoded commas in URI + expect(uriResult).toContain('reserved=123%2C45%2C678'); + }); +}); + +// ============================================================ +// 测试:ClashMeta tls 删除 —— 确认 Bug 修复 +// ============================================================ + +describe('ClashMeta implicit TLS deletion (Bug fix)', () => { + it('Trojan should not have tls: field', async () => { + const nodes = parse(TEST_URIS.trojanWs); + const result = await convert(nodes, 'clashmeta'); + + // Trojan 隐式 TLS,不应出现 "tls: true" + expect(result).not.toMatch(/\btls:\s*true\b/); + }); + + it('Hysteria2 should not have tls: field', async () => { + const nodes = parse(TEST_URIS.hysteria2); + const result = await convert(nodes, 'clashmeta'); + + expect(result).not.toMatch(/\btls:\s*true\b/); + }); + + it('TUIC should not have tls: field', async () => { + const nodes = parse(TEST_URIS.tuicV5); + const result = await convert(nodes, 'clashmeta'); + + expect(result).not.toMatch(/\btls:\s*true\b/); + }); + + it('AnyTLS should not have tls: field', async () => { + const nodes = parse(TEST_URIS.anytls); + const result = await convert(nodes, 'clashmeta'); + + expect(result).not.toMatch(/\btls:\s*true\b/); + }); +}); + +// ============================================================ +// 测试:Loon Reality 支持 (AnyTLS/Trojan/VMess) +// ============================================================ + +describe('Loon Reality conversions (feat: AnyTLS/Trojan + Reality, VMess + Reality)', () => { + it('Loon VMess + Reality should output sni/public-key/short-id', async () => { + const nodes = [ + { + type: 'vmess', + name: 'Loon VMess Reality', + server: 'vmess.example.com', + port: 443, + cipher: 'auto', + uuid: '12345678-1234-1234-1234-123456789abc', + alterId: 0, + tls: true, + sni: 'sni.example.com', + 'reality-opts': { + 'public-key': 'vmess-pubkey', + 'short-id': '01' + }, + 'skip-cert-verify': true + } + ] as unknown as ProxyNode[]; + const result = await convert(nodes, 'loon'); + + expect(result).toContain('vmess,vmess.example.com,443'); + expect(result).toContain('sni=sni.example.com'); + expect(result).toContain('public-key="vmess-pubkey"'); + expect(result).toContain('short-id=01'); + expect(result).toContain('over-tls=true'); + expect(result).toContain('skip-cert-verify=true'); + expect(result).not.toContain('tls-name='); + }); + + it('Loon Trojan + Reality should output sni/public-key/short-id', async () => { + const nodes = [ + { + type: 'trojan', + name: 'Loon Trojan Reality', + server: 'trojan.example.com', + port: 443, + password: 'secret', + sni: 'sni.example.com', + 'reality-opts': { + 'public-key': 'trojan-pubkey', + 'short-id': '02' + }, + 'skip-cert-verify': true + } + ] as unknown as ProxyNode[]; + const result = await convert(nodes, 'loon'); + + expect(result).toContain('trojan,trojan.example.com,443'); + expect(result).toContain('sni=sni.example.com'); + expect(result).toContain('public-key="trojan-pubkey"'); + expect(result).toContain('short-id=02'); + expect(result).toContain('skip-cert-verify=true'); + expect(result).not.toContain('tls-name='); + expect(result).not.toContain('tls-cert-sha256='); + }); + + it('Loon AnyTLS + Reality should output sni/public-key/short-id', async () => { + const nodes = [ + { + type: 'anytls', + name: 'Loon AnyTLS Reality', + server: 'anytls.example.com', + port: 443, + password: 'secret', + sni: 'sni.example.com', + 'reality-opts': { + 'public-key': 'anytls-pubkey', + 'short-id': '03' + }, + 'skip-cert-verify': true + } + ] as unknown as ProxyNode[]; + const result = await convert(nodes, 'loon'); + + expect(result).toContain('anytls,anytls.example.com,443'); + expect(result).toContain('sni=sni.example.com'); + expect(result).toContain('public-key="anytls-pubkey"'); + expect(result).toContain('short-id=03'); + expect(result).toContain('skip-cert-verify=true'); + expect(result).not.toContain('tls-name='); + expect(result).not.toContain('tls-cert-sha256='); + }); + + it('Loon Trojan without Reality should use tls-name (normal TLS)', async () => { + const nodes = [ + { + type: 'trojan', + name: 'Loon Trojan Normal', + server: 'trojan.example.com', + port: 443, + password: 'secret', + tls: true, + sni: 'sni.example.com', + 'skip-cert-verify': true + } + ] as unknown as ProxyNode[]; + const result = await convert(nodes, 'loon'); + + expect(result).toContain('trojan,trojan.example.com,443'); + expect(result).toContain('tls-name=sni.example.com'); + expect(result).not.toContain('public-key='); + }); +}); + +// ============================================================ +// 测试:SS2022 + obfs 转换 +// ============================================================ + +describe('SS2022 + obfs Loon conversion', () => { + it('Loon SS2022 + HTTP obfs should output correctly', async () => { + const nodes = [ + { + type: 'ss', + name: 'Loon SS2022 HTTP Obfs', + server: 'ss2022-http.example.com', + port: 8388, + cipher: '2022-blake3-aes-128-gcm', + password: 'server-key:user-key', + plugin: 'obfs', + 'plugin-opts': { + mode: 'http', + host: 'obfs.example.com', + path: '/' + } + } + ] as unknown as ProxyNode[]; + const result = await convert(nodes, 'loon'); + + expect(result).toContain('shadowsocks,ss2022-http.example.com,8388'); + expect(result).toContain('2022-blake3-aes-128-gcm'); + expect(result).toContain('obfs-name=http'); + expect(result).toContain('obfs-host=obfs.example.com'); + expect(result).toContain('obfs-uri=/'); + }); + + it('Loon SS2022 + TLS obfs should output correctly', async () => { + const nodes = [ + { + type: 'ss', + name: 'Loon SS2022 TLS Obfs', + server: 'ss2022-tls.example.com', + port: 8389, + cipher: '2022-blake3-aes-256-gcm', + password: 'server-key:user-key', + plugin: 'obfs', + 'plugin-opts': { + mode: 'tls', + host: 'tls.example.com', + path: '/tls' + } + } + ] as unknown as ProxyNode[]; + const result = await convert(nodes, 'loon'); + + expect(result).toContain('shadowsocks,ss2022-tls.example.com,8389'); + expect(result).toContain('2022-blake3-aes-256-gcm'); + expect(result).toContain('obfs-name=tls'); + expect(result).toContain('obfs-host=tls.example.com'); + expect(result).toContain('obfs-uri=/tls'); + }); +}); + +// ============================================================ +// 测试:Egern 字段转换验证 +// ============================================================ + +describe('Egern field conversions', () => { + it('Egern: VMess should output user_id and security', async () => { + const nodes = parse(TEST_URIS.vmessWsTls); + const result = await convert(nodes, 'egern'); + + expect(result).toContain('"user_id"'); + expect(result).toContain('"vmess"'); + expect(result).toContain('"transport"'); + }); + + it('Egern: Trojan + Reality should output reality.public_key', async () => { + const nodes = parse(TEST_URIS.trojanReality); + const result = await convert(nodes, 'egern'); + + expect(result).toContain('"trojan"'); + expect(result).toContain('"reality"'); + expect(result).toContain('"public_key"'); + expect(result).toContain('"short_id"'); + }); + + it('Egern: Hysteria2 should output obfs.salamander', async () => { + const nodes = parse(TEST_URIS.hysteria2); + const result = await convert(nodes, 'egern'); + + expect(result).toContain('"hysteria2"'); + expect(result).toContain('"salamander"'); + }); + + it('Egern: SS should output method (not cipher)', async () => { + const nodes = parse(TEST_URIS.ssObfs); + const result = await convert(nodes, 'egern'); + + expect(result).toContain('"shadowsocks"'); + expect(result).toContain('"method"'); + expect(result).toContain('"obfs"'); + }); + + it('Egern: AnyTLS should output anytls type', async () => { + const nodes = parse(TEST_URIS.anytls); + const result = await convert(nodes, 'egern'); + + expect(result).toContain('"anytls"'); + }); + + it('Egern: VLESS + Reality should output reality', async () => { + const nodes = parse(TEST_URIS.vlessRealityWs); + const result = await convert(nodes, 'egern'); + + expect(result).toContain('"vless"'); + expect(result).toContain('"reality"'); + expect(result).toContain('"public_key"'); + }); +}); + +// ============================================================ +// 测试:全平台转换不崩溃 +// ============================================================ + +describe('All protocols × All platforms smoke test', () => { + const platforms = ['clash', 'clashmeta', 'surge', 'loon', 'qx', 'shadowrocket', 'surfboard', 'singbox', 'uri', 'egern'] as const; + + const uriEntries = Object.entries(TEST_URIS); + + for (const [name, uri] of uriEntries) { + for (const platform of platforms) { + it(`${name} → ${platform} should not throw`, async () => { + const nodes = parse(uri); + expect(nodes.length).toBeGreaterThanOrEqual(1); + + // Surge 不支持 VLESS —— 跳过 + if (platform === 'surge' && (name.startsWith('vless') || name === 'trojanReality')) { + return; + } + // Egern 不支持 naive —— 跳过 + if (platform === 'egern' && name === 'naive') { + return; + } + + const result = await convert(nodes, platform); + expect(typeof result).toBe('string'); + }); + } + } +}); diff --git a/lib/backend/proxy/__tests__/comprehensive-all-protocols-all-formats.test.ts b/lib/backend/proxy/__tests__/comprehensive-all-protocols-all-formats.test.ts new file mode 100644 index 00000000..0ccc757a --- /dev/null +++ b/lib/backend/proxy/__tests__/comprehensive-all-protocols-all-formats.test.ts @@ -0,0 +1,1540 @@ +/** + * Comprehensive Test Suite + * 覆盖范围: + * 1. 所有协议 URI 解析 (SS/SSR/VMess/VLESS/Trojan/Hysteria/Hysteria2/TUIC/WireGuard/SOCKS5/HTTP/HTTPS/Snell/AnyTLS/Naive) + * 2. 所有协议所有参数的完整验证 + * 3. 所有客户端格式转换 (URI/ClashMeta/Surge/Loon/QX/Shadowrocket/Singbox/Stash/Surfboard/Egern) + * 4. 解析→转换往返 (roundtrip) 一致性 + * 5. 边界情况和错误处理 + */ +import { describe, expect, it } from 'vitest'; + +import { convert, parse } from '../index'; +import type { ProxyNode } from '../types'; + +// ============================================================================ +// Helper: 快速创建 ProxyNode +// ============================================================================ +function makeNode(overrides: Partial & { type: ProxyNode['type']; name: string; server: string; port: number }): ProxyNode { + return { id: 'test-id', ...overrides } as ProxyNode; +} + +// ============================================================================ +// 1. Shadowsocks — 完整参数 +// ============================================================================ +describe('1. Shadowsocks (SS) — 完整参数解析与转换', () => { + // --- URI 格式 --- + it('1.1.1 应解析标准 SS URI (SIP002)', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@server.com:443#TestSS'; + const nodes = parse(uri); + expect(nodes.length).toBe(1); + expect(nodes[0].type).toBe('ss'); + expect(nodes[0].cipher).toBe('aes-128-gcm'); + expect(nodes[0].password).toBe('password'); + expect(nodes[0].server).toBe('server.com'); + expect(nodes[0].port).toBe(443); + expect(nodes[0].name).toBe('TestSS'); + }); + + it('1.1.2 应解析旧版 SS URI (全 Base64)', () => { + const inner = 'aes-256-gcm:mypassword@10.0.0.1:8388'; + const encoded = Buffer.from(inner).toString('base64'); + const uri = `ss://${encoded}#OldSS`; + const nodes = parse(uri); + expect(nodes.length).toBe(1); + expect(nodes[0].cipher).toBe('aes-256-gcm'); + expect(nodes[0].password).toBe('mypassword'); + expect(nodes[0].server).toBe('10.0.0.1'); + expect(nodes[0].port).toBe(8388); + }); + + it('1.1.3 应解析 2022-blake3 cipher', () => { + const cipher = '2022-blake3-aes-128-gcm'; + const pass = 'testkey1234567890123456789012'; + const encoded = Buffer.from(`${cipher}:${pass}`).toString('base64'); + const uri = `ss://${encoded}@example.com:8443#SS2022`; + const nodes = parse(uri); + expect(nodes[0].cipher).toBe(cipher); + expect(nodes[0].password).toBe(pass); + }); + + it('1.1.4 应解析 SS + obfs plugin', () => { + const uri = 'ss://YWVzLTI1Ni1nY206dGVzdA@1.2.3.4:443?plugin=obfs-local%3Bmode%3Dhttp%3Bobfs-host%3Dexample.com#SS-Obfs'; + const nodes = parse(uri); + expect(nodes[0].plugin).toBe('obfs'); + expect(nodes[0]['plugin-opts']?.mode).toBe('http'); + expect(nodes[0]['plugin-opts']?.host).toBe('example.com'); + }); + + it('1.1.5 应解析 SS + v2ray-plugin (plugin 字符串)', () => { + const uri = 'ss://YWVzLTI1Ni1nY206dGVzdA@1.2.3.4:443?plugin=v2ray-plugin%3Bmode%3Dwebsocket%3Bpath%3D%2Fws%3Btls%3D1#SS-V2'; + const nodes = parse(uri); + expect(nodes[0].plugin).toBe('v2ray-plugin'); + expect(nodes[0]['plugin-opts']?.mode).toBe('websocket'); + }); + + it('1.1.6 应解析 SS + shadow-tls plugin', () => { + const uri = 'ss://YWVzLTI1Ni1nY206dGVzdA@1.2.3.4:443?plugin=shadow-tls%3Bhost%3Dst.example.com%3Bpassword%3Dstpass%3Bversion%3D3#SS-ST'; + const nodes = parse(uri); + expect(nodes[0].plugin).toBe('shadow-tls'); + expect(nodes[0]['plugin-opts']?.host).toBe('st.example.com'); + expect(nodes[0]['plugin-opts']?.password).toBe('stpass'); + }); + + it('1.1.7 应解析 SS + v2ray-plugin (Base64 params)', () => { + const opts = Buffer.from(JSON.stringify({ type: 'websocket', obfs: 'http', host: 'example.com' })).toString('base64'); + const uri = `ss://YWVzLTI1Ni1nY206dGVzdA@1.2.3.4:443?v2ray-plugin=${opts}#SS-V2B64`; + const nodes = parse(uri); + expect(nodes[0].plugin).toBe('v2ray-plugin'); + }); + + it('1.1.8 应解析 SS + WebSocket 传输', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@1.2.3.4:443?type=ws&host=ws.example.com&path=%2Fws#SS-WS'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('ws'); + expect(nodes[0]['ws-opts']?.headers?.Host).toBe('ws.example.com'); + }); + + it('1.1.9 应解析 SS + gRPC 传输', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@1.2.3.4:443?type=grpc&serviceName=grpc-service#SS-gRPC'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('grpc'); + expect(nodes[0]['grpc-opts']?.['service-name']).toBe('grpc-service'); + }); + + it('1.1.10 应解析 SS + httpupgrade', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@1.2.3.4:443?type=httpupgrade&host=hu.example.com&path=%2Fhu#SS-HU'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('ws'); + expect(nodes[0].httpupgrade).toBe(true); + }); + + it('1.1.11 应解析 SS + TLS 参数', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@1.2.3.4:443?security=tls&sni=example.com&allowInsecure=1&fp=chrome#SS-TLS'; + const nodes = parse(uri); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('example.com'); + expect(nodes[0]['skip-cert-verify']).toBe(true); + expect(nodes[0]['client-fingerprint']).toBe('chrome'); + }); + + it('1.1.12 应解析 SS + UoT 和 TFO', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@1.2.3.4:443?uot=1&tfo=1&udp=1#SS-UoT'; + const nodes = parse(uri); + expect(nodes[0]['udp-over-tcp']).toBe(true); + expect(nodes[0].tfo).toBe(true); + expect(nodes[0].udp).toBe(true); + }); + + // --- 转换测试 --- + it('1.2.1 SS → URI 往返', async () => { + const node = makeNode({ type: 'ss', name: 'SS-Round', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'uri'); + expect(result).toContain('ss://'); + expect(result).toContain('1.2.3.4:443'); + expect(result).toContain('SS-Round'); + }); + + it('1.2.2 SS → ClashMeta', async () => { + const node = makeNode({ type: 'ss', name: 'SS-CM', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'clashmeta'); + expect(result).toContain('name: SS-CM'); + expect(result).toContain('type: ss'); + expect(result).toContain('cipher: aes-256-gcm'); + }); + + it('1.2.3 SS → Surge', async () => { + const node = makeNode({ type: 'ss', name: 'SS-Surge', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'surge'); + expect(result).toContain('SS-Surge=ss,1.2.3.4,443'); + expect(result).toContain('encrypt-method=aes-256-gcm'); + }); + + it('1.2.4 SS → Loon', async () => { + const node = makeNode({ type: 'ss', name: 'SS-Loon', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'loon'); + expect(result).toContain('SS-Loon=shadowsocks,1.2.3.4,443'); + }); + + it('1.2.5 SS → QX', async () => { + const node = makeNode({ type: 'ss', name: 'SS-QX', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'qx'); + expect(result).toContain('shadowsocks=1.2.3.4:443'); + expect(result).toContain('method=aes-256-gcm'); + }); + + it('1.2.6 SS → Singbox', async () => { + const node = makeNode({ type: 'ss', name: 'SS-SB', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'singbox'); + const config = JSON.parse(result); + expect(config.outbounds[0].type).toBe('shadowsocks'); + expect(config.outbounds[0].method).toBe('aes-256-gcm'); + }); + + it('1.2.7 SS → Shadowrocket', async () => { + const node = makeNode({ type: 'ss', name: 'SS-SR', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'shadowrocket'); + expect(result).toContain('name: SS-SR'); + expect(result).toContain('type: ss'); + }); + + it('1.2.8 SS → Egern', async () => { + const node = makeNode({ type: 'ss', name: 'SS-Egern', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'egern'); + expect(result).toContain('shadowsocks'); + expect(result).toContain('SS-Egern'); + }); + + it('1.2.9 SS → Surfboard', async () => { + const node = makeNode({ type: 'ss', name: 'SS-Surf', server: '1.2.3.4', port: 443, cipher: 'aes-256-gcm', password: 'pass123' }); + const result = await convert([node], 'surfboard'); + expect(result).toContain('SS-Surf=ss,1.2.3.4,443'); + }); + + it('1.2.10 SS obfs → 各平台格式正确', async () => { + const node = makeNode({ + type: 'ss', name: 'SS-Obfs', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass123', + plugin: 'obfs', 'plugin-opts': { mode: 'http', host: 'example.com' } + }); + const surge = await convert([node], 'surge'); + expect(surge).toContain('obfs=http'); + expect(surge).toContain('obfs-host=example.com'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('obfs-name=http'); + + const qx = await convert([node], 'qx'); + expect(qx).toContain('obfs=http'); + }); +}); + +// ============================================================================ +// 2. ShadowsocksR — 完整参数 +// ============================================================================ +describe('2. ShadowsocksR (SSR) — 完整参数解析与转换', () => { + it('2.1 应解析标准 SSR URI', () => { + const inner = 'server.com:443:origin:aes-256-cfb:plain:' + Buffer.from('password123').toString('base64'); + const params = 'remarks=' + Buffer.from('TestSSR').toString('base64'); + const encoded = Buffer.from(inner + '/?' + params).toString('base64'); + const uri = `ssr://${encoded}`; + const nodes = parse(uri); + expect(nodes.length).toBe(1); + expect(nodes[0].type).toBe('ssr'); + expect(nodes[0].server).toBe('server.com'); + expect(nodes[0].port).toBe(443); + expect(nodes[0].protocol).toBe('origin'); + expect(nodes[0].cipher).toBe('aes-256-cfb'); + expect(nodes[0].obfs).toBe('plain'); + expect(nodes[0].password).toBe('password123'); + }); + + it('2.2 应解析 SSR 带 obfs-param 和 protocol-param', () => { + const inner = 'server.com:443:auth_aes128_md5:aes-256-cfb:http_simple:' + Buffer.from('ssrpass').toString('base64'); + const params = [ + 'remarks=' + Buffer.from('SSR-Params').toString('base64'), + 'obfsparam=' + Buffer.from('obfsParam123').toString('base64'), + 'protoparam=' + Buffer.from('protoParam456').toString('base64') + ].join('&'); + const encoded = Buffer.from(inner + '/?' + params).toString('base64'); + const uri = `ssr://${encoded}`; + const nodes = parse(uri); + expect(nodes[0]['obfs-param']).toBe('obfsParam123'); + expect(nodes[0]['protocol-param']).toBe('protoParam456'); + }); + + it('2.3 SSR → Loon', async () => { + const node = makeNode({ + type: 'ssr', name: 'SSR-Loon', server: '1.2.3.4', port: 443, + protocol: 'origin', cipher: 'aes-256-cfb', obfs: 'plain', password: 'pass' + }); + const result = await convert([node], 'loon'); + expect(result).toContain('SSR-Loon=shadowsocksr'); + }); + + it('2.4 SSR → QX', async () => { + const node = makeNode({ + type: 'ssr', name: 'SSR-QX', server: '1.2.3.4', port: 443, + protocol: 'origin', cipher: 'aes-256-cfb', obfs: 'plain', password: 'pass' + }); + const result = await convert([node], 'qx'); + expect(result).toContain('shadowsocks='); + expect(result).toContain('ssr-protocol='); + }); +}); + +// ============================================================================ +// 3. VMess — 完整参数 +// ============================================================================ +describe('3. VMess — 完整参数解析与转换', () => { + const vmessConfig = { + v: '2', ps: 'TestVMess', add: 'server.com', port: '443', + id: 'uuid-1234-5678', aid: '0', scy: 'auto', + net: 'ws', type: 'none', host: 'ws.example.com', + path: '/ws', tls: 'tls', sni: 'sni.example.com', + alpn: 'h2,http/1.1', fp: 'chrome' + }; + + it('3.1 应解析 VMess JSON URI', () => { + const encoded = Buffer.from(JSON.stringify(vmessConfig)).toString('base64'); + const uri = `vmess://${encoded}`; + const nodes = parse(uri); + expect(nodes.length).toBe(1); + expect(nodes[0].type).toBe('vmess'); + expect(nodes[0].uuid).toBe('uuid-1234-5678'); + expect(nodes[0].server).toBe('server.com'); + expect(nodes[0].port).toBe(443); + expect(nodes[0].cipher).toBe('auto'); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('sni.example.com'); + }); + + it('3.2 应解析 VMess + gRPC', () => { + const cfg = { ...vmessConfig, net: 'grpc', path: 'grpc-service', host: '', type: 'gun' }; + const encoded = Buffer.from(JSON.stringify(cfg)).toString('base64'); + const nodes = parse(`vmess://${encoded}`); + expect(nodes[0].network).toBe('grpc'); + expect(nodes[0]['grpc-opts']?.['service-name']).toBe('grpc-service'); + }); + + it('3.3 应解析 VMess + H2', () => { + const cfg = { ...vmessConfig, net: 'h2', host: 'h2.example.com', path: '/h2' }; + const encoded = Buffer.from(JSON.stringify(cfg)).toString('base64'); + const nodes = parse(`vmess://${encoded}`); + expect(nodes[0].network).toBe('h2'); + }); + + it('3.4 应解析 VMess 无 TLS', () => { + const cfg = { ...vmessConfig, tls: '', net: 'tcp' }; + const encoded = Buffer.from(JSON.stringify(cfg)).toString('base64'); + const nodes = parse(`vmess://${encoded}`); + expect(nodes[0].tls).toBeFalsy(); + }); + + it('3.5 应解析 VMess alterId 非 0', () => { + const cfg = { ...vmessConfig, aid: '2', net: 'tcp', tls: '' }; + const encoded = Buffer.from(JSON.stringify(cfg)).toString('base64'); + const nodes = parse(`vmess://${encoded}`); + expect(nodes[0].alterId).toBe(2); + }); + + it('3.6 VMess → URI 往返', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-URI', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'uri'); + expect(result).toContain('vmess://'); + const decoded = JSON.parse(Buffer.from(result.replace('vmess://', ''), 'base64').toString()); + expect(decoded.id).toBe('test-uuid'); + }); + + it('3.7 VMess → ClashMeta', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-CM', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'clashmeta'); + expect(result).toContain('name: VMess-CM'); + expect(result).toContain('type: vmess'); + expect(result).toContain('alterId: 0'); + }); + + it('3.8 VMess → Surge', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-Surge', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true + }); + const result = await convert([node], 'surge'); + expect(result).toContain('vmess,1.2.3.4,443'); + expect(result).toContain('username=test-uuid'); + expect(result).toContain('vmess-aead=true'); + }); + + it('3.9 VMess → Loon', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-Loon', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'loon'); + expect(result).toContain('vmess,1.2.3.4,443'); + expect(result).toContain('over-tls=true'); + }); + + it('3.10 VMess → QX', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-QX', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'qx'); + expect(result).toContain('vmess=1.2.3.4:443'); + expect(result).toContain('aead=true'); + }); + + it('3.11 VMess → Singbox', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-SB', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'singbox'); + const config = JSON.parse(result); + expect(config.outbounds[0].type).toBe('vmess'); + expect(config.outbounds[0].uuid).toBe('test-uuid'); + }); + + it('3.12 VMess → Shadowrocket', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-SR', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'shadowrocket'); + expect(result).toContain('name: VMess-SR'); + expect(result).toContain('type: vmess'); + }); + + it('3.13 VMess → Egern', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-Egern', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'egern'); + expect(result).toContain('vmess'); + expect(result).toContain('VMess-Egern'); + }); + + it('3.14 VMess → Surfboard', async () => { + const node = makeNode({ + type: 'vmess', name: 'VMess-Surf', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'surfboard'); + expect(result).toContain('VMess-Surf=vmess,1.2.3.4,443'); + }); +}); + +// ============================================================================ +// 4. VLESS — 完整参数 +// ============================================================================ +describe('4. VLESS — 完整参数解析与转换', () => { + it('4.1 应解析 VLESS + TLS', () => { + const uri = 'vless://uuid-test@1.2.3.4:443?security=tls&sni=sni.com&fp=chrome#VLESS-TLS'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('vless'); + expect(nodes[0].uuid).toBe('uuid-test'); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('sni.com'); + expect(nodes[0]['client-fingerprint']).toBe('chrome'); + }); + + it('4.2 应解析 VLESS + Reality', () => { + const uri = 'vless://uuid-reality@1.2.3.4:443?security=reality&sni=www.google.com&fp=chrome&pbk=pubkey123&sid=shortid123&spx=%2Fspider#VLESS-Reality'; + const nodes = parse(uri); + expect(nodes[0].tls).toBe(true); + expect(nodes[0]['reality-opts']?.['public-key']).toBe('pubkey123'); + expect(nodes[0]['reality-opts']?.['short-id']).toBe('shortid123'); + expect(nodes[0]['reality-opts']?.['_spider-x']).toBe('/spider'); + }); + + it('4.3 应解析 VLESS + WebSocket', () => { + const uri = 'vless://uuid-ws@1.2.3.4:443?type=ws&host=ws.example.com&path=%2Fws&security=tls&sni=sni.com#VLESS-WS'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('ws'); + expect(nodes[0]['ws-opts']?.headers?.Host).toBe('ws.example.com'); + }); + + it('4.4 应解析 VLESS + gRPC', () => { + const uri = 'vless://uuid-grpc@1.2.3.4:443?type=grpc&serviceName=grpc-svc&security=tls&sni=sni.com#VLESS-gRPC'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('grpc'); + expect(nodes[0]['grpc-opts']?.['service-name']).toBe('grpc-svc'); + }); + + it('4.5 应解析 VLESS + httpupgrade', () => { + const uri = 'vless://uuid-hu@1.2.3.4:443?type=httpupgrade&host=hu.com&path=%2Fhu%3Fed%3D2048&security=reality&sni=www.google.com&fp=chrome&pbk=pk&sid=sid#VLESS-HU'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('ws'); + // httpupgrade 解析后设置 network=ws + xhttp-opts.mode=stream-one + expect(nodes[0]['xhttp-opts']?.mode).toBe('stream-one'); + }); + + it('4.6 应解析 VLESS + flow', () => { + const uri = 'vless://uuid-flow@1.2.3.4:443?security=reality&sni=sni.com&fp=chrome&pbk=pk&sid=sid&flow=xtls-rprx-vision#VLESS-Flow'; + const nodes = parse(uri); + expect(nodes[0].flow).toBe('xtls-rprx-vision'); + }); + + it('4.7 应解析 VLESS + ALPN', () => { + const uri = 'vless://uuid-alpn@1.2.3.4:443?security=tls&sni=sni.com&alpn=h2,http/1.1#VLESS-ALPN'; + const nodes = parse(uri); + expect(nodes[0].alpn).toEqual(['h2', 'http/1.1']); + }); + + it('4.8 VLESS Reality → 所有支持平台', async () => { + const node = makeNode({ + type: 'vless', name: 'VLESS-All', server: '1.2.3.4', port: 443, + uuid: 'test-uuid', tls: true, sni: 'sni.com', 'client-fingerprint': 'chrome', + 'reality-opts': { 'public-key': 'pk123', 'short-id': 'sid123' } + }); + + // URI + const uri = await convert([node], 'uri'); + expect(uri).toContain('vless://'); + expect(uri).toContain('security=reality'); + expect(uri).toContain('pbk=pk123'); + + // ClashMeta + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('type: vless'); + expect(cm).toContain('reality-opts:'); + expect(cm).toContain('public-key: pk123'); + + // Loon + const loon = await convert([node], 'loon'); + expect(loon).toContain('vless,1.2.3.4,443'); + + // QX + const qx = await convert([node], 'qx'); + expect(qx).toContain('vless='); + expect(qx).toContain('reality-base64-pubkey='); + + // Singbox + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].type).toBe('vless'); + expect(config.outbounds[0].tls.reality.enabled).toBe(true); + + // Shadowrocket + const sr = await convert([node], 'shadowrocket'); + expect(sr).toContain('type: vless'); + + // Egern + const eg = await convert([node], 'egern'); + expect(eg).toContain('vless'); + + // Surge: VLESS 不支持 + const surge = await convert([node], 'surge'); + expect(surge).toBe(''); + }); +}); + +// ============================================================================ +// 5. Trojan — 完整参数 +// ============================================================================ +describe('5. Trojan — 完整参数解析与转换', () => { + it('5.1 应解析标准 Trojan URI', () => { + const uri = 'trojan://pass123@1.2.3.4:443?sni=sni.com#Trojan-Std'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('trojan'); + expect(nodes[0].password).toBe('pass123'); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('sni.com'); + }); + + it('5.2 应解析 Trojan + WebSocket', () => { + const uri = 'trojan://pass123@1.2.3.4:443?type=ws&host=ws.com&path=%2Fws&sni=sni.com#Trojan-WS'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('ws'); + expect(nodes[0]['ws-opts']?.headers?.Host).toBe('ws.com'); + }); + + it('5.3 应解析 Trojan + gRPC', () => { + const uri = 'trojan://pass123@1.2.3.4:443?type=grpc&serviceName=trojan-grpc&sni=sni.com#Trojan-gRPC'; + const nodes = parse(uri); + expect(nodes[0].network).toBe('grpc'); + expect(nodes[0]['grpc-opts']?.['service-name']).toBe('trojan-grpc'); + }); + + it('5.4 应解析 Trojan + Reality', () => { + const uri = 'trojan://pass123@1.2.3.4:443?security=reality&sni=sni.com&fp=chrome&pbk=pk123&sid=sid123#Trojan-Reality'; + const nodes = parse(uri); + expect(nodes[0]['reality-opts']?.['public-key']).toBe('pk123'); + }); + + it('5.5 应解析 Trojan + ALPN', () => { + const uri = 'trojan://pass123@1.2.3.4:443?sni=sni.com&alpn=h2,http/1.1#Trojan-ALPN'; + const nodes = parse(uri); + expect(nodes[0].alpn).toEqual(['h2', 'http/1.1']); + }); + + it('5.6 Trojan → 全平台', async () => { + const node = makeNode({ + type: 'trojan', name: 'Trojan-All', server: '1.2.3.4', port: 443, + password: 'pass123', tls: true, sni: 'sni.com' + }); + + const uri = await convert([node], 'uri'); + expect(uri).toContain('trojan://'); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('type: trojan'); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('trojan,1.2.3.4,443'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('trojan,1.2.3.4,443'); + + const qx = await convert([node], 'qx'); + expect(qx).toContain('trojan='); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].type).toBe('trojan'); + }); +}); + +// ============================================================================ +// 6. Hysteria — 完整参数 +// ============================================================================ +describe('6. Hysteria (v1) — 完整参数解析与转换', () => { + it('6.1 应解析标准 Hysteria URI', () => { + const uri = 'hysteria://1.2.3.4:443/?auth=auth123&upmbps=100&downmbps=200&peer=sni.com#Hysteria'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('hysteria'); + expect(nodes[0].auth).toBe('auth123'); + expect(nodes[0].up).toBe(100); + expect(nodes[0].down).toBe(200); + expect(nodes[0].sni).toBe('sni.com'); + expect(nodes[0].tls).toBe(true); + }); + + it('6.2 应解析 Hysteria + obfs', () => { + const uri = 'hysteria://1.2.3.4:443/?obfs=xor&insecure=1#Hysteria-Obfs'; + const nodes = parse(uri); + expect(nodes[0].obfs).toBe('xor'); + expect(nodes[0]['skip-cert-verify']).toBe(true); + }); + + it('6.3 Hysteria → Loon', async () => { + const node = makeNode({ + type: 'hysteria', name: 'Hy-Loon', server: '1.2.3.4', port: 443, + auth: 'auth123', tls: true, up: 100, down: 200 + }); + const result = await convert([node], 'loon'); + expect(result).toContain('hysteria,1.2.3.4,443'); + }); +}); + +// ============================================================================ +// 7. Hysteria2 — 完整参数 +// ============================================================================ +describe('7. Hysteria2 — 完整参数解析与转换', () => { + it('7.1 应解析标准 Hysteria2 URI', () => { + const uri = 'hysteria2://pass@1.2.3.4:443?sni=sni.com#Hy2'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('hysteria2'); + expect(nodes[0].password).toBe('pass'); + expect(nodes[0].sni).toBe('sni.com'); + }); + + it('7.2 应解析 Hysteria2 + obfs', () => { + const uri = 'hysteria2://pass@1.2.3.4:443?obfs=salamander&obfs-password=obfspass#Hy2-Obfs'; + const nodes = parse(uri); + expect(nodes[0].obfs).toBe('salamander'); + expect(nodes[0]['obfs-password']).toBe('obfspass'); + }); + + it('7.3 应解析 Hysteria2 + 端口跳跃', () => { + const uri = 'hysteria2://pass@1.2.3.4:443?ports=443-8443-4#Hy2-PH'; + const nodes = parse(uri); + expect(nodes[0].ports).toBe('443-8443-4'); + }); + + it('7.4 应解析 Hysteria2 + hop-interval', () => { + const uri = 'hysteria2://pass@1.2.3.4:443?hop-interval=3#Hy2-HI'; + const nodes = parse(uri); + expect(nodes[0]['hop-interval']).toBe('3'); + }); + + it('7.5 Hysteria2 → 全平台', async () => { + const node = makeNode({ + type: 'hysteria2', name: 'Hy2-All', server: '1.2.3.4', port: 443, + password: 'pass123', sni: 'sni.com', obfs: 'salamander', 'obfs-password': 'obfspass' + }); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('type: hysteria2'); + expect(cm).toContain('obfs: salamander'); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].type).toBe('hysteria2'); + expect(config.outbounds[0].obfs.type).toBe('salamander'); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('hysteria2'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('Hysteria2'); + }); +}); + +// ============================================================================ +// 8. TUIC — 完整参数 +// ============================================================================ +describe('8. TUIC — 完整参数解析与转换', () => { + it('8.1 应解析标准 TUIC v5', () => { + const uri = 'tuic://uuid:pass@1.2.3.4:443?sni=sni.com&alpn=h3&congestion_control=bbr&udp_relay_mode=native&reduce_rtt=1#TUIC'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('tuic'); + expect(nodes[0].uuid).toBe('uuid'); + expect(nodes[0].password).toBe('pass'); + expect(nodes[0]['congestion-controller']).toBe('bbr'); + expect(nodes[0]['udp-relay-mode']).toBe('native'); + expect(nodes[0]['reduce-rtt']).toBe(true); + }); + + it('8.2 应解析 TUIC + disable-sni', () => { + const uri = 'tuic://uuid:pass@1.2.3.4:443?disable_sni=1#TUIC-DSNI'; + const nodes = parse(uri); + expect(nodes[0]['disable-sni']).toBe(true); + }); + + it('8.3 TUIC → 全平台', async () => { + const node = makeNode({ + type: 'tuic', name: 'TUIC-All', server: '1.2.3.4', port: 443, + uuid: 'uuid123', password: 'pass123', 'congestion-controller': 'bbr', + alpn: ['h3'] + }); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('type: tuic'); + expect(cm).toContain('congestion-controller: bbr'); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('tuic-v5'); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].type).toBe('tuic'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('tuic,1.2.3.4,443'); + }); +}); + +// ============================================================================ +// 9. WireGuard — 完整参数 +// ============================================================================ +describe('9. WireGuard — 完整参数解析与转换', () => { + it('9.1 应解析标准 WireGuard URI', () => { + const uri = 'wireguard://privkey@1.2.3.4:51820?publickey=pubkey123&ip=10.0.0.1&dns=1.1.1.1&mtu=1420&reserved=1,2,3#WG'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('wireguard'); + expect(nodes[0]['private-key']).toBe('privkey'); + expect(nodes[0].ip).toBe('10.0.0.1'); + expect(nodes[0].mtu).toBe(1420); + expect(nodes[0].reserved).toEqual([1, 2, 3]); + expect(nodes[0]['public-key']).toBe('pubkey123'); + }); + + it('9.2 应解析 WireGuard 带 pre-shared-key', () => { + const uri = 'wireguard://privkey@1.2.3.4:51820?publickey=pk&ip=10.0.0.1&pre-shared-key=psk123#WG-PSK'; + const nodes = parse(uri); + expect(nodes[0]['pre-shared-key']).toBe('psk123'); + }); + + it('9.3 应解析 WireGuard 带 keepalive', () => { + const uri = 'wireguard://privkey@1.2.3.4:51820?publickey=pk&ip=10.0.0.1&keepalive=25#WG-KA'; + const nodes = parse(uri); + expect(nodes[0].keepalive).toBe('25'); + }); + + it('9.4 WireGuard → Singbox', async () => { + const node = makeNode({ + type: 'wireguard', name: 'WG-SB', server: '1.2.3.4', port: 51820, + 'private-key': 'privkey', 'public-key': 'pubkey', ip: '10.0.0.1', mtu: 1420, + reserved: [1, 2, 3] + }); + const result = await convert([node], 'singbox'); + const config = JSON.parse(result); + expect(config.outbounds[0].type).toBe('wireguard'); + expect(config.outbounds[0].private_key).toBe('privkey'); + expect(config.outbounds[0].mtu).toBe(1420); + }); + + it('9.5 WireGuard → Surge', async () => { + const node = makeNode({ + type: 'wireguard', name: 'WG-Surge', server: '1.2.3.4', port: 51820, + 'private-key': 'privkey', 'public-key': 'pubkey', ip: '10.0.0.1', + reserved: [1, 2, 3] + }); + const result = await convert([node], 'surge'); + expect(result).toContain('WireGuard WG-Surge'); + expect(result).toContain('private-key = privkey'); + }); + + it('9.6 WireGuard → Loon', async () => { + const node = makeNode({ + type: 'wireguard', name: 'WG-Loon', server: '1.2.3.4', port: 51820, + 'private-key': 'privkey', 'public-key': 'pubkey', ip: '10.0.0.1' + }); + const result = await convert([node], 'loon'); + expect(result).toContain('wireguard'); + expect(result).toContain('private-key="privkey"'); + }); +}); + +// ============================================================================ +// 10. SOCKS5 — 完整参数 +// ============================================================================ +describe('10. SOCKS5 — 完整参数解析与转换', () => { + it('10.1 应解析标准 SOCKS5 URI', () => { + // SOCKS5 URI auth 是 user:pass 的 Base64 编码 + const encoded = Buffer.from('user:pass').toString('base64'); + const uri = `socks5://${encoded}@1.2.3.4:1080#Socks5`; + const nodes = parse(uri); + expect(nodes[0].type).toBe('socks5'); + expect(nodes[0].username).toBe('user'); + expect(nodes[0].password).toBe('pass'); + expect(nodes[0].server).toBe('1.2.3.4'); + expect(nodes[0].port).toBe(1080); + }); + + it('10.2 应解析 SOCKS5 无认证', () => { + // 无认证 SOCKS5: 使用 cipher: none + const encoded = Buffer.from('none:').toString('base64'); + const uri = `socks5://${encoded}@1.2.3.4:1080?sni=sni.com#Socks5NoAuth`; + const nodes = parse(uri); + expect(nodes[0].type).toBe('socks5'); + expect(nodes[0].server).toBe('1.2.3.4'); + }); + + it('10.3 SOCKS5 → 全平台', async () => { + const node = makeNode({ + type: 'socks5', name: 'Socks5-All', server: '1.2.3.4', port: 1080, + username: 'user', password: 'pass' + }); + + const uri = await convert([node], 'uri'); + expect(uri).toContain('socks://'); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('socks5,1.2.3.4,1080'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('socks5,1.2.3.4,1080'); + }); +}); + +// ============================================================================ +// 11. HTTP/HTTPS — 完整参数 +// ============================================================================ +describe('11. HTTP/HTTPS — 完整参数解析与转换', () => { + it('11.1 应解析 HTTP URI', () => { + const uri = 'http://user:pass@1.2.3.4:8080#HTTP'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('http'); + expect(nodes[0].username).toBe('user'); + expect(nodes[0].password).toBe('pass'); + expect(nodes[0].port).toBe(8080); + }); + + it('11.2 应解析 HTTPS URI', () => { + const uri = 'https://user:pass@1.2.3.4:443?sni=sni.com#HTTPS'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('https'); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('sni.com'); + }); + + it('11.3 HTTP → 全平台', async () => { + const node = makeNode({ + type: 'http', name: 'HTTP-All', server: '1.2.3.4', port: 8080, + username: 'user', password: 'pass' + }); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('http,1.2.3.4,8080'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('http,1.2.3.4,8080'); + }); +}); + +// ============================================================================ +// 12. Snell — 完整参数 +// ============================================================================ +describe('12. Snell — 完整参数解析与转换', () => { + it('12.1 应解析标准 Snell URI', () => { + const uri = 'snell://psk123@1.2.3.4:8388?version=4#Snell'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('snell'); + expect(nodes[0].password).toBe('psk123'); + expect(nodes[0].version).toBe(4); + }); + + it('12.2 Snell → Surge', async () => { + const node = makeNode({ + type: 'snell', name: 'Snell-Surge', server: '1.2.3.4', port: 8388, + password: 'psk123', version: 4 + }); + const result = await convert([node], 'surge'); + expect(result).toContain('snell,1.2.3.4,8388'); + expect(result).toContain('psk=psk123'); + expect(result).toContain('version=4'); + }); + + it('12.3 Snell → Loon', async () => { + const node = makeNode({ + type: 'snell', name: 'Snell-Loon', server: '1.2.3.4', port: 8388, + password: 'psk123', version: 4 + }); + const result = await convert([node], 'loon'); + expect(result).toContain('snell,1.2.3.4,8388'); + expect(result).toContain('psk="psk123"'); + }); + + it('12.4 Snell → Egern', async () => { + const node = makeNode({ + type: 'snell', name: 'Snell-Egern', server: '1.2.3.4', port: 8388, + password: 'psk123', version: 4 + }); + const result = await convert([node], 'egern'); + expect(result).toContain('snell'); + }); +}); + +// ============================================================================ +// 13. AnyTLS — 完整参数 +// ============================================================================ +describe('13. AnyTLS — 完整参数解析与转换', () => { + it('13.1 应解析标准 AnyTLS URI', () => { + const uri = 'anytls://pass@1.2.3.4:443?sni=sni.com&fp=chrome#AnyTLS'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('anytls'); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('sni.com'); + expect(nodes[0]['client-fingerprint']).toBe('chrome'); + }); + + it('13.2 应解析 AnyTLS 所有会话参数', () => { + const uri = 'anytls://pass@1.2.3.4:443?idle-session-check-interval=30&idle-session-timeout=60&min-idle-session=2&max-stream-count=8#AnyTLS-Full'; + const nodes = parse(uri); + expect(nodes[0]['idle-session-check-interval']).toBe(30); + expect(nodes[0]['idle-session-timeout']).toBe(60); + expect(nodes[0]['min-idle-session']).toBe(2); + expect(nodes[0]['max-stream-count']).toBe(8); + }); + + it('13.3 AnyTLS → 所有支持平台', async () => { + const node = makeNode({ + type: 'anytls', name: 'AnyTLS-All', server: '1.2.3.4', port: 443, + password: 'pass123', tls: true, sni: 'sni.com', + 'client-fingerprint': 'chrome', + 'idle-session-timeout': 60, 'max-stream-count': 8 + }); + + const uri = await convert([node], 'uri'); + expect(uri).toContain('anytls://'); + expect(uri).toContain('sni=sni.com'); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('type: anytls'); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('anytls'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('anytls'); + + const qx = await convert([node], 'qx'); + expect(qx).toContain('anytls='); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].type).toBe('anytls'); + + const sr = await convert([node], 'shadowrocket'); + expect(sr).toContain('type: anytls'); + }); +}); + +// ============================================================================ +// 14. Naive — 完整参数 +// ============================================================================ +describe('14. Naive — 完整参数解析与转换', () => { + it('14.1 应解析 Naive URI', () => { + const uri = 'naive+https://user:pass@1.2.3.4:443?sni=sni.com#Naive'; + const nodes = parse(uri); + expect(nodes[0].type).toBe('naive'); + expect(nodes[0].username).toBe('user'); + expect(nodes[0].password).toBe('pass'); + expect(nodes[0].tls).toBe(true); + expect(nodes[0].sni).toBe('sni.com'); + }); + + it('14.2 Naive → URI 往返', async () => { + const node = makeNode({ + type: 'naive', name: 'Naive-URI', server: '1.2.3.4', port: 443, + username: 'user', password: 'pass', tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'uri'); + expect(result).toContain('naive+https://'); + }); + + it('14.3 Naive → Singbox', async () => { + const node = makeNode({ + type: 'naive', name: 'Naive-SB', server: '1.2.3.4', port: 443, + username: 'user', password: 'pass', tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'singbox'); + const config = JSON.parse(result); + expect(config.outbounds[0].type).toBe('http'); + expect(config.outbounds[0].username).toBe('user'); + }); +}); + +// ============================================================================ +// 15. 完整往返测试 (Roundtrip) +// ============================================================================ +describe('15. 完整往返测试 — Parse → Convert → 再 Parse', () => { + it('15.1 SS 往返: URI → Parse → Convert URI', async () => { + const original = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@server.com:443#Roundtrip-SS'; + const nodes = parse(original); + const uri = await convert(nodes, 'uri'); + const reparsed = parse(uri); + expect(reparsed[0].type).toBe('ss'); + expect(reparsed[0].cipher).toBe('aes-128-gcm'); + }); + + it('15.2 Trojan 往返: URI → Parse → Convert URI', async () => { + const original = 'trojan://pass@server.com:443?sni=sni.com#Roundtrip-Trojan'; + const nodes = parse(original); + const uri = await convert(nodes, 'uri'); + const reparsed = parse(uri); + expect(reparsed[0].type).toBe('trojan'); + expect(reparsed[0].password).toBe('pass'); + }); + + it('15.3 Hysteria2 往返: URI → Parse → Convert URI', async () => { + const original = 'hysteria2://pass@1.2.3.4:443?sni=sni.com&obfs=salamander&obfs-password=obfspass#Roundtrip-Hy2'; + const nodes = parse(original); + const uri = await convert(nodes, 'uri'); + const reparsed = parse(uri); + expect(reparsed[0].type).toBe('hysteria2'); + expect(reparsed[0].obfs).toBe('salamander'); + }); + + it('15.4 TUIC 往返: URI → Parse → Convert URI', async () => { + const original = 'tuic://uuid:pass@1.2.3.4:443?congestion_control=bbr&alpn=h3#Roundtrip-TUIC'; + const nodes = parse(original); + const uri = await convert(nodes, 'uri'); + const reparsed = parse(uri); + expect(reparsed[0].type).toBe('tuic'); + expect(reparsed[0]['congestion-controller']).toBe('bbr'); + }); + + it('15.5 AnyTLS 往返: URI → Parse → Convert URI', async () => { + const original = 'anytls://pass@1.2.3.4:443?sni=sni.com&fp=chrome#Roundtrip-AnyTLS'; + const nodes = parse(original); + const uri = await convert(nodes, 'uri'); + const reparsed = parse(uri); + expect(reparsed[0].type).toBe('anytls'); + expect(reparsed[0].sni).toBe('sni.com'); + }); + + it('15.6 Naive 往返: URI → Parse → Convert URI', async () => { + const original = 'naive+https://user:pass@1.2.3.4:443?sni=sni.com#Roundtrip-Naive'; + const nodes = parse(original); + const uri = await convert(nodes, 'uri'); + const reparsed = parse(uri); + expect(reparsed[0].type).toBe('naive'); + expect(reparsed[0].username).toBe('user'); + }); +}); + +// ============================================================================ +// 16. 格式检测与 Base64 解码 +// ============================================================================ +describe('16. 格式检测与 Base64 解码', () => { + it('16.1 应正确解码 Base64 编码的订阅', () => { + const content = [ + 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@server.com:443#Node1', + 'trojan://pass@server.com:443#Node2' + ].join('\n'); + const b64 = Buffer.from(content).toString('base64'); + const nodes = parse(b64); + expect(nodes.length).toBe(2); + expect(nodes[0].type).toBe('ss'); + expect(nodes[1].type).toBe('trojan'); + }); + + it('16.2 应正确解码 URL-Safe Base64', () => { + const content = 'ss://YWVzLTI1Ni1nY206dGVzdA@server.com:443#URLSafe'; + const safe = Buffer.from(content).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + const nodes = parse(safe); + expect(nodes.length).toBe(1); + }); + + it('16.3 应正确解析 Clash YAML 格式', () => { + const yaml = `proxies: + - name: ClashNode + type: ss + server: server.com + port: 443 + cipher: aes-256-gcm + password: testpass`; + const nodes = parse(yaml); + expect(nodes.length).toBe(1); + expect(nodes[0].type).toBe('ss'); + expect(nodes[0].name).toBe('ClashNode'); + }); + + it('16.4 应正确解析 Surge 配置格式', () => { + const surge = 'TestNode=ss,server.com,443,encrypt-method=aes-256-gcm,password="pass123"'; + const nodes = parse(surge); + expect(nodes.length).toBe(1); + expect(nodes[0].type).toBe('ss'); + }); + + it('16.5 应正确解析 Loon 配置格式', () => { + const loon = 'TestNode=shadowsocks,server.com,443,aes-256-gcm,"pass123"'; + const nodes = parse(loon); + expect(nodes.length).toBe(1); + // Loon parser 将 shadowsocks 类型存为 'shadowsocks',normalize 后为 'ss' + expect(['ss', 'shadowsocks']).toContain(nodes[0].type); + }); + + it('16.6 应正确解析 QX 配置格式', () => { + const qx = 'shadowsocks=server.com:443,method=aes-256-gcm,password=pass123,tag=TestNode'; + const nodes = parse(qx); + expect(nodes.length).toBe(1); + expect(nodes[0].type).toBe('ss'); + }); +}); + +// ============================================================================ +// 17. 边界情况和错误处理 +// ============================================================================ +describe('17. 边界情况和错误处理', () => { + it('17.1 空字符串应返回空数组', () => { + expect(parse('')).toEqual([]); + }); + + it('17.2 未知协议应返回空数组', () => { + expect(parse('unknown://test@host:443')).toEqual([]); + }); + + it('17.3 无效 URI 格式应正常处理 (不抛异常)', () => { + const nodes = parse('ss://'); + // parser 可能创建空节点或返回空,关键是不抛异常 + expect(Array.isArray(nodes)).toBe(true); + }); + + it('17.4 无效端口应正常处理 (不抛异常)', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@server.com:abc#BadPort'; + const nodes = parse(uri); + // parser 可能将 NaN 端口解析为 0 或 undefined,关键是不抛异常 + expect(Array.isArray(nodes)).toBe(true); + }); + + it('17.5 HTML 内容应返回空数组', () => { + expect(parse('Error')).toEqual([]); + }); + + it('17.6 注释行应被忽略', () => { + const content = `# Comment +// Another comment +ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@server.com:443#RealNode`; + const nodes = parse(content); + expect(nodes.length).toBe(1); + expect(nodes[0].name).toBe('RealNode'); + }); + + it('17.7 混合格式多行应正确解析', () => { + const content = `ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@server.com:443#SS1 +trojan://pass@server.com:443#Trojan1 +hysteria2://pass@server.com:443#Hy2`; + const nodes = parse(content); + expect(nodes.length).toBe(3); + }); + + it('17.8 不支持的协议转换应返回空字符串', async () => { + const node = makeNode({ + type: 'ssh' as any, name: 'SSH', server: '1.2.3.4', port: 22 + }); + const result = await convert([node], 'qx'); + // QX 不支持 SSH + expect(result).toBe(''); + }); + + it('17.9 IPv6 地址应正常解析', () => { + const uri = 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@[2001:db8::1]:443#IPv6'; + const nodes = parse(uri); + expect(nodes.length).toBe(1); + expect(nodes[0].server).toContain('2001:db8::1'); + }); +}); + +// ============================================================================ +// 18. 多节点批量转换 +// ============================================================================ +describe('18. 多节点批量转换', () => { + it('18.1 批量解析和转换', async () => { + const content = [ + 'ss://YWVzLTEyOC1nY206cGFzc3dvcmQ@1.1.1.1:443#SS1', + 'vmess://' + Buffer.from(JSON.stringify({ + v: '2', ps: 'VM1', add: '2.2.2.2', port: '443', + id: 'uuid', aid: '0', scy: 'auto', net: 'tcp', type: 'none', host: '', path: '', tls: '' + })).toString('base64'), + 'trojan://pass@3.3.3.3:443?sni=sni.com#TR1', + 'hysteria2://pass@4.4.4.4:443#HY2', + 'tuic://uuid:pass@5.5.5.5:443?alpn=h3#TU1' + ].join('\n'); + + const nodes = parse(content); + expect(nodes.length).toBe(5); + + // 批量转换到各平台 + const clash = await convert(nodes, 'clashmeta'); + expect(clash).toContain('proxies:'); + + const surge = await convert(nodes, 'surge'); + expect(surge).toContain('SS1='); + expect(surge).toContain('VM1='); + + const loon = await convert(nodes, 'loon'); + expect(loon).toContain('SS1='); + + const singbox = await convert(nodes, 'singbox'); + const config = JSON.parse(singbox); + expect(config.outbounds.length).toBe(5); + }); +}); + +// ============================================================================ +// 19. ClientFormat 转换器完整性检查 +// ============================================================================ +describe('19. 所有客户端格式转换器覆盖检查', () => { + const allProtocols: Array<{ type: ProxyNode['type']; node: ProxyNode }> = [ + { type: 'ss', node: makeNode({ type: 'ss', name: 'SS', server: '1.1.1.1', port: 443, cipher: 'aes-256-gcm', password: 'pass' }) }, + { type: 'vmess', node: makeNode({ type: 'vmess', name: 'VM', server: '1.1.1.1', port: 443, uuid: 'uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com' }) }, + { type: 'vless', node: makeNode({ type: 'vless', name: 'VL', server: '1.1.1.1', port: 443, uuid: 'uuid', tls: true, sni: 'sni.com', 'reality-opts': { 'public-key': 'pk', 'short-id': 'sid' } }) }, + { type: 'trojan', node: makeNode({ type: 'trojan', name: 'TR', server: '1.1.1.1', port: 443, password: 'pass', tls: true, sni: 'sni.com' }) }, + { type: 'hysteria2', node: makeNode({ type: 'hysteria2', name: 'HY2', server: '1.1.1.1', port: 443, password: 'pass' }) }, + { type: 'tuic', node: makeNode({ type: 'tuic', name: 'TU', server: '1.1.1.1', port: 443, uuid: 'uuid', password: 'pass', 'congestion-controller': 'bbr' }) }, + { type: 'wireguard', node: makeNode({ type: 'wireguard', name: 'WG', server: '1.1.1.1', port: 51820, 'private-key': 'pk', 'public-key': 'pub', ip: '10.0.0.1' }) }, + { type: 'snell', node: makeNode({ type: 'snell', name: 'SN', server: '1.1.1.1', port: 8388, password: 'psk', version: 4 }) }, + { type: 'anytls', node: makeNode({ type: 'anytls', name: 'AT', server: '1.1.1.1', port: 443, password: 'pass', tls: true, sni: 'sni.com' }) }, + { type: 'socks5', node: makeNode({ type: 'socks5', name: 'S5', server: '1.1.1.1', port: 1080, username: 'user', password: 'pass' }) }, + { type: 'http', node: makeNode({ type: 'http', name: 'HT', server: '1.1.1.1', port: 8080, username: 'user', password: 'pass' }) }, + ]; + + const platforms = ['uri', 'clashmeta', 'surge', 'loon', 'qx', 'singbox', 'shadowrocket', 'egern', 'surfboard']; + + it.each(platforms)('19.%s 平台对所有协议不抛出异常', async (platform) => { + for (const { type, node } of allProtocols) { + try { + await convert([node], platform); + } catch (e: any) { + // Surge 不支持 VLESS,应返回空字符串而非抛异常 + // QX 不支持 hysteria2/wireguard,应返回空字符串 + if (type === 'vless' && platform === 'surge') { + // Surge VLESS 抛出 Error 是预期行为 + expect(e.message).toContain('does not support VLESS'); + } else if (['hysteria2', 'wireguard'].includes(type) && platform === 'qx') { + // QX 不支持这些协议是预期行为 + expect(e.message).toContain('Unsupported'); + } else { + throw e; + } + } + } + }); +}); + +// ============================================================================ +// 20. Stash 专属行为测试 +// ============================================================================ +describe('20. Stash — 基于 ClashMeta 扩展', () => { + it('20.1 Stash → 支持 Hysteria2 转换', async () => { + const node = makeNode({ + type: 'hysteria2', name: 'Stash-Hy2', server: '1.2.3.4', port: 443, + password: 'pass123' + }); + const result = await convert([node], 'stash'); + expect(result).toContain('name: Stash-Hy2'); + }); + + it('20.2 Stash → Hysteria2 密码映射为 auth', async () => { + const node = makeNode({ + type: 'hysteria2', name: 'Stash-Hy2-Auth', server: '1.2.3.4', port: 443, + password: 'pass123' + }); + const result = await convert([node], 'stash'); + expect(result).toContain('auth: pass123'); + }); + + it('20.3 Stash → 不支持 Snell v4+', async () => { + const node = makeNode({ + type: 'snell', name: 'Stash-Snell', server: '1.2.3.4', port: 8388, + password: 'psk', version: 5 + }); + const result = await convert([node], 'stash'); + // Snell v5 不支持,应被过滤 + expect(result).not.toContain('Stash-Snell'); + }); +}); + +// ============================================================================ +// 21. Egern 专属行为测试 +// ============================================================================ +describe('21. Egern — 完整格式验证', () => { + it('21.1 Egern → SS 输出 obfs 嵌套结构', async () => { + const node = makeNode({ + type: 'ss', name: 'Eg-SS', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass', + plugin: 'obfs', 'plugin-opts': { mode: 'http', host: 'example.com' } + }); + const result = await convert([node], 'egern'); + expect(result).toContain('shadowsocks'); + expect(result).toContain('obfs'); + }); + + it('21.2 Egern → WireGuard 输出完整 peer 结构', async () => { + const node = makeNode({ + type: 'wireguard', name: 'Eg-WG', server: '1.2.3.4', port: 51820, + 'private-key': 'priv', 'public-key': 'pub', ip: '10.0.0.1', mtu: 1420, + dns: ['1.1.1.1'] + }); + const result = await convert([node], 'egern'); + expect(result).toContain('wireguard'); + expect(result).toContain('Eg-WG'); + }); + + it('21.3 Egern → TUIC 含 congestion_control', async () => { + const node = makeNode({ + type: 'tuic', name: 'Eg-TU', server: '1.2.3.4', port: 443, + uuid: 'uuid', password: 'pass', 'congestion-controller': 'bbr' + }); + const result = await convert([node], 'egern'); + expect(result).toContain('congestion_control'); + }); +}); + +// ============================================================================ +// 22. Surfboard 专属行为测试 +// ============================================================================ +describe('22. Surfboard — 格式验证', () => { + it('22.1 Surfboard → SS', async () => { + const node = makeNode({ + type: 'ss', name: 'Surf-SS', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass' + }); + const result = await convert([node], 'surfboard'); + expect(result).toContain('Surf-SS=ss,1.2.3.4,443'); + }); + + it('22.2 Surfboard → Trojan', async () => { + const node = makeNode({ + type: 'trojan', name: 'Surf-TR', server: '1.2.3.4', port: 443, + password: 'pass', tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'surfboard'); + expect(result).toContain('Surf-TR=trojan,1.2.3.4,443'); + }); + + it('22.3 Surfboard → Snell v3', async () => { + const node = makeNode({ + type: 'snell', name: 'Surf-SN', server: '1.2.3.4', port: 8388, + password: 'psk', version: 3 + }); + const result = await convert([node], 'surfboard'); + expect(result).toContain('Surf-SN=snell'); + }); + + it('22.4 Surfboard → AnyTLS', async () => { + const node = makeNode({ + type: 'anytls', name: 'Surf-AT', server: '1.2.3.4', port: 443, + password: 'pass', sni: 'sni.com' + }); + const result = await convert([node], 'surfboard'); + expect(result).toContain('Surf-AT=anytls,1.2.3.4,443'); + }); +}); + +// ============================================================================ +// 23. Shadowrocket ClashYAML 输出验证 +// ============================================================================ +describe('23. Shadowrocket — ClashYAML 输出', () => { + it('23.1 Shadowrocket → SS 输出有效 YAML', async () => { + const node = makeNode({ + type: 'ss', name: 'SR-SS', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass' + }); + const result = await convert([node], 'shadowrocket'); + expect(result).toContain('proxies:'); + expect(result).toContain('name: SR-SS'); + expect(result).toContain('type: ss'); + }); + + it('23.2 Shadowrocket → VLESS Reality 含 fingerprint', async () => { + const node = makeNode({ + type: 'vless', name: 'SR-VL', server: '1.2.3.4', port: 443, + uuid: 'uuid', tls: true, sni: 'sni.com', 'client-fingerprint': 'chrome', + 'reality-opts': { 'public-key': 'pk', 'short-id': 'sid' } + }); + const result = await convert([node], 'shadowrocket'); + expect(result).toContain('fingerprint: chrome'); + expect(result).toContain('public-key: pk'); + }); + + it('23.3 Shadowrocket → Trojan', async () => { + const node = makeNode({ + type: 'trojan', name: 'SR-TR', server: '1.2.3.4', port: 443, + password: 'pass', tls: true, sni: 'sni.com' + }); + const result = await convert([node], 'shadowrocket'); + expect(result).toContain('name: SR-TR'); + expect(result).toContain('type: trojan'); + }); +}); + +// ============================================================================ +// 24. 流量属性与附加参数测试 +// ============================================================================ +describe('24. 流量属性与附加参数', () => { + it('24.1 TFO 参数在所有支持平台正确输出', async () => { + const node = makeNode({ + type: 'ss', name: 'TFO-Test', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass', tfo: true + }); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('tfo=true'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('fast-open=true'); + + const qx = await convert([node], 'qx'); + expect(qx).toContain('fast-open=true'); + }); + + it('24.2 UDP 参数在支持平台正确输出', async () => { + const node = makeNode({ + type: 'ss', name: 'UDP-Test', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass', udp: true + }); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('udp-relay=true'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('udp=true'); + }); + + it('24.3 skip-cert-verify 参数在支持平台正确输出', async () => { + const node = makeNode({ + type: 'trojan', name: 'SCV-Test', server: '1.2.3.4', port: 443, + password: 'pass', tls: true, sni: 'sni.com', 'skip-cert-verify': true + }); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('skip-cert-verify=true'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('skip-cert-verify=true'); + }); + + it('24.4 SNI 参数在支持平台正确输出', async () => { + const node = makeNode({ + type: 'trojan', name: 'SNI-Test', server: '1.2.3.4', port: 443, + password: 'pass', tls: true, sni: 'custom-sni.com' + }); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('sni=custom-sni.com'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('tls-name=custom-sni.com'); + + const qx = await convert([node], 'qx'); + expect(qx).toContain('tls-host=custom-sni.com'); + }); + + it('24.5 ip-version 参数在支持平台正确输出', async () => { + const node = makeNode({ + type: 'ss', name: 'IPv-Test', server: '1.2.3.4', port: 443, + cipher: 'aes-256-gcm', password: 'pass', 'ip-version': 'ipv4-prefer' + }); + + const surge = await convert([node], 'surge'); + expect(surge).toContain('ip-version=prefer-v4'); + + const loon = await convert([node], 'loon'); + expect(loon).toContain('ip-mode=prefer-v4'); + }); +}); + +// ============================================================================ +// 25. 传输层参数完整测试 +// ============================================================================ +describe('25. 传输层参数完整测试', () => { + it('25.1 VMess + WS + Early Data → 全平台', async () => { + const node = makeNode({ + type: 'vmess', name: 'VM-WS-ED', server: '1.2.3.4', port: 443, + uuid: 'uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com', + network: 'ws', 'ws-opts': { + path: '/ws', headers: { Host: 'ws.example.com' }, + 'max-early-data': 2048, 'early-data-header-name': 'Sec-WebSocket-Protocol' + } + }); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('max-early-data: 2048'); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].transport.type).toBe('ws'); + expect(config.outbounds[0].transport.max_early_data).toBe(2048); + }); + + it('25.2 VMess + gRPC → 全平台', async () => { + const node = makeNode({ + type: 'vmess', name: 'VM-gRPC', server: '1.2.3.4', port: 443, + uuid: 'uuid', cipher: 'auto', alterId: 0, tls: true, sni: 'sni.com', + network: 'grpc', 'grpc-opts': { 'service-name': 'grpc-svc' } + }); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('service-name: grpc-svc'); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].transport.type).toBe('grpc'); + expect(config.outbounds[0].transport.service_name).toBe('grpc-svc'); + }); + + it('25.3 VLESS + httpupgrade → 正确输出', async () => { + const node = makeNode({ + type: 'vless', name: 'VL-HU', server: '1.2.3.4', port: 443, + uuid: 'uuid', tls: true, sni: 'sni.com', + network: 'ws', httpupgrade: true, + 'ws-opts': { path: '/ws', 'v2ray-http-upgrade': true, 'v2ray-http-upgrade-fast-open': true } + }); + + const cm = await convert([node], 'clashmeta'); + // httpupgrade 内部标记应被清理 + expect(cm).not.toContain('v2ray-http-upgrade'); + + // URI converter 输出 httpupgrade 类型 + const uri = await convert([node], 'uri'); + expect(uri).toContain('type=httpupgrade'); + }); + + it('25.4 XHTTP/SplitHTTP → ClashMeta 和 Singbox', async () => { + const node = makeNode({ + type: 'vless', name: 'VL-XH', server: '1.2.3.4', port: 443, + uuid: 'uuid', tls: true, sni: 'sni.com', + network: 'xhttp', 'xhttp-opts': { mode: 'stream-one', path: '/xh', host: 'xh.example.com' } + }); + + const cm = await convert([node], 'clashmeta'); + expect(cm).toContain('xhttp-opts:'); + + const sb = await convert([node], 'singbox'); + const config = JSON.parse(sb); + expect(config.outbounds[0].transport.type).toBe('splithttp'); + }); +}); \ No newline at end of file diff --git a/lib/backend/proxy/constants.ts b/lib/backend/proxy/constants.ts index 528d18cc..33edee5e 100644 --- a/lib/backend/proxy/constants.ts +++ b/lib/backend/proxy/constants.ts @@ -30,7 +30,17 @@ export const PROXY_PROTOCOLS = [ /** * 传输协议 */ -export const NETWORK_TYPES = ['tcp', 'ws', 'grpc', 'h2', 'http', 'kcp', 'quic'] as const; +export const NETWORK_TYPES = [ + 'tcp', + 'ws', // WebSocket + 'grpc', // gRPC + 'h2', // HTTP/2 + 'http', // HTTP + 'kcp', // mKCP + 'quic', // QUIC + 'xhttp', // XHTTP/SplitHTTP (Xray-core) + 'splithttp' // SplitHTTP 别名 +] as const; // ============================================================================ // 平台常量 @@ -147,6 +157,11 @@ export const VLESS_FLOWS = [ 'xtls-rprx-vision-udp443' ] as const; +/** + * Hysteria 混淆类型 + */ +export const HYSTERIA_OBFS = ['salamander'] as const; + /** * Hysteria2 混淆类型 */ @@ -162,6 +177,46 @@ export const TUIC_CONGESTION_CONTROLLERS = ['cubic', 'new_reno', 'bbr'] as const */ export const ALPN_PROTOCOLS = ['h3', 'h2', 'http/1.1'] as const; +/** + * VLESS Reality Xray 模式 + */ +export const XHTTP_MODES = ['auto', 'packet-up', 'stream-up', 'stream-one'] as const; + +/** + * mKCP 伪装头部类型 + */ +export const KCP_HEADER_TYPES = ['none', 'srtp', 'utp', 'wechat-video', 'dtls', 'wireguard'] as const; + +/** + * VMess Security 别名映射 + * 用于兼容不同客户端的加密方法命名 + */ +export const VMESS_SECURITY_ALIASES: Record = { + 'aes-128-gcm': 'aes-128-gcm', + 'chacha20-poly1305': 'chacha20-poly1305', + 'auto': 'auto', + 'none': 'none', + 'zero': 'zero', + // Shadowrocket 兼容 + 'aes_128_gcm': 'aes-128-gcm', + 'chacha20_poly1305': 'chacha20-poly1305', + // 旧版兼容 -> 统一到 auto + 'aes-128-cfb': 'auto', + 'aes-128-ctr': 'auto', + 'rc4-md5': 'auto', + 'aes-128-gcm-ipv6': 'aes-128-gcm', + 'chacha20-poly1305-ipv6': 'chacha20-poly1305' +}; + +/** + * 标准化 VMess 加密方法 + */ +export function normalizeVmessSecurity(security: string | undefined): string { + if (!security) return 'auto'; + const normalized = security.toLowerCase().trim(); + return VMESS_SECURITY_ALIASES[normalized] || 'auto'; +} + // ============================================================================ // 默认值 // ============================================================================ diff --git a/lib/backend/proxy/converter/clash.ts b/lib/backend/proxy/converter/clash.ts index e90e5e3a..fcbd3f1b 100644 --- a/lib/backend/proxy/converter/clash.ts +++ b/lib/backend/proxy/converter/clash.ts @@ -146,6 +146,23 @@ export class ClashConverter extends BaseConverter { if (node.cipher && !vmessCiphers.includes(node.cipher)) { node.cipher = 'auto'; } + } else if (node.type === 'ss') { + if (isPresent(node, 'shadow-tls-password') && !isPresent(node, 'plugin')) { + node.plugin = 'shadow-tls'; + node['plugin-opts'] = { + host: node['shadow-tls-sni'], + password: node['shadow-tls-password'], + version: node['shadow-tls-version'] + }; + delete node['shadow-tls-password']; + delete node['shadow-tls-sni']; + delete node['shadow-tls-version']; + } + if (isPresent(node, 'plugin-opts.mux') && node['plugin-opts']?.mux !== undefined) { + const v = String(node['plugin-opts'].mux).toLowerCase(); + if (v === 'true' || v === '1') node['plugin-opts'].mux = true; + else if (v === 'false' || v === '0') node['plugin-opts'].mux = false; + } } else if (node.type === 'vless') { node.cipher = 'none'; // VLESS always none in Clash Meta if (node['reality-opts']) { @@ -155,6 +172,23 @@ export class ClashConverter extends BaseConverter { 'spider-x': node['reality-opts']['_spider-x'] || '' }; } + if (node['ech-opts'] && node['ech-opts']['config-list']) { + node['ech-opts'] = { + 'config-list': node['ech-opts']['config-list'] + }; + } + if (node['xhttp-opts']) { + const xopts: any = {}; + const rawOpts = node['xhttp-opts'] as Record; + if (rawOpts.mode) xopts.mode = rawOpts.mode; + if (rawOpts.path) xopts.path = rawOpts.path; + if (rawOpts.host) xopts.host = rawOpts.host; + if (rawOpts.headers) xopts.headers = rawOpts.headers; + if (rawOpts['download-settings']) xopts['download-settings'] = rawOpts['download-settings']; + if (Object.keys(xopts).length > 0) { + node['xhttp-opts'] = xopts; + } + } } else if (node.type === 'wireguard') { node.keepalive = node.keepalive ?? node['persistent-keepalive']; node['persistent-keepalive'] = node.keepalive; @@ -184,6 +218,10 @@ export class ClashConverter extends BaseConverter { } } + if (['trojan', 'tuic', 'hysteria', 'hysteria2', 'anytls', 'naive'].includes(node.type)) { + delete node.tls; + } + if (node['client-fingerprint']) { node['client-fingerprint'] = node['client-fingerprint']; } else if (['vmess', 'vless', 'trojan'].includes(node.type)) { @@ -228,6 +266,32 @@ export class ClashConverter extends BaseConverter { ) { node['http-opts'].headers.Host = [node['http-opts'].headers.Host]; } + } else if ( + (node.type === 'vmess' || node.type === 'vless') && + node.network === 'h2' + ) { + if (node['h2-opts']) { + // h2-opts.path: array → first element + const h2Path = node['h2-opts'].path; + if (isPresent(node, 'h2-opts.path') && Array.isArray(h2Path)) { + node['h2-opts'].path = h2Path[0]; + } + // h2-opts.host: 从 headers.host 或 host 提取,输出为数组 + const h2Host = node['h2-opts'].host ?? + node['h2-opts'].headers?.host ?? + node['h2-opts'].headers?.Host; + if (isPresent(node, 'h2-opts.host') || isPresent(node, 'h2-opts.headers.host') || isPresent(node, 'h2-opts.headers.Host')) { + node['h2-opts'].host = Array.isArray(h2Host) ? h2Host : [h2Host]; + } + // 清理 headers.host/Host + if (node['h2-opts'].headers) { + delete node['h2-opts'].headers.host; + delete node['h2-opts'].headers.Host; + if (Object.keys(node['h2-opts'].headers).length === 0) { + delete node['h2-opts'].headers; + } + } + } } else if (node.network === 'kcp') { // mKCP 传输层处理 node['kcp-opts'] = node['kcp-opts'] || {}; @@ -265,12 +329,32 @@ export class ClashConverter extends BaseConverter { // Cleanup delete node.subName; delete node.id; + delete node.resolved; + + if (node['tls-fingerprint']) { + node.fingerprint = node['tls-fingerprint']; + } + delete node['tls-fingerprint']; + for (const key in node) { if (key.startsWith('_') || node[key] === null) { delete node[key]; } } + if (node.network === 'ws' && node['ws-opts']) { + const wsOpts = node['ws-opts']; + if (wsOpts['v2ray-http-upgrade'] !== undefined) delete wsOpts['v2ray-http-upgrade']; + if (wsOpts['v2ray-http-upgrade-fast-open'] !== undefined) delete wsOpts['v2ray-http-upgrade-fast-open']; + if (wsOpts['_v2ray-http-upgrade-ed'] !== undefined) delete wsOpts['_v2ray-http-upgrade-ed']; + } + if (node.httpupgrade !== undefined) delete node.httpupgrade; + + if (node.network === 'grpc' && node['grpc-opts']) { + delete node['grpc-opts']['_grpc-type']; + delete node['grpc-opts']['_grpc-authority']; + } + // === 按照 Clash 标准重新排序字段 === // 确保输出的 YAML 符合 Clash 规范: name, type, server, port 在最前面 const orderedNode: any = { @@ -300,6 +384,8 @@ export class ClashConverter extends BaseConverter { 'flow', // Reality 'reality-opts', + // ECH (ClashMeta) + 'ech-opts', // WebSocket 'ws-opts', 'ws-path', @@ -313,6 +399,8 @@ export class ClashConverter extends BaseConverter { 'kcp-opts', // QUIC 'quic-opts', + // XHTTP/SplitHTTP (ClashMeta) + 'xhttp-opts', // 协议特定 'auth', 'auth-str', diff --git a/lib/backend/proxy/converter/egern.ts b/lib/backend/proxy/converter/egern.ts new file mode 100644 index 00000000..e09fce66 --- /dev/null +++ b/lib/backend/proxy/converter/egern.ts @@ -0,0 +1,520 @@ +/** + * Sub-One Egern Converter + * https://egernapp.com/zh-CN/docs/configuration/proxies + */ +import type { ConvertOptions, ProxyNode } from '../types'; +import { BaseConverter } from './base'; +import { isPresent } from './utils'; + +type EgernProxy = Record; + +export class EgernConverter extends BaseConverter { + name = 'Egern'; + + async convert(nodes: ProxyNode[], _options: ConvertOptions = {}): Promise { + const list = nodes + .map((node) => this.convertSingle(node)) + .filter(Boolean); + + return ( + 'proxies:\n' + + list.map((p) => ' - ' + JSON.stringify(p) + '\n').join('') + ); + } + + private convertSingle(proxy: ProxyNode): EgernProxy | null { + try { + switch (proxy.type) { + case 'ss': + return this.ss(proxy); + case 'vmess': + return this.vmess(proxy); + case 'vless': + return this.vless(proxy); + case 'trojan': + return this.trojan(proxy); + case 'http': + case 'https': + return this.http(proxy); + case 'socks5': + return this.socks5(proxy); + case 'hysteria2': + return this.hysteria2(proxy); + case 'tuic': + return this.tuic(proxy); + case 'wireguard': + return this.wireguard(proxy); + case 'anytls': + return this.anytls(proxy); + case 'snell': + return this.snell(proxy); + default: + console.warn(`[EgernConverter] Unsupported proxy type: ${proxy.type}`); + return null; + } + } catch (e) { + console.error(`[EgernConverter] Failed to produce Egern config for ${proxy.name}:`, e); + return null; + } + } + + // ── Shadowsocks ────────────────────────────────────────── + private ss(proxy: ProxyNode): EgernProxy { + const method = proxy.cipher === 'chacha20-ietf-poly1305' + ? 'chacha20-poly1305' + : proxy.cipher; + const result: EgernProxy = { + type: 'shadowsocks', + name: proxy.name, + method, + server: proxy.server, + port: proxy.port, + password: proxy.password, + tfo: getTfo(proxy), + }; + + // obfs plugin + if (proxy.plugin === 'obfs') { + const opts = (proxy['plugin-opts'] || {}) as any; + if (['http', 'tls'].includes(opts.mode)) { + result.obfs = { + [opts.mode]: { + host: opts.host, + path: opts.path, + }, + }; + } + } + + // shadow-tls + appendShadowTls(result, proxy); + + return result; + } + + // ── VMess ──────────────────────────────────────────────── + private vmess(proxy: ProxyNode): EgernProxy { + const security = proxy.cipher || 'auto'; + const transport = buildTransport(proxy); + + let legacy = false; + if (isPresent(proxy, 'aead') && !proxy.aead) { + legacy = true; + } else if (isPresent(proxy, 'alterId') && proxy.alterId !== 0) { + legacy = true; + } + + const result: EgernProxy = { + type: 'vmess', + name: proxy.name, + server: proxy.server, + port: proxy.port, + user_id: proxy.uuid, + security, + tfo: getTfo(proxy), + legacy, + udp_relay: getUdpRelay(proxy), + transport, + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + reality: getReality(proxy), + }; + + appendShadowTls(result, proxy); + appendFingerprintSha256(result, proxy); + + return result; + } + + // ── VLESS ──────────────────────────────────────────────── + private vless(proxy: ProxyNode): EgernProxy { + if (proxy.encryption && proxy.encryption !== 'none') { + throw new Error('VLESS encryption is not supported'); + } + const transport = buildTransport(proxy); + + const result: EgernProxy = { + type: 'vless', + name: proxy.name, + server: proxy.server, + port: proxy.port, + uuid: proxy.uuid, + tfo: getTfo(proxy), + udp_relay: getUdpRelay(proxy), + flow: proxy.flow || undefined, + transport, + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + reality: getReality(proxy), + }; + + appendShadowTls(result, proxy); + appendFingerprintSha256(result, proxy); + + return result; + } + + // ── Trojan ─────────────────────────────────────────────── + private trojan(proxy: ProxyNode): EgernProxy { + const transport = buildTransport(proxy); + + const result: EgernProxy = { + type: 'trojan', + name: proxy.name, + server: proxy.server, + port: proxy.port, + password: proxy.password, + tfo: getTfo(proxy), + udp_relay: getUdpRelay(proxy), + transport, + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + reality: getReality(proxy), + }; + + appendShadowTls(result, proxy); + appendFingerprintSha256(result, proxy); + + return result; + } + + // ── HTTP / HTTPS ───────────────────────────────────────── + private http(proxy: ProxyNode): EgernProxy { + const isHttps = proxy.type === 'https' || proxy.tls; + const result: EgernProxy = { + type: isHttps ? 'https' : 'http', + name: proxy.name, + server: proxy.server, + port: proxy.port, + username: proxy.username, + password: proxy.password, + tfo: getTfo(proxy), + }; + + if (hasHeaders(proxy)) { + result.headers = proxy.headers; + } + + if (isHttps) { + result.sni = getSni(proxy); + result.skip_tls_verify = proxy['skip-cert-verify']; + result.reality = getReality(proxy); + } + + appendShadowTls(result, proxy); + + return result; + } + + // ── SOCKS5 ─────────────────────────────────────────────── + private socks5(proxy: ProxyNode): EgernProxy { + const result: EgernProxy = { + type: proxy.tls ? 'socks5_tls' : 'socks5', + name: proxy.name, + server: proxy.server, + port: proxy.port, + username: proxy.username, + password: proxy.password, + tfo: getTfo(proxy), + udp_relay: getUdpRelay(proxy), + }; + + if (proxy.tls) { + result.sni = getSni(proxy); + result.skip_tls_verify = proxy['skip-cert-verify']; + result.reality = getReality(proxy); + } + + return result; + } + + // ── Hysteria2 ──────────────────────────────────────────── + private hysteria2(proxy: ProxyNode): EgernProxy { + const result: EgernProxy = { + type: 'hysteria2', + name: proxy.name, + server: proxy.server, + port: proxy.port, + password: proxy.password, + tfo: getTfo(proxy), + udp_relay: getUdpRelay(proxy), + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + reality: getReality(proxy), + }; + + // obfs + if (proxy.obfs === 'salamander' && proxy['obfs-password']) { + result.obfs = { + salamander: { + password: proxy['obfs-password'], + }, + }; + } + + // bandwidth + if (proxy.up) result.up = proxy.up; + if (proxy.down) result.down = proxy.down; + + appendFingerprintSha256(result, proxy); + + return result; + } + + // ── TUIC ───────────────────────────────────────────────── + private tuic(proxy: ProxyNode): EgernProxy { + const result: EgernProxy = { + type: 'tuic', + name: proxy.name, + server: proxy.server, + port: proxy.port, + uuid: proxy.uuid, + password: proxy.password, + tfo: getTfo(proxy), + udp_relay: getUdpRelay(proxy), + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + reality: getReality(proxy), + congestion_control: proxy['congestion-control'] || 'bbr', + }; + + if (proxy.alpn) { + result.alpn = Array.isArray(proxy.alpn) ? proxy.alpn : [proxy.alpn]; + } + + appendFingerprintSha256(result, proxy); + + return result; + } + + // ── WireGuard ──────────────────────────────────────────── + private wireguard(proxy: ProxyNode): EgernProxy { + const privateKey = proxy['private-key'] || proxy.privateKey; + const publicKey = proxy['public-key'] || proxy.publicKey; + const allowedIps = proxy['allowed-ips'] || '0.0.0.0/0, ::/0'; + const reserved = proxy.reserved + ? Array.isArray(proxy.reserved) + ? proxy.reserved + : String(proxy.reserved).split(',').map((s: string) => s.trim()) + : undefined; + const psk = proxy['pre-shared-key'] || proxy['preshared-key']; + + const result: EgernProxy = { + type: 'wireguard', + name: proxy.name, + server: proxy.server, + port: proxy.port, + private_key: privateKey, + peer: { + public_key: publicKey, + allowed_ips: allowedIps, + endpoint: `${proxy.server}:${proxy.port}`, + }, + interface_ip: proxy.ip, + interface_ipv6: proxy.ipv6, + mtu: proxy.mtu, + dns: proxy.dns, + keepalive: proxy.keepalive || proxy['persistent-keepalive'], + }; + + if (reserved) result.peer.reserved = reserved; + if (psk) result.peer.preshared_key = psk; + + return result; + } + + // ── AnyTLS ─────────────────────────────────────────────── + private anytls(proxy: ProxyNode): EgernProxy { + const result: EgernProxy = { + type: 'anytls', + name: proxy.name, + server: proxy.server, + port: proxy.port, + password: proxy.password, + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + reality: getReality(proxy), + udp_relay: getUdpRelay(proxy), + }; + + appendShadowTls(result, proxy); + appendFingerprintSha256(result, proxy); + + return result; + } + + // ── Snell ──────────────────────────────────────────────── + private snell(proxy: ProxyNode): EgernProxy { + const version = normalizeSnellVersion(proxy.version); + if (version === null) { + throw new Error(`Snell version ${proxy.version} is not supported`); + } + + const result: EgernProxy = { + type: 'snell', + name: proxy.name, + server: proxy.server, + port: proxy.port, + psk: proxy.password, + version, + tfo: getTfo(proxy), + }; + + if (version >= 3) { + result.udp_relay = getUdpRelay(proxy); + } + + if (proxy.obfs || proxy['obfs-opts']?.mode) { + result.obfs = proxy['obfs-opts']?.mode || proxy.obfs; + result.obfs_host = proxy['obfs-opts']?.host || proxy['obfs-host']; + } + + return result; + } +} + +// ── Helper functions ────────────────────────────────────── + +function getTfo(proxy: ProxyNode): boolean | undefined { + return proxy.tfo ?? proxy['fast-open']; +} + +function getUdpRelay(proxy: ProxyNode): boolean | undefined { + return proxy.udp; +} + +function getSni(proxy: ProxyNode): string | undefined { + if (proxy.tls && !proxy.sni) return proxy.server; + return proxy.sni; +} + +function getReality(proxy: ProxyNode): { public_key: string; short_id: string } | undefined { + const opts = proxy['reality-opts']; + if (!opts) return undefined; + const publicKey = opts['public-key']; + const shortId = opts['short-id']; + if (!publicKey && !shortId) return undefined; + const reality: Record = {}; + if (publicKey) reality.public_key = publicKey; + if (shortId) reality.short_id = shortId; + return reality as { public_key: string; short_id: string }; +} + +function hasHeaders(proxy: ProxyNode): boolean { + return !!(proxy.headers && typeof proxy.headers === 'object' && Object.keys(proxy.headers).length > 0); +} + +function normalizeSnellVersion(version: any): number | null { + if (version == null) return undefined as any; + const v = `${version}`.trim(); + if (!/^[1-5]$/.test(v)) return null; + return parseInt(v, 10); +} + +function buildTransport(proxy: ProxyNode): Record | undefined { + const network = proxy.network || 'tcp'; + + if (network === 'ws') { + const wsOpts = (proxy['ws-opts'] || {}) as any; + const key = proxy.tls ? 'wss' : 'ws'; + return { + [key]: { + path: wsOpts.path, + headers: wsOpts.headers?.Host ? { Host: wsOpts.headers.Host } : undefined, + sni: proxy.tls ? getSni(proxy) : undefined, + skip_tls_verify: proxy.tls ? proxy['skip-cert-verify'] : undefined, + reality: proxy.tls ? getReality(proxy) : undefined, + }, + }; + } + + if (network === 'h2') { + const h2Opts = (proxy['h2-opts'] || {}) as any; + const headers: Record = {}; + if (h2Opts.headers) { + for (const [k, v] of Object.entries(h2Opts.headers)) { + if (/^host$/i.test(k)) continue; + const val = Array.isArray(v) ? v[0] : v; + if (val != null) headers[k] = String(val); + } + } + const host = Array.isArray(h2Opts.host) ? h2Opts.host[0] : h2Opts.host; + if (host) headers.Host = host; + return { + http2: { + method: h2Opts.method, + path: Array.isArray(h2Opts.path) ? h2Opts.path[0] : h2Opts.path, + headers: Object.keys(headers).length > 0 ? headers : undefined, + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + }, + }; + } + + if (network === 'http') { + const httpOpts = (proxy['http-opts'] || {}) as any; + const host = Array.isArray(httpOpts.headers?.Host) + ? httpOpts.headers.Host[0] + : httpOpts.headers?.Host; + return { + http1: { + method: httpOpts.method, + path: Array.isArray(httpOpts.path) ? httpOpts.path[0] : httpOpts.path, + headers: host ? { Host: host } : undefined, + skip_tls_verify: proxy['skip-cert-verify'], + }, + }; + } + + if (network === 'grpc') { + const grpcOpts = (proxy['grpc-opts'] || {}) as any; + return { + grpc: { + service_name: grpcOpts['grpc-service-name'], + sni: getSni(proxy), + reality: getReality(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + }, + }; + } + + if (network === 'tcp' && proxy.tls) { + return { + tls: { + sni: getSni(proxy), + skip_tls_verify: proxy['skip-cert-verify'], + }, + }; + } + + return undefined; +} + +function appendShadowTls(result: EgernProxy, proxy: ProxyNode) { + if (isPresent(proxy, 'shadow-tls-password')) { + if (proxy['shadow-tls-version'] != null && proxy['shadow-tls-version'] != 3) { + throw new Error(`shadow-tls version ${proxy['shadow-tls-version']} is not supported`); + } + result.shadow_tls = { + password: proxy['shadow-tls-password'], + sni: proxy['shadow-tls-sni'], + }; + } else if (proxy.plugin === 'shadow-tls' && proxy['plugin-opts']) { + const opts = proxy['plugin-opts'] as any; + if (opts.version != null && opts.version != 3) { + throw new Error(`shadow-tls version ${opts.version} is not supported`); + } + result.shadow_tls = { + password: opts.password, + sni: opts.host, + }; + } +} + +function appendFingerprintSha256(result: EgernProxy, proxy: ProxyNode) { + const fp = proxy['tls-fingerprint']; + if (typeof fp !== 'string') return; + const trimmed = fp.trim(); + if (trimmed.length === 0) return; + result.fingerprint_sha256 = trimmed; +} diff --git a/lib/backend/proxy/converter/index.ts b/lib/backend/proxy/converter/index.ts index 57716ed4..fc60326a 100644 --- a/lib/backend/proxy/converter/index.ts +++ b/lib/backend/proxy/converter/index.ts @@ -7,6 +7,7 @@ import { LoonConverter } from './loon'; import { QuantumultXConverter } from './quantumultx'; import { ShadowrocketConverter } from './shadowrocket'; import { SingboxConverter } from './singbox'; +import { EgernConverter } from './egern'; import { StashConverter } from './stash'; import { SurfboardConverter } from './surfboard'; import { SurgeConverter } from './surge'; @@ -24,7 +25,8 @@ const converters: Record = { QuantumultX: new QuantumultXConverter(), Shadowrocket: new ShadowrocketConverter(), Stash: new StashConverter(), - Surfboard: new SurfboardConverter() + Surfboard: new SurfboardConverter(), + Egern: new EgernConverter() }; /** @@ -58,6 +60,8 @@ export async function convert( converter = converters['Shadowrocket']; } else if (target === 'stash') { converter = converters['Stash']; + } else if (target === 'egern') { + converter = converters['Egern']; } else if (target === 'surfboard') { converter = converters['Surfboard']; } else if (target === 'uri' || target === 'base64' || target === 'v2ray') { @@ -83,5 +87,6 @@ export { SurgeConverter, LoonConverter, QuantumultXConverter, - ShadowrocketConverter + ShadowrocketConverter, + EgernConverter }; diff --git a/lib/backend/proxy/converter/loon.ts b/lib/backend/proxy/converter/loon.ts index 13f30b40..20891ac7 100644 --- a/lib/backend/proxy/converter/loon.ts +++ b/lib/backend/proxy/converter/loon.ts @@ -13,6 +13,18 @@ const ipVersions: Record = { 'ipv6-prefer': 'prefer-v6' }; +function appendReality(result: Result, proxy: ProxyNode) { + result.appendIfPresent(`,sni=${proxy.sni}`, 'sni'); + result.appendIfPresent( + `,public-key="${proxy['reality-opts']!['public-key']}"`, + 'reality-opts.public-key' + ); + result.appendIfPresent( + `,short-id=${proxy['reality-opts']!['short-id']}`, + 'reality-opts.short-id' + ); +} + export class LoonConverter extends BaseConverter { name = 'Loon'; @@ -104,6 +116,7 @@ export class LoonConverter extends BaseConverter { } private trojan(proxy: ProxyNode): string { + const isReality = !!proxy['reality-opts']; const result = new Result(proxy); result.append(`${proxy.name}=trojan,${proxy.server},${proxy.port},"${proxy.password}"`); @@ -118,12 +131,32 @@ export class LoonConverter extends BaseConverter { `,skip-cert-verify=${proxy['skip-cert-verify']}`, 'skip-cert-verify' ); - result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); + + if (isReality) { + appendReality(result, proxy); + } else { + result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); + result.appendIfPresent( + `,tls-cert-sha256=${proxy['tls-fingerprint']}`, + 'tls-fingerprint' + ); + result.appendIfPresent( + `,tls-pubkey-sha256=${proxy['tls-pubkey-sha256']}`, + 'tls-pubkey-sha256' + ); + } + + if (proxy.alpn) { + const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn; + if (alpn) result.append(`,alpn="${alpn}"`); + } + this.appendCommon(result, proxy); return result.toString(); } private vmess(proxy: ProxyNode): string { + const isReality = !!proxy['reality-opts']; const result = new Result(proxy); result.append( `${proxy.name}=vmess,${proxy.server},${proxy.port},${proxy.cipher || 'auto'},"${proxy.uuid}"` @@ -134,7 +167,13 @@ export class LoonConverter extends BaseConverter { `,skip-cert-verify=${proxy['skip-cert-verify']}`, 'skip-cert-verify' ); - result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); + + if (isReality) { + appendReality(result, proxy); + } else { + result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); + } + // aead 字段存在时: true→alterId=0 (AEAD开启), false→alterId=1; 不存在时直接用 alterId if (proxy.aead !== undefined) { result.append(`,alterId=${proxy.aead ? 0 : 1}`); @@ -161,14 +200,12 @@ export class LoonConverter extends BaseConverter { ); if (proxy.flow) result.append(`,flow=${proxy.flow}`); if (proxy['reality-opts']) { + appendReality(result, proxy); const reality = proxy['reality-opts']; - result.append(`,public-key="${reality['public-key']}"`); - result.appendIfPresent(`,short-id=${reality['short-id']}`, 'reality-opts.short-id'); result.appendIfPresent( `,spider-x=${reality['_spider-x'] || reality['spider-x']}`, 'reality-opts.spider-x' ); - result.appendIfPresent(`,sni=${proxy.sni}`, 'sni'); } else { result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); result.appendIfPresent( @@ -176,6 +213,19 @@ export class LoonConverter extends BaseConverter { 'tls-fingerprint' ); } + // TLS profile (client-fingerprint → Loon tls-profile) + if (proxy['client-fingerprint']) { + const fpMap: Record = { + 'chrome': 'chrome', 'ios': 'ios26', 'ios18': 'ios18' + }; + const profile = fpMap[proxy['client-fingerprint']]; + if (profile) result.append(`,tls-profile=${profile}`); + } + // ALPN + if (proxy.alpn) { + const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn; + if (alpn) result.append(`,alpn="${alpn}"`); + } this.appendCommon(result, proxy); return result.toString(); } @@ -291,7 +341,10 @@ export class LoonConverter extends BaseConverter { 'skip-cert-verify' ); result.appendIfPresent(`,sni=${proxy.sni}`, 'sni'); - result.appendIfPresent(`,alpn=${proxy.alpn?.join(',') || 'h3'}`, 'alpn'); + if (proxy.alpn) { + const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn; + if (alpn) result.append(`,alpn=${alpn}`); + } this.appendCommon(result, proxy); return result.toString(); } @@ -305,6 +358,7 @@ export class LoonConverter extends BaseConverter { } private anytls(proxy: ProxyNode): string { + const isReality = !!proxy['reality-opts']; const result = new Result(proxy); result.append(`${proxy.name}=anytls,${proxy.server},${proxy.port},"${proxy.password}"`); @@ -320,12 +374,25 @@ export class LoonConverter extends BaseConverter { `,skip-cert-verify=${proxy['skip-cert-verify']}`, 'skip-cert-verify' ); - result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); - result.appendIfPresent(`,tls-cert-sha256=${proxy['tls-fingerprint']}`, 'tls-fingerprint'); - result.appendIfPresent( - `,tls-pubkey-sha256=${proxy['tls-pubkey-sha256']}`, - 'tls-pubkey-sha256' - ); + + if (isReality) { + appendReality(result, proxy); + } else { + result.appendIfPresent(`,tls-name=${proxy.sni}`, 'sni'); + result.appendIfPresent( + `,tls-cert-sha256=${proxy['tls-fingerprint']}`, + 'tls-fingerprint' + ); + result.appendIfPresent( + `,tls-pubkey-sha256=${proxy['tls-pubkey-sha256']}`, + 'tls-pubkey-sha256' + ); + } + + if (proxy.alpn) { + const alpn = Array.isArray(proxy.alpn) ? proxy.alpn.join(',') : proxy.alpn; + if (alpn) result.append(`,alpn="${alpn}"`); + } // TFO result.appendIfPresent(`,fast-open=${proxy.tfo}`, 'tfo'); @@ -351,8 +418,18 @@ export class LoonConverter extends BaseConverter { if (network === 'ws') { result.append(`,transport=ws`); const wsOpts = (proxy['ws-opts'] || {}) as any; - result.appendIfPresent(`,path=${wsOpts.path || '/'}`, 'ws-opts.path'); + let path = wsOpts.path || '/'; + const ed = wsOpts['max-early-data']; + if (ed && !path.includes('ed=')) { + const sep = path.includes('?') ? '&' : '?'; + path = `${path}${sep}ed=${ed}`; + } + result.append(`,path=${path}`); result.appendIfPresent(`,host=${wsOpts.headers?.Host}`, 'ws-opts.headers.Host'); + } else if (network === 'grpc') { + result.append(`,transport=grpc`); + const grpcOpts = (proxy['grpc-opts'] || {}) as any; + result.appendIfPresent(`,path=${grpcOpts['service-name']}`, 'grpc-opts.service-name'); } else if (network === 'http') { result.append(`,transport=http`); const opts = (proxy['http-opts'] || {}) as any; diff --git a/lib/backend/proxy/converter/quantumultx.ts b/lib/backend/proxy/converter/quantumultx.ts index bc630a07..0b814169 100644 --- a/lib/backend/proxy/converter/quantumultx.ts +++ b/lib/backend/proxy/converter/quantumultx.ts @@ -32,6 +32,9 @@ export class QuantumultXConverter extends BaseConverter { case 'vless': result = this.vless(proxy); break; + case 'anytls': + result = this.anytls(proxy); + break; case 'http': case 'https': result = this.http(proxy); @@ -43,13 +46,6 @@ export class QuantumultXConverter extends BaseConverter { throw new Error(`[QXConverter] Unsupported proxy type: ${proxy.type}`); } - // Reality support (appended to any type that support it in QX) - if (proxy['reality-opts']) { - const r = proxy['reality-opts']; - if (r['public-key']) result += `,reality-base64-pubkey=${r['public-key']}`; - if (r['short-id']) result += `,reality-hex-shortid=${r['short-id']}`; - } - result += `,tag=${proxy.name}`; return result; @@ -154,6 +150,8 @@ export class QuantumultXConverter extends BaseConverter { if (proxy.network === 'ws') { result.append(`,obfs=${proxy.tls ? 'wss' : 'ws'}`); + } else if (proxy.network === 'h2') { + result.append(`,obfs=${proxy.tls ? 'h2-tls' : 'h2'}`); } else if (proxy.network === 'http') { result.append(`,obfs=http`); } else if (proxy.tls) { @@ -162,7 +160,7 @@ export class QuantumultXConverter extends BaseConverter { // 只在 ws/http 传输时才读对应的 opts const netForVlessOpts = - proxy.network === 'ws' || proxy.network === 'http' ? proxy.network : null; + proxy.network === 'ws' || proxy.network === 'http' || proxy.network === 'h2' ? proxy.network : null; const vlessOpts = netForVlessOpts ? ((proxy[`${netForVlessOpts}-opts`] || {}) as any) : {}; if (vlessOpts.path) result.append( @@ -174,6 +172,11 @@ export class QuantumultXConverter extends BaseConverter { ); this.appendTLS(result, proxy); + if (proxy['reality-opts']) { + const r = proxy['reality-opts']; + if (r['public-key']) result.append(`,reality-base64-pubkey=${r['public-key']}`); + if (r['short-id']) result.append(`,reality-hex-shortid=${r['short-id']}`); + } if (proxy.flow) result.append(`,vless-flow=${proxy.flow}`); this.appendCommon(result, proxy); return result.toString(); @@ -201,6 +204,40 @@ export class QuantumultXConverter extends BaseConverter { return result.toString(); } + private anytls(proxy: ProxyNode): string { + const network = proxy.network?.trim().toLowerCase(); + if (network && network !== 'tcp') { + throw new Error( + `[QXConverter] Does not support AnyTLS with transport ${proxy.network}`, + ); + } + + const result = new Result(proxy); + result.append(`anytls=${proxy.server}:${proxy.port}`); + result.append(`,password=${proxy.password}`); + + // AnyTLS 强制 TLS + result.append(`,over-tls=true`); + result.appendIfPresent(`,tls-host=${proxy.sni}`, 'sni'); + result.appendIfPresent( + `,tls-verification=${!proxy['skip-cert-verify']}`, + 'skip-cert-verify' + ); + result.appendIfPresent(`,tls-cert-sha256=${proxy['tls-fingerprint']}`, 'tls-fingerprint'); + result.appendIfPresent(`,tls-alpn=${proxy.alpn}`, 'alpn'); + if (proxy['tls-no-session-ticket']) result.append(`,tls-no-session-ticket=true`); + if (proxy['tls-no-session-reuse']) result.append(`,tls-no-session-reuse=true`); + + // TFO / UDP + if (proxy.tfo) result.append(`,fast-open=true`); + if (proxy.udp) result.append(`,udp-relay=true`); + + // test-url + result.appendIfPresent(`,server_check_url=${proxy['test-url']}`, 'test-url'); + + return result.toString(); + } + private appendTLS(result: Result, proxy: ProxyNode) { if (!proxy.tls) return; result.appendIfPresent(`,tls-host=${proxy.sni}`, 'sni'); diff --git a/lib/backend/proxy/converter/shadowrocket.ts b/lib/backend/proxy/converter/shadowrocket.ts index a0c7b391..05218869 100644 --- a/lib/backend/proxy/converter/shadowrocket.ts +++ b/lib/backend/proxy/converter/shadowrocket.ts @@ -43,6 +43,14 @@ export class ShadowrocketConverter extends BaseConverter { } } else if (node.type === 'vless') { node.cipher = 'none'; + // Reality 支持 (Shadowrocket) + if (node['reality-opts']) { + node['reality-opts'] = { + 'public-key': node['reality-opts']['public-key'], + 'short-id': node['reality-opts']['short-id'], + 'spider-x': node['reality-opts']['_spider-x'] || '' + }; + } } else if (node.type === 'tuic' || node.type === 'hysteria2') { if (node.alpn) node.alpn = Array.isArray(node.alpn) ? node.alpn : [node.alpn]; if (node.tfo && !node['fast-open']) node['fast-open'] = node.tfo; @@ -70,6 +78,16 @@ export class ShadowrocketConverter extends BaseConverter { node['client-fingerprint'] = 'chrome'; } + if (node['reality-opts'] && !node['client-fingerprint']) { + node['client-fingerprint'] = 'chrome'; + } + + if (node['ech-opts'] && node['ech-opts']['config-list']) { + node['ech-opts'] = { + 'config-list': node['ech-opts']['config-list'] + }; + } + // Move SNI to servername for Clash YAML compatibility used by Shadowrocket if (node.sni && !node.servername) { node.servername = node.sni; @@ -112,6 +130,11 @@ export class ShadowrocketConverter extends BaseConverter { delete node.resolved; delete node.encryption; // VLESS uses cipher: none instead + if (node['tls-fingerprint']) { + node.fingerprint = node['tls-fingerprint']; + } + delete node['tls-fingerprint']; + // Remove fields starting with underscore (internal) for (const key in node) { if (key.startsWith('_') || node[key] === null) { @@ -119,6 +142,18 @@ export class ShadowrocketConverter extends BaseConverter { } } + if (node.network === 'ws' && node['ws-opts']) { + const wsOpts = node['ws-opts']; + if (wsOpts['v2ray-http-upgrade'] !== undefined) delete wsOpts['v2ray-http-upgrade']; + if (wsOpts['v2ray-http-upgrade-fast-open'] !== undefined) delete wsOpts['v2ray-http-upgrade-fast-open']; + } + if (node.httpupgrade !== undefined) delete node.httpupgrade; + + if (node.network === 'grpc' && node['grpc-opts']) { + delete node['grpc-opts']['_grpc-type']; + delete node['grpc-opts']['_grpc-authority']; + } + return node; } catch (e) { console.error(`[ShadowrocketConverter] Failed to process node ${proxy.name}:`, e); diff --git a/lib/backend/proxy/converter/singbox.ts b/lib/backend/proxy/converter/singbox.ts index 99043a19..c8c21f2b 100644 --- a/lib/backend/proxy/converter/singbox.ts +++ b/lib/backend/proxy/converter/singbox.ts @@ -108,6 +108,31 @@ export class SingboxConverter extends BaseConverter { outbound.server = node.server; outbound.server_port = node.port; break; + case 'naive': + outbound.username = node.username; + outbound.password = node.password; + // NaiveProxy 天然需要 TLS,删除 insecure(sing-box 不支持) + this.appendTLS(outbound, node); + if (outbound.tls?.insecure) delete outbound.tls.insecure; + // udp-over-tcp + if (node['udp-over-tcp'] || node.uot) { + if (typeof node['udp-over-tcp'] === 'object') { + outbound.udp_over_tcp = { + enabled: true, + version: (!node['udp-over-tcp-version'] || node['udp-over-tcp-version'] === 1) ? 1 : 2 + }; + } else { + outbound.udp_over_tcp = true; + } + } + // extra-headers + if (node['extra-headers']) outbound.extra_headers = node['extra-headers']; + // quic + if (node.quic) outbound.quic = true; + if (node['quic-congestion-control']) outbound.quic_congestion_control = node['quic-congestion-control']; + // fast-open + if (node['fast-open']) outbound.udp_fragment = true; + break; case 'vmess': outbound.uuid = node.uuid; outbound.security = node.cipher || 'auto'; @@ -140,10 +165,14 @@ export class SingboxConverter extends BaseConverter { if (node.type === 'https' || node.tls) this.appendTLS(outbound, node); break; case 'hysteria': - outbound.auth_str = node.auth; + outbound.auth_str = node['auth-str'] || node.auth; outbound.up_mbps = node.up; outbound.down_mbps = node.down; if (node.obfs) outbound.obfs = { type: 'salamander', password: node.obfs }; + if (node['recv-window-conn']) outbound.recv_window_conn = node['recv-window-conn']; + if (node['recv-window']) outbound.recv_window = node['recv-window']; + if (node['fast-open']) outbound.udp_fragment = true; + if (node['disable-mtu-discovery']) outbound.disable_mtu_discovery = true; this.appendTLS(outbound, node); break; case 'hysteria2': @@ -166,6 +195,8 @@ export class SingboxConverter extends BaseConverter { const hi = String(node['hop-interval']); outbound.hop_interval = /^\d+$/.test(hi) ? `${hi}s` : hi; } + if (node['fast-open']) outbound.udp_fragment = true; + if (node['disable-mtu-discovery']) outbound.disable_mtu_discovery = true; this.appendTLS(outbound, node); break; case 'tuic': @@ -290,6 +321,16 @@ export class SingboxConverter extends BaseConverter { } } + if (node['ech-opts']) { + const ech = node['ech-opts']; + if (ech['config-list'] && ech['config-list'].length > 0) { + tls.ech = { + enabled: true, + config_list: ech['config-list'] + }; + } + } + if (node['client-fingerprint'] && !['hysteria', 'hysteria2', 'tuic'].includes(node.type)) { tls.utls = { enabled: true, @@ -324,6 +365,15 @@ export class SingboxConverter extends BaseConverter { transport.type = 'http'; transport.path = node['h2-opts']?.path || node['http-opts']?.path || '/'; transport.host = node['h2-opts']?.host || node['http-opts']?.headers?.Host || []; + } else if (node.network === 'xhttp' || node.network === 'splithttp') { + transport.type = 'splithttp'; + const xopts = node['xhttp-opts'] as Record; + if (xopts) { + if (xopts.mode) transport.mode = xopts.mode; + if (xopts.path) transport.path = xopts.path; + if (xopts.host) transport.host = xopts.host; + if (xopts.headers) transport.headers = xopts.headers; + } } outbound.transport = transport; diff --git a/lib/backend/proxy/converter/surge.ts b/lib/backend/proxy/converter/surge.ts index 15dff404..d916183f 100644 --- a/lib/backend/proxy/converter/surge.ts +++ b/lib/backend/proxy/converter/surge.ts @@ -190,16 +190,23 @@ export class SurgeConverter extends BaseConverter { private hysteria2(proxy: ProxyNode): string { const result = new Result(proxy); result.append( - `${proxy.name}=hysteria2,${proxy.server},${proxy.port},password=\"${proxy.password}\"` + `${proxy.name}=hysteria2,${proxy.server},${proxy.port}` ); + result.appendIfPresent(`,password="${proxy.password}"`, 'password'); if (proxy.ports) { - result.append(`,port-hopping=\"${String(proxy.ports).replace(/,/g, ';')}\"`); + result.append(`,port-hopping="${String(proxy.ports).replace(/,/g, ';')}"`); } if (proxy.down) { const down = String(proxy.down).match(/\d+/)?.[0] || '0'; result.append(`,download-bandwidth=${down}`); } - this.appendTLS(result, proxy); + // Hysteria2 强制 TLS,直接追加 TLS 参数,不依赖 proxy.tls + result.appendIfPresent(`,tls-fingerprint=${proxy['tls-fingerprint']}`, 'tls-fingerprint'); + result.appendIfPresent(`,sni="${proxy.sni}"`, 'sni'); + result.appendIfPresent( + `,skip-cert-verify=${proxy['skip-cert-verify']}`, + 'skip-cert-verify' + ); this.appendCommon(result, proxy); return result.toString(); } @@ -343,6 +350,12 @@ export class SurgeConverter extends BaseConverter { if (proxy['tls-fingerprint']) { result.append(`,server-cert-fingerprint-sha256=${proxy['tls-fingerprint']}`); } + if (proxy['reality-opts']) { + const r = proxy['reality-opts']; + result.appendIfPresent(`,reality-public-key=${r['public-key']}`, 'public-key'); + result.appendIfPresent(`,reality-short-id=${r['short-id']}`, 'short-id'); + result.appendIfPresent(`,reality-spider-x=${r['_spider-x']}`, '_spider-x'); + } } } diff --git a/lib/backend/proxy/converter/uri.ts b/lib/backend/proxy/converter/uri.ts index 9f00ab3a..b5c03b30 100644 --- a/lib/backend/proxy/converter/uri.ts +++ b/lib/backend/proxy/converter/uri.ts @@ -172,6 +172,7 @@ export class URIConverter extends BaseConverter { if (node['skip-cert-verify']) params.set('insecure', '1'); if (node.tfo) params.set('fastopen', '1'); if (node.ports) params.set('mport', String(node.ports)); + if (node['hop-interval']) params.set('hop-interval', String(node['hop-interval'])); if (node.alpn) params.set('alpn', Array.isArray(node.alpn) ? node.alpn[0] : node.alpn); let queryString = params.toString(); @@ -213,6 +214,12 @@ export class URIConverter extends BaseConverter { if (node['disable-sni']) params.set('disable_sni', '1'); if (node['reduce-rtt']) params.set('reduce_rtt', '1'); if (node['udp-relay-mode']) params.set('udp_relay_mode', node['udp-relay-mode']); + if (node['heartbeat-interval']) + params.set('heartbeat_interval', String(node['heartbeat-interval'])); + if (node['max-udp-relay-packet-size']) + params.set('max_udp_relay_packet_size', String(node['max-udp-relay-packet-size'])); + if (node['max-open-streams']) + params.set('max_open_streams', String(node['max-open-streams'])); let queryString = params.toString(); if (queryString) queryString = '?' + queryString; @@ -235,6 +242,15 @@ export class URIConverter extends BaseConverter { const res = Array.isArray(node.reserved) ? node.reserved.join(',') : node.reserved; params.set('reserved', String(res)); } + if (node['persistent-keepalive']) + params.set('keepalive', String(node['persistent-keepalive'])); + if (node['allowed-ips'] && node['allowed-ips'].length > 0) { + params.set('allowed', node['allowed-ips'].join(',')); + } + if (node.dns) { + const dnsStr = Array.isArray(node.dns) ? node.dns.join(',') : node.dns; + params.set('dns', dnsStr); + } let queryString = params.toString(); if (queryString) queryString = '?' + queryString; @@ -293,7 +309,9 @@ export class URIConverter extends BaseConverter { private appendTransportParams(params: URLSearchParams, node: ProxyNode) { if (!node.network) return; - params.set('type', node.network); + + const networkType = (node as any).httpupgrade ? 'httpupgrade' : node.network; + params.set('type', networkType); const opts = (node[`${node.network}-opts`] || {}) as any; if (opts) { if (opts.path) params.set('path', Array.isArray(opts.path) ? opts.path[0] : opts.path); @@ -318,6 +336,22 @@ export class URIConverter extends BaseConverter { } } + const xopts = node['xhttp-opts'] as Record; + if (xopts && Object.keys(xopts).length > 0) { + if (xopts.mode) params.set('mode', xopts.mode); + if (xopts.path) params.set('path', Array.isArray(xopts.path) ? xopts.path[0] : xopts.path); + if (xopts.host) params.set('host', Array.isArray(xopts.host) ? xopts.host[0] : xopts.host); + } + + if ((node as any).httpupgrade && opts) { + const edValue = opts['_v2ray-http-upgrade-ed'] || (node['ws-opts'] as any)?.['_v2ray-http-upgrade-ed']; + if (edValue) { + const basePath = opts.path || '/'; + const sep = basePath.includes('?') ? '&' : '?'; + params.set('path', `${basePath}${sep}ed=${edValue}`); + } + } + // 兼容旧版本的简化写法 if (node.network === 'kcp') { if (node.seed && !opts?.seed) params.set('seed', node.seed); @@ -339,6 +373,20 @@ export class URIConverter extends BaseConverter { if (r['short-id']) params.set('sid', r['short-id']); if (r['_spider-x']) params.set('spx', r['_spider-x']); } + if (node['ech-opts']) { + const ech = node['ech-opts']; + if (ech['config-list'] && ech['config-list'].length > 0) { + params.set('ech', '1'); + // config-list 编码到 extra 字段 + } else if (ech._dns) { + params.set('ech', '1'); + } + } + if (node._echConfigList) { + params.set('ech', node._echConfigList); + } + // skip-cert-verify 通用处理 + if (node['skip-cert-verify']) params.set('allowInsecure', '1'); } private appendVLESSParams(params: URLSearchParams, node: ProxyNode) { @@ -348,5 +396,29 @@ export class URIConverter extends BaseConverter { // 强制设置 encryption=none,提高外部转换兼容性 params.set('encryption', node.encryption || 'none'); if (node['skip-cert-verify']) params.set('allowInsecure', '1'); + + if (node._extra) { + const extra = typeof node._extra === 'string' ? node._extra : JSON.stringify(node._extra); + params.set('extra', extra); + } + if (node._mode) params.set('mode', node._mode); + if (node._pqv) params.set('pqv', node._pqv); + if (node._h2) params.set('h2', '1'); + if (node._pcs) params.set('pcs', '1'); + + const xopts = node['xhttp-opts'] as Record; + if (xopts && xopts.mode) { + params.set('mode', xopts.mode); + } + + if (node.network === 'ws' && node['ws-opts']) { + const wsOpts = node['ws-opts'] as Record; + if (wsOpts['max-early-data']) { + const basePath = wsOpts.path || '/'; + const ed = wsOpts['max-early-data']; + const sep = basePath.includes('?') ? '&' : '?'; + params.set('path', `${basePath}${sep}ed=${ed}`); + } + } } } diff --git a/lib/backend/proxy/parser/normalizer.ts b/lib/backend/proxy/parser/normalizer.ts index bdf21c6c..f0db0db0 100644 --- a/lib/backend/proxy/parser/normalizer.ts +++ b/lib/backend/proxy/parser/normalizer.ts @@ -46,7 +46,7 @@ export function normalizeProxyNode(proxy: ProxyNode): ProxyNode { // 7. TLS 默认值处理 if (proxy.tls === undefined) { // 某些协议默认开启 TLS - if (['hysteria2', 'tuic', 'https', 'anytls'].includes(proxy.type)) { + if (['hysteria2', 'tuic', 'https', 'anytls', 'naive'].includes(proxy.type)) { proxy.tls = true; } else { proxy.tls = false; @@ -58,6 +58,53 @@ export function normalizeProxyNode(proxy: ProxyNode): ProxyNode { proxy.name = `${proxy.type.toUpperCase()} ${proxy.server}:${proxy.port}`; } + if (proxy.type === 'vmess' && proxy.cipher) { + const validCiphers = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero']; + if (!validCiphers.includes(proxy.cipher)) { + proxy.cipher = 'auto'; + } + } + + // 10. 标准化 VLESS 加密方式 + if (proxy.type === 'vless') { + if (!proxy.encryption) { + proxy.encryption = 'none'; + } + // VLESS 标准化 flow + if (proxy.flow && !proxy['reality-opts'] && !proxy.tls) { + delete proxy.flow; + } + } + + // 11. 标准化 Reality 选项 + if (proxy['reality-opts']) { + const r = proxy['reality-opts'] as Record; + // 统一 _spider-x 和 spider-x + if (r['spider-x'] && !r['_spider-x']) { + r['_spider-x'] = r['spider-x']; + delete r['spider-x']; + } + // 如果 reality-opts 为空则删除 + if (Object.keys(r).filter(k => r[k] != null && r[k] !== '').length === 0) { + delete proxy['reality-opts']; + } + } + + // 12. 标准化 ECH 选项 + if (proxy['ech-opts']) { + const ech = proxy['ech-opts'] as Record; + if (Object.keys(ech).filter(k => ech[k] != null && ech[k] !== '').length === 0) { + delete proxy['ech-opts']; + } + } + + // 13. 标准化 xhttp 选项 (标准化 splithttp -> xhttp) + if ((proxy as any)['xhttp-opts']) { + if (!proxy.network || proxy.network === 'tcp') { + proxy.network = 'xhttp' as any; + } + } + return proxy; } diff --git a/lib/backend/proxy/parser/uri-parsers.ts b/lib/backend/proxy/parser/uri-parsers.ts index ab1c9cfb..8b31237d 100644 --- a/lib/backend/proxy/parser/uri-parsers.ts +++ b/lib/backend/proxy/parser/uri-parsers.ts @@ -9,6 +9,21 @@ import { Base64 } from 'js-base64'; import type { ProxyNode } from '../types'; import { isIPv4, isIPv6, isPresent, parsePort, parseSpeed, randomId } from '../utils'; +/** + * 解析 URL 查询参数(正确处理值中含 = 的情况) + */ +function parseQueryParams(query: string): Record { + const params: Record = {}; + for (const part of query.replace(/^\?/, '').split('&')) { + if (!part) continue; + const eqIdx = part.indexOf('='); + if (eqIdx >= 0) { + params[part.substring(0, eqIdx)] = decodeURIComponent(part.substring(eqIdx + 1)); + } + } + return params; +} + /** * 处理 Surge 风格的端口跳跃参数 */ @@ -94,6 +109,7 @@ export function parseShadowsocks(uri: string): ProxyNode | null { const parsed = content.match(/(\?.*)$/); if (parsed) query = parsed[1]; } + userInfoStr = Base64.decode(rawUserInfoStr); } const userInfo = userInfoStr.match(/(^.*?):(.*$)/); @@ -102,14 +118,7 @@ export function parseShadowsocks(uri: string): ProxyNode | null { proxy.password = userInfo[2]; } - const params: Record = {}; - for (const addon of query.replace(/^\?/, '').split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - const value = decodeURIComponent(valueRaw); - params[key] = value; - } - } + const params = parseQueryParams(query) as Record; proxy.tls = params.security && params.security !== 'none'; proxy['skip-cert-verify'] = !!params['allowInsecure']; @@ -121,21 +130,39 @@ export function parseShadowsocks(uri: string): ProxyNode | null { const netType = params['type'] as string; proxy.network = netType as any; + let httpupgrade = false; + let httpUpgradeEd = ''; if (netType === 'httpupgrade') { proxy.network = 'ws'; + httpupgrade = true; } if (proxy.network === 'grpc') { - proxy['grpc-opts'] = { - 'service-name': params['serviceName'] + (proxy['grpc-opts'] as any) = { + 'service-name': params['serviceName'], + '_grpc-type': params['mode'], + '_grpc-authority': params['authority'] }; } else if (proxy.network === 'ws' || proxy.network === 'h2') { proxy[`${proxy.network}-opts`] = {} as any; if (params['path']) { - (proxy[`${proxy.network}-opts`] as any).path = decodeURIComponent( - params['path'] - ); + let transportPath = decodeURIComponent(params['path']); + if (proxy.network === 'ws') { + const edMatch = transportPath.match(/[?&]ed=(\d+)/); + if (edMatch) { + const edValue = edMatch[1]; + if (httpupgrade) { + httpUpgradeEd = edValue; + } else { + // 普通 WebSocket early data + (proxy['ws-opts'] as any)['max-early-data'] = parseInt(edValue, 10); + (proxy['ws-opts'] as any)['early-data-header-name'] = 'Sec-WebSocket-Protocol'; + } + } + transportPath = transportPath.split(/[?&]ed=/)[0]; + } + (proxy[`${proxy.network}-opts`] as any).path = transportPath; } if (params['host']) { if (!proxy[`${proxy.network}-opts`]) { @@ -145,6 +172,16 @@ export function parseShadowsocks(uri: string): ProxyNode | null { Host: decodeURIComponent(params['host']) }; } + + if (httpupgrade) { + proxy.httpupgrade = true; + (proxy as any)[`${proxy.network}-opts`] = { + ...(proxy[`${proxy.network}-opts`] as any), + 'v2ray-http-upgrade': true, + 'v2ray-http-upgrade-fast-open': true, + '_v2ray-http-upgrade-ed': httpUpgradeEd || params.ed || '' + }; + } } } @@ -173,16 +210,19 @@ export function parseShadowsocks(uri: string): ProxyNode | null { if (pluginName === 'obfs-local' || pluginName === 'simple-obfs') { proxy.plugin = 'obfs'; proxy['plugin-opts'] = { - mode: pluginOpts.obfs, - host: pluginOpts['obfs-host'] + mode: pluginOpts.obfs || pluginOpts.mode, + host: pluginOpts['obfs-host'] || pluginOpts.host }; } else if (pluginName === 'v2ray-plugin') { proxy.plugin = 'v2ray-plugin'; proxy['plugin-opts'] = { - mode: 'websocket', - host: pluginOpts['obfs-host'], + mode: pluginOpts.obfs || pluginOpts.mode || 'websocket', + host: pluginOpts['obfs-host'] || pluginOpts.host, path: pluginOpts.path, - tls: !!pluginOpts.tls + tls: !!pluginOpts.tls, + sni: pluginOpts.sni, + 'skip-cert-verify': ['1', 'true'].includes(pluginOpts['skip-cert-verify']), + mux: /^\d+$/.test(pluginOpts.mux) ? parseInt(pluginOpts.mux, 10) : undefined }; } else if (pluginName === 'shadow-tls') { proxy.plugin = 'shadow-tls'; @@ -195,6 +235,48 @@ export function parseShadowsocks(uri: string): ProxyNode | null { } } + if (params['v2ray-plugin'] && !proxy.plugin) { + try { + const decoded = JSON.parse(Base64.decode(params['v2ray-plugin'])); + proxy.plugin = 'v2ray-plugin'; + proxy['plugin-opts'] = decoded; + } catch { /* 忽略解析失败 */ } + } + + if (params['shadow-tls'] && !proxy.plugin) { + try { + const decoded = JSON.parse(Base64.decode(params['shadow-tls'])); + const version = decoded.version; + const address = decoded.address; + const port = decoded.port; + proxy.plugin = 'shadow-tls'; + proxy['plugin-opts'] = { + host: decoded.host || undefined, + password: decoded.password || undefined, + version: version ? parseInt(String(version), 10) : undefined + }; + if (address) proxy.server = address; + if (port) proxy.port = parseInt(String(port), 10); + } catch { /* 忽略解析失败 */ } + } + + if (params['gost'] && !proxy.plugin) { + try { + const decoded = JSON.parse(Base64.decode(decodeURIComponent(params['gost']))); + const route = decoded.route?.trim().toLowerCase(); + const isWebsocketRoute = ['ws', 'wss', 'websocket'].includes(route); + proxy.plugin = 'gost-plugin'; + proxy['plugin-opts'] = { + mode: isWebsocketRoute ? 'websocket' : decoded.route, + host: decoded.host || undefined, + path: decoded.path || undefined, + tls: route === 'wss' || undefined + }; + if (decoded.address) proxy.server = decoded.address; + if (decoded.port) proxy.port = parseInt(String(decoded.port), 10); + } catch { /* 忽略解析失败 */ } + } + proxy.name = name ? decodeURIComponent(name) : `SS ${proxy.server}:${proxy.port}`; return proxy as ProxyNode; @@ -296,13 +378,7 @@ export function parseTrojan(uri: string): ProxyNode | null { name: name ? decodeURIComponent(name) : `Trojan ${server}:${port}` }; - const params: Record = {}; - for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - params[key] = decodeURIComponent(valueRaw || ''); - } - } + const params = parseQueryParams(query) as Record; proxy.tls = params.security !== 'none'; proxy.sni = params.sni || params.peer; @@ -327,6 +403,16 @@ export function parseTrojan(uri: string): ProxyNode | null { proxy.udp = /TRUE|1/i.test(params.udp); + // Reality support + if (params.security === 'reality' || params.pbk) { + const opts: Record = {}; + if (params.pbk) opts['public-key'] = params.pbk; + if (params.sid) opts['short-id'] = params.sid; + if (Object.keys(opts).length > 0) { + proxy['reality-opts'] = opts as any; + } + } + return proxy as ProxyNode; } catch (e) { console.error('[parseTrojan] Error:', e); @@ -466,7 +552,7 @@ export function parseVMess(uri: string): ProxyNode | null { try { const line = uri.split(/vmess:\/\//i)[1]; - let content = Base64.decode(line.replace(/\?.*?$/, '')); + let content = Base64.decode(line.replace(/#.*$/, '').replace(/\?.*$/, '')); if (/=\s*vmess/.test(content)) { return parseQuantumultVMess(content); @@ -483,9 +569,12 @@ export function parseVMess(uri: string): ProxyNode | null { content = Base64.decode(base64Line); for (const addon of qs.split('&')) { - const [key, valueRaw] = addon.split('='); - const value = decodeURIComponent(valueRaw); - params[key] = value.indexOf(',') === -1 ? value : value.split(','); + const eqIdx = addon.indexOf('='); + if (eqIdx >= 0) { + const key = addon.substring(0, eqIdx); + const value = decodeURIComponent(addon.substring(eqIdx + 1)); + params[key] = value.indexOf(',') === -1 ? value : value.split(','); + } } const contentMatch = /(^[^:]+?):([^:]+?)@(.*):(\ d+)$/.exec(content); @@ -694,13 +783,7 @@ export function parseVLESS(uri: string): ProxyNode | null { name: name ? decodeURIComponent(name) : `VLESS ${server}:${port}` }; - const params: Record = {}; - for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - params[key] = decodeURIComponent(valueRaw || ''); - } - } + const params = parseQueryParams(query) as Record; /* // [FIX-ED] 显式捕获 'ed' (Early Data) 参数,防止在部分客户端中丢失 @@ -771,6 +854,10 @@ export function parseVLESS(uri: string): ProxyNode | null { const netType = params.type as string; proxy.network = netType as any; + if (proxy.network === 'splithttp') { + proxy.network = 'xhttp' as any; + } + if (proxy.network === 'tcp' && params.headerType === 'http') { proxy.network = 'http'; } else if (netType === 'httpupgrade') { @@ -780,10 +867,24 @@ export function parseVLESS(uri: string): ProxyNode | null { proxy.network = 'ws'; } + // httpupgrade 在 URI 中映射为 network=xhttp + mode=stream-one + if (httpupgrade && !proxy['xhttp-opts']) { + proxy['xhttp-opts'] = { + mode: 'stream-one' + }; + } + if (!proxy.network && isShadowrocket && params.obfs) { proxy.network = params.obfs === 'none' ? undefined : params.obfs; } + if (params.mode && proxy.network === 'xhttp') { + if (!proxy['xhttp-opts']) { + proxy['xhttp-opts'] = {}; + } + proxy['xhttp-opts'].mode = params.mode; + } + if (proxy.network && proxy.network !== 'tcp') { const opts: any = {}; const host = params.host || params.obfsParam; @@ -793,15 +894,36 @@ export function parseVLESS(uri: string): ProxyNode | null { } if (params.serviceName) { - opts[`${proxy.network}-service-name`] = params.serviceName; + if (proxy.network === 'grpc') { + opts['service-name'] = params.serviceName; + opts['_grpc-type'] = params.mode || 'gun'; + } else { + opts[`${proxy.network}-service-name`] = params.serviceName; + } } if (params.path) { opts.path = params.path; } - if (proxy.network === 'grpc') { - opts['_grpc-type'] = params.mode || 'gun'; + if (proxy.network === 'ws' && params.path) { + const edMatch = params.path.match(/[?&]ed=(\d+)/); + if (edMatch) { + const edValue = parseInt(edMatch[1], 10); + if (!isNaN(edValue) && edValue > 0) { + if (httpupgrade) { + // httpupgrade 的 early data + if (!proxy['xhttp-opts']) { + proxy['xhttp-opts'] = {}; + } + proxy['xhttp-opts']['_v2ray-http-upgrade-ed'] = String(edValue); + } else { + // 普通 WebSocket early data + opts['max-early-data'] = edValue; + opts['early-data-header-name'] = 'Sec-WebSocket-Protocol'; + } + } + } } if (proxy.network === 'kcp') { @@ -858,13 +980,7 @@ export function parseHysteria(uri: string): ProxyNode | null { name: name ? decodeURIComponent(name) : `Hysteria ${server}:${port}` }; - const params: Record = {}; - for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - params[key] = decodeURIComponent(valueRaw || ''); - } - } + const params = parseQueryParams(query); proxy.auth = params.auth || params['auth-str'] || ''; proxy.up = params.upmbps ? parseInt(params.upmbps, 10) : undefined; @@ -909,13 +1025,7 @@ export function parseSnell(uri: string): ProxyNode | null { name: name ? decodeURIComponent(name) : `Snell ${server}:${port}` }; - const params: Record = {}; - for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - params[key] = decodeURIComponent(valueRaw || ''); - } - } + const params = parseQueryParams(query); if (params.version) { proxy.version = parseInt(params.version, 10); @@ -1057,29 +1167,30 @@ export function parseHysteria2(uri: string): ProxyNode | null { }; for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - const value = decodeURIComponent(valueRaw || ''); - const normalizedKey = key.replace(/_/g, '-'); - - if (normalizedKey === 'insecure') { - proxy['skip-cert-verify'] = /(TRUE)|1/i.test(value); - } else if (normalizedKey === 'alpn') { - proxy.alpn = value ? value.split(',') : undefined; - } else if (normalizedKey === 'obfs') { - proxy.obfs = value || 'salamander'; - } else if (normalizedKey === 'obfs-password') { - proxy['obfs-password'] = value; - } else if (normalizedKey === 'mport' || normalizedKey === 'ports') { - proxy.ports = value; - } else if (normalizedKey === 'sni') { - proxy.sni = value; - } else if (normalizedKey.match(/^(up|down)(-?mbps)?$/)) { - const speedKey = normalizedKey.startsWith('up') ? 'up' : 'down'; - proxy[speedKey] = parseSpeed(value); - } else if (!Object.keys(proxy).includes(normalizedKey)) { - proxy[normalizedKey] = value; - } + if (!addon) continue; + const eqIdx = addon.indexOf('='); + if (eqIdx < 0) continue; + const key = addon.substring(0, eqIdx); + const value = decodeURIComponent(addon.substring(eqIdx + 1)); + const normalizedKey = key.replace(/_/g, '-'); + + if (normalizedKey === 'insecure') { + proxy['skip-cert-verify'] = /(TRUE)|1/i.test(value); + } else if (normalizedKey === 'alpn') { + proxy.alpn = value ? value.split(',') : undefined; + } else if (normalizedKey === 'obfs') { + proxy.obfs = value || 'salamander'; + } else if (normalizedKey === 'obfs-password') { + proxy['obfs-password'] = value; + } else if (normalizedKey === 'mport' || normalizedKey === 'ports') { + proxy.ports = value; + } else if (normalizedKey === 'sni') { + proxy.sni = value; + } else if (normalizedKey.match(/^(up|down)(-?mbps)?$/)) { + const speedKey = normalizedKey.startsWith('up') ? 'up' : 'down'; + proxy[speedKey] = parseSpeed(value); + } else if (!Object.keys(proxy).includes(normalizedKey)) { + proxy[normalizedKey] = value; } } @@ -1121,24 +1232,25 @@ export function parseTUIC(uri: string): ProxyNode | null { }; for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - const value = decodeURIComponent(valueRaw || ''); - const normalizedKey = key.replace(/_/g, '-'); - - if (['allow-insecure', 'insecure'].includes(normalizedKey)) { - proxy['skip-cert-verify'] = /(TRUE)|1/i.test(value); - } else if (normalizedKey === 'alpn') { - proxy.alpn = value ? value.split(',') : undefined; - } else if (normalizedKey === 'fast-open') { - proxy.tfo = true; - } else if (['disable-sni', 'reduce-rtt'].includes(normalizedKey)) { - proxy[normalizedKey] = /(TRUE)|1/i.test(value); - } else if (normalizedKey === 'congestion-control') { - proxy['congestion-controller'] = value; - } else if (!Object.keys(proxy).includes(normalizedKey)) { - proxy[normalizedKey] = value; - } + if (!addon) continue; + const eqIdx = addon.indexOf('='); + if (eqIdx < 0) continue; + const key = addon.substring(0, eqIdx); + const value = decodeURIComponent(addon.substring(eqIdx + 1)); + const normalizedKey = key.replace(/_/g, '-'); + + if (['allow-insecure', 'insecure'].includes(normalizedKey)) { + proxy['skip-cert-verify'] = /(TRUE)|1/i.test(value); + } else if (normalizedKey === 'alpn') { + proxy.alpn = value ? value.split(',') : undefined; + } else if (normalizedKey === 'fast-open') { + proxy.tfo = true; + } else if (['disable-sni', 'reduce-rtt'].includes(normalizedKey)) { + proxy[normalizedKey] = /(TRUE)|1/i.test(value); + } else if (normalizedKey === 'congestion-control') { + proxy['congestion-controller'] = value; + } else if (!Object.keys(proxy).includes(normalizedKey)) { + proxy[normalizedKey] = value; } } @@ -1176,47 +1288,48 @@ export function parseWireGuard(uri: string): ProxyNode | null { }; for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - const value = decodeURIComponent(valueRaw || ''); - const normalizedKey = key.replace(/_/g, '-'); - - if (normalizedKey === 'reserved') { - const parsed = value - .split(',') - .map((i) => parseInt(i.trim(), 10)) - .filter((i) => Number.isInteger(i)); - if (parsed.length === 3) { - proxy.reserved = parsed; - } - } else if (['address', 'ip'].includes(normalizedKey)) { - value.split(',').forEach((i) => { - const ip = i - .trim() - .replace(/\/\d+$/, '') - .replace(/^\[/, '') - .replace(/\]$/, ''); - if (isIPv4(ip)) { - proxy.ip = ip; - } else if (isIPv6(ip)) { - proxy.ipv6 = ip; - } - }); - } else if (normalizedKey === 'mtu') { - const parsedMtu = parseInt(value.trim(), 10); - if (Number.isInteger(parsedMtu)) { - proxy.mtu = parsedMtu; + if (!addon) continue; + const eqIdx = addon.indexOf('='); + if (eqIdx < 0) continue; + const key = addon.substring(0, eqIdx); + const value = decodeURIComponent(addon.substring(eqIdx + 1)); + const normalizedKey = key.replace(/_/g, '-'); + + if (normalizedKey === 'reserved') { + const parsed = value + .split(',') + .map((i) => parseInt(i.trim(), 10)) + .filter((i) => Number.isInteger(i)); + if (parsed.length === 3) { + proxy.reserved = parsed; + } + } else if (['address', 'ip'].includes(normalizedKey)) { + value.split(',').forEach((i) => { + const ip = i + .trim() + .replace(/\/\d+$/, '') + .replace(/^\[/, '') + .replace(/\]$/, ''); + if (isIPv4(ip)) { + proxy.ip = ip; + } else if (isIPv6(ip)) { + proxy.ipv6 = ip; } - } else if (normalizedKey === 'public-key' || normalizedKey === 'publickey') { - proxy['public-key'] = value; - } else if ( - normalizedKey === 'pre-shared-key' || - normalizedKey === 'preshared-key' - ) { - proxy['pre-shared-key'] = value; - } else if (!Object.keys(proxy).includes(normalizedKey)) { - proxy[normalizedKey] = value; + }); + } else if (normalizedKey === 'mtu') { + const parsedMtu = parseInt(value.trim(), 10); + if (Number.isInteger(parsedMtu)) { + proxy.mtu = parsedMtu; } + } else if (normalizedKey === 'public-key' || normalizedKey === 'publickey') { + proxy['public-key'] = value; + } else if ( + normalizedKey === 'pre-shared-key' || + normalizedKey === 'preshared-key' + ) { + proxy['pre-shared-key'] = value; + } else if (!Object.keys(proxy).includes(normalizedKey)) { + proxy[normalizedKey] = value; } } @@ -1266,13 +1379,7 @@ export function parseNaive(uri: string): ProxyNode | null { tls: true }; - const params: Record = {}; - for (const addon of query.split('&')) { - if (addon) { - const [key, valueRaw] = addon.split('='); - params[key] = decodeURIComponent(valueRaw || ''); - } - } + const params = parseQueryParams(query); proxy.sni = params.sni; proxy.padding = params.padding === 'true' || params.padding === '1'; diff --git a/lib/backend/proxy/types.ts b/lib/backend/proxy/types.ts index 8fee88fd..3b36f1a3 100644 --- a/lib/backend/proxy/types.ts +++ b/lib/backend/proxy/types.ts @@ -40,7 +40,9 @@ export type NetworkType = | 'h2' // HTTP/2 | 'http' // HTTP | 'kcp' // KCP - | 'quic'; // QUIC + | 'quic' // QUIC + | 'xhttp' // XHTTP/SplitHTTP (Xray-core) + | 'splithttp'; // SplitHTTP 别名 // ============================================================================ // 平台类型 @@ -52,11 +54,85 @@ export type PlatformType = | 'Singbox' | 'Surge' | 'Loon' + | 'Egern' | 'QuantumultX' | 'Shadowrocket' | 'URI' | 'JSON'; +// ============================================================================ +// XHTTP/SplitHTTP 选项 +// ============================================================================ + +/** + * XHTTP Download Settings (下行独立配置) + */ +export interface XhttpDownloadSettings { + server?: string; + port?: number; + network?: string; + tls?: boolean; + servername?: string; + 'client-fingerprint'?: string; + 'skip-cert-verify'?: boolean; + alpn?: string[]; + path?: string; + host?: string; + mode?: string; + 'reality-opts'?: { + 'public-key'?: string; + 'short-id'?: string; + }; + 'ech-opts'?: EchOptions; +} + +/** + * XHTTP 传输选项 + */ +export interface XhttpOptions { + mode?: string; // auto | packet-up | stream-up | stream-one + path?: string; + host?: string; + headers?: Record; + 'download-settings'?: XhttpDownloadSettings; + // XHTTP 扩展字段 + 'no-grpc-header'?: boolean; + 'x-padding-bytes'?: number; + 'x-padding-obfs-mode'?: boolean; + 'x-padding-key'?: string; + 'x-padding-header'?: string; + 'x-padding-placement'?: string; + 'x-padding-method'?: string; + 'uplink-http-method'?: string; + 'session-placement'?: string; + 'session-key'?: string; + 'session-table'?: string; + 'session-length'?: number | string; + 'seq-placement'?: string; + 'seq-key'?: string; + 'uplink-data-placement'?: string; + 'uplink-data-key'?: string; + 'uplink-chunk-size'?: number | string; + 'sc-max-each-post-bytes'?: number | string; + 'sc-min-posts-interval-ms'?: number | string; + 'reuse-settings'?: Record; + [key: string]: any; +} + +// ============================================================================ +// ECH (Encrypted Client Hello) 选项 +// ============================================================================ + +/** + * ECH 选项 + */ +export interface EchOptions { + 'query-server-name'?: string; + 'config-list'?: string[]; + _dns?: string; // DNS 服务器 (用于查询 ECH config) + [key: string]: any; +} + // ============================================================================ // ProxyNode - 统一节点中间表示 (IR) // ============================================================================ @@ -103,10 +179,20 @@ export interface ProxyNode { headers?: Record; 'max-early-data'?: number; 'early-data-header-name'?: string; + // httpupgrade + 'v2ray-http-upgrade'?: boolean; + 'v2ray-http-upgrade-fast-open'?: boolean; + '_v2ray-http-upgrade-ed'?: string; }; 'ws-path'?: string; // 兼容简化写法 'ws-headers'?: Record; + // === XHTTP/SplitHTTP 选项 === + 'xhttp-opts'?: XhttpOptions; + + // === HTTP Upgrade (httpupgrade) 标记 === + 'httpupgrade'?: boolean; // WS 实际为 httpupgrade + // === gRPC 选项 === 'grpc-opts'?: { 'service-name'?: string; @@ -142,6 +228,17 @@ export interface ProxyNode { '_spider-x'?: string; // 兼容 }; + // === ECH (Encrypted Client Hello) 选项 === + 'ech-opts'?: EchOptions; + _echConfigList?: string; // ECH config-list (URI round-trip) + + // === VMess 额外字段 === + _extra?: string | Record; // VLESS/VMess extra JSON (URI round-trip) + _mode?: string; // VLESS Reality mode (URI round-trip) + _pqv?: string; // VLESS pre-shared quantum version (URI round-trip) + _h2?: boolean; // VLESS H2 标记 (URI round-trip) + _extra_unsupported?: Record; // VLESS xhttp 不支持字段 + // === UDP/TCP 选项 === udp?: boolean; // 是否支持UDP 'udp-relay'?: boolean; // UDP中继 diff --git a/package-lock.json b/package-lock.json index fb1175af..a020e6d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "js-yaml": "^4.1.1", "pinia": "^3.0.3", "vue": "^3.5.18", + "vue-i18n": "^9.14.5", "vue-router": "^4.6.4", "vuedraggable": "^4.1.0" }, @@ -1451,6 +1452,50 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@intlify/core-base": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -7126,6 +7171,33 @@ "dev": true, "license": "MIT" }, + "node_modules/vue-i18n": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", + "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.5", + "@intlify/shared": "9.14.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-i18n/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", diff --git a/package.json b/package.json index 66a62f54..063e2977 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "js-yaml": "^4.1.1", "pinia": "^3.0.3", "vue": "^3.5.18", + "vue-i18n": "^9.14.5", "vue-router": "^4.6.4", "vuedraggable": "^4.1.0" }, diff --git a/src/app/layouts/AppFooter.vue b/src/app/layouts/AppFooter.vue index 9983c925..dad1f805 100644 --- a/src/app/layouts/AppFooter.vue +++ b/src/app/layouts/AppFooter.vue @@ -19,6 +19,9 @@ @@ -105,18 +89,18 @@ onMounted(() => {
{

- 每日一言 + {{ t('views.dashboard.dailyQuote') }}

- Daily Inspiration + {{ t('views.dashboard.dailyInspiration') }}

@@ -142,23 +126,16 @@ onMounted(() => {
{{ currentQuote.category }} -
+
{
{{ activeProfilesCount }} Active{{ t('views.dashboard.activeCount', { count: activeProfilesCount }) }}
{{ profiles.length }} - 订阅组 + {{ t('views.dashboard.profiles') }}
{

- 添加订阅 + {{ t('views.dashboard.addSubscription') }}

-

支持 HTTP/HTTPS

+

{{ t('views.dashboard.supportsHttp') }}

diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 81db4839..4d45ef23 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -24,6 +24,7 @@ import { computed, ref } from 'vue'; import { useSessionStore } from '@/stores/useSessionStore'; import { useRouter } from 'vue-router'; +import { useI18n } from 'vue-i18n'; /** * 组件属性定义 (保留接口兼容性,但优先使用 Store) @@ -66,6 +67,8 @@ const isLoading = ref(false); /** 错误消息 */ const error = ref(''); +const { t } = useI18n(); + // ==================== 登录处理 ==================== /** @@ -79,28 +82,28 @@ const error = ref(''); const handleSubmit = async () => { // 验证用户名不为空 if (!username.value.trim()) { - error.value = '请输入用户名'; + error.value = t('views.login.errors.emptyUsername'); return; } // 验证密码不为空 if (!password.value.trim()) { - error.value = '请输入密码'; + error.value = t('views.login.errors.emptyPassword'); return; } // 如果是设置模式,验证确认密码 if (actualIsSetup.value) { if (!confirmPassword.value.trim()) { - error.value = '请确认密码'; + error.value = t('views.login.errors.emptyConfirmPassword'); return; } if (password.value !== confirmPassword.value) { - error.value = '两次输入的密码不一致'; + error.value = t('views.login.errors.passwordMismatch'); return; } if (password.value.length < 6) { - error.value = '密码长度至少为6位'; + error.value = t('views.login.errors.passwordTooShort'); return; } } @@ -118,7 +121,7 @@ const handleSubmit = async () => { } catch (err: unknown) { // 捕获并显示错误信息 const msg = err instanceof Error ? err.message : String(err); - error.value = msg || '登录失败,请重试'; + error.value = msg || t('views.login.errors.loginFailed'); } finally { // 无论成功失败都重置加载状态 isLoading.value = false; @@ -131,19 +134,19 @@ const handleSubmit = async () => {
{

- {{ actualIsSetup ? '首次使用,请创建管理员账号' : '现代化订阅管理平台' }} + {{ actualIsSetup ? t('views.login.setupSubtitle') : t('views.login.subtitle') }}

@@ -195,7 +198,7 @@ const handleSubmit = async () => { d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> - 用户名 + {{ t('views.login.username') }}
@@ -203,9 +206,9 @@ const handleSubmit = async () => { id="username" v-model="username" type="text" - class="form-input-login bg-gray-50/50 py-2.5 text-sm transition-colors focus:bg-white sm:py-3 sm:text-base dark:bg-gray-800/50 dark:focus:bg-gray-800" + class="form-input-login bg-gray-50/50 py-2.5 text-sm transition-colors focus:bg-white sm:py-3 sm:text-base dark:bg-white/5 dark:focus:bg-white/10" :class="{ 'input-error': error && !username }" - placeholder="请输入您的用户名" + :placeholder="t('views.login.placeholders.username')" autocomplete="username" :disabled="isLoading" @keydown.enter="handleSubmit" @@ -233,7 +236,7 @@ const handleSubmit = async () => { { d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> - 密码 + {{ t('views.login.password') }}
@@ -277,9 +280,9 @@ const handleSubmit = async () => { id="password" v-model="password" type="password" - class="form-input-login bg-gray-50/50 py-2.5 text-sm transition-colors focus:bg-white sm:py-3 sm:text-base dark:bg-gray-800/50 dark:focus:bg-gray-800" + class="form-input-login bg-gray-50/50 py-2.5 text-sm transition-colors focus:bg-white sm:py-3 sm:text-base dark:bg-white/5 dark:focus:bg-white/10" :class="{ 'input-error': error }" - placeholder="请输入您的密码" + :placeholder="t('views.login.placeholders.password')" autocomplete="current-password" :disabled="isLoading" @keydown.enter="handleSubmit" @@ -310,7 +313,7 @@ const handleSubmit = async () => { {

{ d="M9 12l2 2 4-4m5.818-4.818A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> - 确认密码 + {{ t('views.login.confirmPassword') }}

@@ -379,9 +382,9 @@ const handleSubmit = async () => { id="confirmPassword" v-model="confirmPassword" type="password" - class="form-input-login bg-gray-50/50 py-2.5 text-sm transition-colors focus:bg-white sm:py-3 sm:text-base dark:bg-gray-800/50 dark:focus:bg-gray-800" + class="form-input-login bg-gray-50/50 py-2.5 text-sm transition-colors focus:bg-white sm:py-3 sm:text-base dark:bg-white/5 dark:focus:bg-white/10" :class="{ 'input-error': error }" - placeholder="请再次输入密码" + :placeholder="t('views.login.placeholders.confirmPassword')" autocomplete="new-password" :disabled="isLoading" @keydown.enter="handleSubmit" @@ -409,7 +412,7 @@ const handleSubmit = async () => { { { " /> - {{ actualIsSetup ? '创建管理员账号' : '立即登录' }} + {{ actualIsSetup ? t('views.login.createAdmin') : t('views.login.loginBtn') }}
- {{ actualIsSetup ? '创建中...' : '登录中...' }} + {{ actualIsSetup ? t('views.login.creating') : t('views.login.loggingIn') }}
@@ -481,7 +484,7 @@ const handleSubmit = async () => {
{ d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> - 安全加密传输 + {{ t('views.login.secureTransport') }}
diff --git a/src/views/NodesView.vue b/src/views/NodesView.vue index 12b5b01d..e4a14235 100644 --- a/src/views/NodesView.vue +++ b/src/views/NodesView.vue @@ -1,5 +1,6 @@ @@ -56,10 +58,10 @@ const handleCopy = async (url: string) => {
@@ -394,11 +389,11 @@ onUnmounted(() => {
{

- {{ searchTerm ? '没有找到匹配的节点' : '暂无节点信息' }} + {{ searchTerm ? t('widgets.node.detailsModal.noMatch') : t('widgets.node.detailsModal.empty') }}

@@ -474,13 +469,13 @@ onUnmounted(() => {
diff --git a/src/widgets/node/NodeModal.vue b/src/widgets/node/NodeModal.vue index 81f945f9..7aa125c5 100644 --- a/src/widgets/node/NodeModal.vue +++ b/src/widgets/node/NodeModal.vue @@ -12,6 +12,7 @@