stream-wire-normalize.test.ts 9.3 KB

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