///
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('./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]> {
return Object.entries(fullFixtures)
.filter(([, raw]) => (raw as { protocol?: string }).protocol === protocol)
.map(([path, raw]): [string, Record] => [
fixtureName(path),
raw as Record,
])
.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), version: 2 },
streamSettings: {
...(raw.streamSettings as Record),
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), version: 2 },
streamSettings: {
...(raw.streamSettings as Record),
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;
const tls = stream.tlsSettings as Record;
const tlsClientSettings = tls.settings as Record;
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: '',
i2: '',
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 = ',
'I2 = ',
'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] => [
fixtureName(path),
raw as Record,
])
.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: 'a@example.test',
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: 'a@example.test',
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');
});
});