inbound-link.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. /// <reference types="vite/client" />
  2. import { describe, expect, it } from 'vitest';
  3. import {
  4. genHysteriaLink,
  5. genInboundLinks,
  6. genShadowsocksLink,
  7. genTrojanLink,
  8. genVlessLink,
  9. genVmessLink,
  10. genWireguardConfig,
  11. genWireguardLink,
  12. preferPublicHost,
  13. resolveAddr,
  14. } from '@/lib/xray/inbound-link';
  15. import { InboundSchema } from '@/schemas/api/inbound';
  16. import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
  17. // Snapshot baseline for the share-link generators. Snapshots were locked
  18. // at the close of the legacy class migration — at that point each
  19. // generator was verified byte-equal to the corresponding legacy Inbound
  20. // class method. Future drift past this baseline is a regression.
  21. const fullFixtures = import.meta.glob<unknown>(
  22. './golden/fixtures/inbound-full/*.json',
  23. { eager: true, import: 'default' },
  24. );
  25. function fixtureName(path: string): string {
  26. const file = path.split('/').pop() ?? path;
  27. return file.replace(/\.json$/, '');
  28. }
  29. function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
  30. return Object.entries(fullFixtures)
  31. .filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
  32. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  33. .sort(([a], [b]) => a.localeCompare(b));
  34. }
  35. describe('genVmessLink', () => {
  36. const fixtures = fixturesForProtocol('vmess');
  37. expect(fixtures.length, 'need at least one vmess full-inbound fixture').toBeGreaterThan(0);
  38. for (const [name, raw] of fixtures) {
  39. it(`${name}: byte-stable`, () => {
  40. const typed = InboundSchema.parse(raw);
  41. const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } }).settings;
  42. const client = settings.clients[0];
  43. const link = genVmessLink({
  44. inbound: typed,
  45. address: 'example.test',
  46. port: typed.port,
  47. forceTls: 'same',
  48. remark: 'parity-test',
  49. clientId: client.id,
  50. security: client.security as never,
  51. externalProxy: null,
  52. });
  53. expect(link).toMatchSnapshot();
  54. });
  55. }
  56. });
  57. describe('genVlessLink', () => {
  58. const fixtures = fixturesForProtocol('vless');
  59. expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
  60. for (const [name, raw] of fixtures) {
  61. it(`${name}: byte-stable`, () => {
  62. const typed = InboundSchema.parse(raw);
  63. const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } }).settings;
  64. const client = settings.clients[0];
  65. const link = genVlessLink({
  66. inbound: typed,
  67. address: 'example.test',
  68. port: typed.port,
  69. forceTls: 'same',
  70. remark: 'parity-test',
  71. clientId: client.id,
  72. flow: client.flow as never,
  73. externalProxy: null,
  74. });
  75. expect(link).toMatchSnapshot();
  76. });
  77. }
  78. });
  79. describe('genTrojanLink', () => {
  80. const fixtures = fixturesForProtocol('trojan');
  81. expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);
  82. for (const [name, raw] of fixtures) {
  83. it(`${name}: byte-stable`, () => {
  84. const typed = InboundSchema.parse(raw);
  85. const settings = (raw as { settings: { clients: Array<{ password: string }> } }).settings;
  86. const client = settings.clients[0];
  87. const link = genTrojanLink({
  88. inbound: typed,
  89. address: 'example.test',
  90. port: typed.port,
  91. forceTls: 'same',
  92. remark: 'parity-test',
  93. clientPassword: client.password,
  94. externalProxy: null,
  95. });
  96. expect(link).toMatchSnapshot();
  97. });
  98. }
  99. });
  100. describe('genHysteriaLink', () => {
  101. const fixtures = fixturesForProtocol('hysteria');
  102. expect(fixtures.length, 'need at least one hysteria full-inbound fixture').toBeGreaterThan(0);
  103. for (const [name, raw] of fixtures) {
  104. it(`${name}: byte-stable`, () => {
  105. const typed = InboundSchema.parse(raw);
  106. const settings = (raw as { settings: { clients: Array<{ auth: string }> } }).settings;
  107. const client = settings.clients[0];
  108. const link = genHysteriaLink({
  109. inbound: typed,
  110. address: 'example.test',
  111. port: typed.port,
  112. remark: 'parity-test',
  113. clientAuth: client.auth,
  114. });
  115. expect(link).toMatchSnapshot();
  116. });
  117. }
  118. it('emits the UDP hop range as the v2rayN-compatible mport param', () => {
  119. const [, raw] = fixtures[0];
  120. const withHop = {
  121. ...raw,
  122. settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
  123. streamSettings: {
  124. ...(raw.streamSettings as Record<string, unknown>),
  125. finalmask: { quicParams: { udpHop: { ports: '20000-50000', interval: '5-10' } } },
  126. },
  127. };
  128. const typed = InboundSchema.parse(withHop);
  129. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  130. const link = genHysteriaLink({
  131. inbound: typed,
  132. address: 'example.test',
  133. port: typed.port,
  134. remark: 'hop-test',
  135. clientAuth: client.auth,
  136. });
  137. expect(link.startsWith('hysteria2://')).toBe(true);
  138. expect(link).toContain(`@example.test:${typed.port}`);
  139. expect(link).toContain('mport=20000-50000');
  140. expect(link.endsWith('#hop-test')).toBe(true);
  141. });
  142. it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
  143. const [, raw] = fixtures[0];
  144. const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
  145. const hexPin = '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293';
  146. const colonPin = 'C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4';
  147. const stream = raw.streamSettings as Record<string, unknown>;
  148. const tls = stream.tlsSettings as Record<string, unknown>;
  149. const tlsClientSettings = tls.settings as Record<string, unknown>;
  150. const withPins = {
  151. ...raw,
  152. streamSettings: {
  153. ...stream,
  154. tlsSettings: {
  155. ...tls,
  156. settings: { ...tlsClientSettings, pinnedPeerCertSha256: [base64Pin, hexPin, colonPin] },
  157. },
  158. },
  159. };
  160. const typed = InboundSchema.parse(withPins);
  161. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  162. const link = genHysteriaLink({
  163. inbound: typed,
  164. address: 'example.test',
  165. port: typed.port,
  166. remark: 'pin-test',
  167. clientAuth: client.auth,
  168. });
  169. const pin = new URL(link).searchParams.get('pinSHA256');
  170. expect(pin).toBe(
  171. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4,' +
  172. '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293,' +
  173. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
  174. );
  175. });
  176. it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => {
  177. const [, raw] = fixtures[0];
  178. const typed = InboundSchema.parse(raw);
  179. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  180. const link = genHysteriaLink({
  181. inbound: typed,
  182. address: 'edge.example.com',
  183. port: 8443,
  184. remark: 'ep-pin',
  185. clientAuth: client.auth,
  186. externalProxy: {
  187. forceTls: 'tls',
  188. dest: 'edge.example.com',
  189. port: 8443,
  190. remark: 'ep-pin',
  191. // base64 SHA-256 — must come out hex-normalized for Hysteria.
  192. pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='],
  193. },
  194. });
  195. const url = new URL(link);
  196. expect(url.searchParams.get('pinSHA256')).toBe(
  197. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
  198. );
  199. expect(url.searchParams.has('pcs')).toBe(false);
  200. });
  201. });
  202. describe('genWireguardLink + genWireguardConfig', () => {
  203. const fixtures = fixturesForProtocol('wireguard');
  204. expect(fixtures.length, 'need at least one wireguard full-inbound fixture').toBeGreaterThan(0);
  205. for (const [name, raw] of fixtures) {
  206. it(`${name}: byte-stable`, () => {
  207. const typed = InboundSchema.parse(raw);
  208. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  209. // InboundSchema is an intersection of two DUs, so TS can't auto-narrow
  210. // `settings` from `protocol`. The runtime guard above is the real
  211. // check; this cast just helps the type checker.
  212. const settings = typed.settings as WireguardInboundSettings;
  213. const link = genWireguardLink({
  214. settings,
  215. address: 'wg.example.test',
  216. port: typed.port,
  217. remark: 'wg-peer-1',
  218. peerIndex: 0,
  219. });
  220. const config = genWireguardConfig({
  221. settings,
  222. address: 'wg.example.test',
  223. port: typed.port,
  224. remark: 'wg-peer-1',
  225. peerIndex: 0,
  226. });
  227. expect({ link, config }).toMatchSnapshot();
  228. });
  229. }
  230. });
  231. describe('resolveAddr precedence', () => {
  232. const baseInbound = {
  233. listen: '',
  234. port: 443,
  235. protocol: 'vless' as const,
  236. };
  237. it('prefers hostOverride over listen and fallback', () => {
  238. expect(resolveAddr(
  239. { ...baseInbound, listen: '10.0.0.1' } as never,
  240. 'cdn.example.test',
  241. 'fallback.test',
  242. )).toBe('cdn.example.test');
  243. });
  244. it('uses listen when override is empty and listen is explicit', () => {
  245. expect(resolveAddr(
  246. { ...baseInbound, listen: '10.0.0.1' } as never,
  247. '',
  248. 'fallback.test',
  249. )).toBe('10.0.0.1');
  250. });
  251. it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
  252. expect(resolveAddr(
  253. { ...baseInbound, listen: '0.0.0.0' } as never,
  254. '',
  255. 'fallback.test',
  256. )).toBe('fallback.test');
  257. });
  258. it('skips a unix socket path listen and falls through to fallbackHostname', () => {
  259. expect(resolveAddr(
  260. { ...baseInbound, listen: '/run/xray/in.sock' } as never,
  261. '',
  262. 'fallback.test',
  263. )).toBe('fallback.test');
  264. expect(resolveAddr(
  265. { ...baseInbound, listen: '@xray-abstract' } as never,
  266. '',
  267. 'fallback.test',
  268. )).toBe('fallback.test');
  269. });
  270. it('falls through to fallbackHostname when listen is empty', () => {
  271. expect(resolveAddr(
  272. baseInbound as never,
  273. '',
  274. 'fallback.test',
  275. )).toBe('fallback.test');
  276. });
  277. it('uses listen strategy with a shareable IPv6 listen before node override', () => {
  278. expect(resolveAddr(
  279. { ...baseInbound, listen: '[2001:db8::1]', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  280. 'node.example.test',
  281. 'fallback.test',
  282. )).toBe('[2001:db8::1]');
  283. });
  284. it('uses listen strategy to prefer listen and fall back to node override', () => {
  285. expect(resolveAddr(
  286. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  287. 'node.example.test',
  288. 'fallback.test',
  289. )).toBe('10.0.0.1');
  290. expect(resolveAddr(
  291. { ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  292. 'node.example.test',
  293. 'fallback.test',
  294. )).toBe('node.example.test');
  295. expect(resolveAddr(
  296. { ...baseInbound, listen: 'localhost', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  297. 'node.example.test',
  298. 'fallback.test',
  299. )).toBe('node.example.test');
  300. });
  301. it('uses custom strategy address before node override', () => {
  302. expect(resolveAddr(
  303. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: 'edge.example.test' } as never,
  304. 'node.example.test',
  305. 'fallback.test',
  306. )).toBe('edge.example.test');
  307. });
  308. it('normalizes a bare IPv6 custom strategy address', () => {
  309. expect(resolveAddr(
  310. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: '2001:db8::2' } as never,
  311. 'node.example.test',
  312. 'fallback.test',
  313. )).toBe('[2001:db8::2]');
  314. });
  315. it('ignores invalid custom strategy addresses and falls back to node override', () => {
  316. for (const shareAddr of ['https://edge.example.test', 'edge.example.test:8443', '[2001:db8::2]:8443', 'bad host']) {
  317. expect(resolveAddr(
  318. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
  319. 'node.example.test',
  320. 'fallback.test',
  321. )).toBe('node.example.test');
  322. }
  323. });
  324. });
  325. // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
  326. // leak the loopback host into share/QR links; a configured public host wins.
  327. describe('preferPublicHost (loopback fallback)', () => {
  328. it('keeps a routable browser host as-is even when a public host is configured', () => {
  329. expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
  330. expect(preferPublicHost('203.0.113.7', 'sub.example.com')).toBe('203.0.113.7');
  331. });
  332. it('substitutes the public host for loopback browser hosts', () => {
  333. for (const loop of ['127.0.0.1', 'localhost', '::1', '[::1]', '127.5.6.7']) {
  334. expect(preferPublicHost(loop, 'sub.example.com')).toBe('sub.example.com');
  335. }
  336. });
  337. it('leaves loopback untouched when no public host is configured', () => {
  338. expect(preferPublicHost('127.0.0.1', '')).toBe('127.0.0.1');
  339. expect(preferPublicHost('localhost', '')).toBe('localhost');
  340. });
  341. it('an explicit per-inbound listen still wins over the loopback fallback', () => {
  342. const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
  343. expect(resolveAddr(
  344. inbound as never,
  345. '',
  346. preferPublicHost('127.0.0.1', 'sub.example.com'),
  347. )).toBe('203.0.113.9');
  348. });
  349. });
  350. describe('genInboundLinks orchestrator', () => {
  351. // Every full-inbound fixture should produce the same \r\n-joined link
  352. // block at this baseline.
  353. const fixtures = Object.entries(fullFixtures)
  354. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  355. .sort(([a], [b]) => a.localeCompare(b));
  356. for (const [name, raw] of fixtures) {
  357. it(`${name}: byte-stable`, () => {
  358. const typed = InboundSchema.parse(raw);
  359. const block = genInboundLinks({
  360. inbound: typed,
  361. remark: 'parity-test',
  362. hostOverride: 'override.test',
  363. fallbackHostname: 'fallback.test',
  364. });
  365. expect(block).toMatchSnapshot();
  366. });
  367. }
  368. });
  369. describe('genShadowsocksLink', () => {
  370. const fixtures = fixturesForProtocol('shadowsocks');
  371. expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);
  372. for (const [name, raw] of fixtures) {
  373. it(`${name}: byte-stable`, () => {
  374. const typed = InboundSchema.parse(raw);
  375. const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings;
  376. const client = settings.clients?.[0];
  377. const link = genShadowsocksLink({
  378. inbound: typed,
  379. address: 'example.test',
  380. port: typed.port,
  381. forceTls: 'same',
  382. remark: 'parity-test',
  383. clientPassword: client?.password ?? '',
  384. externalProxy: null,
  385. });
  386. expect(link).toMatchSnapshot();
  387. });
  388. }
  389. });
  390. describe('IPv6 bracket wrapping in share-link authority', () => {
  391. it('genVlessLink brackets a bare IPv6 address', () => {
  392. const [, raw] = fixturesForProtocol('vless')[0];
  393. const typed = InboundSchema.parse(raw);
  394. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  395. const link = genVlessLink({
  396. inbound: typed,
  397. address: '2001:db8::1',
  398. port: 443,
  399. clientId,
  400. });
  401. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  402. });
  403. it('genTrojanLink brackets a bare IPv6 address', () => {
  404. const [, raw] = fixturesForProtocol('trojan')[0];
  405. const typed = InboundSchema.parse(raw);
  406. const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings.clients[0].password;
  407. const link = genTrojanLink({
  408. inbound: typed,
  409. address: '2001:db8::1',
  410. port: 443,
  411. clientPassword,
  412. });
  413. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  414. });
  415. it('genShadowsocksLink brackets a bare IPv6 address', () => {
  416. const [, raw] = fixturesForProtocol('shadowsocks')[0];
  417. const typed = InboundSchema.parse(raw);
  418. const clientPassword = (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]?.password ?? '';
  419. const link = genShadowsocksLink({
  420. inbound: typed,
  421. address: '2001:db8::1',
  422. port: 443,
  423. clientPassword,
  424. });
  425. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  426. });
  427. it('genHysteriaLink brackets a bare IPv6 address', () => {
  428. const [, raw] = fixturesForProtocol('hysteria')[0];
  429. const typed = InboundSchema.parse(raw);
  430. const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings.clients[0].auth;
  431. const link = genHysteriaLink({
  432. inbound: typed,
  433. address: '2001:db8::1',
  434. port: 443,
  435. clientAuth,
  436. });
  437. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  438. });
  439. it('genWireguardLink brackets a bare IPv6 address', () => {
  440. const [, raw] = fixturesForProtocol('wireguard')[0];
  441. const typed = InboundSchema.parse(raw);
  442. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  443. const settings = typed.settings as WireguardInboundSettings;
  444. const link = genWireguardLink({
  445. settings,
  446. address: '2001:db8::1',
  447. port: 443,
  448. peerIndex: 0,
  449. });
  450. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  451. });
  452. it('does not bracket IPv4 addresses or hostnames', () => {
  453. const [, raw] = fixturesForProtocol('vless')[0];
  454. const typed = InboundSchema.parse(raw);
  455. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  456. const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId });
  457. expect(new URL(v4).host).toBe('203.0.113.7:443');
  458. const host = genVlessLink({ inbound: typed, address: 'example.test', port: 443, clientId });
  459. expect(new URL(host).host).toBe('example.test:443');
  460. });
  461. });
  462. describe('external proxy pinned cert (pcs)', () => {
  463. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
  464. const typed = InboundSchema.parse(raw);
  465. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  466. it('emits the external proxy pin list as pcs when forcing TLS', () => {
  467. const link = genVlessLink({
  468. inbound: typed,
  469. address: 'edge.example.com',
  470. port: 8443,
  471. forceTls: 'tls',
  472. remark: 'ep-pin',
  473. clientId,
  474. externalProxy: {
  475. forceTls: 'tls',
  476. dest: 'edge.example.com',
  477. port: 8443,
  478. remark: 'ep-pin',
  479. pinnedPeerCertSha256: ['aa11', 'bb22'],
  480. },
  481. });
  482. expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
  483. });
  484. it('omits pcs when the external proxy forces security off', () => {
  485. const link = genVlessLink({
  486. inbound: typed,
  487. address: 'edge.example.com',
  488. port: 8080,
  489. forceTls: 'none',
  490. remark: 'ep-none',
  491. clientId,
  492. externalProxy: {
  493. forceTls: 'none',
  494. dest: 'edge.example.com',
  495. port: 8080,
  496. remark: 'ep-none',
  497. pinnedPeerCertSha256: ['aa11'],
  498. },
  499. });
  500. expect(new URL(link).searchParams.has('pcs')).toBe(false);
  501. });
  502. });
  503. // #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
  504. // when VLESS encryption (vlessenc) is on, matching the form's flow display
  505. // and the backend subscription. Gating is via canEnableTlsFlow.
  506. describe('genVlessLink flow gating (#5322)', () => {
  507. function vlessXhttp(encryption: string) {
  508. return InboundSchema.parse({
  509. id: 1,
  510. up: 0,
  511. down: 0,
  512. total: 0,
  513. remark: 'vlessenc',
  514. enable: true,
  515. expiryTime: 0,
  516. listen: '',
  517. port: 443,
  518. tag: 'inbound-vless-xhttp',
  519. sniffing: {
  520. enabled: false,
  521. destOverride: [],
  522. metadataOnly: false,
  523. routeOnly: false,
  524. ipsExcluded: [],
  525. domainsExcluded: [],
  526. },
  527. protocol: 'vless',
  528. settings: {
  529. clients: [
  530. {
  531. id: '11111111-2222-3333-4444-555555555555',
  532. email: '[email protected]',
  533. flow: 'xtls-rprx-vision',
  534. limitIp: 0,
  535. totalGB: 0,
  536. expiryTime: 0,
  537. enable: true,
  538. tgId: 0,
  539. subId: 's1',
  540. comment: '',
  541. reset: 0,
  542. },
  543. ],
  544. decryption: 'none',
  545. encryption,
  546. fallbacks: [],
  547. },
  548. streamSettings: {
  549. network: 'xhttp',
  550. xhttpSettings: {},
  551. security: 'none',
  552. },
  553. });
  554. }
  555. const clientId = '11111111-2222-3333-4444-555555555555';
  556. it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
  557. const link = genVlessLink({
  558. inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
  559. address: 'example.test',
  560. port: 443,
  561. clientId,
  562. flow: 'xtls-rprx-vision',
  563. });
  564. expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
  565. });
  566. it('omits flow for VLESS+XHTTP without vless encryption', () => {
  567. const link = genVlessLink({
  568. inbound: vlessXhttp('none'),
  569. address: 'example.test',
  570. port: 443,
  571. clientId,
  572. flow: 'xtls-rprx-vision',
  573. });
  574. expect(new URL(link).searchParams.has('flow')).toBe(false);
  575. });
  576. it('still emits flow for classic TCP+REALITY Vision', () => {
  577. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
  578. const typed = InboundSchema.parse(raw);
  579. const link = genVlessLink({
  580. inbound: typed,
  581. address: 'example.test',
  582. port: 443,
  583. clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
  584. flow: 'xtls-rprx-vision',
  585. });
  586. expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
  587. });
  588. });