inbound-link.test.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089
  1. /// <reference types="vite/client" />
  2. import { describe, expect, it } from 'vitest';
  3. import {
  4. amneziawgConfigFromLink,
  5. genAmneziaWGConfig,
  6. genAmneziaWGLink,
  7. genHysteriaLink,
  8. genInboundLinks,
  9. genShadowsocksLink,
  10. genTrojanLink,
  11. applyVlessRoute,
  12. genVlessLink,
  13. genVmessLink,
  14. genWireguardConfig,
  15. genWireguardLink,
  16. preferPublicHost,
  17. resolveAddr,
  18. } from '@/lib/xray/inbound-link';
  19. import { InboundSchema } from '@/schemas/api/inbound';
  20. import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
  21. import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
  22. // reverse of inbound-link.ts's own toBase64Url, for asserting on the
  23. // decoded vpn:// payload without depending on that helper being exported.
  24. function fromBase64Url(value: string): string {
  25. const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
  26. const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
  27. return atob(padded);
  28. }
  29. // Snapshot baseline for the share-link generators. Snapshots were locked
  30. // at the close of the legacy class migration — at that point each
  31. // generator was verified byte-equal to the corresponding legacy Inbound
  32. // class method. Future drift past this baseline is a regression.
  33. const fullFixtures = import.meta.glob<unknown>('./golden/fixtures/inbound-full/*.json', {
  34. eager: true,
  35. import: 'default',
  36. });
  37. function fixtureName(path: string): string {
  38. const file = path.split('/').pop() ?? path;
  39. return file.replace(/\.json$/, '');
  40. }
  41. function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
  42. return Object.entries(fullFixtures)
  43. .filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
  44. .map(([path, raw]): [string, Record<string, unknown>] => [
  45. fixtureName(path),
  46. raw as Record<string, unknown>,
  47. ])
  48. .sort(([a], [b]) => a.localeCompare(b));
  49. }
  50. describe('genVmessLink', () => {
  51. const fixtures = fixturesForProtocol('vmess');
  52. expect(fixtures.length, 'need at least one vmess full-inbound fixture').toBeGreaterThan(0);
  53. for (const [name, raw] of fixtures) {
  54. it(`${name}: byte-stable`, () => {
  55. const typed = InboundSchema.parse(raw);
  56. const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } })
  57. .settings;
  58. const client = settings.clients[0];
  59. const link = genVmessLink({
  60. inbound: typed,
  61. address: 'example.test',
  62. port: typed.port,
  63. forceTls: 'same',
  64. remark: 'parity-test',
  65. clientId: client.id,
  66. security: client.security as never,
  67. externalProxy: null,
  68. });
  69. expect(link).toMatchSnapshot();
  70. });
  71. }
  72. });
  73. describe('genVlessLink', () => {
  74. const fixtures = fixturesForProtocol('vless');
  75. expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
  76. for (const [name, raw] of fixtures) {
  77. it(`${name}: byte-stable`, () => {
  78. const typed = InboundSchema.parse(raw);
  79. const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } })
  80. .settings;
  81. const client = settings.clients[0];
  82. const link = genVlessLink({
  83. inbound: typed,
  84. address: 'example.test',
  85. port: typed.port,
  86. forceTls: 'same',
  87. remark: 'parity-test',
  88. clientId: client.id,
  89. flow: client.flow as never,
  90. externalProxy: null,
  91. });
  92. expect(link).toMatchSnapshot();
  93. });
  94. }
  95. });
  96. describe('applyVlessRoute', () => {
  97. const id = '11111111-2222-4333-8444-555555555555';
  98. it('encodes a single value into the 3rd group and no-ops on invalid input', () => {
  99. expect(applyVlessRoute(id, '443')).toBe('11111111-2222-01bb-8444-555555555555');
  100. expect(applyVlessRoute(id, '53')).toBe('11111111-2222-0035-8444-555555555555');
  101. expect(applyVlessRoute(id, '0')).toBe('11111111-2222-0000-8444-555555555555');
  102. expect(applyVlessRoute(id, '65535')).toBe('11111111-2222-ffff-8444-555555555555');
  103. expect(applyVlessRoute(id, '')).toBe(id);
  104. expect(applyVlessRoute(id, undefined)).toBe(id);
  105. expect(applyVlessRoute(id, '70000')).toBe(id);
  106. expect(applyVlessRoute(id, '53,443')).toBe(id);
  107. expect(applyVlessRoute(id, 'abc')).toBe(id);
  108. expect(applyVlessRoute('short', '443')).toBe('short');
  109. });
  110. });
  111. describe('genVlessLink vlessRoute', () => {
  112. const [, raw] = fixturesForProtocol('vless')[0];
  113. const typed = InboundSchema.parse(raw);
  114. it('bakes a host route value into the link UUID 3rd group', () => {
  115. const link = genVlessLink({
  116. inbound: typed,
  117. address: 'example.test',
  118. port: typed.port,
  119. forceTls: 'same',
  120. remark: 'r',
  121. clientId: '11111111-2222-4333-8444-555555555555',
  122. flow: '' as never,
  123. externalProxy: {
  124. forceTls: 'same',
  125. dest: 'example.test',
  126. port: typed.port,
  127. remark: '',
  128. vlessRoute: '443',
  129. },
  130. });
  131. expect(link).toContain('vless://11111111-2222-01bb-8444-555555555555@');
  132. });
  133. it('leaves the UUID unchanged when no route is set', () => {
  134. const link = genVlessLink({
  135. inbound: typed,
  136. address: 'example.test',
  137. port: typed.port,
  138. forceTls: 'same',
  139. remark: 'r',
  140. clientId: '11111111-2222-4333-8444-555555555555',
  141. flow: '' as never,
  142. externalProxy: null,
  143. });
  144. expect(link).toContain('vless://11111111-2222-4333-8444-555555555555@');
  145. });
  146. });
  147. describe('genTrojanLink', () => {
  148. const fixtures = fixturesForProtocol('trojan');
  149. expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);
  150. for (const [name, raw] of fixtures) {
  151. it(`${name}: byte-stable`, () => {
  152. const typed = InboundSchema.parse(raw);
  153. const settings = (raw as { settings: { clients: Array<{ password: string }> } }).settings;
  154. const client = settings.clients[0];
  155. const link = genTrojanLink({
  156. inbound: typed,
  157. address: 'example.test',
  158. port: typed.port,
  159. forceTls: 'same',
  160. remark: 'parity-test',
  161. clientPassword: client.password,
  162. externalProxy: null,
  163. });
  164. expect(link).toMatchSnapshot();
  165. });
  166. }
  167. });
  168. describe('genHysteriaLink', () => {
  169. const fixtures = fixturesForProtocol('hysteria');
  170. expect(fixtures.length, 'need at least one hysteria full-inbound fixture').toBeGreaterThan(0);
  171. for (const [name, raw] of fixtures) {
  172. it(`${name}: byte-stable`, () => {
  173. const typed = InboundSchema.parse(raw);
  174. const settings = (raw as { settings: { clients: Array<{ auth: string }> } }).settings;
  175. const client = settings.clients[0];
  176. const link = genHysteriaLink({
  177. inbound: typed,
  178. address: 'example.test',
  179. port: typed.port,
  180. remark: 'parity-test',
  181. clientAuth: client.auth,
  182. });
  183. expect(link).toMatchSnapshot();
  184. });
  185. }
  186. it('emits the UDP hop range as the v2rayN-compatible mport param', () => {
  187. const [, raw] = fixtures[0];
  188. const withHop = {
  189. ...raw,
  190. settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
  191. streamSettings: {
  192. ...(raw.streamSettings as Record<string, unknown>),
  193. finalmask: { quicParams: { udpHop: { ports: '20000-50000', interval: '5-10' } } },
  194. },
  195. };
  196. const typed = InboundSchema.parse(withHop);
  197. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  198. const link = genHysteriaLink({
  199. inbound: typed,
  200. address: 'example.test',
  201. port: typed.port,
  202. remark: 'hop-test',
  203. clientAuth: client.auth,
  204. });
  205. expect(link.startsWith('hysteria2://')).toBe(true);
  206. expect(link).toContain(`@example.test:${typed.port}`);
  207. expect(link).toContain('mport=20000-50000');
  208. expect(link.endsWith('#hop-test')).toBe(true);
  209. });
  210. it('emits mport from the udphop mask xray-core 26.9.9 moved hopping to', () => {
  211. const [, raw] = fixtures[0];
  212. const withHop = {
  213. ...raw,
  214. settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
  215. streamSettings: {
  216. ...(raw.streamSettings as Record<string, unknown>),
  217. finalmask: {
  218. udp: [
  219. {
  220. type: 'udphop',
  221. settings: { mode: 'intervalremote', interval: '5-10', remotePorts: '30000-40000' },
  222. },
  223. ],
  224. },
  225. },
  226. };
  227. const typed = InboundSchema.parse(withHop);
  228. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  229. const link = genHysteriaLink({
  230. inbound: typed,
  231. address: 'example.test',
  232. port: typed.port,
  233. remark: 'hop-mask',
  234. clientAuth: client.auth,
  235. });
  236. expect(link).toContain('mport=30000-40000');
  237. });
  238. it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
  239. const [, raw] = fixtures[0];
  240. const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
  241. const hexPin = '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293';
  242. const colonPin =
  243. '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';
  244. const stream = raw.streamSettings as Record<string, unknown>;
  245. const tls = stream.tlsSettings as Record<string, unknown>;
  246. const tlsClientSettings = tls.settings as Record<string, unknown>;
  247. const withPins = {
  248. ...raw,
  249. streamSettings: {
  250. ...stream,
  251. tlsSettings: {
  252. ...tls,
  253. settings: { ...tlsClientSettings, pinnedPeerCertSha256: [base64Pin, hexPin, colonPin] },
  254. },
  255. },
  256. };
  257. const typed = InboundSchema.parse(withPins);
  258. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  259. const link = genHysteriaLink({
  260. inbound: typed,
  261. address: 'example.test',
  262. port: typed.port,
  263. remark: 'pin-test',
  264. clientAuth: client.auth,
  265. });
  266. const pin = new URL(link).searchParams.get('pinSHA256');
  267. expect(pin).toBe(
  268. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4,' +
  269. '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293,' +
  270. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
  271. );
  272. });
  273. it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => {
  274. const [, raw] = fixtures[0];
  275. const typed = InboundSchema.parse(raw);
  276. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  277. const link = genHysteriaLink({
  278. inbound: typed,
  279. address: 'edge.example.com',
  280. port: 8443,
  281. remark: 'ep-pin',
  282. clientAuth: client.auth,
  283. externalProxy: {
  284. forceTls: 'tls',
  285. dest: 'edge.example.com',
  286. port: 8443,
  287. remark: 'ep-pin',
  288. // base64 SHA-256 — must come out hex-normalized for Hysteria.
  289. pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='],
  290. },
  291. });
  292. const url = new URL(link);
  293. expect(url.searchParams.get('pinSHA256')).toBe(
  294. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
  295. );
  296. expect(url.searchParams.has('pcs')).toBe(false);
  297. });
  298. });
  299. describe('genWireguardLink + genWireguardConfig', () => {
  300. const fixtures = fixturesForProtocol('wireguard');
  301. expect(fixtures.length, 'need at least one wireguard full-inbound fixture').toBeGreaterThan(0);
  302. for (const [name, raw] of fixtures) {
  303. it(`${name}: byte-stable`, () => {
  304. const typed = InboundSchema.parse(raw);
  305. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  306. // InboundSchema is an intersection of two DUs, so TS can't auto-narrow
  307. // `settings` from `protocol`. The runtime guard above is the real
  308. // check; this cast just helps the type checker.
  309. const settings = typed.settings as WireguardInboundSettings;
  310. const link = genWireguardLink({
  311. settings,
  312. address: 'wg.example.test',
  313. port: typed.port,
  314. remark: 'wg-peer-1',
  315. peerIndex: 0,
  316. });
  317. const config = genWireguardConfig({
  318. settings,
  319. address: 'wg.example.test',
  320. port: typed.port,
  321. remark: 'wg-peer-1',
  322. peerIndex: 0,
  323. });
  324. expect({ link, config }).toMatchSnapshot();
  325. });
  326. }
  327. });
  328. describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
  329. const settings = {
  330. secretKey: '',
  331. mtu: 1280,
  332. dns: '',
  333. peers: [
  334. {
  335. privateKey: 'cLI',
  336. allowedIPs: ['10.0.0.2/32', 'fd00::2/128'],
  337. },
  338. ],
  339. } as unknown as WireguardInboundSettings;
  340. it('joins every allowed IP into the share-link address param', () => {
  341. const link = genWireguardLink({
  342. settings,
  343. address: 'wg.example.test',
  344. port: 51820,
  345. remark: 'dual-stack',
  346. peerIndex: 0,
  347. });
  348. const u = new URL(link);
  349. expect(u.searchParams.get('address')).toBe('10.0.0.2/32,fd00::2/128');
  350. });
  351. it('joins every allowed IP into the .conf Address line', () => {
  352. const config = genWireguardConfig({
  353. settings,
  354. address: 'wg.example.test',
  355. port: 51820,
  356. remark: 'dual-stack',
  357. peerIndex: 0,
  358. });
  359. expect(config).toContain('Address = 10.0.0.2/32, fd00::2/128\n');
  360. });
  361. });
  362. // Real AmneziaVPN app's import path (confirmed by reading its own source)
  363. // base64url-decodes a vpn:// link, best-effort decompresses it (falling back
  364. // to the raw bytes for plain text, which is never qCompress-framed), then
  365. // parses the result as a flat "Key = Value" bag -- so genAmneziaWGLink just
  366. // needs to wrap genAmneziaWGConfig's already-correct .conf text.
  367. describe('genAmneziaWGLink vpn:// scheme', () => {
  368. const settings = {
  369. server: {
  370. publicKey: 'serverPubKey==',
  371. mtu: 1420,
  372. primaryDns: '8.8.8.8',
  373. secondaryDns: '8.8.4.4',
  374. jc: 5,
  375. jmin: 10,
  376. jmax: 50,
  377. s1: 30,
  378. s2: 45,
  379. s3: 10,
  380. s4: 5,
  381. h1: '',
  382. h2: '',
  383. h3: '',
  384. h4: '',
  385. i1: '',
  386. },
  387. clients: [
  388. {
  389. email: 'peer-1',
  390. privateKey: 'clientPrivKey==',
  391. allowedIPs: ['10.8.1.2/32'],
  392. keepAlive: 25,
  393. },
  394. ],
  395. } as unknown as AmneziawgInboundSettings;
  396. const input = {
  397. settings,
  398. address: 'awg.example.test',
  399. port: 51820,
  400. remark: 'awg-peer-1',
  401. peerIndex: 0,
  402. };
  403. it('wraps the .conf text as a base64url-encoded vpn:// link, byte-identical to genAmneziaWGConfig', () => {
  404. const link = genAmneziaWGLink(input);
  405. expect(link.startsWith('vpn://')).toBe(true);
  406. const decoded = fromBase64Url(link.slice('vpn://'.length));
  407. expect(decoded).toBe(genAmneziaWGConfig(input));
  408. expect(decoded).toContain('PrivateKey = clientPrivKey==\n');
  409. expect(decoded).toContain('PublicKey = serverPubKey==\n');
  410. expect(decoded).toContain('Endpoint = awg.example.test:51820');
  411. // No trailing newline: the text ends on its last set field whichever that
  412. // is, so the three emitters produce the same shape for the same client.
  413. expect(decoded.endsWith('PersistentKeepalive = 25')).toBe(true);
  414. });
  415. it('omits every unset 3.1 field — a lone HeaderProtectionKey line would break the handshake', () => {
  416. const decoded = fromBase64Url(genAmneziaWGLink(input).slice('vpn://'.length));
  417. for (const absent of [
  418. 'I2',
  419. 'HeaderProtectionKey',
  420. 'ContentPaddingAddition',
  421. 'RekeyAfterTime',
  422. 'RekeyTimeout',
  423. 'RejectAfterTime',
  424. 'KeepaliveTimeout',
  425. 'MaxHandshakeAttempts',
  426. 'RandomTrailers',
  427. 'DisableCookies',
  428. ]) {
  429. expect(decoded).not.toContain(absent);
  430. }
  431. });
  432. it('returns an empty string when the peer index has no client', () => {
  433. expect(genAmneziaWGLink({ ...input, peerIndex: 5 })).toBe('');
  434. });
  435. // The subscription page's own reverse of the above: recovers a vpn://
  436. // link's .conf text for the same copy/download/QR "Config" block
  437. // WireGuard already gets there (wireguardConfigFromLink's AmneziaWG
  438. // counterpart) -- found missing from that page in production (no
  439. // download-config affordance for AmneziaWG links, unlike WireGuard's),
  440. // even though every other surface in the panel (InboundInfoModal,
  441. // ClientInfoModal, ClientQrModal) already had parity.
  442. it('amneziawgConfigFromLink round-trips genAmneziaWGLink byte-identical to genAmneziaWGConfig', () => {
  443. const link = genAmneziaWGLink(input);
  444. expect(amneziawgConfigFromLink(link)).toBe(genAmneziaWGConfig(input));
  445. });
  446. });
  447. describe('amneziawgConfigFromLink edge cases', () => {
  448. it('returns an empty string for a non-vpn:// link', () => {
  449. expect(amneziawgConfigFromLink('wireguard://abc')).toBe('');
  450. expect(amneziawgConfigFromLink('')).toBe('');
  451. });
  452. it('returns an empty string for an unparseable vpn:// payload', () => {
  453. expect(amneziawgConfigFromLink('vpn://not-valid-base64url!!!')).toBe('');
  454. });
  455. });
  456. /*
  457. * The full AmneziaWG 3.1 parameter block, pinned line-by-line and in order:
  458. * the emitted client config must carry the identical block the Go server
  459. * emitter writes (internal/amneziawg.writeObfuscation) or the tunnel breaks.
  460. */
  461. describe('genAmneziaWGConfig 3.1 parameters', () => {
  462. const settings = {
  463. server: {
  464. publicKey: 'serverPubKey==',
  465. jc: 4,
  466. jmin: 40,
  467. jmax: 100,
  468. s1: 30,
  469. s2: 90,
  470. s3: 20,
  471. s4: 10,
  472. h1: '10-2000',
  473. h2: '3000-5000',
  474. h3: '6000-8000',
  475. h4: '9000-11000',
  476. i1: '<r 64>',
  477. i2: '<r 80>',
  478. i3: '',
  479. i4: '',
  480. i5: '',
  481. headerProtectionKey: 'MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
  482. contentPaddingAddition: '16-48',
  483. rekeyAfterTime: '110-140',
  484. rekeyTimeout: '4-8',
  485. rejectAfterTime: '190-250',
  486. keepaliveTimeout: '9-15',
  487. maxHandshakeAttempts: '20-40',
  488. randomTrailers: true,
  489. disableCookies: true,
  490. },
  491. clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
  492. } as unknown as AmneziawgInboundSettings;
  493. const input = {
  494. settings,
  495. address: 'awg.example.test',
  496. port: 51820,
  497. remark: 'awg-31',
  498. peerIndex: 0,
  499. };
  500. it('emits every 3.1 line in the shared emitter order and round-trips through vpn://', () => {
  501. const cfg = genAmneziaWGConfig(input);
  502. const expectedOrder = [
  503. 'Jc = 4',
  504. 'H4 = 9000-11000',
  505. 'I1 = <r 64>',
  506. 'I2 = <r 80>',
  507. 'HeaderProtectionKey = MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
  508. 'ContentPaddingAddition = 16-48',
  509. 'RekeyAfterTime = 110-140',
  510. 'RekeyTimeout = 4-8',
  511. 'RejectAfterTime = 190-250',
  512. 'KeepaliveTimeout = 9-15',
  513. 'MaxHandshakeAttempts = 20-40',
  514. 'RandomTrailers = on',
  515. 'DisableCookies = on',
  516. '[Peer]',
  517. ];
  518. let pos = -1;
  519. for (const line of expectedOrder) {
  520. const i = cfg.indexOf(line);
  521. expect(i, `missing or out-of-order: ${line}\n${cfg}`).toBeGreaterThan(pos);
  522. pos = i;
  523. }
  524. expect(cfg).not.toContain('I3');
  525. expect(amneziawgConfigFromLink(genAmneziaWGLink(input))).toBe(cfg);
  526. });
  527. });
  528. describe('resolveAddr precedence', () => {
  529. const baseInbound = {
  530. listen: '',
  531. port: 443,
  532. protocol: 'vless' as const,
  533. };
  534. it('prefers hostOverride over listen and fallback', () => {
  535. expect(
  536. resolveAddr(
  537. { ...baseInbound, listen: '10.0.0.1' } as never,
  538. 'cdn.example.test',
  539. 'fallback.test',
  540. ),
  541. ).toBe('cdn.example.test');
  542. });
  543. it('uses listen when override is empty and listen is explicit', () => {
  544. expect(resolveAddr({ ...baseInbound, listen: '10.0.0.1' } as never, '', 'fallback.test')).toBe(
  545. '10.0.0.1',
  546. );
  547. });
  548. it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
  549. expect(resolveAddr({ ...baseInbound, listen: '0.0.0.0' } as never, '', 'fallback.test')).toBe(
  550. 'fallback.test',
  551. );
  552. });
  553. it('skips a unix socket path listen and falls through to fallbackHostname', () => {
  554. expect(
  555. resolveAddr({ ...baseInbound, listen: '/run/xray/in.sock' } as never, '', 'fallback.test'),
  556. ).toBe('fallback.test');
  557. expect(
  558. resolveAddr({ ...baseInbound, listen: '@xray-abstract' } as never, '', 'fallback.test'),
  559. ).toBe('fallback.test');
  560. });
  561. it('falls through to fallbackHostname when listen is empty', () => {
  562. expect(resolveAddr(baseInbound as never, '', 'fallback.test')).toBe('fallback.test');
  563. });
  564. it('uses listen strategy with a shareable IPv6 listen before node override', () => {
  565. expect(
  566. resolveAddr(
  567. {
  568. ...baseInbound,
  569. listen: '[2001:db8::1]',
  570. shareAddrStrategy: 'listen',
  571. shareAddr: '',
  572. } as never,
  573. 'node.example.test',
  574. 'fallback.test',
  575. ),
  576. ).toBe('[2001:db8::1]');
  577. });
  578. it('uses listen strategy to prefer listen and fall back to node override', () => {
  579. expect(
  580. resolveAddr(
  581. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  582. 'node.example.test',
  583. 'fallback.test',
  584. ),
  585. ).toBe('10.0.0.1');
  586. expect(
  587. resolveAddr(
  588. { ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
  589. 'node.example.test',
  590. 'fallback.test',
  591. ),
  592. ).toBe('node.example.test');
  593. expect(
  594. resolveAddr(
  595. {
  596. ...baseInbound,
  597. listen: 'localhost',
  598. shareAddrStrategy: 'listen',
  599. shareAddr: '',
  600. } as never,
  601. 'node.example.test',
  602. 'fallback.test',
  603. ),
  604. ).toBe('node.example.test');
  605. });
  606. it('uses custom strategy address before node override', () => {
  607. expect(
  608. resolveAddr(
  609. {
  610. ...baseInbound,
  611. listen: '10.0.0.1',
  612. shareAddrStrategy: 'custom',
  613. shareAddr: 'edge.example.test',
  614. } as never,
  615. 'node.example.test',
  616. 'fallback.test',
  617. ),
  618. ).toBe('edge.example.test');
  619. });
  620. it('normalizes a bare IPv6 custom strategy address', () => {
  621. expect(
  622. resolveAddr(
  623. {
  624. ...baseInbound,
  625. listen: '10.0.0.1',
  626. shareAddrStrategy: 'custom',
  627. shareAddr: '2001:db8::2',
  628. } as never,
  629. 'node.example.test',
  630. 'fallback.test',
  631. ),
  632. ).toBe('[2001:db8::2]');
  633. });
  634. it('ignores invalid custom strategy addresses and falls back to node override', () => {
  635. for (const shareAddr of [
  636. 'https://edge.example.test',
  637. 'edge.example.test:8443',
  638. '[2001:db8::2]:8443',
  639. 'bad host',
  640. ]) {
  641. expect(
  642. resolveAddr(
  643. { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
  644. 'node.example.test',
  645. 'fallback.test',
  646. ),
  647. ).toBe('node.example.test');
  648. }
  649. });
  650. });
  651. // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
  652. // leak the loopback host into share/QR links; a configured public host wins.
  653. describe('preferPublicHost (loopback fallback)', () => {
  654. it('keeps a routable browser host as-is even when a public host is configured', () => {
  655. expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
  656. expect(preferPublicHost('203.0.113.7', 'sub.example.com')).toBe('203.0.113.7');
  657. });
  658. it('substitutes the public host for loopback browser hosts', () => {
  659. for (const loop of ['127.0.0.1', 'localhost', '::1', '[::1]', '127.5.6.7']) {
  660. expect(preferPublicHost(loop, 'sub.example.com')).toBe('sub.example.com');
  661. }
  662. });
  663. it('leaves loopback untouched when no public host is configured', () => {
  664. expect(preferPublicHost('127.0.0.1', '')).toBe('127.0.0.1');
  665. expect(preferPublicHost('localhost', '')).toBe('localhost');
  666. });
  667. it('an explicit per-inbound listen still wins over the loopback fallback', () => {
  668. const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
  669. expect(
  670. resolveAddr(inbound as never, '', preferPublicHost('127.0.0.1', 'sub.example.com')),
  671. ).toBe('203.0.113.9');
  672. });
  673. });
  674. describe('genInboundLinks orchestrator', () => {
  675. // Every full-inbound fixture should produce the same \r\n-joined link
  676. // block at this baseline.
  677. const fixtures = Object.entries(fullFixtures)
  678. .map(([path, raw]): [string, Record<string, unknown>] => [
  679. fixtureName(path),
  680. raw as Record<string, unknown>,
  681. ])
  682. .sort(([a], [b]) => a.localeCompare(b));
  683. for (const [name, raw] of fixtures) {
  684. it(`${name}: byte-stable`, () => {
  685. const typed = InboundSchema.parse(raw);
  686. const block = genInboundLinks({
  687. inbound: typed,
  688. remark: 'parity-test',
  689. hostOverride: 'override.test',
  690. fallbackHostname: 'fallback.test',
  691. });
  692. expect(block).toMatchSnapshot();
  693. });
  694. }
  695. });
  696. describe('genShadowsocksLink', () => {
  697. const fixtures = fixturesForProtocol('shadowsocks');
  698. expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);
  699. for (const [name, raw] of fixtures) {
  700. it(`${name}: byte-stable`, () => {
  701. const typed = InboundSchema.parse(raw);
  702. const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings;
  703. const client = settings.clients?.[0];
  704. const link = genShadowsocksLink({
  705. inbound: typed,
  706. address: 'example.test',
  707. port: typed.port,
  708. forceTls: 'same',
  709. remark: 'parity-test',
  710. clientPassword: client?.password ?? '',
  711. externalProxy: null,
  712. });
  713. expect(link).toMatchSnapshot();
  714. });
  715. }
  716. });
  717. describe('IPv6 bracket wrapping in share-link authority', () => {
  718. it('genVlessLink brackets a bare IPv6 address', () => {
  719. const [, raw] = fixturesForProtocol('vless')[0];
  720. const typed = InboundSchema.parse(raw);
  721. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0]
  722. .id;
  723. const link = genVlessLink({
  724. inbound: typed,
  725. address: '2001:db8::1',
  726. port: 443,
  727. clientId,
  728. });
  729. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  730. });
  731. it('genTrojanLink brackets a bare IPv6 address', () => {
  732. const [, raw] = fixturesForProtocol('trojan')[0];
  733. const typed = InboundSchema.parse(raw);
  734. const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings
  735. .clients[0].password;
  736. const link = genTrojanLink({
  737. inbound: typed,
  738. address: '2001:db8::1',
  739. port: 443,
  740. clientPassword,
  741. });
  742. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  743. });
  744. it('genShadowsocksLink brackets a bare IPv6 address', () => {
  745. const [, raw] = fixturesForProtocol('shadowsocks')[0];
  746. const typed = InboundSchema.parse(raw);
  747. const clientPassword =
  748. (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]
  749. ?.password ?? '';
  750. const link = genShadowsocksLink({
  751. inbound: typed,
  752. address: '2001:db8::1',
  753. port: 443,
  754. clientPassword,
  755. });
  756. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  757. });
  758. it('genHysteriaLink brackets a bare IPv6 address', () => {
  759. const [, raw] = fixturesForProtocol('hysteria')[0];
  760. const typed = InboundSchema.parse(raw);
  761. const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings
  762. .clients[0].auth;
  763. const link = genHysteriaLink({
  764. inbound: typed,
  765. address: '2001:db8::1',
  766. port: 443,
  767. clientAuth,
  768. });
  769. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  770. });
  771. it('genWireguardLink brackets a bare IPv6 address', () => {
  772. const [, raw] = fixturesForProtocol('wireguard')[0];
  773. const typed = InboundSchema.parse(raw);
  774. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  775. const settings = typed.settings as WireguardInboundSettings;
  776. const link = genWireguardLink({
  777. settings,
  778. address: '2001:db8::1',
  779. port: 443,
  780. peerIndex: 0,
  781. });
  782. expect(new URL(link).host).toBe('[2001:db8::1]:443');
  783. });
  784. it('does not bracket IPv4 addresses or hostnames', () => {
  785. const [, raw] = fixturesForProtocol('vless')[0];
  786. const typed = InboundSchema.parse(raw);
  787. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0]
  788. .id;
  789. const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId });
  790. expect(new URL(v4).host).toBe('203.0.113.7:443');
  791. const host = genVlessLink({ inbound: typed, address: 'example.test', port: 443, clientId });
  792. expect(new URL(host).host).toBe('example.test:443');
  793. });
  794. });
  795. describe('external proxy pinned cert (pcs)', () => {
  796. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
  797. const typed = InboundSchema.parse(raw);
  798. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  799. it('emits the external proxy pin list as pcs when forcing TLS', () => {
  800. const link = genVlessLink({
  801. inbound: typed,
  802. address: 'edge.example.com',
  803. port: 8443,
  804. forceTls: 'tls',
  805. remark: 'ep-pin',
  806. clientId,
  807. externalProxy: {
  808. forceTls: 'tls',
  809. dest: 'edge.example.com',
  810. port: 8443,
  811. remark: 'ep-pin',
  812. pinnedPeerCertSha256: ['aa11', 'bb22'],
  813. },
  814. });
  815. expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
  816. });
  817. it('omits pcs when the external proxy forces security off', () => {
  818. const link = genVlessLink({
  819. inbound: typed,
  820. address: 'edge.example.com',
  821. port: 8080,
  822. forceTls: 'none',
  823. remark: 'ep-none',
  824. clientId,
  825. externalProxy: {
  826. forceTls: 'none',
  827. dest: 'edge.example.com',
  828. port: 8080,
  829. remark: 'ep-none',
  830. pinnedPeerCertSha256: ['aa11'],
  831. },
  832. });
  833. expect(new URL(link).searchParams.has('pcs')).toBe(false);
  834. });
  835. });
  836. // #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
  837. // when VLESS encryption (vlessenc) is on, matching the form's flow display
  838. // and the backend subscription. Gating is via canEnableTlsFlow.
  839. describe('genVlessLink flow gating (#5322)', () => {
  840. function vlessXhttp(encryption: string) {
  841. return InboundSchema.parse({
  842. id: 1,
  843. up: 0,
  844. down: 0,
  845. total: 0,
  846. remark: 'vlessenc',
  847. enable: true,
  848. expiryTime: 0,
  849. listen: '',
  850. port: 443,
  851. tag: 'inbound-vless-xhttp',
  852. sniffing: {
  853. enabled: false,
  854. destOverride: [],
  855. metadataOnly: false,
  856. routeOnly: false,
  857. ipsExcluded: [],
  858. domainsExcluded: [],
  859. },
  860. protocol: 'vless',
  861. settings: {
  862. clients: [
  863. {
  864. id: '11111111-2222-3333-4444-555555555555',
  865. email: '[email protected]',
  866. flow: 'xtls-rprx-vision',
  867. limitIp: 0,
  868. totalGB: 0,
  869. expiryTime: 0,
  870. enable: true,
  871. tgId: 0,
  872. subId: 's1',
  873. comment: '',
  874. reset: 0,
  875. },
  876. ],
  877. decryption: 'none',
  878. encryption,
  879. fallbacks: [],
  880. },
  881. streamSettings: {
  882. network: 'xhttp',
  883. xhttpSettings: {},
  884. security: 'none',
  885. },
  886. });
  887. }
  888. const clientId = '11111111-2222-3333-4444-555555555555';
  889. it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
  890. const link = genVlessLink({
  891. inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
  892. address: 'example.test',
  893. port: 443,
  894. clientId,
  895. flow: 'xtls-rprx-vision',
  896. });
  897. expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
  898. });
  899. it('omits flow for VLESS+XHTTP without vless encryption', () => {
  900. const link = genVlessLink({
  901. inbound: vlessXhttp('none'),
  902. address: 'example.test',
  903. port: 443,
  904. clientId,
  905. flow: 'xtls-rprx-vision',
  906. });
  907. expect(new URL(link).searchParams.has('flow')).toBe(false);
  908. });
  909. it('still emits flow for classic TCP+REALITY Vision', () => {
  910. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
  911. const typed = InboundSchema.parse(raw);
  912. const link = genVlessLink({
  913. inbound: typed,
  914. address: 'example.test',
  915. port: 443,
  916. clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
  917. flow: 'xtls-rprx-vision',
  918. });
  919. expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
  920. });
  921. });
  922. describe('genVlessLink XHTTP extra compatibility', () => {
  923. it('emits both sessionID and legacy session keys in XHTTP extra', () => {
  924. const typed = InboundSchema.parse({
  925. id: 1,
  926. up: 0,
  927. down: 0,
  928. total: 0,
  929. remark: 'xhttp-session',
  930. enable: true,
  931. expiryTime: 0,
  932. listen: '',
  933. port: 443,
  934. tag: 'inbound-vless-xhttp',
  935. sniffing: {
  936. enabled: false,
  937. destOverride: [],
  938. metadataOnly: false,
  939. routeOnly: false,
  940. ipsExcluded: [],
  941. domainsExcluded: [],
  942. },
  943. protocol: 'vless',
  944. settings: {
  945. clients: [
  946. {
  947. id: '11111111-2222-3333-4444-555555555555',
  948. email: '[email protected]',
  949. flow: '',
  950. limitIp: 0,
  951. totalGB: 0,
  952. expiryTime: 0,
  953. enable: true,
  954. tgId: 0,
  955. subId: 's1',
  956. comment: '',
  957. reset: 0,
  958. },
  959. ],
  960. decryption: 'none',
  961. encryption: 'none',
  962. fallbacks: [],
  963. },
  964. streamSettings: {
  965. network: 'xhttp',
  966. security: 'none',
  967. xhttpSettings: {
  968. path: '/sp',
  969. host: 'edge.example.test',
  970. mode: 'auto',
  971. sessionIDPlacement: 'header',
  972. sessionIDKey: 'X-Session',
  973. },
  974. },
  975. });
  976. const link = genVlessLink({
  977. inbound: typed,
  978. address: 'example.test',
  979. port: 443,
  980. clientId: '11111111-2222-3333-4444-555555555555',
  981. });
  982. const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<
  983. string,
  984. unknown
  985. >;
  986. expect(extra.sessionIDPlacement).toBe('header');
  987. expect(extra.sessionIDKey).toBe('X-Session');
  988. expect(extra.sessionPlacement).toBe('header');
  989. expect(extra.sessionKey).toBe('X-Session');
  990. });
  991. });