stream-wire-normalize.test.ts 15 KB

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