inbound-link.test.ts 38 KB

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