stream-wire-normalize.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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('keeps xmux on outbound stream-one', () => {
  51. const out = normalizeXhttpForWire({
  52. path: '/app',
  53. mode: 'stream-one',
  54. xPaddingBytes: '100-1000',
  55. xmux: { maxConcurrency: '16-32' },
  56. scMaxEachPostBytes: '1000000',
  57. }, 'outbound');
  58. expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
  59. expect(out).not.toHaveProperty('scMaxEachPostBytes');
  60. });
  61. it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
  62. const out = normalizeXhttpForWire({
  63. path: '/app',
  64. mode: 'auto',
  65. enableXmux: true,
  66. xmux: { maxConcurrency: '16-32' },
  67. }, 'inbound');
  68. expect(out).not.toHaveProperty('enableXmux');
  69. expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
  70. });
  71. it('drops inbound xmux when enableXmux is off', () => {
  72. const out = normalizeXhttpForWire({
  73. path: '/app',
  74. mode: 'auto',
  75. enableXmux: false,
  76. xmux: { maxConcurrency: '16-32' },
  77. }, 'inbound');
  78. expect(out).not.toHaveProperty('enableXmux');
  79. expect(out).not.toHaveProperty('xmux');
  80. });
  81. // xray-core rejects a config with both maxConnections and maxConcurrency.
  82. it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
  83. const out = normalizeXhttpForWire({
  84. path: '/app',
  85. mode: 'auto',
  86. enableXmux: true,
  87. xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
  88. }, 'inbound');
  89. const xmux = out.xmux as Record<string, unknown>;
  90. expect(xmux).not.toHaveProperty('maxConcurrency');
  91. expect(xmux.maxConnections).toBe(4);
  92. expect(xmux.hKeepAlivePeriod).toBe(30);
  93. });
  94. it('keeps maxConcurrency when maxConnections is 0/unset', () => {
  95. const out = normalizeXhttpForWire({
  96. path: '/app',
  97. mode: 'stream-one',
  98. xmux: { maxConcurrency: '16-32', maxConnections: 0 },
  99. }, 'outbound');
  100. const xmux = out.xmux as Record<string, unknown>;
  101. expect(xmux.maxConcurrency).toBe('16-32');
  102. expect(xmux.maxConnections).toBe(0);
  103. });
  104. it('applies xmux exclusivity on the outbound side too', () => {
  105. const out = normalizeXhttpForWire({
  106. path: '/app',
  107. mode: 'stream-one',
  108. xmux: { maxConcurrency: '16-32', maxConnections: '8' },
  109. }, 'outbound');
  110. const xmux = out.xmux as Record<string, unknown>;
  111. expect(xmux).not.toHaveProperty('maxConcurrency');
  112. expect(xmux.maxConnections).toBe('8');
  113. });
  114. });
  115. describe('normalizeSockoptForWire', () => {
  116. it('omits doc-example defaults that throttle throughput', () => {
  117. const out = normalizeSockoptForWire({
  118. tcpWindowClamp: 0,
  119. tcpMaxSeg: 0,
  120. tcpUserTimeout: 0,
  121. tcpFastOpen: true,
  122. tcpcongestion: 'bbr',
  123. domainStrategy: 'AsIs',
  124. tproxy: 'off',
  125. mark: 0,
  126. });
  127. expect(out).toEqual({
  128. tcpFastOpen: true,
  129. tcpcongestion: 'bbr',
  130. });
  131. });
  132. it('preserves happyEyeballs on freedom-style outbound', () => {
  133. const out = normalizeSockoptForWire({
  134. domainStrategy: 'UseIP',
  135. happyEyeballs: {
  136. tryDelayMs: 150,
  137. prioritizeIPv6: true,
  138. interleave: 1,
  139. maxConcurrentTry: 4,
  140. },
  141. });
  142. expect(out?.happyEyeballs).toMatchObject({
  143. tryDelayMs: 150,
  144. prioritizeIPv6: true,
  145. });
  146. expect(out?.domainStrategy).toBe('UseIP');
  147. });
  148. });
  149. describe('normalizeStreamSettingsForWire reality', () => {
  150. it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
  151. const out = normalizeStreamSettingsForWire({
  152. network: 'xhttp',
  153. security: 'reality',
  154. realitySettings: {
  155. target: 'play.google.com:443',
  156. privateKey: 'priv',
  157. serverNames: ['play.google.com'],
  158. shortIds: ['abcd'],
  159. settings: {
  160. publicKey: 'pub',
  161. fingerprint: 'chrome',
  162. spiderX: '/',
  163. },
  164. },
  165. }, { side: 'inbound' });
  166. const reality = out.realitySettings as Record<string, unknown>;
  167. expect(reality.target).toBe('play.google.com:443');
  168. expect(reality.privateKey).toBe('priv');
  169. const settings = reality.settings as Record<string, unknown>;
  170. expect(settings.publicKey).toBe('pub');
  171. expect(settings.spiderX).toBe('/');
  172. });
  173. it('passes client realitySettings through unchanged on outbound', () => {
  174. const out = normalizeStreamSettingsForWire({
  175. network: 'xhttp',
  176. security: 'reality',
  177. realitySettings: {
  178. publicKey: 'pub',
  179. fingerprint: 'chrome',
  180. serverName: 'play.google.com',
  181. shortId: 'abcd',
  182. spiderX: '/x',
  183. },
  184. }, { side: 'outbound' });
  185. const reality = out.realitySettings as Record<string, unknown>;
  186. expect(reality.publicKey).toBe('pub');
  187. expect(reality.serverName).toBe('play.google.com');
  188. expect(reality.spiderX).toBe('/x');
  189. });
  190. });
  191. describe('normalizeStreamSettingsForWire tls', () => {
  192. it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
  193. const out = normalizeStreamSettingsForWire({
  194. network: 'hysteria',
  195. security: 'tls',
  196. tlsSettings: {
  197. fingerprint: '',
  198. settings: {
  199. fingerprint: '',
  200. echConfigList: '',
  201. },
  202. },
  203. }, { side: 'inbound' });
  204. const tls = out.tlsSettings as Record<string, unknown>;
  205. const settings = tls.settings as Record<string, unknown>;
  206. expect(tls).not.toHaveProperty('fingerprint');
  207. expect(settings).not.toHaveProperty('fingerprint');
  208. expect(settings.echConfigList).toBe('');
  209. });
  210. });
  211. describe('inbound formValuesToWirePayload integration', () => {
  212. it('emits lean stream-one xhttp + sockopt on save', () => {
  213. const values = {
  214. remark: 't',
  215. enable: true,
  216. port: 443,
  217. listen: '0.0.0.0',
  218. tag: 'in-443',
  219. expiryTime: 0,
  220. sniffing: { enabled: false },
  221. up: 0,
  222. down: 0,
  223. total: 0,
  224. trafficReset: 'never',
  225. lastTrafficResetTime: 0,
  226. nodeId: null,
  227. protocol: 'vless',
  228. settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
  229. streamSettings: {
  230. network: 'xhttp',
  231. security: 'reality',
  232. realitySettings: {
  233. target: 'play.google.com:443',
  234. privateKey: 'priv',
  235. serverNames: ['play.google.com'],
  236. shortIds: ['44003d86dc1e'],
  237. settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
  238. },
  239. xhttpSettings: {
  240. path: '/app',
  241. host: 'play.google.com',
  242. mode: 'stream-one',
  243. xPaddingBytes: '100-1000',
  244. scMaxEachPostBytes: '1000000',
  245. scMinPostsIntervalMs: '30',
  246. enableXmux: false,
  247. },
  248. sockopt: {
  249. tcpWindowClamp: 0,
  250. tcpMaxSeg: 0,
  251. tcpUserTimeout: 0,
  252. tcpFastOpen: true,
  253. tcpcongestion: 'bbr',
  254. },
  255. },
  256. } as InboundFormValues;
  257. const payload = formValuesToWirePayload(values);
  258. const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
  259. const xhttp = stream.xhttpSettings as Record<string, unknown>;
  260. const sockopt = stream.sockopt as Record<string, unknown>;
  261. const reality = stream.realitySettings as Record<string, unknown>;
  262. expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
  263. expect(sockopt).not.toHaveProperty('tcpWindowClamp');
  264. expect(sockopt.tcpFastOpen).toBe(true);
  265. const realitySettings = reality.settings as Record<string, unknown>;
  266. expect(realitySettings.publicKey).toBe('pub');
  267. });
  268. it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => {
  269. const values = {
  270. remark: 'hy2',
  271. enable: true,
  272. port: 443,
  273. listen: '',
  274. tag: 'hy2-443',
  275. expiryTime: 0,
  276. sniffing: { enabled: false },
  277. up: 0,
  278. down: 0,
  279. total: 0,
  280. trafficReset: 'never',
  281. lastTrafficResetTime: 0,
  282. nodeId: null,
  283. protocol: 'hysteria',
  284. settings: { version: 2, clients: [] },
  285. streamSettings: {
  286. network: 'hysteria',
  287. security: 'tls',
  288. hysteriaSettings: {
  289. version: 2,
  290. auth: 'auth',
  291. udpIdleTimeout: 60,
  292. },
  293. tlsSettings: {
  294. alpn: ['h3'],
  295. settings: {
  296. fingerprint: '',
  297. },
  298. },
  299. },
  300. };
  301. const parsed = InboundFormSchema.safeParse(values);
  302. expect(parsed.success).toBe(true);
  303. if (!parsed.success) throw parsed.error;
  304. const payload = formValuesToWirePayload(parsed.data);
  305. const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
  306. const tls = stream.tlsSettings as Record<string, unknown>;
  307. const settings = tls.settings as Record<string, unknown>;
  308. expect(settings).not.toHaveProperty('fingerprint');
  309. });
  310. });
  311. describe('freedom outbound sockopt wire payload', () => {
  312. it('preserves happyEyeballs on direct freedom outbound', () => {
  313. const wire = outboundToWire({
  314. protocol: 'freedom',
  315. tag: 'direct',
  316. settings: { domainStrategy: 'UseIP' },
  317. streamSettings: {
  318. sockopt: {
  319. domainStrategy: 'UseIP',
  320. happyEyeballs: {
  321. tryDelayMs: 150,
  322. prioritizeIPv6: true,
  323. interleave: 1,
  324. maxConcurrentTry: 4,
  325. },
  326. },
  327. },
  328. } as Parameters<typeof outboundToWire>[0]);
  329. expect(wire.streamSettings).toMatchObject({
  330. sockopt: {
  331. domainStrategy: 'UseIP',
  332. happyEyeballs: {
  333. tryDelayMs: 150,
  334. prioritizeIPv6: true,
  335. },
  336. },
  337. });
  338. });
  339. });