outbound-link-parser.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import { describe, expect, it } from 'vitest';
  2. import {
  3. parseOutboundLink,
  4. parseShadowsocksLink,
  5. parseTrojanLink,
  6. parseVlessLink,
  7. parseVmessLink,
  8. parseHysteria2Link,
  9. parseWireguardLink,
  10. } from '@/lib/xray/outbound-link-parser';
  11. import { Base64 } from '@/utils';
  12. // Focused acceptance tests for the share-link parsers — one happy-path
  13. // case per protocol family, plus a few common edge cases. The parsers
  14. // produce wire-shape outbound rows; the modal hands them to
  15. // rawOutboundToFormValues to seed Form.useForm.
  16. describe('parseVmessLink', () => {
  17. it('parses a vmess:// link with ws + tls', () => {
  18. const json = {
  19. v: '2', ps: 'imported-vmess', add: '1.2.3.4', port: 8443,
  20. id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
  21. net: 'ws', host: 'example.com', path: '/ws',
  22. tls: 'tls', sni: 'example.com', fp: 'chrome', alpn: 'h2,http/1.1',
  23. };
  24. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  25. const out = parseVmessLink(link);
  26. expect(out).not.toBeNull();
  27. expect(out?.protocol).toBe('vmess');
  28. expect(out?.tag).toBe('imported-vmess');
  29. const settings = out?.settings as { vnext: Array<{ address: string; port: number; users: Array<{ id: string; security: string }> }> };
  30. expect(settings.vnext[0].address).toBe('1.2.3.4');
  31. expect(settings.vnext[0].port).toBe(8443);
  32. expect(settings.vnext[0].users[0].id).toBe('11111111-2222-4333-8444-555555555555');
  33. const stream = out?.streamSettings as Record<string, unknown>;
  34. expect(stream.network).toBe('ws');
  35. expect(stream.security).toBe('tls');
  36. expect((stream.wsSettings as Record<string, unknown>).path).toBe('/ws');
  37. expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
  38. expect((stream.tlsSettings as Record<string, unknown>).alpn).toEqual(['h2', 'http/1.1']);
  39. });
  40. it('returns null for non-vmess links', () => {
  41. expect(parseVmessLink('vless://x@y:1')).toBeNull();
  42. });
  43. it('returns null for malformed base64', () => {
  44. expect(parseVmessLink('vmess://!!!not-base64!!!')).toBeNull();
  45. });
  46. });
  47. describe('parseVmessLink — XHTTP advanced fields', () => {
  48. it('round-trips xhttp knobs from the vmess JSON', () => {
  49. const json = {
  50. v: '2', ps: 'imported-xhttp', add: '1.2.3.4', port: 443,
  51. id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
  52. net: 'xhttp', host: 'edge.example', path: '/sp', mode: 'stream-up',
  53. xPaddingBytes: '500-1500',
  54. scMaxEachPostBytes: '2000000',
  55. scMinPostsIntervalMs: '60',
  56. uplinkChunkSize: 8192,
  57. noGRPCHeader: true,
  58. tls: 'tls', sni: 'edge.example',
  59. };
  60. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  61. const out = parseVmessLink(link);
  62. const stream = out?.streamSettings as Record<string, unknown>;
  63. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  64. expect(xhttp.host).toBe('edge.example');
  65. expect(xhttp.path).toBe('/sp');
  66. expect(xhttp.mode).toBe('stream-up');
  67. expect(xhttp.xPaddingBytes).toBe('500-1500');
  68. expect(xhttp.scMaxEachPostBytes).toBe('2000000');
  69. expect(xhttp.scMinPostsIntervalMs).toBe('60');
  70. expect(xhttp.uplinkChunkSize).toBe(8192);
  71. expect(xhttp.noGRPCHeader).toBe(true);
  72. });
  73. it('round-trips xhttp padding-obfs knobs from the vmess JSON', () => {
  74. const json = {
  75. v: '2', ps: 'imported-pad', add: '1.2.3.4', port: 443,
  76. id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
  77. net: 'xhttp', host: 'edge.example', path: '/sp',
  78. xPaddingObfsMode: true,
  79. xPaddingKey: 'secret-key',
  80. xPaddingHeader: 'X-Pad',
  81. xPaddingPlacement: 'header',
  82. xPaddingMethod: 'random',
  83. sessionKey: 'X-Session',
  84. seqKey: 'X-Seq',
  85. noSSEHeader: true,
  86. scMaxBufferedPosts: 50,
  87. tls: 'tls',
  88. };
  89. // legacy sessionKey must alias onto the renamed sessionIDKey (#6258)
  90. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  91. const out = parseVmessLink(link);
  92. const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  93. expect(xhttp.xPaddingObfsMode).toBe(true);
  94. expect(xhttp.xPaddingKey).toBe('secret-key');
  95. expect(xhttp.xPaddingHeader).toBe('X-Pad');
  96. expect(xhttp.xPaddingPlacement).toBe('header');
  97. expect(xhttp.xPaddingMethod).toBe('random');
  98. expect(xhttp.sessionIDKey).toBe('X-Session');
  99. expect(xhttp.sessionKey).toBeUndefined();
  100. expect(xhttp.seqKey).toBe('X-Seq');
  101. expect(xhttp.noSSEHeader).toBe(true);
  102. expect(xhttp.scMaxBufferedPosts).toBe(50);
  103. });
  104. });
  105. describe('parseVlessLink — XHTTP advanced fields', () => {
  106. it('round-trips xhttp knobs from URL query params', () => {
  107. const link
  108. = 'vless://[email protected]:443'
  109. + '?type=xhttp&security=tls&host=edge.example&path=%2Fsp&mode=stream-up'
  110. + '&xPaddingBytes=500-1500&scMaxEachPostBytes=2000000'
  111. + '&scMinPostsIntervalMs=60&uplinkChunkSize=8192&noGRPCHeader=true'
  112. + '#imported-xhttp';
  113. const out = parseVlessLink(link);
  114. const stream = out?.streamSettings as Record<string, unknown>;
  115. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  116. expect(xhttp.host).toBe('edge.example');
  117. expect(xhttp.path).toBe('/sp');
  118. expect(xhttp.mode).toBe('stream-up');
  119. expect(xhttp.xPaddingBytes).toBe('500-1500');
  120. expect(xhttp.scMaxEachPostBytes).toBe('2000000');
  121. expect(xhttp.scMinPostsIntervalMs).toBe('60');
  122. expect(xhttp.uplinkChunkSize).toBe(8192);
  123. expect(xhttp.noGRPCHeader).toBe(true);
  124. });
  125. it('round-trips xhttp padding-obfs knobs from URL query params', () => {
  126. const link
  127. = 'vless://[email protected]:443'
  128. + '?type=xhttp&security=tls&host=edge.example&path=%2Fsp'
  129. + '&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad'
  130. + '&xPaddingPlacement=header&xPaddingMethod=random'
  131. + '&sessionIDKey=X-Session&sessionIDTable=Base62&sessionIDLength=16-32'
  132. + '&seqKey=X-Seq&noSSEHeader=true'
  133. + '&scMaxBufferedPosts=50'
  134. + '#imported-pad';
  135. const out = parseVlessLink(link);
  136. const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  137. expect(xhttp.xPaddingObfsMode).toBe(true);
  138. expect(xhttp.xPaddingKey).toBe('secret-key');
  139. expect(xhttp.xPaddingHeader).toBe('X-Pad');
  140. expect(xhttp.xPaddingPlacement).toBe('header');
  141. expect(xhttp.xPaddingMethod).toBe('random');
  142. expect(xhttp.sessionIDKey).toBe('X-Session');
  143. expect(xhttp.sessionIDTable).toBe('Base62');
  144. expect(xhttp.sessionIDLength).toBe('16-32');
  145. expect(xhttp.seqKey).toBe('X-Seq');
  146. expect(xhttp.noSSEHeader).toBe(true);
  147. expect(xhttp.scMaxBufferedPosts).toBe(50);
  148. });
  149. });
  150. describe('parseVlessLink', () => {
  151. it('parses a vless:// link with reality', () => {
  152. const link
  153. = 'vless://[email protected]:443'
  154. + '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision'
  155. + '#imported-vless';
  156. const out = parseVlessLink(link);
  157. expect(out?.protocol).toBe('vless');
  158. expect(out?.tag).toBe('imported-vless');
  159. const settings = out?.settings as { id: string; flow: string; address: string; port: number };
  160. expect(settings.id).toBe('11111111-2222-4333-8444-555555555555');
  161. expect(settings.address).toBe('srv.example');
  162. expect(settings.port).toBe(443);
  163. expect(settings.flow).toBe('xtls-rprx-vision');
  164. const stream = out?.streamSettings as Record<string, unknown>;
  165. expect(stream.security).toBe('reality');
  166. const reality = stream.realitySettings as Record<string, unknown>;
  167. expect(reality.publicKey).toBe('pubkey');
  168. expect(reality.shortId).toBe('abcd');
  169. expect(reality.serverName).toBe('cloudflare.com');
  170. });
  171. it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {
  172. const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
  173. const pqv = 'GIsemxbGPjDRH1ONfmoGlVkJ4etNuLmYDvzpjmFFreDLd8WjoJxJ4Fmt_NQJaC6';
  174. const link
  175. = 'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121'
  176. + `?encryption=${enc}&pqv=${pqv}`
  177. + '&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com'
  178. + '&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp'
  179. + '#giqssbgmo9';
  180. const out = parseVlessLink(link);
  181. const settings = out?.settings as { encryption: string };
  182. expect(settings.encryption).toBe(enc);
  183. const reality = (out?.streamSettings as Record<string, unknown>).realitySettings as Record<string, unknown>;
  184. expect(reality.mldsa65Verify).toBe(pqv);
  185. expect(reality.publicKey).toBe('aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I');
  186. });
  187. });
  188. describe('parseTrojanLink', () => {
  189. it('parses a trojan:// link with ws + tls', () => {
  190. const link = 'trojan://[email protected]:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan';
  191. const out = parseTrojanLink(link);
  192. expect(out?.protocol).toBe('trojan');
  193. const settings = out?.settings as { servers: Array<{ address: string; port: number; password: string }> };
  194. expect(settings.servers[0].address).toBe('srv.example');
  195. expect(settings.servers[0].port).toBe(8443);
  196. expect(settings.servers[0].password).toBe('secret-pw');
  197. const stream = out?.streamSettings as Record<string, unknown>;
  198. expect(stream.network).toBe('ws');
  199. expect((stream.wsSettings as Record<string, unknown>).path).toBe('/tj');
  200. });
  201. });
  202. describe('parseShadowsocksLink', () => {
  203. it('parses the modern userinfo@host:port form', () => {
  204. // ss://base64(method:password)@host:port#remark
  205. const userinfo = Base64.encode('2022-blake3-aes-128-gcm:supersecret');
  206. const link = `ss://${userinfo}@1.2.3.4:8388#imported-ss`;
  207. const out = parseShadowsocksLink(link);
  208. expect(out?.protocol).toBe('shadowsocks');
  209. expect(out?.tag).toBe('imported-ss');
  210. const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
  211. expect(settings.servers[0].address).toBe('1.2.3.4');
  212. expect(settings.servers[0].port).toBe(8388);
  213. expect(settings.servers[0].method).toBe('2022-blake3-aes-128-gcm');
  214. expect(settings.servers[0].password).toBe('supersecret');
  215. });
  216. it('keeps the port when the link carries a query string (2022 two-key password)', () => {
  217. const link = 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206LzhsdFZKaU90azE2QmhKZG9WZVRmSkNNUEJlRGhjcmkycTN0dzU1OUZvYz06YUhuTTB6ZnpFaTdRejc5dzlxNWFFWWVQVnpDU0wxaHV4RnZXZFB6OFZHST0@localhost:30757?type=tcp#pahf4urt53';
  218. const out = parseShadowsocksLink(link);
  219. expect(out?.protocol).toBe('shadowsocks');
  220. expect(out?.tag).toBe('pahf4urt53');
  221. const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
  222. expect(settings.servers[0].address).toBe('localhost');
  223. expect(settings.servers[0].port).toBe(30757);
  224. expect(settings.servers[0].method).toBe('2022-blake3-aes-256-gcm');
  225. expect(settings.servers[0].password).toBe('/8ltVJiOtk16BhJdoVeTfJCMPBeDhcri2q3tw559Foc=:aHnM0zfzEi7Qz79w9q5aEYePVzCSL1huxFvWdPz8VGI=');
  226. });
  227. it('parses the legacy base64-of-whole form', () => {
  228. // ss://base64(method:password@host:port)#remark
  229. const inner = Base64.encode('aes-256-gcm:[email protected]:1080');
  230. const link = `ss://${inner}#imported-legacy`;
  231. const out = parseShadowsocksLink(link);
  232. const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
  233. expect(settings.servers[0].address).toBe('10.0.0.1');
  234. expect(settings.servers[0].port).toBe(1080);
  235. expect(settings.servers[0].method).toBe('aes-256-gcm');
  236. expect(settings.servers[0].password).toBe('legacypw');
  237. });
  238. });
  239. describe('parseHysteria2Link', () => {
  240. it('parses a hysteria2:// link with sni', () => {
  241. const link = 'hysteria2://[email protected]:443?sni=example.com#imported-hy2';
  242. const out = parseHysteria2Link(link);
  243. expect(out?.protocol).toBe('hysteria');
  244. expect(out?.tag).toBe('imported-hy2');
  245. const settings = out?.settings as { address: string; port: number; version: number };
  246. expect(settings.address).toBe('srv.example');
  247. expect(settings.port).toBe(443);
  248. expect(settings.version).toBe(2);
  249. const stream = out?.streamSettings as Record<string, unknown>;
  250. const hys = stream.hysteriaSettings as Record<string, unknown>;
  251. expect(hys.auth).toBe('auth-secret');
  252. expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
  253. });
  254. it('also accepts hy2:// alias', () => {
  255. const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
  256. expect(out?.protocol).toBe('hysteria');
  257. });
  258. it('parses alpn, fingerprint and the salamander UDP mask (fm) — #4760', () => {
  259. const link = 'hysteria2://[email protected]:8443?'
  260. + 'alpn=h2%2Chttp%2F1.1&'
  261. + 'fm=%7B%22udp%22%3A%5B%7B%22settings%22%3A%7B%22password%22%3A%22ftwfgb9655hh2mgo%22%7D%2C%22type%22%3A%22salamander%22%7D%5D%7D&'
  262. + 'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org'
  263. + '#hy2-ej596ty350qs';
  264. const out = parseHysteria2Link(link);
  265. expect(out).not.toBeNull();
  266. const stream = out!.streamSettings as Record<string, unknown>;
  267. const tls = stream.tlsSettings as Record<string, unknown>;
  268. expect(tls.alpn).toEqual(['h2', 'http/1.1']);
  269. expect(tls.fingerprint).toBe('chrome');
  270. expect(tls.serverName).toBe('news.domain.org');
  271. const finalmask = stream.finalmask as Record<string, unknown>;
  272. expect(finalmask).toBeDefined();
  273. const udp = finalmask.udp as Array<Record<string, unknown>>;
  274. expect(udp[0].type).toBe('salamander');
  275. expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
  276. });
  277. it('round-trips the salamander packetSize (Gecko) under fm', () => {
  278. const fm = encodeURIComponent(JSON.stringify({
  279. udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],
  280. }));
  281. const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-gecko`;
  282. const out = parseHysteria2Link(link);
  283. expect(out).not.toBeNull();
  284. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
  285. const udp = finalmask.udp as Array<Record<string, unknown>>;
  286. const settings = udp[0].settings as Record<string, unknown>;
  287. expect(udp[0].type).toBe('salamander');
  288. expect(settings.password).toBe('ftwfgb9655hh2mgo');
  289. expect(settings.packetSize).toBe('100-200');
  290. });
  291. it('coerces string quicParams numerics under fm to integers — #5783', () => {
  292. const fm = encodeURIComponent(JSON.stringify({
  293. quicParams: {
  294. keepAlivePeriod: '10s',
  295. maxIdleTimeout: '30',
  296. initStreamReceiveWindow: 524288,
  297. maxIncomingStreams: true,
  298. brutalUp: '100 mbps',
  299. },
  300. }));
  301. const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-quic`;
  302. const out = parseHysteria2Link(link);
  303. expect(out).not.toBeNull();
  304. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
  305. const quic = finalmask.quicParams as Record<string, unknown>;
  306. expect(quic.keepAlivePeriod).toBe(10);
  307. expect(quic.maxIdleTimeout).toBe(30);
  308. expect(quic.initStreamReceiveWindow).toBe(524288);
  309. expect('maxIncomingStreams' in quic).toBe(false);
  310. expect(quic.brutalUp).toBe('100 mbps');
  311. });
  312. it('round-trips the realm tlsConfig under fm', () => {
  313. const fm = encodeURIComponent(JSON.stringify({
  314. udp: [{
  315. type: 'realm',
  316. settings: {
  317. url: 'realm://[email protected]/my-realm',
  318. stunServers: ['stun.l.google.com:19302'],
  319. tlsConfig: { serverName: 'example.com', alpn: ['h3'], fingerprint: 'chrome', allowInsecure: false },
  320. },
  321. }],
  322. }));
  323. const link = `hysteria2://auth@srv:443?security=tls&sni=srv&fm=${fm}#hy2-realm`;
  324. const out = parseHysteria2Link(link);
  325. expect(out).not.toBeNull();
  326. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
  327. const udp = finalmask.udp as Array<Record<string, unknown>>;
  328. const settings = udp[0].settings as Record<string, unknown>;
  329. expect(udp[0].type).toBe('realm');
  330. expect(settings.url).toBe('realm://[email protected]/my-realm');
  331. const tlsConfig = settings.tlsConfig as Record<string, unknown>;
  332. expect(tlsConfig.serverName).toBe('example.com');
  333. expect(tlsConfig.alpn).toEqual(['h3']);
  334. expect(tlsConfig.fingerprint).toBe('chrome');
  335. });
  336. it('defaults alpn to h3 when the link omits it', () => {
  337. const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
  338. const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
  339. expect(tls.alpn).toEqual(['h3']);
  340. });
  341. });
  342. describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
  343. it('round-trips a real inbound-generated link with extra+fm+reality+xhttp', () => {
  344. // Real user-reported link — bundled xhttp knobs via `extra` JSON,
  345. // full finalmask via `fm` JSON, reality auth, snake_case
  346. // x_padding_bytes alias. All three parse-paths must combine.
  347. const link = 'vless://b622ac2f-f155-47db-a3b2-b64e8d7f6342@localhost:37723?'
  348. + 'encryption=none&'
  349. + 'extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMinPostsIntervalMs%22%3A%2230%22%2C%22xPaddingBytes%22%3A%22100-1000%22%7D&'
  350. + 'fm=%7B%22quicParams%22%3A%7B%22congestion%22%3A%22bbr%22%2C%22maxIdleTimeout%22%3A30%2C%22udpHop%22%3A%7B%22interval%22%3A%225-10%22%2C%22ports%22%3A%2220000-50000%22%7D%7D%7D&'
  351. + 'fp=chrome&host=&mode=auto&path=%2F&'
  352. + 'pbk=nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o&'
  353. + 'security=reality&sid=14ebccc4d3&sni=aws.amazon.com&'
  354. + 'spx=%2F97L2FjycXEwrE67&type=xhttp&x_padding_bytes=100-1000'
  355. + '#sda-8ud3us6rt';
  356. const parsed = parseVlessLink(link);
  357. expect(parsed).not.toBeNull();
  358. expect(parsed!.tag).toBe('sda-8ud3us6rt');
  359. const stream = parsed!.streamSettings as Record<string, unknown>;
  360. expect(stream.network).toBe('xhttp');
  361. expect(stream.security).toBe('reality');
  362. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  363. expect(xhttp.xPaddingBytes).toBe('100-1000');
  364. expect(xhttp.scMaxEachPostBytes).toBe('1000000');
  365. expect(xhttp.scMinPostsIntervalMs).toBe('30');
  366. const reality = stream.realitySettings as Record<string, unknown>;
  367. expect(reality.publicKey).toBe('nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o');
  368. expect(reality.shortId).toBe('14ebccc4d3');
  369. expect(reality.spiderX).toBe('/97L2FjycXEwrE67');
  370. expect(reality.serverName).toBe('aws.amazon.com');
  371. const finalmask = stream.finalmask as Record<string, unknown>;
  372. expect(finalmask).toBeDefined();
  373. const quicParams = finalmask.quicParams as Record<string, unknown>;
  374. expect(quicParams.congestion).toBe('bbr');
  375. expect(quicParams.maxIdleTimeout).toBe(30);
  376. expect((quicParams.udpHop as Record<string, unknown>).interval).toBe('5-10');
  377. expect((quicParams.udpHop as Record<string, unknown>).ports).toBe('20000-50000');
  378. });
  379. it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => {
  380. const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto&x_padding_bytes=200-2000#t';
  381. const parsed = parseVlessLink(link);
  382. const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  383. expect(xhttp.xPaddingBytes).toBe('200-2000');
  384. });
  385. it('extra takes precedence — camelCase wins over snake_case alias', () => {
  386. const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
  387. + '&xPaddingBytes=900-9000&x_padding_bytes=100-1000#t';
  388. const parsed = parseVlessLink(link);
  389. const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  390. expect(xhttp.xPaddingBytes).toBe('900-9000');
  391. });
  392. it('extracts the nested xmux object from the extra JSON blob', () => {
  393. // The inbound link bundles xmux into `extra` as a nested object
  394. // (sub/service.go). It must survive import so the outbound form's
  395. // XMUX sub-form populates rather than silently dropping it (#5353).
  396. const extra = encodeURIComponent(JSON.stringify({
  397. xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' },
  398. }));
  399. const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
  400. + '&extra=' + extra + '#t';
  401. const parsed = parseVlessLink(link);
  402. const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  403. const xmux = xhttp.xmux as Record<string, unknown>;
  404. expect(xmux).toBeDefined();
  405. expect(xmux.maxConcurrency).toBe('8-16');
  406. expect(xmux.hMaxRequestTimes).toBe('700-1000');
  407. });
  408. it('ignores malformed extra JSON without breaking the rest of the link', () => {
  409. const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
  410. + '&extra=not-json&fp=chrome#t';
  411. const parsed = parseVlessLink(link);
  412. expect(parsed).not.toBeNull();
  413. const stream = parsed!.streamSettings as Record<string, unknown>;
  414. expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
  415. });
  416. it('round-trips ech and pcs from a TLS vless link', () => {
  417. const ech = 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
  418. const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
  419. const link = 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?'
  420. + 'alpn=h3&ech=' + encodeURIComponent(ech) + '&encryption=none&fp=firefox&host=&'
  421. + 'mode=packet-up&path=%2F&pcs=' + pcs + '&security=tls&sni=123&type=xhttp#i5sboxj07w';
  422. const parsed = parseVlessLink(link);
  423. expect(parsed).not.toBeNull();
  424. const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
  425. expect(tls.echConfigList).toBe(ech);
  426. expect(tls.pinnedPeerCertSha256).toBe(pcs);
  427. expect(tls.serverName).toBe('123');
  428. expect(tls.fingerprint).toBe('firefox');
  429. });
  430. });
  431. describe('parseWireguardLink', () => {
  432. it('parses a wireguard:// link with percent-encoded secret and publickey', () => {
  433. const link = 'wireguard://IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U%3D@localhost:22824'
  434. + '?publickey=3CnNsCy74TOlupjaii%2BRFp%2FgDMk5vvUuFD0SNZ%2FGl2s%3D'
  435. + '&address=10.0.0.2%2F32&mtu=1420#-1';
  436. const out = parseWireguardLink(link);
  437. expect(out?.protocol).toBe('wireguard');
  438. expect(out?.tag).toBe('-1');
  439. const settings = out?.settings as {
  440. secretKey: string; address: string[]; mtu: number;
  441. peers: Array<{ publicKey: string; endpoint: string; allowedIPs: string[] }>;
  442. };
  443. expect(settings.secretKey).toBe('IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U=');
  444. expect(settings.address).toEqual(['10.0.0.2/32']);
  445. expect(settings.mtu).toBe(1420);
  446. expect(settings.peers[0].publicKey).toBe('3CnNsCy74TOlupjaii+RFp/gDMk5vvUuFD0SNZ/Gl2s=');
  447. expect(settings.peers[0].endpoint).toBe('localhost:22824');
  448. expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0', '::/0']);
  449. });
  450. it('parses reserved, presharedkey and keepalive aliases', () => {
  451. const link = 'wireguard://[email protected]:51820'
  452. + '?publickey=peerpub&address=10.0.0.2/32,fd00::2/128'
  453. + '&reserved=1,2,3&presharedkey=psk-secret&persistentkeepalive=25'
  454. + '&allowedips=0.0.0.0/0#wg-peer';
  455. const out = parseWireguardLink(link);
  456. const settings = out?.settings as {
  457. reserved: number[];
  458. peers: Array<{ preSharedKey: string; keepAlive: number; allowedIPs: string[] }>;
  459. address: string[];
  460. };
  461. expect(settings.address).toEqual(['10.0.0.2/32', 'fd00::2/128']);
  462. expect(settings.reserved).toEqual([1, 2, 3]);
  463. expect(settings.peers[0].preSharedKey).toBe('psk-secret');
  464. expect(settings.peers[0].keepAlive).toBe(25);
  465. expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0']);
  466. });
  467. it('returns null for non-wireguard links', () => {
  468. expect(parseWireguardLink('vless://x@y:1')).toBeNull();
  469. });
  470. });
  471. describe('parseOutboundLink dispatcher', () => {
  472. it('dispatches vmess via base64 JSON', () => {
  473. const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' };
  474. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  475. expect(parseOutboundLink(link)?.protocol).toBe('vmess');
  476. });
  477. it('dispatches vless via URL', () => {
  478. expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless');
  479. });
  480. it('dispatches wireguard via URL', () => {
  481. expect(parseOutboundLink('wireguard://pk@host:22824?publickey=pub&address=10.0.0.2/32')?.protocol).toBe('wireguard');
  482. });
  483. it('returns null for an unknown scheme', () => {
  484. expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
  485. });
  486. it('returns null for empty input', () => {
  487. expect(parseOutboundLink('')).toBeNull();
  488. expect(parseOutboundLink(' ')).toBeNull();
  489. });
  490. });