outbound-link-parser.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { describe, expect, it } from 'vitest';
  2. import {
  3. parseOutboundLink,
  4. parseShadowsocksLink,
  5. parseTrojanLink,
  6. parseVlessLink,
  7. parseVmessLink,
  8. parseHysteria2Link,
  9. } from '@/lib/xray/outbound-link-parser';
  10. import { Base64 } from '@/utils';
  11. // Focused acceptance tests for the share-link parsers — one happy-path
  12. // case per protocol family, plus a few common edge cases. The parsers
  13. // produce wire-shape outbound rows; the modal hands them to
  14. // rawOutboundToFormValues to seed Form.useForm.
  15. describe('parseVmessLink', () => {
  16. it('parses a vmess:// link with ws + tls', () => {
  17. const json = {
  18. v: '2', ps: 'imported-vmess', add: '1.2.3.4', port: 8443,
  19. id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
  20. net: 'ws', host: 'example.com', path: '/ws',
  21. tls: 'tls', sni: 'example.com', fp: 'chrome', alpn: 'h2,http/1.1',
  22. };
  23. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  24. const out = parseVmessLink(link);
  25. expect(out).not.toBeNull();
  26. expect(out?.protocol).toBe('vmess');
  27. expect(out?.tag).toBe('imported-vmess');
  28. const settings = out?.settings as { vnext: Array<{ address: string; port: number; users: Array<{ id: string; security: string }> }> };
  29. expect(settings.vnext[0].address).toBe('1.2.3.4');
  30. expect(settings.vnext[0].port).toBe(8443);
  31. expect(settings.vnext[0].users[0].id).toBe('11111111-2222-4333-8444-555555555555');
  32. const stream = out?.streamSettings as Record<string, unknown>;
  33. expect(stream.network).toBe('ws');
  34. expect(stream.security).toBe('tls');
  35. expect((stream.wsSettings as Record<string, unknown>).path).toBe('/ws');
  36. expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
  37. expect((stream.tlsSettings as Record<string, unknown>).alpn).toEqual(['h2', 'http/1.1']);
  38. });
  39. it('returns null for non-vmess links', () => {
  40. expect(parseVmessLink('vless://x@y:1')).toBeNull();
  41. });
  42. it('returns null for malformed base64', () => {
  43. expect(parseVmessLink('vmess://!!!not-base64!!!')).toBeNull();
  44. });
  45. });
  46. describe('parseVmessLink — XHTTP advanced fields', () => {
  47. it('round-trips xhttp knobs from the vmess JSON', () => {
  48. const json = {
  49. v: '2', ps: 'imported-xhttp', add: '1.2.3.4', port: 443,
  50. id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
  51. net: 'xhttp', host: 'edge.example', path: '/sp', mode: 'stream-up',
  52. xPaddingBytes: '500-1500',
  53. scMaxEachPostBytes: '2000000',
  54. scMinPostsIntervalMs: '60',
  55. uplinkChunkSize: 8192,
  56. noGRPCHeader: true,
  57. tls: 'tls', sni: 'edge.example',
  58. };
  59. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  60. const out = parseVmessLink(link);
  61. const stream = out?.streamSettings as Record<string, unknown>;
  62. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  63. expect(xhttp.host).toBe('edge.example');
  64. expect(xhttp.path).toBe('/sp');
  65. expect(xhttp.mode).toBe('stream-up');
  66. expect(xhttp.xPaddingBytes).toBe('500-1500');
  67. expect(xhttp.scMaxEachPostBytes).toBe('2000000');
  68. expect(xhttp.scMinPostsIntervalMs).toBe('60');
  69. expect(xhttp.uplinkChunkSize).toBe(8192);
  70. expect(xhttp.noGRPCHeader).toBe(true);
  71. });
  72. it('round-trips xhttp padding-obfs knobs from the vmess JSON', () => {
  73. const json = {
  74. v: '2', ps: 'imported-pad', add: '1.2.3.4', port: 443,
  75. id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto',
  76. net: 'xhttp', host: 'edge.example', path: '/sp',
  77. xPaddingObfsMode: true,
  78. xPaddingKey: 'secret-key',
  79. xPaddingHeader: 'X-Pad',
  80. xPaddingPlacement: 'header',
  81. xPaddingMethod: 'random',
  82. sessionKey: 'X-Session',
  83. seqKey: 'X-Seq',
  84. noSSEHeader: true,
  85. scMaxBufferedPosts: 50,
  86. tls: 'tls',
  87. };
  88. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  89. const out = parseVmessLink(link);
  90. const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  91. expect(xhttp.xPaddingObfsMode).toBe(true);
  92. expect(xhttp.xPaddingKey).toBe('secret-key');
  93. expect(xhttp.xPaddingHeader).toBe('X-Pad');
  94. expect(xhttp.xPaddingPlacement).toBe('header');
  95. expect(xhttp.xPaddingMethod).toBe('random');
  96. expect(xhttp.sessionKey).toBe('X-Session');
  97. expect(xhttp.seqKey).toBe('X-Seq');
  98. expect(xhttp.noSSEHeader).toBe(true);
  99. expect(xhttp.scMaxBufferedPosts).toBe(50);
  100. });
  101. });
  102. describe('parseVlessLink — XHTTP advanced fields', () => {
  103. it('round-trips xhttp knobs from URL query params', () => {
  104. const link
  105. = 'vless://[email protected]:443'
  106. + '?type=xhttp&security=tls&host=edge.example&path=%2Fsp&mode=stream-up'
  107. + '&xPaddingBytes=500-1500&scMaxEachPostBytes=2000000'
  108. + '&scMinPostsIntervalMs=60&uplinkChunkSize=8192&noGRPCHeader=true'
  109. + '#imported-xhttp';
  110. const out = parseVlessLink(link);
  111. const stream = out?.streamSettings as Record<string, unknown>;
  112. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  113. expect(xhttp.host).toBe('edge.example');
  114. expect(xhttp.path).toBe('/sp');
  115. expect(xhttp.mode).toBe('stream-up');
  116. expect(xhttp.xPaddingBytes).toBe('500-1500');
  117. expect(xhttp.scMaxEachPostBytes).toBe('2000000');
  118. expect(xhttp.scMinPostsIntervalMs).toBe('60');
  119. expect(xhttp.uplinkChunkSize).toBe(8192);
  120. expect(xhttp.noGRPCHeader).toBe(true);
  121. });
  122. it('round-trips xhttp padding-obfs knobs from URL query params', () => {
  123. const link
  124. = 'vless://[email protected]:443'
  125. + '?type=xhttp&security=tls&host=edge.example&path=%2Fsp'
  126. + '&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad'
  127. + '&xPaddingPlacement=header&xPaddingMethod=random'
  128. + '&sessionKey=X-Session&seqKey=X-Seq&noSSEHeader=true'
  129. + '&scMaxBufferedPosts=50'
  130. + '#imported-pad';
  131. const out = parseVlessLink(link);
  132. const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
  133. expect(xhttp.xPaddingObfsMode).toBe(true);
  134. expect(xhttp.xPaddingKey).toBe('secret-key');
  135. expect(xhttp.xPaddingHeader).toBe('X-Pad');
  136. expect(xhttp.xPaddingPlacement).toBe('header');
  137. expect(xhttp.xPaddingMethod).toBe('random');
  138. expect(xhttp.sessionKey).toBe('X-Session');
  139. expect(xhttp.seqKey).toBe('X-Seq');
  140. expect(xhttp.noSSEHeader).toBe(true);
  141. expect(xhttp.scMaxBufferedPosts).toBe(50);
  142. });
  143. });
  144. describe('parseVlessLink', () => {
  145. it('parses a vless:// link with reality', () => {
  146. const link
  147. = 'vless://[email protected]:443'
  148. + '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision'
  149. + '#imported-vless';
  150. const out = parseVlessLink(link);
  151. expect(out?.protocol).toBe('vless');
  152. expect(out?.tag).toBe('imported-vless');
  153. const settings = out?.settings as { id: string; flow: string; address: string; port: number };
  154. expect(settings.id).toBe('11111111-2222-4333-8444-555555555555');
  155. expect(settings.address).toBe('srv.example');
  156. expect(settings.port).toBe(443);
  157. expect(settings.flow).toBe('xtls-rprx-vision');
  158. const stream = out?.streamSettings as Record<string, unknown>;
  159. expect(stream.security).toBe('reality');
  160. const reality = stream.realitySettings as Record<string, unknown>;
  161. expect(reality.publicKey).toBe('pubkey');
  162. expect(reality.shortId).toBe('abcd');
  163. expect(reality.serverName).toBe('cloudflare.com');
  164. });
  165. });
  166. describe('parseTrojanLink', () => {
  167. it('parses a trojan:// link with ws + tls', () => {
  168. const link = 'trojan://[email protected]:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan';
  169. const out = parseTrojanLink(link);
  170. expect(out?.protocol).toBe('trojan');
  171. const settings = out?.settings as { servers: Array<{ address: string; port: number; password: string }> };
  172. expect(settings.servers[0].address).toBe('srv.example');
  173. expect(settings.servers[0].port).toBe(8443);
  174. expect(settings.servers[0].password).toBe('secret-pw');
  175. const stream = out?.streamSettings as Record<string, unknown>;
  176. expect(stream.network).toBe('ws');
  177. expect((stream.wsSettings as Record<string, unknown>).path).toBe('/tj');
  178. });
  179. });
  180. describe('parseShadowsocksLink', () => {
  181. it('parses the modern userinfo@host:port form', () => {
  182. // ss://base64(method:password)@host:port#remark
  183. const userinfo = Base64.encode('2022-blake3-aes-128-gcm:supersecret');
  184. const link = `ss://${userinfo}@1.2.3.4:8388#imported-ss`;
  185. const out = parseShadowsocksLink(link);
  186. expect(out?.protocol).toBe('shadowsocks');
  187. expect(out?.tag).toBe('imported-ss');
  188. const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
  189. expect(settings.servers[0].address).toBe('1.2.3.4');
  190. expect(settings.servers[0].port).toBe(8388);
  191. expect(settings.servers[0].method).toBe('2022-blake3-aes-128-gcm');
  192. expect(settings.servers[0].password).toBe('supersecret');
  193. });
  194. it('parses the legacy base64-of-whole form', () => {
  195. // ss://base64(method:password@host:port)#remark
  196. const inner = Base64.encode('aes-256-gcm:[email protected]:1080');
  197. const link = `ss://${inner}#imported-legacy`;
  198. const out = parseShadowsocksLink(link);
  199. const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> };
  200. expect(settings.servers[0].address).toBe('10.0.0.1');
  201. expect(settings.servers[0].port).toBe(1080);
  202. expect(settings.servers[0].method).toBe('aes-256-gcm');
  203. expect(settings.servers[0].password).toBe('legacypw');
  204. });
  205. });
  206. describe('parseHysteria2Link', () => {
  207. it('parses a hysteria2:// link with sni', () => {
  208. const link = 'hysteria2://[email protected]:443?sni=example.com#imported-hy2';
  209. const out = parseHysteria2Link(link);
  210. expect(out?.protocol).toBe('hysteria');
  211. expect(out?.tag).toBe('imported-hy2');
  212. const settings = out?.settings as { address: string; port: number; version: number };
  213. expect(settings.address).toBe('srv.example');
  214. expect(settings.port).toBe(443);
  215. expect(settings.version).toBe(2);
  216. const stream = out?.streamSettings as Record<string, unknown>;
  217. const hys = stream.hysteriaSettings as Record<string, unknown>;
  218. expect(hys.auth).toBe('auth-secret');
  219. expect((stream.tlsSettings as Record<string, unknown>).serverName).toBe('example.com');
  220. });
  221. it('also accepts hy2:// alias', () => {
  222. const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com');
  223. expect(out?.protocol).toBe('hysteria');
  224. });
  225. });
  226. describe('parseOutboundLink dispatcher', () => {
  227. it('dispatches vmess via base64 JSON', () => {
  228. const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' };
  229. const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
  230. expect(parseOutboundLink(link)?.protocol).toBe('vmess');
  231. });
  232. it('dispatches vless via URL', () => {
  233. expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless');
  234. });
  235. it('returns null for an unknown scheme', () => {
  236. expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
  237. });
  238. it('returns null for empty input', () => {
  239. expect(parseOutboundLink('')).toBeNull();
  240. expect(parseOutboundLink(' ')).toBeNull();
  241. });
  242. });