mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
refactor(frontend): extract genVlessLink to lib/xray/inbound-link
Second link generator. genVlessLink builds the
vless://<uuid>@<host>:<port>?<query>#<remark> share URL from a typed
Inbound + client args, dispatching on streamSettings.network for the
network-specific knobs and on streamSettings.security for the
TLS/Reality knobs. Three param-style helpers move alongside the obj-
style ones already in this file:
- applyXhttpExtraToParams — writes path/host/mode/x_padding_bytes and
the JSON extra blob into URLSearchParams
- applyFinalMaskToParams — writes the fm payload when shareable
- applyExternalProxyTLSParams — overrides sni/fp/alpn when an external
proxy entry is supplied and security is tls
A vless-tcp-reality fixture lands alongside the existing vless-ws-tls
one, so the parity test now exercises both security branches.
Discovered a latent legacy bug while writing parity: the old class
stored realitySettings.serverNames as a comma-joined string and gated
SNI on `!ObjectUtil.isArrEmpty(serverNames)`, which always returns true
for strings — so SNI was never written into Reality share URLs.
Existing clients rely on the omission (they pull SNI from
realitySettings.target instead). We preserve the omission here to keep
this extraction byte-stable; an inline comment marks the spot for a
separate intentional fix.
Suite: 70 tests across 8 files; typecheck + lint clean.
This commit is contained in:
parent
5cdb71ec7d
commit
79c076ee11
4 changed files with 331 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { Base64 } from '@/utils';
|
import { Base64 } from '@/utils';
|
||||||
|
|
||||||
import type { Inbound } from '@/schemas/api/inbound';
|
import type { Inbound } from '@/schemas/api/inbound';
|
||||||
|
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
|
||||||
import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
|
import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
|
||||||
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
||||||
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
||||||
|
|
@ -224,3 +225,149 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
||||||
|
|
||||||
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
|
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
|
||||||
|
// legacy applyXhttpExtraToParams / applyFinalMaskToParams /
|
||||||
|
// applyExternalProxyTLSParams but write to a URLSearchParams instance
|
||||||
|
// directly. Number values get coerced via .toString() on set — same as
|
||||||
|
// what URLSearchParams does internally so the resulting URL bytes match.
|
||||||
|
|
||||||
|
function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
|
||||||
|
if (!xhttp) return;
|
||||||
|
params.set('path', xhttp.path);
|
||||||
|
const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
||||||
|
params.set('host', host);
|
||||||
|
params.set('mode', xhttp.mode);
|
||||||
|
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||||
|
params.set('x_padding_bytes', xhttp.xPaddingBytes);
|
||||||
|
}
|
||||||
|
const extra = buildXhttpExtra(xhttp);
|
||||||
|
if (extra) params.set('extra', JSON.stringify(extra));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
|
||||||
|
const payload = serializeFinalMask(finalmask);
|
||||||
|
if (payload.length > 0) params.set('fm', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyExternalProxyTLSParams(
|
||||||
|
externalProxy: ExternalProxyEntry | null | undefined,
|
||||||
|
params: URLSearchParams,
|
||||||
|
security: string,
|
||||||
|
): void {
|
||||||
|
if (!externalProxy || security !== 'tls') return;
|
||||||
|
const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
||||||
|
if (sni && sni.length > 0) params.set('sni', sni);
|
||||||
|
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
||||||
|
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||||
|
if (alpn.length > 0) params.set('alpn', alpn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenVlessLinkInput {
|
||||||
|
inbound: Inbound;
|
||||||
|
address: string;
|
||||||
|
port?: number;
|
||||||
|
forceTls?: ForceTls;
|
||||||
|
remark?: string;
|
||||||
|
clientId: string;
|
||||||
|
flow?: VlessClient['flow'];
|
||||||
|
externalProxy?: ExternalProxyEntry | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
||||||
|
// query carries network type, encryption, network-specific knobs, and
|
||||||
|
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
||||||
|
// pbk/sid/spx). Returns '' if the inbound isn't vless.
|
||||||
|
export function genVlessLink(input: GenVlessLinkInput): string {
|
||||||
|
const {
|
||||||
|
inbound,
|
||||||
|
address,
|
||||||
|
port = inbound.port,
|
||||||
|
forceTls = 'same',
|
||||||
|
remark = '',
|
||||||
|
clientId,
|
||||||
|
flow = '',
|
||||||
|
externalProxy = null,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
if (inbound.protocol !== 'vless') return '';
|
||||||
|
const stream = inbound.streamSettings;
|
||||||
|
if (!stream) return '';
|
||||||
|
|
||||||
|
const security = forceTls === 'same' ? stream.security : forceTls;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('type', stream.network);
|
||||||
|
params.set('encryption', inbound.settings.encryption);
|
||||||
|
|
||||||
|
if (stream.network === 'tcp') {
|
||||||
|
const tcp = stream.tcpSettings;
|
||||||
|
if (tcp.header?.type === 'http') {
|
||||||
|
const request = tcp.header.request;
|
||||||
|
if (request) {
|
||||||
|
params.set('path', request.path.join(','));
|
||||||
|
const host = getHeaderValue(request.headers, 'host');
|
||||||
|
if (host) params.set('host', host);
|
||||||
|
params.set('headerType', 'http');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stream.network === 'kcp') {
|
||||||
|
const kcp = stream.kcpSettings;
|
||||||
|
params.set('mtu', String(kcp.mtu));
|
||||||
|
params.set('tti', String(kcp.tti));
|
||||||
|
} else if (stream.network === 'ws') {
|
||||||
|
const ws = stream.wsSettings;
|
||||||
|
params.set('path', ws.path);
|
||||||
|
params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
|
||||||
|
} else if (stream.network === 'grpc') {
|
||||||
|
const grpc = stream.grpcSettings;
|
||||||
|
params.set('serviceName', grpc.serviceName);
|
||||||
|
params.set('authority', grpc.authority);
|
||||||
|
if (grpc.multiMode) params.set('mode', 'multi');
|
||||||
|
} else if (stream.network === 'httpupgrade') {
|
||||||
|
const hu = stream.httpupgradeSettings;
|
||||||
|
params.set('path', hu.path);
|
||||||
|
params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
|
||||||
|
} else if (stream.network === 'xhttp') {
|
||||||
|
applyXhttpExtraToParams(stream.xhttpSettings, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFinalMaskToParams(stream.finalmask, params);
|
||||||
|
|
||||||
|
if (security === 'tls') {
|
||||||
|
params.set('security', 'tls');
|
||||||
|
if (stream.security === 'tls') {
|
||||||
|
const tls = stream.tlsSettings;
|
||||||
|
params.set('fp', tls.settings.fingerprint);
|
||||||
|
params.set('alpn', tls.alpn.join(','));
|
||||||
|
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
||||||
|
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
||||||
|
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
||||||
|
}
|
||||||
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||||
|
} else if (security === 'reality') {
|
||||||
|
params.set('security', 'reality');
|
||||||
|
if (stream.security === 'reality') {
|
||||||
|
const reality = stream.realitySettings;
|
||||||
|
params.set('pbk', reality.settings.publicKey);
|
||||||
|
params.set('fp', reality.settings.fingerprint);
|
||||||
|
// Legacy parity quirk: the old class stored realitySettings.serverNames
|
||||||
|
// as a comma-joined string and gated SNI on `!ObjectUtil.isArrEmpty(s)`
|
||||||
|
// — which returns true for any string, so SNI was never written into
|
||||||
|
// Reality share links. Existing deployed clients rely on receiving
|
||||||
|
// the SNI from realitySettings.target instead; we keep the omission
|
||||||
|
// here so this extraction stays byte-stable with the legacy URL.
|
||||||
|
// Fixing the bug is a separate intentional commit.
|
||||||
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
|
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
||||||
|
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
|
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.set('security', 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`vless://${clientId}@${address}:${port}`);
|
||||||
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
|
url.hash = encodeURIComponent(remark);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,88 @@
|
||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`] = `
|
||||||
|
{
|
||||||
|
"down": 0,
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"id": 9,
|
||||||
|
"listen": "",
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless",
|
||||||
|
"remark": "dave-vless-tcp-reality",
|
||||||
|
"settings": {
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"comment": "",
|
||||||
|
"email": "dave@example.test",
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"id": "22222222-3333-4444-9555-666666666666",
|
||||||
|
"limitIp": 0,
|
||||||
|
"reset": 0,
|
||||||
|
"subId": "vless-reality-001",
|
||||||
|
"tgId": 0,
|
||||||
|
"totalGB": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"decryption": "none",
|
||||||
|
"encryption": "none",
|
||||||
|
"fallbacks": [],
|
||||||
|
},
|
||||||
|
"sniffing": {
|
||||||
|
"destOverride": [
|
||||||
|
"http",
|
||||||
|
"tls",
|
||||||
|
"quic",
|
||||||
|
"fakedns",
|
||||||
|
],
|
||||||
|
"domainsExcluded": [],
|
||||||
|
"enabled": true,
|
||||||
|
"ipsExcluded": [],
|
||||||
|
"metadataOnly": false,
|
||||||
|
"routeOnly": false,
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"realitySettings": {
|
||||||
|
"maxClientVer": "",
|
||||||
|
"maxTimediff": 0,
|
||||||
|
"minClientVer": "",
|
||||||
|
"mldsa65Seed": "",
|
||||||
|
"privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
|
||||||
|
"serverNames": [
|
||||||
|
"yahoo.com",
|
||||||
|
"www.yahoo.com",
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"fingerprint": "chrome",
|
||||||
|
"mldsa65Verify": "",
|
||||||
|
"publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
|
||||||
|
"serverName": "",
|
||||||
|
"spiderX": "/",
|
||||||
|
},
|
||||||
|
"shortIds": [
|
||||||
|
"a3f1",
|
||||||
|
"b8c2",
|
||||||
|
],
|
||||||
|
"show": false,
|
||||||
|
"target": "yahoo.com:443",
|
||||||
|
"xver": 0,
|
||||||
|
},
|
||||||
|
"security": "reality",
|
||||||
|
"tcpSettings": {
|
||||||
|
"header": {
|
||||||
|
"type": "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tag": "inbound-vless-reality",
|
||||||
|
"total": 0,
|
||||||
|
"up": 0,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
|
exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
|
||||||
{
|
{
|
||||||
"down": 0,
|
"down": 0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"up": 0,
|
||||||
|
"down": 0,
|
||||||
|
"total": 0,
|
||||||
|
"remark": "dave-vless-tcp-reality",
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"listen": "",
|
||||||
|
"port": 443,
|
||||||
|
"tag": "inbound-vless-reality",
|
||||||
|
"sniffing": {
|
||||||
|
"enabled": true,
|
||||||
|
"destOverride": ["http", "tls", "quic", "fakedns"],
|
||||||
|
"metadataOnly": false,
|
||||||
|
"routeOnly": false,
|
||||||
|
"ipsExcluded": [],
|
||||||
|
"domainsExcluded": []
|
||||||
|
},
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"id": "22222222-3333-4444-9555-666666666666",
|
||||||
|
"email": "dave@example.test",
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"limitIp": 0,
|
||||||
|
"totalGB": 0,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"enable": true,
|
||||||
|
"tgId": 0,
|
||||||
|
"subId": "vless-reality-001",
|
||||||
|
"comment": "",
|
||||||
|
"reset": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decryption": "none",
|
||||||
|
"encryption": "none",
|
||||||
|
"fallbacks": []
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"tcpSettings": {
|
||||||
|
"header": { "type": "none" }
|
||||||
|
},
|
||||||
|
"security": "reality",
|
||||||
|
"realitySettings": {
|
||||||
|
"show": false,
|
||||||
|
"xver": 0,
|
||||||
|
"target": "yahoo.com:443",
|
||||||
|
"serverNames": ["yahoo.com", "www.yahoo.com"],
|
||||||
|
"privateKey": "wM-2_oQRWXyLcXhV5q1ifTBcS3K8mYR3wQI3PqGFK1k",
|
||||||
|
"minClientVer": "",
|
||||||
|
"maxClientVer": "",
|
||||||
|
"maxTimediff": 0,
|
||||||
|
"shortIds": ["a3f1", "b8c2"],
|
||||||
|
"mldsa65Seed": "",
|
||||||
|
"settings": {
|
||||||
|
"publicKey": "Tx5yj1bRcOPHkdvT2pIAQ2zh0gQ8m4OPdnzqXJxxV3o",
|
||||||
|
"fingerprint": "chrome",
|
||||||
|
"serverName": "",
|
||||||
|
"spiderX": "/",
|
||||||
|
"mldsa65Verify": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { genVmessLink } from '@/lib/xray/inbound-link';
|
import { genVlessLink, genVmessLink } from '@/lib/xray/inbound-link';
|
||||||
import { Inbound as LegacyInbound } from '@/models/inbound';
|
import { Inbound as LegacyInbound } from '@/models/inbound';
|
||||||
import { InboundSchema } from '@/schemas/api/inbound';
|
import { InboundSchema } from '@/schemas/api/inbound';
|
||||||
|
|
||||||
|
|
@ -63,3 +63,36 @@ describe('genVmessLink parity', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('genVlessLink parity', () => {
|
||||||
|
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}: matches legacy Inbound.genVLESSLink`, () => {
|
||||||
|
const typed = InboundSchema.parse(raw);
|
||||||
|
const settings = (raw as { settings: { clients: Array<{ id: string; flow?: string }> } }).settings;
|
||||||
|
const client = settings.clients[0];
|
||||||
|
|
||||||
|
const address = 'example.test';
|
||||||
|
const port = typed.port;
|
||||||
|
const remark = 'parity-test';
|
||||||
|
|
||||||
|
const newLink = genVlessLink({
|
||||||
|
inbound: typed,
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
forceTls: 'same',
|
||||||
|
remark,
|
||||||
|
clientId: client.id,
|
||||||
|
flow: client.flow as never,
|
||||||
|
externalProxy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacy = LegacyInbound.fromJson(raw);
|
||||||
|
const legacyLink = legacy.genVLESSLink(address, port, 'same', remark, client.id, client.flow, null);
|
||||||
|
|
||||||
|
expect(newLink).toBe(legacyLink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue