outbound-link-parser.test.ts 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048
  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 { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
  12. import { Base64 } from '@/utils';
  13. // Focused acceptance tests for the share-link parsers — one happy-path
  14. // case per protocol family, plus a few common edge cases. The parsers
  15. // produce wire-shape outbound rows; the modal hands them to
  16. // rawOutboundToFormValues to seed Form.useForm.
  17. describe('parseVmessLink', () => {
  18. it('parses a vmess:// link with ws + tls', () => {
  19. const json = {
  20. v: '2',
  21. ps: 'imported-vmess',
  22. add: '1.2.3.4',
  23. port: 8443,
  24. id: '11111111-2222-4333-8444-555555555555',
  25. aid: 0,
  26. scy: 'auto',
  27. net: 'ws',
  28. host: 'example.com',
  29. path: '/ws',
  30. tls: 'tls',
  31. sni: 'example.com',
  32. fp: 'chrome',
  33. alpn: 'h2,http/1.1',
  34. };
  35. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  36. const out = parseVmessLink(link);
  37. expect(out).not.toBeNull();
  38. expect(out?.protocol).toBe('vmess');
  39. expect(out?.tag).toBe('imported-vmess');
  40. const settings = out?.settings as {
  41. vnext: Array<{
  42. address: string;
  43. port: number;
  44. users: Array<{ id: string; security: string }>;
  45. }>;
  46. };
  47. expect(settings.vnext[0].address).toBe('1.2.3.4');
  48. expect(settings.vnext[0].port).toBe(8443);
  49. expect(settings.vnext[0].users[0].id).toBe('11111111-2222-4333-8444-555555555555');
  50. const stream = out?.streamSettings as Record<string, unknown>;
  51. expect(stream.network).toBe('ws');
  52. expect(stream.security).toBe('tls');
  53. expect((stream.wsSettings as Record<string, unknown>).path).toBe('/ws');
  54. expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
  55. expect((stream.tlsSettings as Record<string, unknown>).alpn).toEqual(['h2', 'http/1.1']);
  56. });
  57. // The exporter writes ech/vcn/pcs into the vmess object, so the importer has
  58. // to read them instead of leaving the tls checks it seeded empty.
  59. it('keeps the ech, vcn and pcs certificate checks', () => {
  60. const json = {
  61. v: '2',
  62. ps: 'pinned-vmess',
  63. add: '1.2.3.4',
  64. port: 8443,
  65. id: '11111111-2222-4333-8444-555555555555',
  66. scy: 'auto',
  67. net: 'tcp',
  68. tls: 'tls',
  69. sni: 'vmess.example.com',
  70. fp: 'chrome',
  71. ech: 'AEX+DQBB',
  72. vcn: 'vcn.example.com',
  73. pcs: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
  74. };
  75. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  76. const out = parseVmessLink(link);
  77. expect(out).not.toBeNull();
  78. const stream = out?.streamSettings as Record<string, unknown>;
  79. const tls = stream.tlsSettings as Record<string, unknown>;
  80. expect(tls.echConfigList).toBe('AEX+DQBB');
  81. expect(tls.verifyPeerCertByName).toBe('vcn.example.com');
  82. expect(tls.pinnedPeerCertSha256).toBe('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
  83. });
  84. it('returns null for non-vmess links', () => {
  85. expect(parseVmessLink('vless://x@y:1')).toBeNull();
  86. });
  87. it('returns null for malformed base64', () => {
  88. expect(parseVmessLink('vmess://!!!not-base64!!!')).toBeNull();
  89. });
  90. });
  91. describe('parseVmessLink — XHTTP advanced fields', () => {
  92. it('round-trips xhttp knobs from the vmess JSON', () => {
  93. const json = {
  94. v: '2',
  95. ps: 'imported-xhttp',
  96. add: '1.2.3.4',
  97. port: 443,
  98. id: '11111111-2222-4333-8444-555555555555',
  99. aid: 0,
  100. scy: 'auto',
  101. net: 'xhttp',
  102. host: 'edge.example',
  103. path: '/sp',
  104. mode: 'stream-up',
  105. xPaddingBytes: '500-1500',
  106. scMaxEachPostBytes: '2000000',
  107. scMinPostsIntervalMs: '60',
  108. uplinkChunkSize: 8192,
  109. noGRPCHeader: true,
  110. tls: 'tls',
  111. sni: 'edge.example',
  112. };
  113. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  114. const out = parseVmessLink(link);
  115. const stream = out?.streamSettings as Record<string, unknown>;
  116. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  117. expect(xhttp.host).toBe('edge.example');
  118. expect(xhttp.path).toBe('/sp');
  119. expect(xhttp.mode).toBe('stream-up');
  120. expect(xhttp.xPaddingBytes).toBe('500-1500');
  121. expect(xhttp.scMaxEachPostBytes).toBe('2000000');
  122. expect(xhttp.scMinPostsIntervalMs).toBe('60');
  123. expect(xhttp.uplinkChunkSize).toBe(8192);
  124. expect(xhttp.noGRPCHeader).toBe(true);
  125. });
  126. it('round-trips xhttp padding-obfs knobs from the vmess JSON', () => {
  127. const json = {
  128. v: '2',
  129. ps: 'imported-pad',
  130. add: '1.2.3.4',
  131. port: 443,
  132. id: '11111111-2222-4333-8444-555555555555',
  133. aid: 0,
  134. scy: 'auto',
  135. net: 'xhttp',
  136. host: 'edge.example',
  137. path: '/sp',
  138. xPaddingObfsMode: true,
  139. xPaddingKey: 'secret-key',
  140. xPaddingHeader: 'X-Pad',
  141. xPaddingPlacement: 'header',
  142. xPaddingMethod: 'random',
  143. sessionKey: 'X-Session',
  144. seqKey: 'X-Seq',
  145. noSSEHeader: true,
  146. scMaxBufferedPosts: 50,
  147. tls: 'tls',
  148. };
  149. // legacy sessionKey must alias onto the renamed sessionIDKey (#6258)
  150. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  151. const out = parseVmessLink(link);
  152. const xhttp = (out!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  153. string,
  154. unknown
  155. >;
  156. expect(xhttp.xPaddingObfsMode).toBe(true);
  157. expect(xhttp.xPaddingKey).toBe('secret-key');
  158. expect(xhttp.xPaddingHeader).toBe('X-Pad');
  159. expect(xhttp.xPaddingPlacement).toBe('header');
  160. expect(xhttp.xPaddingMethod).toBe('random');
  161. expect(xhttp.sessionIDKey).toBe('X-Session');
  162. expect(xhttp.sessionKey).toBeUndefined();
  163. expect(xhttp.seqKey).toBe('X-Seq');
  164. expect(xhttp.noSSEHeader).toBe(true);
  165. expect(xhttp.scMaxBufferedPosts).toBe(50);
  166. });
  167. });
  168. describe('parseVlessLink — XHTTP advanced fields', () => {
  169. it('round-trips xhttp knobs from URL query params', () => {
  170. const link =
  171. 'vless://[email protected]:443' +
  172. '?type=xhttp&security=tls&host=edge.example&path=%2Fsp&mode=stream-up' +
  173. '&xPaddingBytes=500-1500&scMaxEachPostBytes=2000000' +
  174. '&scMinPostsIntervalMs=60&uplinkChunkSize=8192&noGRPCHeader=true' +
  175. '#imported-xhttp';
  176. const out = parseVlessLink(link);
  177. const stream = out?.streamSettings as Record<string, unknown>;
  178. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  179. expect(xhttp.host).toBe('edge.example');
  180. expect(xhttp.path).toBe('/sp');
  181. expect(xhttp.mode).toBe('stream-up');
  182. expect(xhttp.xPaddingBytes).toBe('500-1500');
  183. expect(xhttp.scMaxEachPostBytes).toBe('2000000');
  184. expect(xhttp.scMinPostsIntervalMs).toBe('60');
  185. expect(xhttp.uplinkChunkSize).toBe(8192);
  186. expect(xhttp.noGRPCHeader).toBe(true);
  187. });
  188. it('round-trips xhttp padding-obfs knobs from URL query params', () => {
  189. const link =
  190. 'vless://[email protected]:443' +
  191. '?type=xhttp&security=tls&host=edge.example&path=%2Fsp' +
  192. '&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad' +
  193. '&xPaddingPlacement=header&xPaddingMethod=random' +
  194. '&sessionIDKey=X-Session&sessionIDTable=Base62&sessionIDLength=16-32' +
  195. '&seqKey=X-Seq&noSSEHeader=true' +
  196. '&scMaxBufferedPosts=50' +
  197. '#imported-pad';
  198. const out = parseVlessLink(link);
  199. const xhttp = (out!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  200. string,
  201. unknown
  202. >;
  203. expect(xhttp.xPaddingObfsMode).toBe(true);
  204. expect(xhttp.xPaddingKey).toBe('secret-key');
  205. expect(xhttp.xPaddingHeader).toBe('X-Pad');
  206. expect(xhttp.xPaddingPlacement).toBe('header');
  207. expect(xhttp.xPaddingMethod).toBe('random');
  208. expect(xhttp.sessionIDKey).toBe('X-Session');
  209. expect(xhttp.sessionIDTable).toBe('Base62');
  210. expect(xhttp.sessionIDLength).toBe('16-32');
  211. expect(xhttp.seqKey).toBe('X-Seq');
  212. expect(xhttp.noSSEHeader).toBe(true);
  213. expect(xhttp.scMaxBufferedPosts).toBe(50);
  214. });
  215. });
  216. describe('parseVlessLink', () => {
  217. // Older panels share xdns as string lists (pre-26.9.30, a load error) or as name/typed
  218. // resolver objects (26.9.30) that xray-core 26.10.10 reads as no domain at all.
  219. it.each([
  220. ['pre-26.9.30 string lists', { resolvers: ['t.example.com+udp://8.8.8.8:53'] }],
  221. [
  222. '26.9.30 objects',
  223. {
  224. domains: [{ name: 't.example.com', types: [16], edns0: 1232 }],
  225. resolvers: [{ type: 'udp', settings: { addr: '8.8.8.8:53' } }],
  226. },
  227. ],
  228. ])('upgrades a %s xdns fm= mask to names/addrs', (_shape, settings) => {
  229. const fm = encodeURIComponent(JSON.stringify({ udp: [{ type: 'xdns', settings }] }));
  230. const out = parseVlessLink(
  231. `vless://11111111-2222-4333-8444-555555555555@srv:53?type=kcp&security=none&fm=${fm}#dns`,
  232. );
  233. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as {
  234. udp: Array<{ settings: unknown }>;
  235. };
  236. expect(finalmask.udp[0].settings).toEqual({
  237. domains: [{ names: ['t.example.com'], types: [16], edns0: 1232 }],
  238. resolvers: [{ addrs: ['udp://8.8.8.8:53'] }],
  239. });
  240. });
  241. it('parses a vless:// link with reality', () => {
  242. const link =
  243. 'vless://[email protected]:443' +
  244. '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision' +
  245. '&support-x25519mlkem768=true' +
  246. '#imported-vless';
  247. const out = parseVlessLink(link);
  248. expect(out?.protocol).toBe('vless');
  249. expect(out?.tag).toBe('imported-vless');
  250. const settings = out?.settings as { id: string; flow: string; address: string; port: number };
  251. expect(settings.id).toBe('11111111-2222-4333-8444-555555555555');
  252. expect(settings.address).toBe('srv.example');
  253. expect(settings.port).toBe(443);
  254. expect(settings.flow).toBe('xtls-rprx-vision');
  255. const stream = out?.streamSettings as Record<string, unknown>;
  256. expect(stream.security).toBe('reality');
  257. const reality = stream.realitySettings as Record<string, unknown>;
  258. expect(reality.publicKey).toBe('pubkey');
  259. expect(reality.shortId).toBe('abcd');
  260. expect(reality.serverName).toBe('cloudflare.com');
  261. // The hint is for Mihomo; xray-core's REALITYConfig has no such field.
  262. expect(reality).not.toHaveProperty('supportX25519Mlkem768');
  263. const form = rawOutboundToFormValues(out!);
  264. const saved = formValuesToWirePayload(form);
  265. const savedReality = (saved.streamSettings as Record<string, unknown>)
  266. .realitySettings as Record<string, unknown>;
  267. expect(savedReality).not.toHaveProperty('supportX25519Mlkem768');
  268. });
  269. it('parses encryption + pqv (post-quantum) into settings and mldsa65Verify', () => {
  270. const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
  271. const pqv = 'GIsemxbGPjDRH1ONfmoGlVkJ4etNuLmYDvzpjmFFreDLd8WjoJxJ4Fmt_NQJaC6';
  272. const link =
  273. 'vless://9406c224-8ac6-4675-ae0b-f93785959418@localhost:1121' +
  274. `?encryption=${enc}&pqv=${pqv}` +
  275. '&security=reality&sid=29cf418813d5bac7&sni=aws.amazon.com' +
  276. '&pbk=aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I&fp=chrome&spx=%2FOUTjB7xHRiP4zBP&type=tcp' +
  277. '#giqssbgmo9';
  278. const out = parseVlessLink(link);
  279. const settings = out?.settings as { encryption: string };
  280. expect(settings.encryption).toBe(enc);
  281. const reality = (out!.streamSettings as Record<string, unknown>).realitySettings as Record<
  282. string,
  283. unknown
  284. >;
  285. expect(reality.mldsa65Verify).toBe(pqv);
  286. expect(reality.publicKey).toBe('aQaGBOT2hMfXWebYtjADoOVUrP8qZRdwXVap7nrId0I');
  287. });
  288. });
  289. describe('mKCP share params', () => {
  290. // The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
  291. // mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
  292. type Mask = { type: string; settings: { header: string; value: string } };
  293. const parse = (link: string) => {
  294. const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link);
  295. expect(out).not.toBeNull();
  296. const stream = out!.streamSettings as Record<string, unknown>;
  297. const kcp = stream.kcpSettings as { mtu: number; tti: number };
  298. const udp = (stream.finalmask as { udp?: Mask[] } | undefined)?.udp ?? [];
  299. return {
  300. kcp: { mtu: kcp.mtu, tti: kcp.tti },
  301. masks: udp.map((m) => [m.type, m.settings.header, m.settings.value]),
  302. };
  303. };
  304. it.each([
  305. [
  306. 'vless header and seed become two masks, seed first',
  307. 'vless://[email protected]:443?type=kcp&headerType=wechat-video&seed=secret-seed&mtu=1400&tti=50&security=none#kcp1',
  308. { mtu: 1400, tti: 50 },
  309. [
  310. ['mkcp-legacy', '', 'secret-seed'],
  311. ['mkcp-legacy', 'wechat', ''],
  312. ],
  313. ],
  314. [
  315. 'trojan header only adds no seed mask',
  316. 'trojan://[email protected]:443?type=kcp&headerType=srtp&security=none#kcp-tj',
  317. { mtu: 1350, tti: 20 },
  318. [['mkcp-legacy', 'srtp', '']],
  319. ],
  320. [
  321. 'seed only adds no header mask',
  322. 'vless://[email protected]:443?type=kcp&headerType=none&seed=abc123&security=none',
  323. { mtu: 1350, tti: 20 },
  324. [['mkcp-legacy', '', 'abc123']],
  325. ],
  326. [
  327. 'mtu/tti outside KCPConfig.Build bounds keep the defaults',
  328. 'vless://[email protected]:443?type=kcp&mtu=10&tti=5000&security=none',
  329. { mtu: 1350, tti: 20 },
  330. [],
  331. ],
  332. [
  333. 'non-decimal mtu keeps the default like the Go importer',
  334. 'vless://[email protected]:443?type=kcp&mtu=1.5&tti=1e2&security=none',
  335. { mtu: 1350, tti: 20 },
  336. [],
  337. ],
  338. [
  339. 'a prototype key is not a header type',
  340. 'vless://[email protected]:443?type=kcp&headerType=constructor&seed=abc&security=none',
  341. { mtu: 1350, tti: 20 },
  342. [],
  343. ],
  344. ])('%s', (_name, link, kcp, masks) => {
  345. expect(parse(link)).toEqual({ kcp, masks });
  346. });
  347. });
  348. describe('parseTrojanLink', () => {
  349. it('parses a trojan:// link with ws + tls', () => {
  350. const link =
  351. 'trojan://[email protected]:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan';
  352. const out = parseTrojanLink(link);
  353. expect(out?.protocol).toBe('trojan');
  354. const settings = out?.settings as {
  355. servers: Array<{ address: string; port: number; password: string }>;
  356. };
  357. expect(settings.servers[0].address).toBe('srv.example');
  358. expect(settings.servers[0].port).toBe(8443);
  359. expect(settings.servers[0].password).toBe('secret-pw');
  360. const stream = out?.streamSettings as Record<string, unknown>;
  361. expect(stream.network).toBe('ws');
  362. expect((stream.wsSettings as Record<string, unknown>).path).toBe('/tj');
  363. });
  364. });
  365. describe('parseShadowsocksLink', () => {
  366. it('parses the modern userinfo@host:port form', () => {
  367. // ss://base64(method:password)@host:port#remark
  368. const userinfo = Base64.encode('2022-blake3-aes-128-gcm:supersecret');
  369. const link = `ss://${userinfo}@1.2.3.4:8388#imported-ss`;
  370. const out = parseShadowsocksLink(link);
  371. expect(out?.protocol).toBe('shadowsocks');
  372. expect(out?.tag).toBe('imported-ss');
  373. const settings = out?.settings as {
  374. servers: Array<{ address: string; port: number; method: string; password: string }>;
  375. };
  376. expect(settings.servers[0].address).toBe('1.2.3.4');
  377. expect(settings.servers[0].port).toBe(8388);
  378. expect(settings.servers[0].method).toBe('2022-blake3-aes-128-gcm');
  379. expect(settings.servers[0].password).toBe('supersecret');
  380. });
  381. it('keeps the port when the link carries a query string (2022 two-key password)', () => {
  382. const link =
  383. 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206LzhsdFZKaU90azE2QmhKZG9WZVRmSkNNUEJlRGhjcmkycTN0dzU1OUZvYz06YUhuTTB6ZnpFaTdRejc5dzlxNWFFWWVQVnpDU0wxaHV4RnZXZFB6OFZHST0@localhost:30757?type=tcp#pahf4urt53';
  384. const out = parseShadowsocksLink(link);
  385. expect(out?.protocol).toBe('shadowsocks');
  386. expect(out?.tag).toBe('pahf4urt53');
  387. const settings = out?.settings as {
  388. servers: Array<{ address: string; port: number; method: string; password: string }>;
  389. };
  390. expect(settings.servers[0].address).toBe('localhost');
  391. expect(settings.servers[0].port).toBe(30757);
  392. expect(settings.servers[0].method).toBe('2022-blake3-aes-256-gcm');
  393. expect(settings.servers[0].password).toBe(
  394. '/8ltVJiOtk16BhJdoVeTfJCMPBeDhcri2q3tw559Foc=:aHnM0zfzEi7Qz79w9q5aEYePVzCSL1huxFvWdPz8VGI=',
  395. );
  396. });
  397. it('parses the legacy base64-of-whole form', () => {
  398. // ss://base64(method:password@host:port)#remark
  399. const inner = Base64.encode('aes-256-gcm:[email protected]:1080');
  400. const link = `ss://${inner}#imported-legacy`;
  401. const out = parseShadowsocksLink(link);
  402. const settings = out?.settings as {
  403. servers: Array<{ address: string; port: number; method: string; password: string }>;
  404. };
  405. expect(settings.servers[0].address).toBe('10.0.0.1');
  406. expect(settings.servers[0].port).toBe(1080);
  407. expect(settings.servers[0].method).toBe('aes-256-gcm');
  408. expect(settings.servers[0].password).toBe('legacypw');
  409. });
  410. it('preserves Xray TLS query params on import (round-trip)', () => {
  411. const userinfo = Base64.encode('chacha20-ietf-poly1305:secretpass', true);
  412. const link =
  413. `ss://${userinfo}@example.com:443` +
  414. '?alpn=h2%2Chttp%2F1.1&fp=firefox&security=tls&sni=example.com&type=tcp#user';
  415. const out = parseShadowsocksLink(link);
  416. expect(out?.protocol).toBe('shadowsocks');
  417. expect(out?.tag).toBe('user');
  418. const settings = out?.settings as {
  419. servers: Array<{ address: string; port: number; method: string; password: string }>;
  420. };
  421. expect(settings.servers[0]).toMatchObject({
  422. address: 'example.com',
  423. port: 443,
  424. method: 'chacha20-ietf-poly1305',
  425. password: 'secretpass',
  426. });
  427. const stream = out?.streamSettings as Record<string, unknown>;
  428. expect(stream.network).toBe('tcp');
  429. expect(stream.security).toBe('tls');
  430. const tls = stream.tlsSettings as Record<string, unknown>;
  431. expect(tls.serverName).toBe('example.com');
  432. expect(tls.fingerprint).toBe('firefox');
  433. expect(tls.alpn).toEqual(['h2', 'http/1.1']);
  434. });
  435. // The panel exports tcp/http obfuscation as the SIP002 plugin only, so the
  436. // importer has to rebuild the header it stands for.
  437. it('rebuilds the tcp/http header from the obfs-local plugin', () => {
  438. const userinfo = Base64.encode('aes-256-gcm:secretpass', true);
  439. const plugin = encodeURIComponent('obfs-local;obfs=http;obfs-host=obfs.example.com');
  440. const link = `ss://${userinfo}@example.com:8388?plugin=${plugin}#user`;
  441. const stream = parseShadowsocksLink(link)?.streamSettings as Record<string, unknown>;
  442. expect((stream.tcpSettings as Record<string, unknown>).header).toMatchObject({
  443. type: 'http',
  444. request: { headers: { Host: ['obfs.example.com'] } },
  445. });
  446. });
  447. it('leaves a plugin without an xray header alone', () => {
  448. const userinfo = Base64.encode('aes-256-gcm:secretpass', true);
  449. const plugin = encodeURIComponent('obfs-local;obfs=tls');
  450. const link = `ss://${userinfo}@example.com:8388?plugin=${plugin}#user`;
  451. const stream = parseShadowsocksLink(link)?.streamSettings as Record<string, unknown>;
  452. expect((stream.tcpSettings as Record<string, unknown>).header).toMatchObject({ type: 'none' });
  453. });
  454. it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
  455. const method = 'aes-256-gcm';
  456. const password = '>>>';
  457. const userinfo = Base64.encode(`${method}:${password}`, true);
  458. expect(userinfo).toMatch(/[-_]/);
  459. const out = parseShadowsocksLink(`ss://${userinfo}@1.2.3.4:8388#urlsafe`);
  460. const settings = out?.settings as { servers: Array<{ method: string; password: string }> };
  461. expect(settings.servers[0].method).toBe(method);
  462. expect(settings.servers[0].password).toBe(password);
  463. });
  464. });
  465. describe('parseHysteria2Link', () => {
  466. // A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop in fm=,
  467. // which the core ignores; the import must hand it the udphop mask instead.
  468. it('upgrades a legacy fm= quicParams.udpHop to the udphop mask', () => {
  469. const fm = encodeURIComponent(
  470. JSON.stringify({ quicParams: { udpHop: { ports: '20000-30000', interval: '5-10' } } }),
  471. );
  472. const out = parseHysteria2Link(
  473. `hysteria2://[email protected]:443?sni=hy.example.com&fm=${fm}#hy`,
  474. );
  475. expect((out!.streamSettings as Record<string, unknown>).finalmask).toEqual({
  476. udp: [
  477. {
  478. type: 'udphop',
  479. settings: { mode: 'intervalRemote', remotePorts: '20000-30000', interval: '5-10' },
  480. },
  481. ],
  482. });
  483. });
  484. it('parses a hysteria2:// link with sni', () => {
  485. const link = 'hysteria2://[email protected]:443?sni=example.com#imported-hy2';
  486. const out = parseHysteria2Link(link);
  487. expect(out?.protocol).toBe('hysteria');
  488. expect(out?.tag).toBe('imported-hy2');
  489. const settings = out?.settings as { address: string; port: number; version: number };
  490. expect(settings.address).toBe('srv.example');
  491. expect(settings.port).toBe(443);
  492. expect(settings.version).toBe(2);
  493. const stream = out?.streamSettings as Record<string, unknown>;
  494. const hys = stream.hysteriaSettings as Record<string, unknown>;
  495. expect(hys.auth).toBe('auth-secret');
  496. expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
  497. });
  498. it('also accepts hy2:// alias', () => {
  499. const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
  500. expect(out?.protocol).toBe('hysteria');
  501. });
  502. it('parses alpn, fingerprint and the salamander UDP mask (fm) — #4760', () => {
  503. const link =
  504. 'hysteria2://[email protected]:8443?' +
  505. 'alpn=h2%2Chttp%2F1.1&' +
  506. '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&' +
  507. 'fp=chrome&obfs=salamander&obfs-password=655hh2mgo&security=tls&sni=news.domain.org' +
  508. '#hy2-ej596ty350qs';
  509. const out = parseHysteria2Link(link);
  510. expect(out).not.toBeNull();
  511. const stream = out!.streamSettings as Record<string, unknown>;
  512. const tls = stream.tlsSettings as Record<string, unknown>;
  513. expect(tls.alpn).toEqual(['h2', 'http/1.1']);
  514. expect(tls.fingerprint).toBe('chrome');
  515. expect(tls.serverName).toBe('news.domain.org');
  516. const finalmask = stream.finalmask as Record<string, unknown>;
  517. expect(finalmask).toBeDefined();
  518. const udp = finalmask.udp as Array<Record<string, unknown>>;
  519. expect(udp).toHaveLength(1);
  520. expect(udp[0].type).toBe('salamander');
  521. expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
  522. });
  523. it('reconstructs the salamander mask from standard obfs= without fm=', () => {
  524. const link =
  525. 'hysteria2://[email protected]:8443?security=tls&sni=news.domain.org' +
  526. '&obfs=salamander&obfs-password=ftwfgb9655hh2mgo#hy2-std-obfs';
  527. const out = parseHysteria2Link(link);
  528. expect(out).not.toBeNull();
  529. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  530. string,
  531. unknown
  532. >;
  533. expect(finalmask).toBeDefined();
  534. const udp = finalmask.udp as Array<Record<string, unknown>>;
  535. expect(udp).toHaveLength(1);
  536. expect(udp[0].type).toBe('salamander');
  537. expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
  538. });
  539. it('adds no salamander mask when the link carries neither obfs nor fm', () => {
  540. const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&sni=srv#hy2-plain');
  541. expect(out).not.toBeNull();
  542. expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
  543. });
  544. it('ignores obfs=salamander when no obfs-password is present', () => {
  545. const out = parseHysteria2Link(
  546. 'hysteria2://auth@srv:443?security=tls&obfs=salamander#hy2-nopw',
  547. );
  548. expect(out).not.toBeNull();
  549. expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
  550. });
  551. it.each([
  552. ['obfs_password', 'obfs_password=aliaspw', 'aliaspw'],
  553. ['obfsPassword', 'obfsPassword=camelpw', 'camelpw'],
  554. ['case-insensitive type', 'obfs=Salamander&obfs-password=mixed', 'mixed'],
  555. ])('accepts the %s form of the obfs pair', (_name, query, want) => {
  556. const base = query.includes('obfs=') ? query : `obfs=salamander&${query}`;
  557. const out = parseHysteria2Link(`hysteria2://auth@srv:443?security=tls&${base}#hy2-alias`);
  558. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  559. string,
  560. unknown
  561. >;
  562. const udp = finalmask.udp as Array<Record<string, unknown>>;
  563. expect(udp).toHaveLength(1);
  564. expect(udp[0].type).toBe('salamander');
  565. expect((udp[0].settings as Record<string, unknown>).password).toBe(want);
  566. });
  567. it('appends the obfs salamander mask alongside a non-salamander fm mask', () => {
  568. const fm = encodeURIComponent(
  569. JSON.stringify({
  570. udp: [{ type: 'mkcp-legacy', settings: { header: 'srtp' } }],
  571. }),
  572. );
  573. const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=added#hy2-append`;
  574. const out = parseHysteria2Link(link);
  575. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  576. string,
  577. unknown
  578. >;
  579. const udp = finalmask.udp as Array<Record<string, unknown>>;
  580. expect(udp).toHaveLength(2);
  581. expect(udp[0].type).toBe('mkcp-legacy');
  582. expect(udp[1].type).toBe('salamander');
  583. expect((udp[1].settings as Record<string, unknown>).password).toBe('added');
  584. });
  585. it('fills the password of a password-less fm salamander mask from obfs', () => {
  586. const fm = encodeURIComponent(
  587. JSON.stringify({
  588. udp: [{ type: 'salamander', settings: {} }],
  589. }),
  590. );
  591. const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=fromobfs#hy2-fill`;
  592. const out = parseHysteria2Link(link);
  593. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  594. string,
  595. unknown
  596. >;
  597. const udp = finalmask.udp as Array<Record<string, unknown>>;
  598. expect(udp).toHaveLength(1);
  599. expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
  600. });
  601. // xray-core 26.9.9 ignores quicParams.udpHop; hopping is a 'udphop' UDP mask
  602. // and its mode must be one the core's UDPHop.Build() accepts.
  603. it('reconstructs a udphop mask from the standard mport param', () => {
  604. const out = parseHysteria2Link(
  605. 'hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport',
  606. );
  607. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  608. string,
  609. unknown
  610. >;
  611. const udp = finalmask.udp as Array<Record<string, unknown>>;
  612. const hop = udp.find((mask) => mask.type === 'udphop');
  613. expect(hop).toBeDefined();
  614. const settings = hop!.settings as Record<string, unknown>;
  615. expect(settings.remotePorts).toBe('20000-50000');
  616. expect(settings.interval).toBe('5-10');
  617. expect(settings.mode).toBe('intervalremote');
  618. expect((finalmask.quicParams as Record<string, unknown> | undefined)?.udpHop).toBeUndefined();
  619. });
  620. it('lets an fm= udphop mask win over mport', () => {
  621. const fm = encodeURIComponent(
  622. JSON.stringify({
  623. udp: [
  624. {
  625. type: 'udphop',
  626. settings: { mode: 'intervalremote', interval: '7-9', remotePorts: '30000-40000' },
  627. },
  628. ],
  629. }),
  630. );
  631. const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
  632. const out = parseHysteria2Link(link);
  633. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  634. string,
  635. unknown
  636. >;
  637. const udp = finalmask.udp as Array<Record<string, unknown>>;
  638. expect(udp).toHaveLength(1);
  639. const settings = udp[0].settings as Record<string, unknown>;
  640. expect(settings.remotePorts).toBe('30000-40000');
  641. expect(settings.interval).toBe('7-9');
  642. });
  643. it('round-trips the salamander packetSize (Gecko) under fm', () => {
  644. const fm = encodeURIComponent(
  645. JSON.stringify({
  646. udp: [
  647. { type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } },
  648. ],
  649. }),
  650. );
  651. const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-gecko`;
  652. const out = parseHysteria2Link(link);
  653. expect(out).not.toBeNull();
  654. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  655. string,
  656. unknown
  657. >;
  658. const udp = finalmask.udp as Array<Record<string, unknown>>;
  659. const settings = udp[0].settings as Record<string, unknown>;
  660. expect(udp[0].type).toBe('salamander');
  661. expect(settings.password).toBe('ftwfgb9655hh2mgo');
  662. expect(settings.packetSize).toBe('100-200');
  663. });
  664. it('coerces string quicParams numerics under fm to integers — #5783', () => {
  665. const fm = encodeURIComponent(
  666. JSON.stringify({
  667. quicParams: {
  668. keepAlivePeriod: '10s',
  669. maxIdleTimeout: '30',
  670. initStreamReceiveWindow: 524288,
  671. maxIncomingStreams: true,
  672. brutalUp: '100 mbps',
  673. },
  674. }),
  675. );
  676. const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-quic`;
  677. const out = parseHysteria2Link(link);
  678. expect(out).not.toBeNull();
  679. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  680. string,
  681. unknown
  682. >;
  683. const quic = finalmask.quicParams as Record<string, unknown>;
  684. expect(quic.keepAlivePeriod).toBe(10);
  685. expect(quic.maxIdleTimeout).toBe(30);
  686. expect(quic.initStreamReceiveWindow).toBe(524288);
  687. expect('maxIncomingStreams' in quic).toBe(false);
  688. expect(quic.brutalUp).toBe('100 mbps');
  689. });
  690. it('clamps quicParams to the ranges xray accepts and drops junk — #5783', () => {
  691. const fm = encodeURIComponent(
  692. JSON.stringify({
  693. quicParams: {
  694. keepAlivePeriod: '1s',
  695. maxIdleTimeout: '10m',
  696. maxIncomingStreams: 4,
  697. initStreamReceiveWindow: 'inf',
  698. maxStreamReceiveWindow: -5,
  699. initConnectionReceiveWindow: 1e30,
  700. },
  701. }),
  702. );
  703. const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-clamp`;
  704. const out = parseHysteria2Link(link);
  705. expect(out).not.toBeNull();
  706. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  707. string,
  708. unknown
  709. >;
  710. const quic = finalmask.quicParams as Record<string, unknown>;
  711. expect(quic.keepAlivePeriod).toBe(2);
  712. expect(quic.maxIdleTimeout).toBe(120);
  713. expect(quic.maxIncomingStreams).toBe(8);
  714. expect('initStreamReceiveWindow' in quic).toBe(false);
  715. expect('maxStreamReceiveWindow' in quic).toBe(false);
  716. expect('initConnectionReceiveWindow' in quic).toBe(false);
  717. });
  718. it('round-trips the realm tlsConfig under fm', () => {
  719. const fm = encodeURIComponent(
  720. JSON.stringify({
  721. udp: [
  722. {
  723. type: 'realm',
  724. settings: {
  725. url: 'realm://[email protected]/my-realm',
  726. stunServers: ['stun.l.google.com:19302'],
  727. tlsConfig: {
  728. serverName: 'example.com',
  729. alpn: ['h3'],
  730. fingerprint: 'chrome',
  731. allowInsecure: false,
  732. },
  733. },
  734. },
  735. ],
  736. }),
  737. );
  738. const link = `hysteria2://auth@srv:443?security=tls&sni=srv&fm=${fm}#hy2-realm`;
  739. const out = parseHysteria2Link(link);
  740. expect(out).not.toBeNull();
  741. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<
  742. string,
  743. unknown
  744. >;
  745. const udp = finalmask.udp as Array<Record<string, unknown>>;
  746. const settings = udp[0].settings as Record<string, unknown>;
  747. expect(udp[0].type).toBe('realm');
  748. expect(settings.url).toBe('realm://[email protected]/my-realm');
  749. const tlsConfig = settings.tlsConfig as Record<string, unknown>;
  750. expect(tlsConfig.serverName).toBe('example.com');
  751. expect(tlsConfig.alpn).toEqual(['h3']);
  752. expect(tlsConfig.fingerprint).toBe('chrome');
  753. });
  754. it('defaults alpn to h3 when the link omits it', () => {
  755. const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
  756. const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<
  757. string,
  758. unknown
  759. >;
  760. expect(tls.alpn).toEqual(['h3']);
  761. });
  762. });
  763. describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
  764. it('round-trips a real inbound-generated link with extra+fm+reality+xhttp', () => {
  765. // Real user-reported link — bundled xhttp knobs via `extra` JSON,
  766. // full finalmask via `fm` JSON, reality auth, snake_case
  767. // x_padding_bytes alias. All three parse-paths must combine.
  768. const link =
  769. 'vless://b622ac2f-f155-47db-a3b2-b64e8d7f6342@localhost:37723?' +
  770. 'encryption=none&' +
  771. 'extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMinPostsIntervalMs%22%3A%2230%22%2C%22xPaddingBytes%22%3A%22100-1000%22%7D&' +
  772. '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&' +
  773. 'fp=chrome&host=&mode=auto&path=%2F&' +
  774. 'pbk=nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o&' +
  775. 'security=reality&sid=14ebccc4d3&sni=aws.amazon.com&' +
  776. 'spx=%2F97L2FjycXEwrE67&type=xhttp&x_padding_bytes=100-1000' +
  777. '#sda-8ud3us6rt';
  778. const parsed = parseVlessLink(link);
  779. expect(parsed).not.toBeNull();
  780. expect(parsed!.tag).toBe('sda-8ud3us6rt');
  781. const stream = parsed!.streamSettings as Record<string, unknown>;
  782. expect(stream.network).toBe('xhttp');
  783. expect(stream.security).toBe('reality');
  784. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  785. expect(xhttp.xPaddingBytes).toBe('100-1000');
  786. expect(xhttp.scMaxEachPostBytes).toBe('1000000');
  787. expect(xhttp.scMinPostsIntervalMs).toBe('30');
  788. const reality = stream.realitySettings as Record<string, unknown>;
  789. expect(reality.publicKey).toBe('nJw4k4CPf5jf64V8nnDwWa8iClDnUvQ1lCI4iKzfJ0o');
  790. expect(reality.shortId).toBe('14ebccc4d3');
  791. expect(reality.spiderX).toBe('/97L2FjycXEwrE67');
  792. expect(reality.serverName).toBe('aws.amazon.com');
  793. const finalmask = stream.finalmask as Record<string, unknown>;
  794. expect(finalmask).toBeDefined();
  795. const quicParams = finalmask.quicParams as Record<string, unknown>;
  796. expect(quicParams.congestion).toBe('bbr');
  797. expect(quicParams.maxIdleTimeout).toBe(30);
  798. // xray-core 26.9.9 ignores quicParams.udpHop; the import moves it to the udphop mask.
  799. expect(quicParams.udpHop).toBeUndefined();
  800. expect(finalmask.udp).toEqual([
  801. {
  802. type: 'udphop',
  803. settings: { mode: 'intervalRemote', remotePorts: '20000-50000', interval: '5-10' },
  804. },
  805. ]);
  806. });
  807. it('falls back to x_padding_bytes when extra has no xPaddingBytes', () => {
  808. const link =
  809. 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto&x_padding_bytes=200-2000#t';
  810. const parsed = parseVlessLink(link);
  811. const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  812. string,
  813. unknown
  814. >;
  815. expect(xhttp.xPaddingBytes).toBe('200-2000');
  816. });
  817. it('extra takes precedence — camelCase wins over snake_case alias', () => {
  818. const link =
  819. 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' +
  820. '&xPaddingBytes=900-9000&x_padding_bytes=100-1000#t';
  821. const parsed = parseVlessLink(link);
  822. const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  823. string,
  824. unknown
  825. >;
  826. expect(xhttp.xPaddingBytes).toBe('900-9000');
  827. });
  828. it('extracts the nested xmux object from the extra JSON blob', () => {
  829. // The inbound link bundles xmux into `extra` as a nested object
  830. // (sub/service.go). It must survive import so the outbound form's
  831. // XMUX sub-form populates rather than silently dropping it (#5353).
  832. const extra = encodeURIComponent(
  833. JSON.stringify({
  834. xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' },
  835. }),
  836. );
  837. const link =
  838. 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' + '&extra=' + extra + '#t';
  839. const parsed = parseVlessLink(link);
  840. const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  841. string,
  842. unknown
  843. >;
  844. const xmux = xhttp.xmux as Record<string, unknown>;
  845. expect(xmux).toBeDefined();
  846. expect(xmux.maxConcurrency).toBe('8-16');
  847. expect(xmux.hMaxRequestTimes).toBe('700-1000');
  848. });
  849. it('ignores malformed extra JSON without breaking the rest of the link', () => {
  850. const link =
  851. 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto' +
  852. '&extra=not-json&fp=chrome#t';
  853. const parsed = parseVlessLink(link);
  854. expect(parsed).not.toBeNull();
  855. const stream = parsed!.streamSettings as Record<string, unknown>;
  856. expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
  857. });
  858. it('round-trips ech and pcs from a TLS vless link', () => {
  859. const ech =
  860. 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
  861. const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
  862. const link =
  863. 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?' +
  864. 'alpn=h3&ech=' +
  865. encodeURIComponent(ech) +
  866. '&encryption=none&fp=firefox&host=&' +
  867. 'mode=packet-up&path=%2F&pcs=' +
  868. pcs +
  869. '&security=tls&sni=123&type=xhttp#i5sboxj07w';
  870. const parsed = parseVlessLink(link);
  871. expect(parsed).not.toBeNull();
  872. const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<
  873. string,
  874. unknown
  875. >;
  876. expect(tls.echConfigList).toBe(ech);
  877. expect(tls.pinnedPeerCertSha256).toBe(pcs);
  878. expect(tls.serverName).toBe('123');
  879. expect(tls.fingerprint).toBe('firefox');
  880. });
  881. });
  882. describe('parseWireguardLink', () => {
  883. it('parses a wireguard:// link with percent-encoded secret and publickey', () => {
  884. const link =
  885. 'wireguard://IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U%3D@localhost:22824' +
  886. '?publickey=3CnNsCy74TOlupjaii%2BRFp%2FgDMk5vvUuFD0SNZ%2FGl2s%3D' +
  887. '&address=10.0.0.2%2F32&mtu=1420#-1';
  888. const out = parseWireguardLink(link);
  889. expect(out?.protocol).toBe('wireguard');
  890. expect(out?.tag).toBe('-1');
  891. const settings = out?.settings as {
  892. secretKey: string;
  893. address: string[];
  894. mtu: number;
  895. peers: Array<{ publicKey: string; endpoint: string; allowedIPs: string[] }>;
  896. };
  897. expect(settings.secretKey).toBe('IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U=');
  898. expect(settings.address).toEqual(['10.0.0.2/32']);
  899. expect(settings.mtu).toBe(1420);
  900. expect(settings.peers[0].publicKey).toBe('3CnNsCy74TOlupjaii+RFp/gDMk5vvUuFD0SNZ/Gl2s=');
  901. expect(settings.peers[0].endpoint).toBe('localhost:22824');
  902. expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0', '::/0']);
  903. });
  904. it('parses reserved, presharedkey and keepalive aliases', () => {
  905. const link =
  906. 'wireguard://[email protected]:51820' +
  907. '?publickey=peerpub&address=10.0.0.2/32,fd00::2/128' +
  908. '&reserved=1,2,3&presharedkey=psk-secret&persistentkeepalive=25' +
  909. '&allowedips=0.0.0.0/0#wg-peer';
  910. const out = parseWireguardLink(link);
  911. const settings = out?.settings as {
  912. reserved: number[];
  913. peers: Array<{ preSharedKey: string; keepAlive: number; allowedIPs: string[] }>;
  914. address: string[];
  915. };
  916. expect(settings.address).toEqual(['10.0.0.2/32', 'fd00::2/128']);
  917. expect(settings.reserved).toEqual([1, 2, 3]);
  918. expect(settings.peers[0].preSharedKey).toBe('psk-secret');
  919. expect(settings.peers[0].keepAlive).toBe(25);
  920. expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0']);
  921. });
  922. it('returns null for non-wireguard links', () => {
  923. expect(parseWireguardLink('vless://x@y:1')).toBeNull();
  924. });
  925. });
  926. describe('parseOutboundLink dispatcher', () => {
  927. it('dispatches vmess via base64 JSON', () => {
  928. const json = {
  929. v: '2',
  930. ps: 'x',
  931. add: '1.1.1.1',
  932. port: 443,
  933. id: '11111111-2222-4333-8444-555555555555',
  934. net: 'tcp',
  935. tls: 'none',
  936. };
  937. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  938. expect(parseOutboundLink(link)?.protocol).toBe('vmess');
  939. });
  940. it('dispatches vless via URL', () => {
  941. expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe(
  942. 'vless',
  943. );
  944. });
  945. it('dispatches wireguard via URL', () => {
  946. expect(
  947. parseOutboundLink('wireguard://pk@host:22824?publickey=pub&address=10.0.0.2/32')?.protocol,
  948. ).toBe('wireguard');
  949. });
  950. it('returns null for an unknown scheme', () => {
  951. expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
  952. });
  953. it('returns null for empty input', () => {
  954. expect(parseOutboundLink('')).toBeNull();
  955. expect(parseOutboundLink(' ')).toBeNull();
  956. });
  957. });
  958. describe('obfs=gecko packetSize validation', () => {
  959. const base = 'hysteria2://[email protected]:443?security=tls&obfs=gecko&obfs-password=pw';
  960. const packetSizeOf = (link: string): string | undefined => {
  961. const out = parseHysteria2Link(link);
  962. expect(out).not.toBeNull();
  963. const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as
  964. | Record<string, unknown>
  965. | undefined;
  966. const udp = (finalmask?.udp ?? []) as Array<Record<string, unknown>>;
  967. const mask = udp.find((m) => m.type === 'salamander');
  968. return (mask?.settings as Record<string, unknown> | undefined)?.packetSize as
  969. | string
  970. | undefined;
  971. };
  972. it('stores a valid range', () => {
  973. expect(packetSizeOf(`${base}&minPacketSize=512&maxPacketSize=1200`)).toBe('512-1200');
  974. });
  975. it.each([
  976. ['min only', `${base}&minPacketSize=512`],
  977. ['max only', `${base}&maxPacketSize=1200`],
  978. ['non-numeric', `${base}&minPacketSize=abc&maxPacketSize=def`],
  979. ['zero min', `${base}&minPacketSize=0&maxPacketSize=1200`],
  980. ['inverted', `${base}&minPacketSize=1200&maxPacketSize=512`],
  981. ['over cap', `${base}&minPacketSize=512&maxPacketSize=4096`],
  982. ])('drops the %s range', (_name, link) => {
  983. expect(packetSizeOf(link)).toBeUndefined();
  984. });
  985. });