inbound-link.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  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('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
  278. const settings = {
  279. secretKey: '',
  280. mtu: 1280,
  281. dns: '',
  282. peers: [
  283. {
  284. privateKey: 'cLI',
  285. allowedIPs: ['10.0.0.2/32', 'fd00::2/128'],
  286. },
  287. ],
  288. } as unknown as WireguardInboundSettings;
  289. it('joins every allowed IP into the share-link address param', () => {
  290. const link = genWireguardLink({
  291. settings,
  292. address: 'wg.example.test',
  293. port: 51820,
  294. remark: 'dual-stack',
  295. peerIndex: 0,
  296. });
  297. const u = new URL(link);
  298. expect(u.searchParams.get('address')).toBe('10.0.0.2/32,fd00::2/128');
  299. });
  300. it('joins every allowed IP into the .conf Address line', () => {
  301. const config = genWireguardConfig({
  302. settings,
  303. address: 'wg.example.test',
  304. port: 51820,
  305. remark: 'dual-stack',
  306. peerIndex: 0,
  307. });
  308. expect(config).toContain('Address = 10.0.0.2/32, fd00::2/128\n');
  309. });
  310. });
  311. describe('resolveAddr precedence', () => {
  312. const baseInbound = {
  313. listen: '',
  314. port: 443,
  315. protocol: 'vless' as const,
  316. };
  317. it('prefers hostOverride over listen and fallback', () => {
  318. expect(resolveAddr(
  319. { ...baseInbound, listen: '10.0.0.1' } as never,
  320. 'cdn.example.test',
  321. 'fallback.test',
  322. )).toBe('cdn.example.test');
  323. });
  324. it('uses listen when override is empty and listen is explicit', () => {
  325. expect(resolveAddr(
  326. { ...baseInbound, listen: '10.0.0.1' } as never,
  327. '',
  328. 'fallback.test',
  329. )).toBe('10.0.0.1');
  330. });
  331. it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
  332. expect(resolveAddr(
  333. { ...baseInbound, listen: '0.0.0.0' } as never,
  334. '',
  335. 'fallback.test',
  336. )).toBe('fallback.test');
  337. });
  338. it('skips a unix socket path listen and falls through to fallbackHostname', () => {
  339. expect(resolveAddr(
  340. { ...baseInbound, listen: '/run/xray/in.sock' } as never,
  341. '',
  342. 'fallback.test',
  343. )).toBe('fallback.test');
  344. expect(resolveAddr(
  345. { ...baseInbound, listen: '@xray-abstract' } as never,
  346. '',
  347. 'fallback.test',
  348. )).toBe('fallback.test');
  349. });
  350. it('falls through to fallbackHostname when listen is empty', () => {
  351. expect(resolveAddr(
  352. baseInbound as never,
  353. '',
  354. 'fallback.test',
  355. )).toBe('fallback.test');
  356. });
  357. it('uses listen strategy with a shareable IPv6 listen before node override', () => {
  358. expect(resolveAddr(
  359. { ...baseInbound, listen: '[2001:db8::1]', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  360. 'node.example.test',
  361. 'fallback.test',
  362. )).toBe('[2001:db8::1]');
  363. });
  364. it('uses listen strategy to prefer listen and fall back to node override', () => {
  365. expect(resolveAddr(
  366. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  367. 'node.example.test',
  368. 'fallback.test',
  369. )).toBe('10.0.0.1');
  370. expect(resolveAddr(
  371. { ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  372. 'node.example.test',
  373. 'fallback.test',
  374. )).toBe('node.example.test');
  375. expect(resolveAddr(
  376. { ...baseInbound, listen: 'localhost', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  377. 'node.example.test',
  378. 'fallback.test',
  379. )).toBe('node.example.test');
  380. });
  381. it('uses custom strategy address before node override', () => {
  382. expect(resolveAddr(
  383. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: 'edge.example.test' } as never,
  384. 'node.example.test',
  385. 'fallback.test',
  386. )).toBe('edge.example.test');
  387. });
  388. it('normalizes a bare IPv6 custom strategy address', () => {
  389. expect(resolveAddr(
  390. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: '2001:db8::2' } as never,
  391. 'node.example.test',
  392. 'fallback.test',
  393. )).toBe('[2001:db8::2]');
  394. });
  395. it('ignores invalid custom strategy addresses and falls back to node override', () => {
  396. for (const shareAddr of ['https://edge.example.test', 'edge.example.test:8443', '[2001:db8::2]:8443', 'bad host']) {
  397. expect(resolveAddr(
  398. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
  399. 'node.example.test',
  400. 'fallback.test',
  401. )).toBe('node.example.test');
  402. }
  403. });
  404. });
  405. // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
  406. // leak the loopback host into share/QR links; a configured public host wins.
  407. describe('preferPublicHost (loopback fallback)', () => {
  408. it('keeps a routable browser host as-is even when a public host is configured', () => {
  409. expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
  410. expect(preferPublicHost('203.0.113.7', 'sub.example.com')).toBe('203.0.113.7');
  411. });
  412. it('substitutes the public host for loopback browser hosts', () => {
  413. for (const loop of ['127.0.0.1', 'localhost', '::1', '[::1]', '127.5.6.7']) {
  414. expect(preferPublicHost(loop, 'sub.example.com')).toBe('sub.example.com');
  415. }
  416. });
  417. it('leaves loopback untouched when no public host is configured', () => {
  418. expect(preferPublicHost('127.0.0.1', '')).toBe('127.0.0.1');
  419. expect(preferPublicHost('localhost', '')).toBe('localhost');
  420. });
  421. it('an explicit per-inbound listen still wins over the loopback fallback', () => {
  422. const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
  423. expect(resolveAddr(
  424. inbound as never,
  425. '',
  426. preferPublicHost('127.0.0.1', 'sub.example.com'),
  427. )).toBe('203.0.113.9');
  428. });
  429. });
  430. describe('genInboundLinks orchestrator', () => {
  431. // Every full-inbound fixture should produce the same \r\n-joined link
  432. // block at this baseline.
  433. const fixtures = Object.entries(fullFixtures)
  434. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  435. .sort(([a], [b]) => a.localeCompare(b));
  436. for (const [name, raw] of fixtures) {
  437. it(`${name}: byte-stable`, () => {
  438. const typed = InboundSchema.parse(raw);
  439. const block = genInboundLinks({
  440. inbound: typed,
  441. remark: 'parity-test',
  442. hostOverride: 'override.test',
  443. fallbackHostname: 'fallback.test',
  444. });
  445. expect(block).toMatchSnapshot();
  446. });
  447. }
  448. });
  449. describe('genShadowsocksLink', () => {
  450. const fixtures = fixturesForProtocol('shadowsocks');
  451. expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);
  452. for (const [name, raw] of fixtures) {
  453. it(`${name}: byte-stable`, () => {
  454. const typed = InboundSchema.parse(raw);
  455. const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings;
  456. const client = settings.clients?.[0];
  457. const link = genShadowsocksLink({
  458. inbound: typed,
  459. address: 'example.test',
  460. port: typed.port,
  461. forceTls: 'same',
  462. remark: 'parity-test',
  463. clientPassword: client?.password ?? '',
  464. externalProxy: null,
  465. });
  466. expect(link).toMatchSnapshot();
  467. });
  468. }
  469. });
  470. describe('IPv6 bracket wrapping in share-link authority', () => {
  471. it('genVlessLink brackets a bare IPv6 address', () => {
  472. const [, raw] = fixturesForProtocol('vless')[0];
  473. const typed = InboundSchema.parse(raw);
  474. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  475. const link = genVlessLink({
  476. inbound: typed,
  477. address: '2001:db8::1',
  478. port: 443,
  479. clientId,
  480. });
  481. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  482. });
  483. it('genTrojanLink brackets a bare IPv6 address', () => {
  484. const [, raw] = fixturesForProtocol('trojan')[0];
  485. const typed = InboundSchema.parse(raw);
  486. const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings.clients[0].password;
  487. const link = genTrojanLink({
  488. inbound: typed,
  489. address: '2001:db8::1',
  490. port: 443,
  491. clientPassword,
  492. });
  493. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  494. });
  495. it('genShadowsocksLink brackets a bare IPv6 address', () => {
  496. const [, raw] = fixturesForProtocol('shadowsocks')[0];
  497. const typed = InboundSchema.parse(raw);
  498. const clientPassword = (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]?.password ?? '';
  499. const link = genShadowsocksLink({
  500. inbound: typed,
  501. address: '2001:db8::1',
  502. port: 443,
  503. clientPassword,
  504. });
  505. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  506. });
  507. it('genHysteriaLink brackets a bare IPv6 address', () => {
  508. const [, raw] = fixturesForProtocol('hysteria')[0];
  509. const typed = InboundSchema.parse(raw);
  510. const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings.clients[0].auth;
  511. const link = genHysteriaLink({
  512. inbound: typed,
  513. address: '2001:db8::1',
  514. port: 443,
  515. clientAuth,
  516. });
  517. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  518. });
  519. it('genWireguardLink brackets a bare IPv6 address', () => {
  520. const [, raw] = fixturesForProtocol('wireguard')[0];
  521. const typed = InboundSchema.parse(raw);
  522. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  523. const settings = typed.settings as WireguardInboundSettings;
  524. const link = genWireguardLink({
  525. settings,
  526. address: '2001:db8::1',
  527. port: 443,
  528. peerIndex: 0,
  529. });
  530. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  531. });
  532. it('does not bracket IPv4 addresses or hostnames', () => {
  533. const [, raw] = fixturesForProtocol('vless')[0];
  534. const typed = InboundSchema.parse(raw);
  535. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  536. const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId });
  537. expect(new URL(v4).host).toBe('203.0.113.7:443');
  538. const host = genVlessLink({ inbound: typed, address: 'example.test', port: 443, clientId });
  539. expect(new URL(host).host).toBe('example.test:443');
  540. });
  541. });
  542. describe('external proxy pinned cert (pcs)', () => {
  543. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
  544. const typed = InboundSchema.parse(raw);
  545. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  546. it('emits the external proxy pin list as pcs when forcing TLS', () => {
  547. const link = genVlessLink({
  548. inbound: typed,
  549. address: 'edge.example.com',
  550. port: 8443,
  551. forceTls: 'tls',
  552. remark: 'ep-pin',
  553. clientId,
  554. externalProxy: {
  555. forceTls: 'tls',
  556. dest: 'edge.example.com',
  557. port: 8443,
  558. remark: 'ep-pin',
  559. pinnedPeerCertSha256: ['aa11', 'bb22'],
  560. },
  561. });
  562. expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
  563. });
  564. it('omits pcs when the external proxy forces security off', () => {
  565. const link = genVlessLink({
  566. inbound: typed,
  567. address: 'edge.example.com',
  568. port: 8080,
  569. forceTls: 'none',
  570. remark: 'ep-none',
  571. clientId,
  572. externalProxy: {
  573. forceTls: 'none',
  574. dest: 'edge.example.com',
  575. port: 8080,
  576. remark: 'ep-none',
  577. pinnedPeerCertSha256: ['aa11'],
  578. },
  579. });
  580. expect(new URL(link).searchParams.has('pcs')).toBe(false);
  581. });
  582. });
  583. // #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
  584. // when VLESS encryption (vlessenc) is on, matching the form's flow display
  585. // and the backend subscription. Gating is via canEnableTlsFlow.
  586. describe('genVlessLink flow gating (#5322)', () => {
  587. function vlessXhttp(encryption: string) {
  588. return InboundSchema.parse({
  589. id: 1,
  590. up: 0,
  591. down: 0,
  592. total: 0,
  593. remark: 'vlessenc',
  594. enable: true,
  595. expiryTime: 0,
  596. listen: '',
  597. port: 443,
  598. tag: 'inbound-vless-xhttp',
  599. sniffing: {
  600. enabled: false,
  601. destOverride: [],
  602. metadataOnly: false,
  603. routeOnly: false,
  604. ipsExcluded: [],
  605. domainsExcluded: [],
  606. },
  607. protocol: 'vless',
  608. settings: {
  609. clients: [
  610. {
  611. id: '11111111-2222-3333-4444-555555555555',
  612. email: '[email protected]',
  613. flow: 'xtls-rprx-vision',
  614. limitIp: 0,
  615. totalGB: 0,
  616. expiryTime: 0,
  617. enable: true,
  618. tgId: 0,
  619. subId: 's1',
  620. comment: '',
  621. reset: 0,
  622. },
  623. ],
  624. decryption: 'none',
  625. encryption,
  626. fallbacks: [],
  627. },
  628. streamSettings: {
  629. network: 'xhttp',
  630. xhttpSettings: {},
  631. security: 'none',
  632. },
  633. });
  634. }
  635. const clientId = '11111111-2222-3333-4444-555555555555';
  636. it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
  637. const link = genVlessLink({
  638. inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
  639. address: 'example.test',
  640. port: 443,
  641. clientId,
  642. flow: 'xtls-rprx-vision',
  643. });
  644. expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
  645. });
  646. it('omits flow for VLESS+XHTTP without vless encryption', () => {
  647. const link = genVlessLink({
  648. inbound: vlessXhttp('none'),
  649. address: 'example.test',
  650. port: 443,
  651. clientId,
  652. flow: 'xtls-rprx-vision',
  653. });
  654. expect(new URL(link).searchParams.has('flow')).toBe(false);
  655. });
  656. it('still emits flow for classic TCP+REALITY Vision', () => {
  657. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
  658. const typed = InboundSchema.parse(raw);
  659. const link = genVlessLink({
  660. inbound: typed,
  661. address: 'example.test',
  662. port: 443,
  663. clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
  664. flow: 'xtls-rprx-vision',
  665. });
  666. expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
  667. });
  668. });
  669. describe('genVlessLink XHTTP extra compatibility', () => {
  670. it('emits both sessionID and legacy session keys in XHTTP extra', () => {
  671. const typed = InboundSchema.parse({
  672. id: 1,
  673. up: 0,
  674. down: 0,
  675. total: 0,
  676. remark: 'xhttp-session',
  677. enable: true,
  678. expiryTime: 0,
  679. listen: '',
  680. port: 443,
  681. tag: 'inbound-vless-xhttp',
  682. sniffing: {
  683. enabled: false,
  684. destOverride: [],
  685. metadataOnly: false,
  686. routeOnly: false,
  687. ipsExcluded: [],
  688. domainsExcluded: [],
  689. },
  690. protocol: 'vless',
  691. settings: {
  692. clients: [
  693. {
  694. id: '11111111-2222-3333-4444-555555555555',
  695. email: '[email protected]',
  696. flow: '',
  697. limitIp: 0,
  698. totalGB: 0,
  699. expiryTime: 0,
  700. enable: true,
  701. tgId: 0,
  702. subId: 's1',
  703. comment: '',
  704. reset: 0,
  705. },
  706. ],
  707. decryption: 'none',
  708. encryption: 'none',
  709. fallbacks: [],
  710. },
  711. streamSettings: {
  712. network: 'xhttp',
  713. security: 'none',
  714. xhttpSettings: {
  715. path: '/sp',
  716. host: 'edge.example.test',
  717. mode: 'auto',
  718. sessionIDPlacement: 'header',
  719. sessionIDKey: 'X-Session',
  720. },
  721. },
  722. });
  723. const link = genVlessLink({
  724. inbound: typed,
  725. address: 'example.test',
  726. port: 443,
  727. clientId: '11111111-2222-3333-4444-555555555555',
  728. });
  729. const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<string, unknown>;
  730. expect(extra.sessionIDPlacement).toBe('header');
  731. expect(extra.sessionIDKey).toBe('X-Session');
  732. expect(extra.sessionPlacement).toBe('header');
  733. expect(extra.sessionKey).toBe('X-Session');
  734. });
  735. });