stream-wire-normalize.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. /// <reference types="vite/client" />
  2. import { describe, expect, it } from 'vitest';
  3. import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
  4. import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
  5. import {
  6. normalizeSockoptForWire,
  7. normalizeStreamSettingsForWire,
  8. normalizeXhttpForWire,
  9. validateRealityTarget,
  10. } from '@/lib/xray/stream-wire-normalize';
  11. import { InboundFormSchema } from '@/schemas/forms/inbound-form';
  12. import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
  13. import type { InboundFormValues } from '@/schemas/forms/inbound-form';
  14. import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
  15. describe('validateRealityTarget', () => {
  16. it('accepts host:port and bare port', () => {
  17. expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
  18. expect(validateRealityTarget('443')).toBeUndefined();
  19. });
  20. it('rejects host without port', () => {
  21. expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
  22. expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
  23. });
  24. });
  25. describe('normalizeXhttpForWire stream-one', () => {
  26. it('drops packet-up and stream-up-only fields on inbound', () => {
  27. const out = normalizeXhttpForWire({
  28. path: '/app',
  29. host: 'play.google.com',
  30. mode: 'stream-one',
  31. xPaddingBytes: '100-1000',
  32. scMaxEachPostBytes: '1000000',
  33. scMinPostsIntervalMs: '30',
  34. scMaxBufferedPosts: 30,
  35. scStreamUpServerSecs: '20-80',
  36. enableXmux: false,
  37. headers: {},
  38. }, 'inbound');
  39. expect(out).toMatchObject({
  40. path: '/app',
  41. host: 'play.google.com',
  42. mode: 'stream-one',
  43. xPaddingBytes: '100-1000',
  44. });
  45. expect(out).not.toHaveProperty('scMaxEachPostBytes');
  46. expect(out).not.toHaveProperty('scMinPostsIntervalMs');
  47. expect(out).not.toHaveProperty('scMaxBufferedPosts');
  48. expect(out).not.toHaveProperty('scStreamUpServerSecs');
  49. expect(out).not.toHaveProperty('enableXmux');
  50. expect(out).not.toHaveProperty('headers');
  51. });
  52. it('preserves non-default scMinPostsIntervalMs on inbound for subscriptions', () => {
  53. const out = normalizeXhttpForWire({
  54. path: '/app',
  55. mode: 'packet-up',
  56. scMinPostsIntervalMs: '50-150',
  57. enableXmux: false,
  58. }, 'inbound');
  59. expect(out.scMinPostsIntervalMs).toBe('50-150');
  60. });
  61. it('strips empty scMinPostsIntervalMs on inbound', () => {
  62. const out = normalizeXhttpForWire({
  63. path: '/app',
  64. mode: 'packet-up',
  65. scMinPostsIntervalMs: '',
  66. enableXmux: false,
  67. }, 'inbound');
  68. expect(out).not.toHaveProperty('scMinPostsIntervalMs');
  69. });
  70. it('keeps xmux on outbound stream-one', () => {
  71. const out = normalizeXhttpForWire({
  72. path: '/app',
  73. mode: 'stream-one',
  74. xPaddingBytes: '100-1000',
  75. xmux: { maxConcurrency: '16-32' },
  76. scMaxEachPostBytes: '1000000',
  77. }, 'outbound');
  78. expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
  79. expect(out).not.toHaveProperty('scMaxEachPostBytes');
  80. });
  81. it('keeps inbound xmux when enableXmux is on (stored for subscription extra; stripped from xray config on Go side)', () => {
  82. const out = normalizeXhttpForWire({
  83. path: '/app',
  84. mode: 'auto',
  85. enableXmux: true,
  86. xmux: { maxConcurrency: '16-32' },
  87. }, 'inbound');
  88. expect(out).not.toHaveProperty('enableXmux');
  89. expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
  90. });
  91. it('drops inbound xmux when enableXmux is off', () => {
  92. const out = normalizeXhttpForWire({
  93. path: '/app',
  94. mode: 'auto',
  95. enableXmux: false,
  96. xmux: { maxConcurrency: '16-32' },
  97. }, 'inbound');
  98. expect(out).not.toHaveProperty('enableXmux');
  99. expect(out).not.toHaveProperty('xmux');
  100. });
  101. // xray-core rejects a config with both maxConnections and maxConcurrency.
  102. it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
  103. const out = normalizeXhttpForWire({
  104. path: '/app',
  105. mode: 'auto',
  106. enableXmux: true,
  107. xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
  108. }, 'inbound');
  109. const xmux = out.xmux as Record<string, unknown>;
  110. expect(xmux).not.toHaveProperty('maxConcurrency');
  111. expect(xmux.maxConnections).toBe(4);
  112. expect(xmux.hKeepAlivePeriod).toBe(30);
  113. });
  114. it('keeps maxConcurrency when maxConnections is 0/unset', () => {
  115. const out = normalizeXhttpForWire({
  116. path: '/app',
  117. mode: 'stream-one',
  118. xmux: { maxConcurrency: '16-32', maxConnections: 0 },
  119. }, 'outbound');
  120. const xmux = out.xmux as Record<string, unknown>;
  121. expect(xmux.maxConcurrency).toBe('16-32');
  122. expect(xmux.maxConnections).toBe(0);
  123. });
  124. it('applies xmux exclusivity on the outbound side too', () => {
  125. const out = normalizeXhttpForWire({
  126. path: '/app',
  127. mode: 'stream-one',
  128. xmux: { maxConcurrency: '16-32', maxConnections: '8' },
  129. }, 'outbound');
  130. const xmux = out.xmux as Record<string, unknown>;
  131. expect(xmux).not.toHaveProperty('maxConcurrency');
  132. expect(xmux.maxConnections).toBe('8');
  133. });
  134. it('defaults xmux maxConnections to 6 (xray-core anti-RKN default) and drops maxConcurrency on the wire', () => {
  135. expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(6);
  136. const out = normalizeXhttpForWire({
  137. path: '/app',
  138. mode: 'stream-one',
  139. enableXmux: true,
  140. xmux: XHttpXmuxSchema.parse({}),
  141. }, 'outbound');
  142. const xmux = out.xmux as Record<string, unknown>;
  143. expect(xmux.maxConnections).toBe(6);
  144. expect(xmux).not.toHaveProperty('maxConcurrency');
  145. });
  146. });
  147. describe('normalizeSockoptForWire', () => {
  148. it('omits doc-example defaults that throttle throughput', () => {
  149. const out = normalizeSockoptForWire({
  150. tcpWindowClamp: 0,
  151. tcpMaxSeg: 0,
  152. tcpUserTimeout: 0,
  153. tcpFastOpen: true,
  154. tcpcongestion: 'bbr',
  155. domainStrategy: 'AsIs',
  156. tproxy: 'off',
  157. mark: 0,
  158. });
  159. expect(out).toEqual({
  160. tcpFastOpen: true,
  161. tcpcongestion: 'bbr',
  162. });
  163. });
  164. it('preserves happyEyeballs on freedom-style outbound', () => {
  165. const out = normalizeSockoptForWire({
  166. domainStrategy: 'UseIP',
  167. happyEyeballs: {
  168. tryDelayMs: 150,
  169. prioritizeIPv6: true,
  170. interleave: 1,
  171. maxConcurrentTry: 4,
  172. },
  173. });
  174. expect(out?.happyEyeballs).toMatchObject({
  175. tryDelayMs: 150,
  176. prioritizeIPv6: true,
  177. });
  178. expect(out?.domainStrategy).toBe('UseIP');
  179. });
  180. it('keeps a freshly toggled happyEyeballs (schema defaults) across the wire round trip', () => {
  181. const out = normalizeSockoptForWire({
  182. happyEyeballs: HappyEyeballsSchema.parse({}),
  183. });
  184. expect(out?.happyEyeballs).toEqual({ tryDelayMs: 250 });
  185. });
  186. it('keeps an explicit tryDelayMs of 0 so it cannot rehydrate as the 250 default', () => {
  187. const out = normalizeSockoptForWire({
  188. happyEyeballs: { tryDelayMs: 0, prioritizeIPv6: true, interleave: 1, maxConcurrentTry: 4 },
  189. });
  190. expect(out?.happyEyeballs).toEqual({ tryDelayMs: 0, prioritizeIPv6: true });
  191. });
  192. });
  193. describe('normalizeStreamSettingsForWire reality', () => {
  194. it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
  195. const out = normalizeStreamSettingsForWire({
  196. network: 'xhttp',
  197. security: 'reality',
  198. realitySettings: {
  199. target: 'play.google.com:443',
  200. privateKey: 'priv',
  201. serverNames: ['play.google.com'],
  202. shortIds: ['abcd'],
  203. settings: {
  204. publicKey: 'pub',
  205. fingerprint: 'chrome',
  206. spiderX: '/',
  207. },
  208. },
  209. }, { side: 'inbound' });
  210. const reality = out.realitySettings as Record<string, unknown>;
  211. expect(reality.target).toBe('play.google.com:443');
  212. expect(reality.privateKey).toBe('priv');
  213. const settings = reality.settings as Record<string, unknown>;
  214. expect(settings.publicKey).toBe('pub');
  215. expect(settings.spiderX).toBe('/');
  216. });
  217. it('passes client realitySettings through unchanged on outbound', () => {
  218. const out = normalizeStreamSettingsForWire({
  219. network: 'xhttp',
  220. security: 'reality',
  221. realitySettings: {
  222. publicKey: 'pub',
  223. fingerprint: 'chrome',
  224. serverName: 'play.google.com',
  225. shortId: 'abcd',
  226. spiderX: '/x',
  227. },
  228. }, { side: 'outbound' });
  229. const reality = out.realitySettings as Record<string, unknown>;
  230. expect(reality.publicKey).toBe('pub');
  231. expect(reality.serverName).toBe('play.google.com');
  232. expect(reality.spiderX).toBe('/x');
  233. });
  234. });
  235. describe('normalizeStreamSettingsForWire tls', () => {
  236. it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
  237. const out = normalizeStreamSettingsForWire({
  238. network: 'hysteria',
  239. security: 'tls',
  240. tlsSettings: {
  241. fingerprint: '',
  242. settings: {
  243. fingerprint: '',
  244. echConfigList: '',
  245. },
  246. },
  247. }, { side: 'inbound' });
  248. const tls = out.tlsSettings as Record<string, unknown>;
  249. const settings = tls.settings as Record<string, unknown>;
  250. expect(tls).not.toHaveProperty('fingerprint');
  251. expect(settings).not.toHaveProperty('fingerprint');
  252. expect(settings.echConfigList).toBe('');
  253. });
  254. });
  255. describe('inbound formValuesToWirePayload integration', () => {
  256. it('emits lean stream-one xhttp + sockopt on save', () => {
  257. const values = {
  258. remark: 't',
  259. enable: true,
  260. port: 443,
  261. listen: '0.0.0.0',
  262. tag: 'in-443',
  263. expiryTime: 0,
  264. sniffing: { enabled: false },
  265. up: 0,
  266. down: 0,
  267. total: 0,
  268. trafficReset: 'never',
  269. lastTrafficResetTime: 0,
  270. nodeId: null,
  271. protocol: 'vless',
  272. settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
  273. streamSettings: {
  274. network: 'xhttp',
  275. security: 'reality',
  276. realitySettings: {
  277. target: 'play.google.com:443',
  278. privateKey: 'priv',
  279. serverNames: ['play.google.com'],
  280. shortIds: ['44003d86dc1e'],
  281. settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
  282. },
  283. xhttpSettings: {
  284. path: '/app',
  285. host: 'play.google.com',
  286. mode: 'stream-one',
  287. xPaddingBytes: '100-1000',
  288. scMaxEachPostBytes: '1000000',
  289. scMinPostsIntervalMs: '30',
  290. enableXmux: false,
  291. },
  292. sockopt: {
  293. tcpWindowClamp: 0,
  294. tcpMaxSeg: 0,
  295. tcpUserTimeout: 0,
  296. tcpFastOpen: true,
  297. tcpcongestion: 'bbr',
  298. },
  299. },
  300. } as InboundFormValues;
  301. const payload = formValuesToWirePayload(values);
  302. const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
  303. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  304. const sockopt = stream.sockopt as Record<string, unknown>;
  305. const reality = stream.realitySettings as Record<string, unknown>;
  306. expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
  307. expect(sockopt).not.toHaveProperty('tcpWindowClamp');
  308. expect(sockopt.tcpFastOpen).toBe(true);
  309. const realitySettings = reality.settings as Record<string, unknown>;
  310. expect(realitySettings.publicKey).toBe('pub');
  311. });
  312. it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => {
  313. const values = {
  314. remark: 'hy2',
  315. enable: true,
  316. port: 443,
  317. listen: '',
  318. tag: 'hy2-443',
  319. expiryTime: 0,
  320. sniffing: { enabled: false },
  321. up: 0,
  322. down: 0,
  323. total: 0,
  324. trafficReset: 'never',
  325. lastTrafficResetTime: 0,
  326. nodeId: null,
  327. protocol: 'hysteria',
  328. settings: { version: 2, clients: [] },
  329. streamSettings: {
  330. network: 'hysteria',
  331. security: 'tls',
  332. hysteriaSettings: {
  333. version: 2,
  334. auth: 'auth',
  335. udpIdleTimeout: 60,
  336. },
  337. tlsSettings: {
  338. alpn: ['h3'],
  339. settings: {
  340. fingerprint: '',
  341. },
  342. },
  343. },
  344. };
  345. const parsed = InboundFormSchema.safeParse(values);
  346. expect(parsed.success).toBe(true);
  347. if (!parsed.success) throw parsed.error;
  348. const payload = formValuesToWirePayload(parsed.data);
  349. const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
  350. const tls = stream.tlsSettings as Record<string, unknown>;
  351. const settings = tls.settings as Record<string, unknown>;
  352. expect(settings).not.toHaveProperty('fingerprint');
  353. });
  354. it('preserves non-default scMinPostsIntervalMs in packet-up inbound wire payload for subscriptions', () => {
  355. const values = {
  356. remark: 't',
  357. enable: true,
  358. port: 443,
  359. listen: '0.0.0.0',
  360. tag: 'in-443',
  361. expiryTime: 0,
  362. sniffing: { enabled: false },
  363. up: 0,
  364. down: 0,
  365. total: 0,
  366. trafficReset: 'never',
  367. lastTrafficResetTime: 0,
  368. nodeId: null,
  369. protocol: 'vless',
  370. settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
  371. streamSettings: {
  372. network: 'xhttp',
  373. security: 'reality',
  374. realitySettings: {
  375. target: 'play.google.com:443',
  376. privateKey: 'priv',
  377. serverNames: ['play.google.com'],
  378. shortIds: ['44003d86dc1e'],
  379. settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
  380. },
  381. xhttpSettings: {
  382. path: '/app',
  383. host: 'play.google.com',
  384. mode: 'packet-up',
  385. scMinPostsIntervalMs: '50-150',
  386. },
  387. sockopt: {},
  388. },
  389. };
  390. const parsed = InboundFormSchema.safeParse(values);
  391. expect(parsed.success).toBe(true);
  392. if (!parsed.success) throw parsed.error;
  393. const payload = formValuesToWirePayload(parsed.data);
  394. const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
  395. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  396. expect(xhttp.scMinPostsIntervalMs).toBe('50-150');
  397. });
  398. it('strips default scMinPostsIntervalMs=30 from inbound wire payload', () => {
  399. const values = {
  400. remark: 't',
  401. enable: true,
  402. port: 443,
  403. listen: '0.0.0.0',
  404. tag: 'in-443',
  405. expiryTime: 0,
  406. sniffing: { enabled: false },
  407. up: 0,
  408. down: 0,
  409. total: 0,
  410. trafficReset: 'never',
  411. lastTrafficResetTime: 0,
  412. nodeId: null,
  413. protocol: 'vless',
  414. settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
  415. streamSettings: {
  416. network: 'xhttp',
  417. security: 'reality',
  418. realitySettings: {
  419. target: 'play.google.com:443',
  420. privateKey: 'priv',
  421. serverNames: ['play.google.com'],
  422. shortIds: ['44003d86dc1e'],
  423. settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
  424. },
  425. xhttpSettings: {
  426. path: '/app',
  427. host: 'play.google.com',
  428. mode: 'packet-up',
  429. scMinPostsIntervalMs: '30',
  430. },
  431. sockopt: {},
  432. },
  433. };
  434. const parsed = InboundFormSchema.safeParse(values);
  435. expect(parsed.success).toBe(true);
  436. if (!parsed.success) throw parsed.error;
  437. const payload = formValuesToWirePayload(parsed.data);
  438. const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
  439. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  440. expect(xhttp).not.toHaveProperty('scMinPostsIntervalMs');
  441. });
  442. });
  443. describe('freedom outbound sockopt wire payload', () => {
  444. it('preserves happyEyeballs on direct freedom outbound', () => {
  445. const wire = outboundToWire({
  446. protocol: 'freedom',
  447. tag: 'direct',
  448. settings: { domainStrategy: 'UseIP' },
  449. streamSettings: {
  450. sockopt: {
  451. domainStrategy: 'UseIP',
  452. happyEyeballs: {
  453. tryDelayMs: 150,
  454. prioritizeIPv6: true,
  455. interleave: 1,
  456. maxConcurrentTry: 4,
  457. },
  458. },
  459. },
  460. } as Parameters<typeof outboundToWire>[0]);
  461. expect(wire.streamSettings).toMatchObject({
  462. sockopt: {
  463. domainStrategy: 'UseIP',
  464. happyEyeballs: {
  465. tryDelayMs: 150,
  466. prioritizeIPv6: true,
  467. },
  468. },
  469. });
  470. });
  471. });