inbound-link.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. /// <reference types="vite/client" />
  2. import { describe, expect, it } from 'vitest';
  3. import {
  4. genHysteriaLink,
  5. genInboundLinks,
  6. genShadowsocksLink,
  7. genTrojanLink,
  8. genVlessLink,
  9. genVmessLink,
  10. genWireguardConfig,
  11. genWireguardLink,
  12. preferPublicHost,
  13. resolveAddr,
  14. } from '@/lib/xray/inbound-link';
  15. import { InboundSchema } from '@/schemas/api/inbound';
  16. import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
  17. // Snapshot baseline for the share-link generators. Snapshots were locked
  18. // at the close of the legacy class migration — at that point each
  19. // generator was verified byte-equal to the corresponding legacy Inbound
  20. // class method. Future drift past this baseline is a regression.
  21. const fullFixtures = import.meta.glob<unknown>(
  22. './golden/fixtures/inbound-full/*.json',
  23. { eager: true, import: 'default' },
  24. );
  25. function fixtureName(path: string): string {
  26. const file = path.split('/').pop() ?? path;
  27. return file.replace(/\.json$/, '');
  28. }
  29. function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
  30. return Object.entries(fullFixtures)
  31. .filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
  32. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  33. .sort(([a], [b]) => a.localeCompare(b));
  34. }
  35. describe('genVmessLink', () => {
  36. const fixtures = fixturesForProtocol('vmess');
  37. expect(fixtures.length, 'need at least one vmess full-inbound fixture').toBeGreaterThan(0);
  38. for (const [name, raw] of fixtures) {
  39. it(`${name}: byte-stable`, () => {
  40. const typed = InboundSchema.parse(raw);
  41. const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } }).settings;
  42. const client = settings.clients[0];
  43. const link = genVmessLink({
  44. inbound: typed,
  45. address: 'example.test',
  46. port: typed.port,
  47. forceTls: 'same',
  48. remark: 'parity-test',
  49. clientId: client.id,
  50. security: client.security as never,
  51. externalProxy: null,
  52. });
  53. expect(link).toMatchSnapshot();
  54. });
  55. }
  56. });
  57. describe('genVlessLink', () => {
  58. const fixtures = fixturesForProtocol('vless');
  59. expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
  60. for (const [name, raw] of fixtures) {
  61. it(`${name}: byte-stable`, () => {
  62. const typed = InboundSchema.parse(raw);
  63. const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } }).settings;
  64. const client = settings.clients[0];
  65. const link = genVlessLink({
  66. inbound: typed,
  67. address: 'example.test',
  68. port: typed.port,
  69. forceTls: 'same',
  70. remark: 'parity-test',
  71. clientId: client.id,
  72. flow: client.flow as never,
  73. externalProxy: null,
  74. });
  75. expect(link).toMatchSnapshot();
  76. });
  77. }
  78. });
  79. describe('genTrojanLink', () => {
  80. const fixtures = fixturesForProtocol('trojan');
  81. expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);
  82. for (const [name, raw] of fixtures) {
  83. it(`${name}: byte-stable`, () => {
  84. const typed = InboundSchema.parse(raw);
  85. const settings = (raw as { settings: { clients: Array<{ password: string }> } }).settings;
  86. const client = settings.clients[0];
  87. const link = genTrojanLink({
  88. inbound: typed,
  89. address: 'example.test',
  90. port: typed.port,
  91. forceTls: 'same',
  92. remark: 'parity-test',
  93. clientPassword: client.password,
  94. externalProxy: null,
  95. });
  96. expect(link).toMatchSnapshot();
  97. });
  98. }
  99. });
  100. describe('genHysteriaLink', () => {
  101. const fixtures = fixturesForProtocol('hysteria');
  102. expect(fixtures.length, 'need at least one hysteria full-inbound fixture').toBeGreaterThan(0);
  103. for (const [name, raw] of fixtures) {
  104. it(`${name}: byte-stable`, () => {
  105. const typed = InboundSchema.parse(raw);
  106. const settings = (raw as { settings: { clients: Array<{ auth: string }> } }).settings;
  107. const client = settings.clients[0];
  108. const link = genHysteriaLink({
  109. inbound: typed,
  110. address: 'example.test',
  111. port: typed.port,
  112. remark: 'parity-test',
  113. clientAuth: client.auth,
  114. });
  115. expect(link).toMatchSnapshot();
  116. });
  117. }
  118. it('emits the UDP hop range as the v2rayN-compatible mport param', () => {
  119. const [, raw] = fixtures[0];
  120. const withHop = {
  121. ...raw,
  122. settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
  123. streamSettings: {
  124. ...(raw.streamSettings as Record<string, unknown>),
  125. finalmask: { quicParams: { udpHop: { ports: '20000-50000', interval: '5-10' } } },
  126. },
  127. };
  128. const typed = InboundSchema.parse(withHop);
  129. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  130. const link = genHysteriaLink({
  131. inbound: typed,
  132. address: 'example.test',
  133. port: typed.port,
  134. remark: 'hop-test',
  135. clientAuth: client.auth,
  136. });
  137. expect(link.startsWith('hysteria2://')).toBe(true);
  138. expect(link).toContain(`@example.test:${typed.port}`);
  139. expect(link).toContain('mport=20000-50000');
  140. expect(link.endsWith('#hop-test')).toBe(true);
  141. });
  142. it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
  143. const [, raw] = fixtures[0];
  144. const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
  145. const hexPin = '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293';
  146. const colonPin = 'C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4';
  147. const stream = raw.streamSettings as Record<string, unknown>;
  148. const tls = stream.tlsSettings as Record<string, unknown>;
  149. const tlsClientSettings = tls.settings as Record<string, unknown>;
  150. const withPins = {
  151. ...raw,
  152. streamSettings: {
  153. ...stream,
  154. tlsSettings: {
  155. ...tls,
  156. settings: { ...tlsClientSettings, pinnedPeerCertSha256: [base64Pin, hexPin, colonPin] },
  157. },
  158. },
  159. };
  160. const typed = InboundSchema.parse(withPins);
  161. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  162. const link = genHysteriaLink({
  163. inbound: typed,
  164. address: 'example.test',
  165. port: typed.port,
  166. remark: 'pin-test',
  167. clientAuth: client.auth,
  168. });
  169. const pin = new URL(link).searchParams.get('pinSHA256');
  170. expect(pin).toBe(
  171. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4,' +
  172. '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293,' +
  173. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
  174. );
  175. });
  176. it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => {
  177. const [, raw] = fixtures[0];
  178. const typed = InboundSchema.parse(raw);
  179. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  180. const link = genHysteriaLink({
  181. inbound: typed,
  182. address: 'edge.example.com',
  183. port: 8443,
  184. remark: 'ep-pin',
  185. clientAuth: client.auth,
  186. externalProxy: {
  187. forceTls: 'tls',
  188. dest: 'edge.example.com',
  189. port: 8443,
  190. remark: 'ep-pin',
  191. // base64 SHA-256 — must come out hex-normalized for Hysteria.
  192. pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='],
  193. },
  194. });
  195. const url = new URL(link);
  196. expect(url.searchParams.get('pinSHA256')).toBe(
  197. 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
  198. );
  199. expect(url.searchParams.has('pcs')).toBe(false);
  200. });
  201. });
  202. describe('genWireguardLink + genWireguardConfig', () => {
  203. const fixtures = fixturesForProtocol('wireguard');
  204. expect(fixtures.length, 'need at least one wireguard full-inbound fixture').toBeGreaterThan(0);
  205. for (const [name, raw] of fixtures) {
  206. it(`${name}: byte-stable`, () => {
  207. const typed = InboundSchema.parse(raw);
  208. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  209. // InboundSchema is an intersection of two DUs, so TS can't auto-narrow
  210. // `settings` from `protocol`. The runtime guard above is the real
  211. // check; this cast just helps the type checker.
  212. const settings = typed.settings as WireguardInboundSettings;
  213. const link = genWireguardLink({
  214. settings,
  215. address: 'wg.example.test',
  216. port: typed.port,
  217. remark: 'wg-peer-1',
  218. peerIndex: 0,
  219. });
  220. const config = genWireguardConfig({
  221. settings,
  222. address: 'wg.example.test',
  223. port: typed.port,
  224. remark: 'wg-peer-1',
  225. peerIndex: 0,
  226. });
  227. expect({ link, config }).toMatchSnapshot();
  228. });
  229. }
  230. });
  231. describe('resolveAddr precedence', () => {
  232. const baseInbound = {
  233. listen: '',
  234. port: 443,
  235. protocol: 'vless' as const,
  236. };
  237. it('prefers hostOverride over listen and fallback', () => {
  238. expect(resolveAddr(
  239. { ...baseInbound, listen: '10.0.0.1' } as never,
  240. 'cdn.example.test',
  241. 'fallback.test',
  242. )).toBe('cdn.example.test');
  243. });
  244. it('uses listen when override is empty and listen is explicit', () => {
  245. expect(resolveAddr(
  246. { ...baseInbound, listen: '10.0.0.1' } as never,
  247. '',
  248. 'fallback.test',
  249. )).toBe('10.0.0.1');
  250. });
  251. it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
  252. expect(resolveAddr(
  253. { ...baseInbound, listen: '0.0.0.0' } as never,
  254. '',
  255. 'fallback.test',
  256. )).toBe('fallback.test');
  257. });
  258. it('skips a unix socket path listen and falls through to fallbackHostname', () => {
  259. expect(resolveAddr(
  260. { ...baseInbound, listen: '/run/xray/in.sock' } as never,
  261. '',
  262. 'fallback.test',
  263. )).toBe('fallback.test');
  264. expect(resolveAddr(
  265. { ...baseInbound, listen: '@xray-abstract' } as never,
  266. '',
  267. 'fallback.test',
  268. )).toBe('fallback.test');
  269. });
  270. it('falls through to fallbackHostname when listen is empty', () => {
  271. expect(resolveAddr(
  272. baseInbound as never,
  273. '',
  274. 'fallback.test',
  275. )).toBe('fallback.test');
  276. });
  277. });
  278. // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
  279. // leak the loopback host into share/QR links; a configured public host wins.
  280. describe('preferPublicHost (loopback fallback)', () => {
  281. it('keeps a routable browser host as-is even when a public host is configured', () => {
  282. expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
  283. expect(preferPublicHost('203.0.113.7', 'sub.example.com')).toBe('203.0.113.7');
  284. });
  285. it('substitutes the public host for loopback browser hosts', () => {
  286. for (const loop of ['127.0.0.1', 'localhost', '::1', '[::1]', '127.5.6.7']) {
  287. expect(preferPublicHost(loop, 'sub.example.com')).toBe('sub.example.com');
  288. }
  289. });
  290. it('leaves loopback untouched when no public host is configured', () => {
  291. expect(preferPublicHost('127.0.0.1', '')).toBe('127.0.0.1');
  292. expect(preferPublicHost('localhost', '')).toBe('localhost');
  293. });
  294. it('an explicit per-inbound listen still wins over the loopback fallback', () => {
  295. const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
  296. expect(resolveAddr(
  297. inbound as never,
  298. '',
  299. preferPublicHost('127.0.0.1', 'sub.example.com'),
  300. )).toBe('203.0.113.9');
  301. });
  302. });
  303. describe('genInboundLinks orchestrator', () => {
  304. // Every full-inbound fixture should produce the same \r\n-joined link
  305. // block at this baseline.
  306. const fixtures = Object.entries(fullFixtures)
  307. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  308. .sort(([a], [b]) => a.localeCompare(b));
  309. for (const [name, raw] of fixtures) {
  310. it(`${name}: byte-stable`, () => {
  311. const typed = InboundSchema.parse(raw);
  312. const block = genInboundLinks({
  313. inbound: typed,
  314. remark: 'parity-test',
  315. hostOverride: 'override.test',
  316. fallbackHostname: 'fallback.test',
  317. });
  318. expect(block).toMatchSnapshot();
  319. });
  320. }
  321. });
  322. describe('genShadowsocksLink', () => {
  323. const fixtures = fixturesForProtocol('shadowsocks');
  324. expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);
  325. for (const [name, raw] of fixtures) {
  326. it(`${name}: byte-stable`, () => {
  327. const typed = InboundSchema.parse(raw);
  328. const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings;
  329. const client = settings.clients?.[0];
  330. const link = genShadowsocksLink({
  331. inbound: typed,
  332. address: 'example.test',
  333. port: typed.port,
  334. forceTls: 'same',
  335. remark: 'parity-test',
  336. clientPassword: client?.password ?? '',
  337. externalProxy: null,
  338. });
  339. expect(link).toMatchSnapshot();
  340. });
  341. }
  342. });
  343. describe('external proxy pinned cert (pcs)', () => {
  344. const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
  345. const typed = InboundSchema.parse(raw);
  346. const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
  347. it('emits the external proxy pin list as pcs when forcing TLS', () => {
  348. const link = genVlessLink({
  349. inbound: typed,
  350. address: 'edge.example.com',
  351. port: 8443,
  352. forceTls: 'tls',
  353. remark: 'ep-pin',
  354. clientId,
  355. externalProxy: {
  356. forceTls: 'tls',
  357. dest: 'edge.example.com',
  358. port: 8443,
  359. remark: 'ep-pin',
  360. pinnedPeerCertSha256: ['aa11', 'bb22'],
  361. },
  362. });
  363. expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
  364. });
  365. it('omits pcs when the external proxy forces security off', () => {
  366. const link = genVlessLink({
  367. inbound: typed,
  368. address: 'edge.example.com',
  369. port: 8080,
  370. forceTls: 'none',
  371. remark: 'ep-none',
  372. clientId,
  373. externalProxy: {
  374. forceTls: 'none',
  375. dest: 'edge.example.com',
  376. port: 8080,
  377. remark: 'ep-none',
  378. pinnedPeerCertSha256: ['aa11'],
  379. },
  380. });
  381. expect(new URL(link).searchParams.has('pcs')).toBe(false);
  382. });
  383. });