inbound-link.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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. resolveAddr,
  13. } from '@/lib/xray/inbound-link';
  14. import { InboundSchema } from '@/schemas/api/inbound';
  15. import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
  16. // Snapshot baseline for the share-link generators. Snapshots were locked
  17. // at the close of the legacy class migration — at that point each
  18. // generator was verified byte-equal to the corresponding legacy Inbound
  19. // class method. Future drift past this baseline is a regression.
  20. const fullFixtures = import.meta.glob<unknown>(
  21. './golden/fixtures/inbound-full/*.json',
  22. { eager: true, import: 'default' },
  23. );
  24. function fixtureName(path: string): string {
  25. const file = path.split('/').pop() ?? path;
  26. return file.replace(/\.json$/, '');
  27. }
  28. function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
  29. return Object.entries(fullFixtures)
  30. .filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
  31. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  32. .sort(([a], [b]) => a.localeCompare(b));
  33. }
  34. describe('genVmessLink', () => {
  35. const fixtures = fixturesForProtocol('vmess');
  36. expect(fixtures.length, 'need at least one vmess full-inbound fixture').toBeGreaterThan(0);
  37. for (const [name, raw] of fixtures) {
  38. it(`${name}: byte-stable`, () => {
  39. const typed = InboundSchema.parse(raw);
  40. const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } }).settings;
  41. const client = settings.clients[0];
  42. const link = genVmessLink({
  43. inbound: typed,
  44. address: 'example.test',
  45. port: typed.port,
  46. forceTls: 'same',
  47. remark: 'parity-test',
  48. clientId: client.id,
  49. security: client.security as never,
  50. externalProxy: null,
  51. });
  52. expect(link).toMatchSnapshot();
  53. });
  54. }
  55. });
  56. describe('genVlessLink', () => {
  57. const fixtures = fixturesForProtocol('vless');
  58. expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
  59. for (const [name, raw] of fixtures) {
  60. it(`${name}: byte-stable`, () => {
  61. const typed = InboundSchema.parse(raw);
  62. const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } }).settings;
  63. const client = settings.clients[0];
  64. const link = genVlessLink({
  65. inbound: typed,
  66. address: 'example.test',
  67. port: typed.port,
  68. forceTls: 'same',
  69. remark: 'parity-test',
  70. clientId: client.id,
  71. flow: client.flow as never,
  72. externalProxy: null,
  73. });
  74. expect(link).toMatchSnapshot();
  75. });
  76. }
  77. });
  78. describe('genTrojanLink', () => {
  79. const fixtures = fixturesForProtocol('trojan');
  80. expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);
  81. for (const [name, raw] of fixtures) {
  82. it(`${name}: byte-stable`, () => {
  83. const typed = InboundSchema.parse(raw);
  84. const settings = (raw as { settings: { clients: Array<{ password: string }> } }).settings;
  85. const client = settings.clients[0];
  86. const link = genTrojanLink({
  87. inbound: typed,
  88. address: 'example.test',
  89. port: typed.port,
  90. forceTls: 'same',
  91. remark: 'parity-test',
  92. clientPassword: client.password,
  93. externalProxy: null,
  94. });
  95. expect(link).toMatchSnapshot();
  96. });
  97. }
  98. });
  99. describe('genHysteriaLink', () => {
  100. const fixtures = fixturesForProtocol('hysteria');
  101. expect(fixtures.length, 'need at least one hysteria full-inbound fixture').toBeGreaterThan(0);
  102. for (const [name, raw] of fixtures) {
  103. it(`${name}: byte-stable`, () => {
  104. const typed = InboundSchema.parse(raw);
  105. const settings = (raw as { settings: { clients: Array<{ auth: string }> } }).settings;
  106. const client = settings.clients[0];
  107. const link = genHysteriaLink({
  108. inbound: typed,
  109. address: 'example.test',
  110. port: typed.port,
  111. remark: 'parity-test',
  112. clientAuth: client.auth,
  113. });
  114. expect(link).toMatchSnapshot();
  115. });
  116. }
  117. it('emits the UDP hop range as the v2rayN-compatible mport param', () => {
  118. const [, raw] = fixtures[0];
  119. const withHop = {
  120. ...raw,
  121. settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
  122. streamSettings: {
  123. ...(raw.streamSettings as Record<string, unknown>),
  124. finalmask: { quicParams: { udpHop: { ports: '20000-50000', interval: '5-10' } } },
  125. },
  126. };
  127. const typed = InboundSchema.parse(withHop);
  128. const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
  129. const link = genHysteriaLink({
  130. inbound: typed,
  131. address: 'example.test',
  132. port: typed.port,
  133. remark: 'hop-test',
  134. clientAuth: client.auth,
  135. });
  136. expect(link.startsWith('hysteria2://')).toBe(true);
  137. expect(link).toContain(`@example.test:${typed.port}`);
  138. expect(link).toContain('mport=20000-50000');
  139. expect(link.endsWith('#hop-test')).toBe(true);
  140. });
  141. });
  142. describe('genWireguardLink + genWireguardConfig', () => {
  143. const fixtures = fixturesForProtocol('wireguard');
  144. expect(fixtures.length, 'need at least one wireguard full-inbound fixture').toBeGreaterThan(0);
  145. for (const [name, raw] of fixtures) {
  146. it(`${name}: byte-stable`, () => {
  147. const typed = InboundSchema.parse(raw);
  148. if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
  149. // InboundSchema is an intersection of two DUs, so TS can't auto-narrow
  150. // `settings` from `protocol`. The runtime guard above is the real
  151. // check; this cast just helps the type checker.
  152. const settings = typed.settings as WireguardInboundSettings;
  153. const link = genWireguardLink({
  154. settings,
  155. address: 'wg.example.test',
  156. port: typed.port,
  157. remark: 'wg-peer-1',
  158. peerIndex: 0,
  159. });
  160. const config = genWireguardConfig({
  161. settings,
  162. address: 'wg.example.test',
  163. port: typed.port,
  164. remark: 'wg-peer-1',
  165. peerIndex: 0,
  166. });
  167. expect({ link, config }).toMatchSnapshot();
  168. });
  169. }
  170. });
  171. describe('resolveAddr precedence', () => {
  172. const baseInbound = {
  173. listen: '',
  174. port: 443,
  175. protocol: 'vless' as const,
  176. };
  177. it('prefers hostOverride over listen and fallback', () => {
  178. expect(resolveAddr(
  179. { ...baseInbound, listen: '10.0.0.1' } as never,
  180. 'cdn.example.test',
  181. 'fallback.test',
  182. )).toBe('cdn.example.test');
  183. });
  184. it('uses listen when override is empty and listen is explicit', () => {
  185. expect(resolveAddr(
  186. { ...baseInbound, listen: '10.0.0.1' } as never,
  187. '',
  188. 'fallback.test',
  189. )).toBe('10.0.0.1');
  190. });
  191. it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
  192. expect(resolveAddr(
  193. { ...baseInbound, listen: '0.0.0.0' } as never,
  194. '',
  195. 'fallback.test',
  196. )).toBe('fallback.test');
  197. });
  198. it('skips a unix socket path listen and falls through to fallbackHostname', () => {
  199. expect(resolveAddr(
  200. { ...baseInbound, listen: '/run/xray/in.sock' } as never,
  201. '',
  202. 'fallback.test',
  203. )).toBe('fallback.test');
  204. expect(resolveAddr(
  205. { ...baseInbound, listen: '@xray-abstract' } as never,
  206. '',
  207. 'fallback.test',
  208. )).toBe('fallback.test');
  209. });
  210. it('falls through to fallbackHostname when listen is empty', () => {
  211. expect(resolveAddr(
  212. baseInbound as never,
  213. '',
  214. 'fallback.test',
  215. )).toBe('fallback.test');
  216. });
  217. });
  218. describe('genInboundLinks orchestrator', () => {
  219. // Every full-inbound fixture should produce the same \r\n-joined link
  220. // block at this baseline.
  221. const fixtures = Object.entries(fullFixtures)
  222. .map(([path, raw]): [string, Record<string, unknown>] => [fixtureName(path), raw as Record<string, unknown>])
  223. .sort(([a], [b]) => a.localeCompare(b));
  224. for (const [name, raw] of fixtures) {
  225. it(`${name}: byte-stable`, () => {
  226. const typed = InboundSchema.parse(raw);
  227. const block = genInboundLinks({
  228. inbound: typed,
  229. remark: 'parity-test',
  230. hostOverride: 'override.test',
  231. fallbackHostname: 'fallback.test',
  232. });
  233. expect(block).toMatchSnapshot();
  234. });
  235. }
  236. });
  237. describe('genShadowsocksLink', () => {
  238. const fixtures = fixturesForProtocol('shadowsocks');
  239. expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);
  240. for (const [name, raw] of fixtures) {
  241. it(`${name}: byte-stable`, () => {
  242. const typed = InboundSchema.parse(raw);
  243. const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings;
  244. const client = settings.clients?.[0];
  245. const link = genShadowsocksLink({
  246. inbound: typed,
  247. address: 'example.test',
  248. port: typed.port,
  249. forceTls: 'same',
  250. remark: 'parity-test',
  251. clientPassword: client?.password ?? '',
  252. externalProxy: null,
  253. });
  254. expect(link).toMatchSnapshot();
  255. });
  256. }
  257. });