diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 698b37cc..9eb4e4fa 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -1,6 +1,7 @@ import { Base64 } from '@/utils'; 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 { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy'; 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)); } + +// 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://@:?#. 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(); +} diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index 4f4997eb..520e2d02 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -1,5 +1,88 @@ // 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`] = ` { "down": 0, diff --git a/frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json b/frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json new file mode 100644 index 00000000..5d3cb560 --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/vless-tcp-reality.json @@ -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": "" + } + } + } +} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index c583a706..146a8652 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -1,7 +1,7 @@ /// 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 { 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); + }); + } +});