| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089 |
- /// <reference types="vite/client" />
- import { describe, expect, it } from 'vitest';
- import {
- amneziawgConfigFromLink,
- genAmneziaWGConfig,
- genAmneziaWGLink,
- genHysteriaLink,
- genInboundLinks,
- genShadowsocksLink,
- genTrojanLink,
- applyVlessRoute,
- genVlessLink,
- genVmessLink,
- genWireguardConfig,
- genWireguardLink,
- preferPublicHost,
- resolveAddr,
- } from '@/lib/xray/inbound-link';
- import { InboundSchema } from '@/schemas/api/inbound';
- import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
- import type { WireguardInboundSettings } from '@/schemas/protocols/inbound/wireguard';
- // reverse of inbound-link.ts's own toBase64Url, for asserting on the
- // decoded vpn:// payload without depending on that helper being exported.
- function fromBase64Url(value: string): string {
- const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
- const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
- return atob(padded);
- }
- // Snapshot baseline for the share-link generators. Snapshots were locked
- // at the close of the legacy class migration — at that point each
- // generator was verified byte-equal to the corresponding legacy Inbound
- // class method. Future drift past this baseline is a regression.
- const fullFixtures = import.meta.glob<unknown>('./golden/fixtures/inbound-full/*.json', {
- eager: true,
- import: 'default',
- });
- function fixtureName(path: string): string {
- const file = path.split('/').pop() ?? path;
- return file.replace(/\.json$/, '');
- }
- function fixturesForProtocol(protocol: string): Array<[string, Record<string, unknown>]> {
- return Object.entries(fullFixtures)
- .filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
- .map(([path, raw]): [string, Record<string, unknown>] => [
- fixtureName(path),
- raw as Record<string, unknown>,
- ])
- .sort(([a], [b]) => a.localeCompare(b));
- }
- describe('genVmessLink', () => {
- const fixtures = fixturesForProtocol('vmess');
- expect(fixtures.length, 'need at least one vmess full-inbound fixture').toBeGreaterThan(0);
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } })
- .settings;
- const client = settings.clients[0];
- const link = genVmessLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- forceTls: 'same',
- remark: 'parity-test',
- clientId: client.id,
- security: client.security as never,
- externalProxy: null,
- });
- expect(link).toMatchSnapshot();
- });
- }
- });
- describe('genVlessLink', () => {
- const fixtures = fixturesForProtocol('vless');
- expect(fixtures.length, 'need at least one vless full-inbound fixture').toBeGreaterThan(0);
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } })
- .settings;
- const client = settings.clients[0];
- const link = genVlessLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- forceTls: 'same',
- remark: 'parity-test',
- clientId: client.id,
- flow: client.flow as never,
- externalProxy: null,
- });
- expect(link).toMatchSnapshot();
- });
- }
- });
- describe('applyVlessRoute', () => {
- const id = '11111111-2222-4333-8444-555555555555';
- it('encodes a single value into the 3rd group and no-ops on invalid input', () => {
- expect(applyVlessRoute(id, '443')).toBe('11111111-2222-01bb-8444-555555555555');
- expect(applyVlessRoute(id, '53')).toBe('11111111-2222-0035-8444-555555555555');
- expect(applyVlessRoute(id, '0')).toBe('11111111-2222-0000-8444-555555555555');
- expect(applyVlessRoute(id, '65535')).toBe('11111111-2222-ffff-8444-555555555555');
- expect(applyVlessRoute(id, '')).toBe(id);
- expect(applyVlessRoute(id, undefined)).toBe(id);
- expect(applyVlessRoute(id, '70000')).toBe(id);
- expect(applyVlessRoute(id, '53,443')).toBe(id);
- expect(applyVlessRoute(id, 'abc')).toBe(id);
- expect(applyVlessRoute('short', '443')).toBe('short');
- });
- });
- describe('genVlessLink vlessRoute', () => {
- const [, raw] = fixturesForProtocol('vless')[0];
- const typed = InboundSchema.parse(raw);
- it('bakes a host route value into the link UUID 3rd group', () => {
- const link = genVlessLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- forceTls: 'same',
- remark: 'r',
- clientId: '11111111-2222-4333-8444-555555555555',
- flow: '' as never,
- externalProxy: {
- forceTls: 'same',
- dest: 'example.test',
- port: typed.port,
- remark: '',
- vlessRoute: '443',
- },
- });
- expect(link).toContain('vless://11111111-2222-01bb-8444-555555555555@');
- });
- it('leaves the UUID unchanged when no route is set', () => {
- const link = genVlessLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- forceTls: 'same',
- remark: 'r',
- clientId: '11111111-2222-4333-8444-555555555555',
- flow: '' as never,
- externalProxy: null,
- });
- expect(link).toContain('vless://11111111-2222-4333-8444-555555555555@');
- });
- });
- describe('genTrojanLink', () => {
- const fixtures = fixturesForProtocol('trojan');
- expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- const settings = (raw as { settings: { clients: Array<{ password: string }> } }).settings;
- const client = settings.clients[0];
- const link = genTrojanLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- forceTls: 'same',
- remark: 'parity-test',
- clientPassword: client.password,
- externalProxy: null,
- });
- expect(link).toMatchSnapshot();
- });
- }
- });
- describe('genHysteriaLink', () => {
- const fixtures = fixturesForProtocol('hysteria');
- expect(fixtures.length, 'need at least one hysteria full-inbound fixture').toBeGreaterThan(0);
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- const settings = (raw as { settings: { clients: Array<{ auth: string }> } }).settings;
- const client = settings.clients[0];
- const link = genHysteriaLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- remark: 'parity-test',
- clientAuth: client.auth,
- });
- expect(link).toMatchSnapshot();
- });
- }
- it('emits the UDP hop range as the v2rayN-compatible mport param', () => {
- const [, raw] = fixtures[0];
- const withHop = {
- ...raw,
- settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
- streamSettings: {
- ...(raw.streamSettings as Record<string, unknown>),
- finalmask: { quicParams: { udpHop: { ports: '20000-50000', interval: '5-10' } } },
- },
- };
- const typed = InboundSchema.parse(withHop);
- const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
- const link = genHysteriaLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- remark: 'hop-test',
- clientAuth: client.auth,
- });
- expect(link.startsWith('hysteria2://')).toBe(true);
- expect(link).toContain(`@example.test:${typed.port}`);
- expect(link).toContain('mport=20000-50000');
- expect(link.endsWith('#hop-test')).toBe(true);
- });
- it('emits mport from the udphop mask xray-core 26.9.9 moved hopping to', () => {
- const [, raw] = fixtures[0];
- const withHop = {
- ...raw,
- settings: { ...(raw.settings as Record<string, unknown>), version: 2 },
- streamSettings: {
- ...(raw.streamSettings as Record<string, unknown>),
- finalmask: {
- udp: [
- {
- type: 'udphop',
- settings: { mode: 'intervalremote', interval: '5-10', remotePorts: '30000-40000' },
- },
- ],
- },
- },
- };
- const typed = InboundSchema.parse(withHop);
- const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
- const link = genHysteriaLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- remark: 'hop-mask',
- clientAuth: client.auth,
- });
- expect(link).toContain('mport=30000-40000');
- });
- it('normalizes pinSHA256 to hex for base64, raw-hex and colon-hex pins (issue #4818)', () => {
- const [, raw] = fixtures[0];
- const base64Pin = 'yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=';
- const hexPin = '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293';
- 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';
- const stream = raw.streamSettings as Record<string, unknown>;
- const tls = stream.tlsSettings as Record<string, unknown>;
- const tlsClientSettings = tls.settings as Record<string, unknown>;
- const withPins = {
- ...raw,
- streamSettings: {
- ...stream,
- tlsSettings: {
- ...tls,
- settings: { ...tlsClientSettings, pinnedPeerCertSha256: [base64Pin, hexPin, colonPin] },
- },
- },
- };
- const typed = InboundSchema.parse(withPins);
- const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
- const link = genHysteriaLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- remark: 'pin-test',
- clientAuth: client.auth,
- });
- const pin = new URL(link).searchParams.get('pinSHA256');
- expect(pin).toBe(
- 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4,' +
- '84491c0312d9e70f519ce24659a2ca7d9c4ec59dc86417ece426945e0f939293,' +
- 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
- );
- });
- it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => {
- const [, raw] = fixtures[0];
- const typed = InboundSchema.parse(raw);
- const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
- const link = genHysteriaLink({
- inbound: typed,
- address: 'edge.example.com',
- port: 8443,
- remark: 'ep-pin',
- clientAuth: client.auth,
- externalProxy: {
- forceTls: 'tls',
- dest: 'edge.example.com',
- port: 8443,
- remark: 'ep-pin',
- // base64 SHA-256 — must come out hex-normalized for Hysteria.
- pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='],
- },
- });
- const url = new URL(link);
- expect(url.searchParams.get('pinSHA256')).toBe(
- 'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
- );
- expect(url.searchParams.has('pcs')).toBe(false);
- });
- });
- describe('genWireguardLink + genWireguardConfig', () => {
- const fixtures = fixturesForProtocol('wireguard');
- expect(fixtures.length, 'need at least one wireguard full-inbound fixture').toBeGreaterThan(0);
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
- // InboundSchema is an intersection of two DUs, so TS can't auto-narrow
- // `settings` from `protocol`. The runtime guard above is the real
- // check; this cast just helps the type checker.
- const settings = typed.settings as WireguardInboundSettings;
- const link = genWireguardLink({
- settings,
- address: 'wg.example.test',
- port: typed.port,
- remark: 'wg-peer-1',
- peerIndex: 0,
- });
- const config = genWireguardConfig({
- settings,
- address: 'wg.example.test',
- port: typed.port,
- remark: 'wg-peer-1',
- peerIndex: 0,
- });
- expect({ link, config }).toMatchSnapshot();
- });
- }
- });
- describe('genWireguardLink + genWireguardConfig multi allowedIPs', () => {
- const settings = {
- secretKey: '',
- mtu: 1280,
- dns: '',
- peers: [
- {
- privateKey: 'cLI',
- allowedIPs: ['10.0.0.2/32', 'fd00::2/128'],
- },
- ],
- } as unknown as WireguardInboundSettings;
- it('joins every allowed IP into the share-link address param', () => {
- const link = genWireguardLink({
- settings,
- address: 'wg.example.test',
- port: 51820,
- remark: 'dual-stack',
- peerIndex: 0,
- });
- const u = new URL(link);
- expect(u.searchParams.get('address')).toBe('10.0.0.2/32,fd00::2/128');
- });
- it('joins every allowed IP into the .conf Address line', () => {
- const config = genWireguardConfig({
- settings,
- address: 'wg.example.test',
- port: 51820,
- remark: 'dual-stack',
- peerIndex: 0,
- });
- expect(config).toContain('Address = 10.0.0.2/32, fd00::2/128\n');
- });
- });
- // Real AmneziaVPN app's import path (confirmed by reading its own source)
- // base64url-decodes a vpn:// link, best-effort decompresses it (falling back
- // to the raw bytes for plain text, which is never qCompress-framed), then
- // parses the result as a flat "Key = Value" bag -- so genAmneziaWGLink just
- // needs to wrap genAmneziaWGConfig's already-correct .conf text.
- describe('genAmneziaWGLink vpn:// scheme', () => {
- const settings = {
- server: {
- publicKey: 'serverPubKey==',
- mtu: 1420,
- primaryDns: '8.8.8.8',
- secondaryDns: '8.8.4.4',
- jc: 5,
- jmin: 10,
- jmax: 50,
- s1: 30,
- s2: 45,
- s3: 10,
- s4: 5,
- h1: '',
- h2: '',
- h3: '',
- h4: '',
- i1: '',
- },
- clients: [
- {
- email: 'peer-1',
- privateKey: 'clientPrivKey==',
- allowedIPs: ['10.8.1.2/32'],
- keepAlive: 25,
- },
- ],
- } as unknown as AmneziawgInboundSettings;
- const input = {
- settings,
- address: 'awg.example.test',
- port: 51820,
- remark: 'awg-peer-1',
- peerIndex: 0,
- };
- it('wraps the .conf text as a base64url-encoded vpn:// link, byte-identical to genAmneziaWGConfig', () => {
- const link = genAmneziaWGLink(input);
- expect(link.startsWith('vpn://')).toBe(true);
- const decoded = fromBase64Url(link.slice('vpn://'.length));
- expect(decoded).toBe(genAmneziaWGConfig(input));
- expect(decoded).toContain('PrivateKey = clientPrivKey==\n');
- expect(decoded).toContain('PublicKey = serverPubKey==\n');
- expect(decoded).toContain('Endpoint = awg.example.test:51820');
- // No trailing newline: the text ends on its last set field whichever that
- // is, so the three emitters produce the same shape for the same client.
- expect(decoded.endsWith('PersistentKeepalive = 25')).toBe(true);
- });
- it('omits every unset 3.1 field — a lone HeaderProtectionKey line would break the handshake', () => {
- const decoded = fromBase64Url(genAmneziaWGLink(input).slice('vpn://'.length));
- for (const absent of [
- 'I2',
- 'HeaderProtectionKey',
- 'ContentPaddingAddition',
- 'RekeyAfterTime',
- 'RekeyTimeout',
- 'RejectAfterTime',
- 'KeepaliveTimeout',
- 'MaxHandshakeAttempts',
- 'RandomTrailers',
- 'DisableCookies',
- ]) {
- expect(decoded).not.toContain(absent);
- }
- });
- it('returns an empty string when the peer index has no client', () => {
- expect(genAmneziaWGLink({ ...input, peerIndex: 5 })).toBe('');
- });
- // The subscription page's own reverse of the above: recovers a vpn://
- // link's .conf text for the same copy/download/QR "Config" block
- // WireGuard already gets there (wireguardConfigFromLink's AmneziaWG
- // counterpart) -- found missing from that page in production (no
- // download-config affordance for AmneziaWG links, unlike WireGuard's),
- // even though every other surface in the panel (InboundInfoModal,
- // ClientInfoModal, ClientQrModal) already had parity.
- it('amneziawgConfigFromLink round-trips genAmneziaWGLink byte-identical to genAmneziaWGConfig', () => {
- const link = genAmneziaWGLink(input);
- expect(amneziawgConfigFromLink(link)).toBe(genAmneziaWGConfig(input));
- });
- });
- describe('amneziawgConfigFromLink edge cases', () => {
- it('returns an empty string for a non-vpn:// link', () => {
- expect(amneziawgConfigFromLink('wireguard://abc')).toBe('');
- expect(amneziawgConfigFromLink('')).toBe('');
- });
- it('returns an empty string for an unparseable vpn:// payload', () => {
- expect(amneziawgConfigFromLink('vpn://not-valid-base64url!!!')).toBe('');
- });
- });
- /*
- * The full AmneziaWG 3.1 parameter block, pinned line-by-line and in order:
- * the emitted client config must carry the identical block the Go server
- * emitter writes (internal/amneziawg.writeObfuscation) or the tunnel breaks.
- */
- describe('genAmneziaWGConfig 3.1 parameters', () => {
- const settings = {
- server: {
- publicKey: 'serverPubKey==',
- jc: 4,
- jmin: 40,
- jmax: 100,
- s1: 30,
- s2: 90,
- s3: 20,
- s4: 10,
- h1: '10-2000',
- h2: '3000-5000',
- h3: '6000-8000',
- h4: '9000-11000',
- i1: '<r 64>',
- i2: '<r 80>',
- i3: '',
- i4: '',
- i5: '',
- headerProtectionKey: 'MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
- contentPaddingAddition: '16-48',
- rekeyAfterTime: '110-140',
- rekeyTimeout: '4-8',
- rejectAfterTime: '190-250',
- keepaliveTimeout: '9-15',
- maxHandshakeAttempts: '20-40',
- randomTrailers: true,
- disableCookies: true,
- },
- clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
- } as unknown as AmneziawgInboundSettings;
- const input = {
- settings,
- address: 'awg.example.test',
- port: 51820,
- remark: 'awg-31',
- peerIndex: 0,
- };
- it('emits every 3.1 line in the shared emitter order and round-trips through vpn://', () => {
- const cfg = genAmneziaWGConfig(input);
- const expectedOrder = [
- 'Jc = 4',
- 'H4 = 9000-11000',
- 'I1 = <r 64>',
- 'I2 = <r 80>',
- 'HeaderProtectionKey = MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=',
- 'ContentPaddingAddition = 16-48',
- 'RekeyAfterTime = 110-140',
- 'RekeyTimeout = 4-8',
- 'RejectAfterTime = 190-250',
- 'KeepaliveTimeout = 9-15',
- 'MaxHandshakeAttempts = 20-40',
- 'RandomTrailers = on',
- 'DisableCookies = on',
- '[Peer]',
- ];
- let pos = -1;
- for (const line of expectedOrder) {
- const i = cfg.indexOf(line);
- expect(i, `missing or out-of-order: ${line}\n${cfg}`).toBeGreaterThan(pos);
- pos = i;
- }
- expect(cfg).not.toContain('I3');
- expect(amneziawgConfigFromLink(genAmneziaWGLink(input))).toBe(cfg);
- });
- });
- describe('resolveAddr precedence', () => {
- const baseInbound = {
- listen: '',
- port: 443,
- protocol: 'vless' as const,
- };
- it('prefers hostOverride over listen and fallback', () => {
- expect(
- resolveAddr(
- { ...baseInbound, listen: '10.0.0.1' } as never,
- 'cdn.example.test',
- 'fallback.test',
- ),
- ).toBe('cdn.example.test');
- });
- it('uses listen when override is empty and listen is explicit', () => {
- expect(resolveAddr({ ...baseInbound, listen: '10.0.0.1' } as never, '', 'fallback.test')).toBe(
- '10.0.0.1',
- );
- });
- it('skips listen when it is 0.0.0.0 and falls through to fallbackHostname', () => {
- expect(resolveAddr({ ...baseInbound, listen: '0.0.0.0' } as never, '', 'fallback.test')).toBe(
- 'fallback.test',
- );
- });
- it('skips a unix socket path listen and falls through to fallbackHostname', () => {
- expect(
- resolveAddr({ ...baseInbound, listen: '/run/xray/in.sock' } as never, '', 'fallback.test'),
- ).toBe('fallback.test');
- expect(
- resolveAddr({ ...baseInbound, listen: '@xray-abstract' } as never, '', 'fallback.test'),
- ).toBe('fallback.test');
- });
- it('falls through to fallbackHostname when listen is empty', () => {
- expect(resolveAddr(baseInbound as never, '', 'fallback.test')).toBe('fallback.test');
- });
- it('uses listen strategy with a shareable IPv6 listen before node override', () => {
- expect(
- resolveAddr(
- {
- ...baseInbound,
- listen: '[2001:db8::1]',
- shareAddrStrategy: 'listen',
- shareAddr: '',
- } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('[2001:db8::1]');
- });
- it('uses listen strategy to prefer listen and fall back to node override', () => {
- expect(
- resolveAddr(
- { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('10.0.0.1');
- expect(
- resolveAddr(
- { ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('node.example.test');
- expect(
- resolveAddr(
- {
- ...baseInbound,
- listen: 'localhost',
- shareAddrStrategy: 'listen',
- shareAddr: '',
- } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('node.example.test');
- });
- it('uses custom strategy address before node override', () => {
- expect(
- resolveAddr(
- {
- ...baseInbound,
- listen: '10.0.0.1',
- shareAddrStrategy: 'custom',
- shareAddr: 'edge.example.test',
- } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('edge.example.test');
- });
- it('normalizes a bare IPv6 custom strategy address', () => {
- expect(
- resolveAddr(
- {
- ...baseInbound,
- listen: '10.0.0.1',
- shareAddrStrategy: 'custom',
- shareAddr: '2001:db8::2',
- } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('[2001:db8::2]');
- });
- it('ignores invalid custom strategy addresses and falls back to node override', () => {
- for (const shareAddr of [
- 'https://edge.example.test',
- 'edge.example.test:8443',
- '[2001:db8::2]:8443',
- 'bad host',
- ]) {
- expect(
- resolveAddr(
- { ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
- 'node.example.test',
- 'fallback.test',
- ),
- ).toBe('node.example.test');
- }
- });
- });
- // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
- // leak the loopback host into share/QR links; a configured public host wins.
- describe('preferPublicHost (loopback fallback)', () => {
- it('keeps a routable browser host as-is even when a public host is configured', () => {
- expect(preferPublicHost('panel.example.com', 'sub.example.com')).toBe('panel.example.com');
- expect(preferPublicHost('203.0.113.7', 'sub.example.com')).toBe('203.0.113.7');
- });
- it('substitutes the public host for loopback browser hosts', () => {
- for (const loop of ['127.0.0.1', 'localhost', '::1', '[::1]', '127.5.6.7']) {
- expect(preferPublicHost(loop, 'sub.example.com')).toBe('sub.example.com');
- }
- });
- it('leaves loopback untouched when no public host is configured', () => {
- expect(preferPublicHost('127.0.0.1', '')).toBe('127.0.0.1');
- expect(preferPublicHost('localhost', '')).toBe('localhost');
- });
- it('an explicit per-inbound listen still wins over the loopback fallback', () => {
- const inbound = { listen: '203.0.113.9', port: 443, protocol: 'vless' as const };
- expect(
- resolveAddr(inbound as never, '', preferPublicHost('127.0.0.1', 'sub.example.com')),
- ).toBe('203.0.113.9');
- });
- });
- describe('genInboundLinks orchestrator', () => {
- // Every full-inbound fixture should produce the same \r\n-joined link
- // block at this baseline.
- const fixtures = Object.entries(fullFixtures)
- .map(([path, raw]): [string, Record<string, unknown>] => [
- fixtureName(path),
- raw as Record<string, unknown>,
- ])
- .sort(([a], [b]) => a.localeCompare(b));
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- const block = genInboundLinks({
- inbound: typed,
- remark: 'parity-test',
- hostOverride: 'override.test',
- fallbackHostname: 'fallback.test',
- });
- expect(block).toMatchSnapshot();
- });
- }
- });
- describe('genShadowsocksLink', () => {
- const fixtures = fixturesForProtocol('shadowsocks');
- expect(fixtures.length, 'need at least one shadowsocks full-inbound fixture').toBeGreaterThan(0);
- for (const [name, raw] of fixtures) {
- it(`${name}: byte-stable`, () => {
- const typed = InboundSchema.parse(raw);
- const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings;
- const client = settings.clients?.[0];
- const link = genShadowsocksLink({
- inbound: typed,
- address: 'example.test',
- port: typed.port,
- forceTls: 'same',
- remark: 'parity-test',
- clientPassword: client?.password ?? '',
- externalProxy: null,
- });
- expect(link).toMatchSnapshot();
- });
- }
- });
- describe('IPv6 bracket wrapping in share-link authority', () => {
- it('genVlessLink brackets a bare IPv6 address', () => {
- const [, raw] = fixturesForProtocol('vless')[0];
- const typed = InboundSchema.parse(raw);
- const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0]
- .id;
- const link = genVlessLink({
- inbound: typed,
- address: '2001:db8::1',
- port: 443,
- clientId,
- });
- expect(new URL(link).host).toBe('[2001:db8::1]:443');
- });
- it('genTrojanLink brackets a bare IPv6 address', () => {
- const [, raw] = fixturesForProtocol('trojan')[0];
- const typed = InboundSchema.parse(raw);
- const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings
- .clients[0].password;
- const link = genTrojanLink({
- inbound: typed,
- address: '2001:db8::1',
- port: 443,
- clientPassword,
- });
- expect(new URL(link).host).toBe('[2001:db8::1]:443');
- });
- it('genShadowsocksLink brackets a bare IPv6 address', () => {
- const [, raw] = fixturesForProtocol('shadowsocks')[0];
- const typed = InboundSchema.parse(raw);
- const clientPassword =
- (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]
- ?.password ?? '';
- const link = genShadowsocksLink({
- inbound: typed,
- address: '2001:db8::1',
- port: 443,
- clientPassword,
- });
- expect(new URL(link).host).toBe('[2001:db8::1]:443');
- });
- it('genHysteriaLink brackets a bare IPv6 address', () => {
- const [, raw] = fixturesForProtocol('hysteria')[0];
- const typed = InboundSchema.parse(raw);
- const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings
- .clients[0].auth;
- const link = genHysteriaLink({
- inbound: typed,
- address: '2001:db8::1',
- port: 443,
- clientAuth,
- });
- expect(new URL(link).host).toBe('[2001:db8::1]:443');
- });
- it('genWireguardLink brackets a bare IPv6 address', () => {
- const [, raw] = fixturesForProtocol('wireguard')[0];
- const typed = InboundSchema.parse(raw);
- if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
- const settings = typed.settings as WireguardInboundSettings;
- const link = genWireguardLink({
- settings,
- address: '2001:db8::1',
- port: 443,
- peerIndex: 0,
- });
- expect(new URL(link).host).toBe('[2001:db8::1]:443');
- });
- it('does not bracket IPv4 addresses or hostnames', () => {
- const [, raw] = fixturesForProtocol('vless')[0];
- const typed = InboundSchema.parse(raw);
- const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0]
- .id;
- const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId });
- expect(new URL(v4).host).toBe('203.0.113.7:443');
- const host = genVlessLink({ inbound: typed, address: 'example.test', port: 443, clientId });
- expect(new URL(host).host).toBe('example.test:443');
- });
- });
- describe('external proxy pinned cert (pcs)', () => {
- const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
- const typed = InboundSchema.parse(raw);
- const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
- it('emits the external proxy pin list as pcs when forcing TLS', () => {
- const link = genVlessLink({
- inbound: typed,
- address: 'edge.example.com',
- port: 8443,
- forceTls: 'tls',
- remark: 'ep-pin',
- clientId,
- externalProxy: {
- forceTls: 'tls',
- dest: 'edge.example.com',
- port: 8443,
- remark: 'ep-pin',
- pinnedPeerCertSha256: ['aa11', 'bb22'],
- },
- });
- expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
- });
- it('omits pcs when the external proxy forces security off', () => {
- const link = genVlessLink({
- inbound: typed,
- address: 'edge.example.com',
- port: 8080,
- forceTls: 'none',
- remark: 'ep-none',
- clientId,
- externalProxy: {
- forceTls: 'none',
- dest: 'edge.example.com',
- port: 8080,
- remark: 'ep-none',
- pinnedPeerCertSha256: ['aa11'],
- },
- });
- expect(new URL(link).searchParams.has('pcs')).toBe(false);
- });
- });
- // #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
- // when VLESS encryption (vlessenc) is on, matching the form's flow display
- // and the backend subscription. Gating is via canEnableTlsFlow.
- describe('genVlessLink flow gating (#5322)', () => {
- function vlessXhttp(encryption: string) {
- return InboundSchema.parse({
- id: 1,
- up: 0,
- down: 0,
- total: 0,
- remark: 'vlessenc',
- enable: true,
- expiryTime: 0,
- listen: '',
- port: 443,
- tag: 'inbound-vless-xhttp',
- sniffing: {
- enabled: false,
- destOverride: [],
- metadataOnly: false,
- routeOnly: false,
- ipsExcluded: [],
- domainsExcluded: [],
- },
- protocol: 'vless',
- settings: {
- clients: [
- {
- id: '11111111-2222-3333-4444-555555555555',
- email: '[email protected]',
- flow: 'xtls-rprx-vision',
- limitIp: 0,
- totalGB: 0,
- expiryTime: 0,
- enable: true,
- tgId: 0,
- subId: 's1',
- comment: '',
- reset: 0,
- },
- ],
- decryption: 'none',
- encryption,
- fallbacks: [],
- },
- streamSettings: {
- network: 'xhttp',
- xhttpSettings: {},
- security: 'none',
- },
- });
- }
- const clientId = '11111111-2222-3333-4444-555555555555';
- it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
- const link = genVlessLink({
- inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
- address: 'example.test',
- port: 443,
- clientId,
- flow: 'xtls-rprx-vision',
- });
- expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
- });
- it('omits flow for VLESS+XHTTP without vless encryption', () => {
- const link = genVlessLink({
- inbound: vlessXhttp('none'),
- address: 'example.test',
- port: 443,
- clientId,
- flow: 'xtls-rprx-vision',
- });
- expect(new URL(link).searchParams.has('flow')).toBe(false);
- });
- it('still emits flow for classic TCP+REALITY Vision', () => {
- const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
- const typed = InboundSchema.parse(raw);
- const link = genVlessLink({
- inbound: typed,
- address: 'example.test',
- port: 443,
- clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
- flow: 'xtls-rprx-vision',
- });
- expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
- });
- });
- describe('genVlessLink XHTTP extra compatibility', () => {
- it('emits both sessionID and legacy session keys in XHTTP extra', () => {
- const typed = InboundSchema.parse({
- id: 1,
- up: 0,
- down: 0,
- total: 0,
- remark: 'xhttp-session',
- enable: true,
- expiryTime: 0,
- listen: '',
- port: 443,
- tag: 'inbound-vless-xhttp',
- sniffing: {
- enabled: false,
- destOverride: [],
- metadataOnly: false,
- routeOnly: false,
- ipsExcluded: [],
- domainsExcluded: [],
- },
- protocol: 'vless',
- settings: {
- clients: [
- {
- id: '11111111-2222-3333-4444-555555555555',
- email: '[email protected]',
- flow: '',
- limitIp: 0,
- totalGB: 0,
- expiryTime: 0,
- enable: true,
- tgId: 0,
- subId: 's1',
- comment: '',
- reset: 0,
- },
- ],
- decryption: 'none',
- encryption: 'none',
- fallbacks: [],
- },
- streamSettings: {
- network: 'xhttp',
- security: 'none',
- xhttpSettings: {
- path: '/sp',
- host: 'edge.example.test',
- mode: 'auto',
- sessionIDPlacement: 'header',
- sessionIDKey: 'X-Session',
- },
- },
- });
- const link = genVlessLink({
- inbound: typed,
- address: 'example.test',
- port: 443,
- clientId: '11111111-2222-3333-4444-555555555555',
- });
- const extra = JSON.parse(new URL(link).searchParams.get('extra') ?? '{}') as Record<
- string,
- unknown
- >;
- expect(extra.sessionIDPlacement).toBe('header');
- expect(extra.sessionIDKey).toBe('X-Session');
- expect(extra.sessionPlacement).toBe('header');
- expect(extra.sessionKey).toBe('X-Session');
- });
- });
|