inbound-link.test.ts 23 KB

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