outbound-form-adapter.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. import { describe, expect, it } from 'vitest';
  2. import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
  3. // Round-trip parity: wire → form → wire should preserve the legacy
  4. // Outbound.fromJson(...).toJson() output shape for each protocol's quirks.
  5. // Spot-checking the cases the modal exercised in v0.x — vmess vnext flatten,
  6. // vless reverse-wrap, wireguard address csv ↔ array, freedom finalRules
  7. // emission, blackhole type wrap, dns rule normalization, mux gating.
  8. describe('outbound-form-adapter: round-trip', () => {
  9. it('vmess flattens vnext to address/port/id/security and re-nests', () => {
  10. const wire = {
  11. protocol: 'vmess',
  12. tag: 'outbound-vmess',
  13. settings: {
  14. vnext: [
  15. {
  16. address: '1.2.3.4',
  17. port: 443,
  18. users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
  19. },
  20. ],
  21. },
  22. };
  23. const form = rawOutboundToFormValues(wire);
  24. expect(form.protocol).toBe('vmess');
  25. if (form.protocol === 'vmess') {
  26. expect(form.settings.address).toBe('1.2.3.4');
  27. expect(form.settings.port).toBe(443);
  28. expect(form.settings.id).toBe('11111111-2222-4333-8444-555555555555');
  29. expect(form.settings.security).toBe('auto');
  30. }
  31. const back = formValuesToWirePayload(form);
  32. expect(back).toMatchObject({
  33. protocol: 'vmess',
  34. tag: 'outbound-vmess',
  35. settings: {
  36. vnext: [
  37. {
  38. address: '1.2.3.4',
  39. port: 443,
  40. users: [{ id: '11111111-2222-4333-8444-555555555555', security: 'auto' }],
  41. },
  42. ],
  43. },
  44. });
  45. });
  46. it('vless preserves flat shape and emits reverse only when reverseTag is set', () => {
  47. const wire = {
  48. protocol: 'vless',
  49. tag: 'out-vless',
  50. settings: {
  51. address: 'srv.example',
  52. port: 8443,
  53. id: '11111111-2222-4333-8444-555555555555',
  54. flow: 'xtls-rprx-vision',
  55. encryption: 'none',
  56. },
  57. };
  58. const form = rawOutboundToFormValues(wire);
  59. expect(form.protocol).toBe('vless');
  60. if (form.protocol === 'vless') {
  61. expect(form.settings.reverseTag).toBe('');
  62. }
  63. const back = formValuesToWirePayload(form);
  64. expect(back.settings).not.toHaveProperty('reverse');
  65. expect(back.settings).toMatchObject({
  66. address: 'srv.example',
  67. port: 8443,
  68. id: '11111111-2222-4333-8444-555555555555',
  69. flow: 'xtls-rprx-vision',
  70. encryption: 'none',
  71. });
  72. });
  73. it('vless preserves a non-none encryption value (post-quantum)', () => {
  74. const enc = 'mlkem768x25519plus.native.0rtt.G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
  75. const wire = {
  76. protocol: 'vless',
  77. settings: {
  78. address: 'srv',
  79. port: 443,
  80. id: '11111111-2222-4333-8444-555555555555',
  81. flow: '',
  82. encryption: enc,
  83. },
  84. };
  85. const form = rawOutboundToFormValues(wire);
  86. if (form.protocol === 'vless') {
  87. expect(form.settings.encryption).toBe(enc);
  88. }
  89. expect((formValuesToWirePayload(form).settings as Record<string, unknown>).encryption).toBe(
  90. enc,
  91. );
  92. });
  93. it('vless emits reverse + sniffing when reverseTag is set', () => {
  94. const wire = {
  95. protocol: 'vless',
  96. settings: {
  97. address: 'srv',
  98. port: 8443,
  99. id: '11111111-2222-4333-8444-555555555555',
  100. flow: '',
  101. encryption: 'none',
  102. reverse: { tag: 'rev-1', sniffing: { enabled: true, destOverride: ['tls'] } },
  103. },
  104. };
  105. const form = rawOutboundToFormValues(wire);
  106. if (form.protocol === 'vless') {
  107. expect(form.settings.reverseTag).toBe('rev-1');
  108. expect(form.settings.reverseSniffing.enabled).toBe(true);
  109. expect(form.settings.reverseSniffing.destOverride).toEqual(['tls']);
  110. }
  111. const back = formValuesToWirePayload(form);
  112. const settings = back.settings as Record<string, unknown>;
  113. expect(settings.reverse).toMatchObject({ tag: 'rev-1' });
  114. });
  115. it('vless does not emit testpre/testseed unless flow is vision', () => {
  116. const wire = {
  117. protocol: 'vless',
  118. settings: {
  119. address: 'srv',
  120. port: 443,
  121. id: '11111111-2222-4333-8444-555555555555',
  122. flow: '',
  123. encryption: 'none',
  124. testpre: 5,
  125. testseed: [1, 2, 3, 4],
  126. },
  127. };
  128. const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
  129. expect(back.settings).not.toHaveProperty('testpre');
  130. expect(back.settings).not.toHaveProperty('testseed');
  131. });
  132. it('trojan flattens servers[0] and re-nests', () => {
  133. const wire = {
  134. protocol: 'trojan',
  135. settings: { servers: [{ address: 's', port: 443, password: 'pw' }] },
  136. };
  137. const form = rawOutboundToFormValues(wire);
  138. if (form.protocol === 'trojan') {
  139. expect(form.settings).toEqual({ address: 's', port: 443, password: 'pw' });
  140. }
  141. expect(formValuesToWirePayload(form).settings).toEqual({
  142. servers: [{ address: 's', port: 443, password: 'pw' }],
  143. });
  144. });
  145. it('shadowsocks preserves uot + UoTVersion', () => {
  146. const wire = {
  147. protocol: 'shadowsocks',
  148. settings: {
  149. servers: [
  150. {
  151. address: 's',
  152. port: 443,
  153. password: 'pw',
  154. method: '2022-blake3-aes-128-gcm',
  155. uot: true,
  156. UoTVersion: 2,
  157. },
  158. ],
  159. },
  160. };
  161. const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
  162. expect(back.settings).toMatchObject({
  163. servers: [{ uot: true, UoTVersion: 2 }],
  164. });
  165. });
  166. it('socks emits users:[] when user is empty, users:[{...}] when set', () => {
  167. const noUser = formValuesToWirePayload(
  168. rawOutboundToFormValues({
  169. protocol: 'socks',
  170. settings: { servers: [{ address: 's', port: 1080 }] },
  171. }),
  172. );
  173. expect(noUser.settings).toMatchObject({ servers: [{ users: [] }] });
  174. const withUser = formValuesToWirePayload(
  175. rawOutboundToFormValues({
  176. protocol: 'socks',
  177. settings: { servers: [{ address: 's', port: 1080, users: [{ user: 'u', pass: 'p' }] }] },
  178. }),
  179. );
  180. expect(withUser.settings).toMatchObject({
  181. servers: [{ users: [{ user: 'u', pass: 'p' }] }],
  182. });
  183. });
  184. it('http preserves top-level settings.headers across wire → form → wire (#5519)', () => {
  185. const headers = { 'X-T5-Auth': '683556433', Host: '153.3.236.22:443' };
  186. const form = rawOutboundToFormValues({
  187. protocol: 'http',
  188. tag: 'h',
  189. settings: { servers: [{ address: 'a', port: 443, users: [] }], headers },
  190. });
  191. expect(form.protocol).toBe('http');
  192. if (form.protocol === 'http') {
  193. expect(form.settings.headers).toEqual(headers);
  194. }
  195. const back = formValuesToWirePayload(form);
  196. expect(back.settings).toMatchObject({ headers });
  197. });
  198. it('http omits headers when empty', () => {
  199. const back = formValuesToWirePayload(
  200. rawOutboundToFormValues({
  201. protocol: 'http',
  202. settings: { servers: [{ address: 'a', port: 8080, users: [] }] },
  203. }),
  204. );
  205. expect(back.settings).not.toHaveProperty('headers');
  206. });
  207. it('wireguard csv-joins address and reserved on read, splits on write', () => {
  208. const wire = {
  209. protocol: 'wireguard',
  210. settings: {
  211. mtu: 1420,
  212. secretKey: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
  213. address: ['10.0.0.1', 'fd00::1'],
  214. peers: [
  215. { publicKey: 'pk', allowedIPs: ['0.0.0.0/0'], endpoint: 'e:51820', preSharedKey: 'psk' },
  216. ],
  217. reserved: [1, 2, 3],
  218. noKernelTun: false,
  219. },
  220. };
  221. const form = rawOutboundToFormValues(wire);
  222. if (form.protocol === 'wireguard') {
  223. expect(form.settings.address).toBe('10.0.0.1,fd00::1');
  224. expect(form.settings.reserved).toBe('1,2,3');
  225. expect(form.settings.peers[0].psk).toBe('psk');
  226. }
  227. const back = formValuesToWirePayload(form);
  228. expect(back.settings).toMatchObject({
  229. address: ['10.0.0.1', 'fd00::1'],
  230. reserved: [1, 2, 3],
  231. peers: [{ preSharedKey: 'psk' }],
  232. });
  233. });
  234. it('blackhole wraps type into {response:{type}} and omits when empty', () => {
  235. const empty = formValuesToWirePayload(
  236. rawOutboundToFormValues({
  237. protocol: 'blackhole',
  238. settings: {},
  239. }),
  240. );
  241. expect(empty.settings).toEqual({ response: undefined });
  242. const withType = formValuesToWirePayload(
  243. rawOutboundToFormValues({
  244. protocol: 'blackhole',
  245. settings: { response: { type: 'http' } },
  246. }),
  247. );
  248. expect(withType.settings).toEqual({ response: { type: 'http' } });
  249. });
  250. it('blackhole carries customResponseData only for the custom response type', () => {
  251. const custom = formValuesToWirePayload(
  252. rawOutboundToFormValues({
  253. protocol: 'blackhole',
  254. settings: { response: { type: 'custom', customResponseData: 'SFRUUC8xLjEgNDAz' } },
  255. }),
  256. );
  257. expect(custom.settings).toEqual({
  258. response: { type: 'custom', customResponseData: 'SFRUUC8xLjEgNDAz' },
  259. });
  260. const http = formValuesToWirePayload(
  261. rawOutboundToFormValues({
  262. protocol: 'blackhole',
  263. settings: { response: { type: 'http', customResponseData: 'ignored' } },
  264. }),
  265. );
  266. expect(http.settings).toEqual({ response: { type: 'http' } });
  267. });
  268. it('wireguard csv-joins remoteDNS on read and splits it on write', () => {
  269. const wire = {
  270. protocol: 'wireguard',
  271. settings: {
  272. secretKey: 'YFVmTVCBsLxXJCe4i+jK8PgD3S6vUqfZ4Zl0JVNDfHA=',
  273. remoteDNS: ['1.1.1.1', '2606:4700:4700::1111'],
  274. peers: [{ publicKey: 'pk', endpoint: 'wg.example.com:51820' }],
  275. },
  276. };
  277. const form = rawOutboundToFormValues(wire);
  278. if (form.protocol === 'wireguard') {
  279. expect(form.settings.remoteDNS).toBe('1.1.1.1,2606:4700:4700::1111');
  280. }
  281. const back = formValuesToWirePayload(form);
  282. expect((back.settings as { remoteDNS?: string[] }).remoteDNS).toEqual([
  283. '1.1.1.1',
  284. '2606:4700:4700::1111',
  285. ]);
  286. const unset = formValuesToWirePayload(
  287. rawOutboundToFormValues({
  288. protocol: 'wireguard',
  289. settings: { secretKey: wire.settings.secretKey, peers: wire.settings.peers },
  290. }),
  291. );
  292. expect((unset.settings as { remoteDNS?: string[] }).remoteDNS).toBeUndefined();
  293. });
  294. it('dns rules normalize qType numeric strings, split domains, carry rCode', () => {
  295. const wire = {
  296. protocol: 'dns',
  297. settings: {
  298. rewriteNetwork: 'udp',
  299. rewriteAddress: '1.1.1.1',
  300. rewritePort: 53,
  301. rules: [
  302. { action: 'direct', qType: 'A,AAAA', domain: ['example.com', 'ext.org'] },
  303. { action: 'return', qType: 28, domain: 'blocked.com', rCode: 3 },
  304. ],
  305. },
  306. };
  307. const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
  308. const settings = back.settings as Record<string, unknown>;
  309. const rules = settings.rules as Array<Record<string, unknown>>;
  310. expect(rules[0]).toEqual({
  311. action: 'direct',
  312. qType: 'A,AAAA',
  313. domain: ['example.com', 'ext.org'],
  314. });
  315. expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
  316. });
  317. it('dns rules read the legacy qtype wire key for back-compat', () => {
  318. const wire = {
  319. protocol: 'dns',
  320. settings: { rules: [{ action: 'direct', qtype: 'TXT' }] },
  321. };
  322. const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
  323. const rules = (back.settings as Record<string, unknown>).rules as Array<
  324. Record<string, unknown>
  325. >;
  326. expect(rules[0]).toEqual({ action: 'direct', qType: 'TXT' });
  327. });
  328. it('freedom emits domainStrategy/redirect/fragment conditionally', () => {
  329. const empty = formValuesToWirePayload(
  330. rawOutboundToFormValues({
  331. protocol: 'freedom',
  332. settings: {},
  333. }),
  334. );
  335. expect(empty.settings).toEqual({
  336. domainStrategy: undefined,
  337. redirect: undefined,
  338. fragment: undefined,
  339. noises: undefined,
  340. finalRules: undefined,
  341. });
  342. const filled = formValuesToWirePayload(
  343. rawOutboundToFormValues({
  344. protocol: 'freedom',
  345. settings: {
  346. domainStrategy: 'UseIPv4',
  347. redirect: '1.1.1.1',
  348. userLevel: 3,
  349. proxyProtocol: 2,
  350. fragment: { packets: 'tlshello', length: '100-200' },
  351. noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
  352. },
  353. }),
  354. );
  355. expect(filled.settings).toMatchObject({
  356. domainStrategy: 'UseIPv4',
  357. redirect: '1.1.1.1',
  358. userLevel: 3,
  359. proxyProtocol: 2,
  360. fragment: { packets: 'tlshello', length: '100-200' },
  361. noises: [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ipv4' }],
  362. });
  363. });
  364. it('freedom tolerates settings without a fragment object (issue #4686)', () => {
  365. const values = {
  366. protocol: 'freedom',
  367. tag: 'direct',
  368. settings: {
  369. domainStrategy: '',
  370. redirect: '',
  371. proxyProtocol: 0,
  372. noises: [],
  373. finalRules: [
  374. { action: 'block', network: '', port: '', ip: ['geoip:private'], blockDelay: '' },
  375. ],
  376. },
  377. } as unknown as Parameters<typeof formValuesToWirePayload>[0];
  378. expect(() => formValuesToWirePayload(values)).not.toThrow();
  379. const back = formValuesToWirePayload(values);
  380. expect((back.settings as { fragment?: unknown }).fragment).toBeUndefined();
  381. expect((back.settings as { finalRules?: unknown[] }).finalRules).toHaveLength(1);
  382. });
  383. it('freedom omits proxyProtocol when disabled (0)', () => {
  384. const round = formValuesToWirePayload(
  385. rawOutboundToFormValues({
  386. protocol: 'freedom',
  387. settings: { proxyProtocol: 0 },
  388. }),
  389. );
  390. expect((round.settings as { proxyProtocol?: number }).proxyProtocol).toBeUndefined();
  391. });
  392. it('mux is only emitted when enabled AND protocol/network/flow allow it', () => {
  393. // Disabled mux: omitted
  394. const disabled = formValuesToWirePayload(
  395. rawOutboundToFormValues({
  396. protocol: 'vless',
  397. settings: {
  398. address: 's',
  399. port: 443,
  400. id: '11111111-2222-4333-8444-555555555555',
  401. flow: '',
  402. encryption: 'none',
  403. },
  404. mux: { enabled: false },
  405. }),
  406. );
  407. expect(disabled).not.toHaveProperty('mux');
  408. // Enabled mux on vless without flow: emitted
  409. const enabled = formValuesToWirePayload(
  410. rawOutboundToFormValues({
  411. protocol: 'vless',
  412. settings: {
  413. address: 's',
  414. port: 443,
  415. id: '11111111-2222-4333-8444-555555555555',
  416. flow: '',
  417. encryption: 'none',
  418. },
  419. mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' },
  420. }),
  421. );
  422. expect(enabled.mux).toMatchObject({ enabled: true });
  423. // Enabled mux on vless with vision flow: gated out
  424. const withFlow = formValuesToWirePayload(
  425. rawOutboundToFormValues({
  426. protocol: 'vless',
  427. settings: {
  428. address: 's',
  429. port: 443,
  430. id: '11111111-2222-4333-8444-555555555555',
  431. flow: 'xtls-rprx-vision',
  432. encryption: 'none',
  433. },
  434. mux: { enabled: true },
  435. }),
  436. );
  437. expect(withFlow).not.toHaveProperty('mux');
  438. // Freedom (non-mux protocol): gated out even if enabled
  439. const freedom = formValuesToWirePayload(
  440. rawOutboundToFormValues({
  441. protocol: 'freedom',
  442. settings: {},
  443. mux: { enabled: true },
  444. }),
  445. );
  446. expect(freedom).not.toHaveProperty('mux');
  447. });
  448. it('hysteria preserves address/port/version literal 2', () => {
  449. const back = formValuesToWirePayload(
  450. rawOutboundToFormValues({
  451. protocol: 'hysteria',
  452. settings: { address: 'h.example', port: 8443, version: 2 },
  453. }),
  454. );
  455. expect(back.settings).toEqual({ address: 'h.example', port: 8443, version: 2 });
  456. });
  457. it('loopback inboundTag round-trips', () => {
  458. const back = formValuesToWirePayload(
  459. rawOutboundToFormValues({
  460. protocol: 'loopback',
  461. settings: { inboundTag: 'tagged-inbound' },
  462. }),
  463. );
  464. expect(back.settings).toEqual({ inboundTag: 'tagged-inbound' });
  465. });
  466. it('loopback omits sniffing when disabled', () => {
  467. const form = rawOutboundToFormValues({
  468. protocol: 'loopback',
  469. settings: { inboundTag: 'tagged-inbound' },
  470. });
  471. if (form.protocol === 'loopback') {
  472. expect(form.settings.sniffing.enabled).toBe(false);
  473. }
  474. const back = formValuesToWirePayload(form);
  475. expect(back.settings).not.toHaveProperty('sniffing');
  476. });
  477. it('loopback round-trips sniffing when enabled', () => {
  478. const wire = {
  479. protocol: 'loopback',
  480. settings: {
  481. inboundTag: 'tagged-inbound',
  482. sniffing: { enabled: true, destOverride: ['tls', 'http'], routeOnly: true },
  483. },
  484. };
  485. const form = rawOutboundToFormValues(wire);
  486. if (form.protocol === 'loopback') {
  487. expect(form.settings.sniffing.enabled).toBe(true);
  488. expect(form.settings.sniffing.destOverride).toEqual(['tls', 'http']);
  489. expect(form.settings.sniffing.routeOnly).toBe(true);
  490. }
  491. const back = formValuesToWirePayload(form);
  492. const sniffing = (back.settings as Record<string, unknown>).sniffing as Record<string, unknown>;
  493. expect(sniffing.enabled).toBe(true);
  494. expect(sniffing.destOverride).toEqual(['tls', 'http']);
  495. expect(sniffing.routeOnly).toBe(true);
  496. });
  497. it('unknown protocol falls back to vless without throwing', () => {
  498. const form = rawOutboundToFormValues({ protocol: 'mysterious', settings: {} });
  499. expect(form.protocol).toBe('vless');
  500. });
  501. });
  502. describe('outbound-form-adapter: targetStrategy', () => {
  503. it('round-trips a top-level targetStrategy', () => {
  504. const back = formValuesToWirePayload(
  505. rawOutboundToFormValues({
  506. protocol: 'vless',
  507. settings: {
  508. address: 's',
  509. port: 443,
  510. id: '11111111-2222-4333-8444-555555555555',
  511. flow: '',
  512. encryption: 'none',
  513. },
  514. targetStrategy: 'ForceIPv6v4',
  515. }),
  516. );
  517. expect(back.targetStrategy).toBe('ForceIPv6v4');
  518. });
  519. it('normalizes wire case to the canonical spelling (core matches case-insensitively)', () => {
  520. const form = rawOutboundToFormValues({
  521. protocol: 'freedom',
  522. settings: {},
  523. targetStrategy: 'useipv4v6',
  524. });
  525. expect(form.targetStrategy).toBe('UseIPv4v6');
  526. });
  527. it('omits targetStrategy when unset and drops unknown values', () => {
  528. const unset = formValuesToWirePayload(
  529. rawOutboundToFormValues({
  530. protocol: 'freedom',
  531. settings: {},
  532. }),
  533. );
  534. expect(unset).not.toHaveProperty('targetStrategy');
  535. const invalid = formValuesToWirePayload(
  536. rawOutboundToFormValues({
  537. protocol: 'freedom',
  538. settings: {},
  539. targetStrategy: 'UseIPv5',
  540. }),
  541. );
  542. expect(invalid).not.toHaveProperty('targetStrategy');
  543. });
  544. it('freedom prefers settings.targetStrategy over domainStrategy and emits the legacy key', () => {
  545. const form = rawOutboundToFormValues({
  546. protocol: 'freedom',
  547. settings: { targetStrategy: 'UseIPv6', domainStrategy: 'UseIPv4' },
  548. });
  549. if (form.protocol === 'freedom') {
  550. expect(form.settings.domainStrategy).toBe('UseIPv6');
  551. }
  552. const back = formValuesToWirePayload(form);
  553. expect(back.settings).toMatchObject({ domainStrategy: 'UseIPv6' });
  554. expect(back.settings).not.toHaveProperty('targetStrategy');
  555. });
  556. });
  557. describe('outbound-form-adapter: xhttp xmux toggle', () => {
  558. const xmuxWire = {
  559. protocol: 'vless',
  560. tag: 'out-xhttp',
  561. settings: {
  562. address: 's',
  563. port: 443,
  564. id: '11111111-2222-4333-8444-555555555555',
  565. flow: '',
  566. encryption: 'none',
  567. },
  568. streamSettings: {
  569. network: 'xhttp',
  570. security: 'none',
  571. xhttpSettings: {
  572. path: '/',
  573. host: '',
  574. mode: '',
  575. xPaddingBytes: '100-1000',
  576. scMaxEachPostBytes: '1000000',
  577. xmux: {
  578. maxConcurrency: '11',
  579. maxConnections: '1',
  580. hMaxRequestTimes: '1',
  581. hMaxReusableSecs: '1',
  582. },
  583. },
  584. },
  585. };
  586. it('derives enableXmux from a saved xmux object and backfills missing knobs', () => {
  587. const form = rawOutboundToFormValues(xmuxWire);
  588. const stream = form.streamSettings as Record<string, unknown>;
  589. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  590. expect(xhttp.enableXmux).toBe(true);
  591. expect(xhttp.xmux).toMatchObject({
  592. maxConcurrency: '11',
  593. maxConnections: '1',
  594. hMaxRequestTimes: '1',
  595. hMaxReusableSecs: '1',
  596. cMaxReuseTimes: 0,
  597. hKeepAlivePeriod: 0,
  598. });
  599. });
  600. it('round-trips xmux on save, strips enableXmux, and enforces xmux exclusivity', () => {
  601. const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
  602. const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  603. string,
  604. unknown
  605. >;
  606. expect(xhttp).not.toHaveProperty('enableXmux');
  607. const xmux = xhttp.xmux as Record<string, unknown>;
  608. // xray-core rejects maxConnections + maxConcurrency together; the
  609. // explicit maxConnections wins and maxConcurrency is dropped.
  610. expect(xmux).not.toHaveProperty('maxConcurrency');
  611. expect(xmux).toMatchObject({
  612. maxConnections: '1',
  613. hMaxRequestTimes: '1',
  614. hMaxReusableSecs: '1',
  615. });
  616. });
  617. it('drops xmux on save when the toggle is off', () => {
  618. const form = rawOutboundToFormValues(xmuxWire);
  619. const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  620. string,
  621. unknown
  622. >;
  623. xhttp.enableXmux = false;
  624. const back = formValuesToWirePayload(form);
  625. const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  626. string,
  627. unknown
  628. >;
  629. expect(wireXhttp).not.toHaveProperty('xmux');
  630. });
  631. it('keeps a saved maxConcurrency through a load/re-save round-trip', () => {
  632. const wire = {
  633. ...xmuxWire,
  634. streamSettings: {
  635. ...xmuxWire.streamSettings,
  636. xhttpSettings: {
  637. ...xmuxWire.streamSettings.xhttpSettings,
  638. xmux: { maxConcurrency: '1-2' },
  639. },
  640. },
  641. };
  642. const form = rawOutboundToFormValues(wire);
  643. const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  644. string,
  645. unknown
  646. >;
  647. const xmux = xhttp.xmux as Record<string, unknown>;
  648. expect(xmux.maxConcurrency).toBe('1-2');
  649. expect(xmux.maxConnections).toBe(0);
  650. const back = formValuesToWirePayload(form);
  651. const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<
  652. string,
  653. unknown
  654. >;
  655. const wireXmux = wireXhttp.xmux as Record<string, unknown>;
  656. expect(wireXmux.maxConcurrency).toBe('1-2');
  657. });
  658. });
  659. describe('outbound-form-adapter: full optional-block round-trip', () => {
  660. const wire = {
  661. protocol: 'vless',
  662. settings: {
  663. address: '1',
  664. port: 443,
  665. id: '1',
  666. flow: '',
  667. encryption: 'none',
  668. reverse: {
  669. tag: '1',
  670. sniffing: {
  671. enabled: true,
  672. destOverride: ['http', 'tls', 'quic', 'fakedns'],
  673. metadataOnly: true,
  674. routeOnly: true,
  675. ipsExcluded: ['1'],
  676. domainsExcluded: ['1'],
  677. },
  678. },
  679. },
  680. tag: '1',
  681. streamSettings: {
  682. network: 'tcp',
  683. tcpSettings: {
  684. header: {
  685. type: 'http',
  686. request: { version: '1.1', method: 'GET', path: ['/'], headers: { '1': ['1'] } },
  687. response: { version: '1.1', status: '200', reason: 'OK', headers: { '1': ['1'] } },
  688. },
  689. },
  690. security: 'none',
  691. sockopt: {
  692. tcpFastOpen: true,
  693. customSockopt: [{ type: 'int', level: '6', opt: '1', value: '1' }],
  694. },
  695. finalmask: {
  696. tcp: [
  697. {
  698. type: 'fragment',
  699. settings: { packets: '1-3', length: '1', delay: '1', maxSplit: '1' },
  700. },
  701. ],
  702. },
  703. },
  704. sendThrough: '1',
  705. mux: { enabled: true, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' },
  706. };
  707. it('preserves sockopt, finalmask, mux, and reverse excludes', () => {
  708. const back = formValuesToWirePayload(rawOutboundToFormValues(wire));
  709. const settings = back.settings as Record<string, unknown>;
  710. const sniffing = (settings.reverse as Record<string, unknown>).sniffing as Record<
  711. string,
  712. unknown
  713. >;
  714. expect(sniffing.ipsExcluded).toEqual(['1']);
  715. expect(sniffing.domainsExcluded).toEqual(['1']);
  716. const stream = back.streamSettings as Record<string, unknown>;
  717. expect(stream.sockopt).toMatchObject({ tcpFastOpen: true });
  718. expect((stream.sockopt as Record<string, unknown>).customSockopt).toHaveLength(1);
  719. expect(stream.finalmask).toMatchObject({ tcp: [{ type: 'fragment' }] });
  720. expect(back.mux).toMatchObject({ enabled: true });
  721. });
  722. });