diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts new file mode 100644 index 00000000..698b37cc --- /dev/null +++ b/frontend/src/lib/xray/inbound-link.ts @@ -0,0 +1,226 @@ +import { Base64 } from '@/utils'; + +import type { Inbound } from '@/schemas/api/inbound'; +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'; +import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp'; + +import { getHeaderValue } from './headers'; + +// Share-link generators. Each per-protocol fn takes a typed inbound plus +// client overrides and returns a URL (or '' when the protocol doesn't +// support shareable links). The helpers below were previously static +// methods on the Inbound class; extracting them removes the +// XrayCommonClass dependency and lets these run against Zod-parsed data +// directly. + +type ForceTls = 'same' | 'tls' | 'none'; + +// xHTTP headers ship as Record on the wire (Zod schema) +// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key. +function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string { + return getHeaderValue(xhttp?.headers, 'host'); +} + +// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a +// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts, +// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client +// reading the share link wouldn't honor them. Mirrors the legacy +// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up. +function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record | null { + if (!xhttp) return null; + const extra: Record = {}; + + if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) { + extra.xPaddingBytes = xhttp.xPaddingBytes; + } + if (xhttp.xPaddingObfsMode === true) { + extra.xPaddingObfsMode = true; + for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) { + const v = xhttp[k]; + if (typeof v === 'string' && v.length > 0) extra[k] = v; + } + } + + const stringFields = [ + 'uplinkHTTPMethod', + 'sessionPlacement', + 'sessionKey', + 'seqPlacement', + 'seqKey', + 'uplinkDataPlacement', + 'uplinkDataKey', + 'scMaxEachPostBytes', + ] as const; + for (const k of stringFields) { + const v = xhttp[k]; + if (typeof v === 'string' && v.length > 0) extra[k] = v; + } + + // Headers on the wire are a record; emit them as a map upstream's + // SplitHTTPConfig.headers expects, dropping Host (already on the URL). + if (xhttp.headers && Object.keys(xhttp.headers).length > 0) { + const headersMap: Record = {}; + for (const [name, value] of Object.entries(xhttp.headers)) { + if (name.toLowerCase() === 'host') continue; + headersMap[name] = value; + } + if (Object.keys(headersMap).length > 0) extra.headers = headersMap; + } + + return Object.keys(extra).length > 0 ? extra : null; +} + +function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record): void { + if (!xhttp) return; + if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) { + obj.x_padding_bytes = xhttp.xPaddingBytes; + } + const extra = buildXhttpExtra(xhttp); + if (!extra) return; + for (const [k, v] of Object.entries(extra)) obj[k] = v; +} + +// Recursively checks whether a finalmask payload has any non-empty +// content. Empty arrays / empty objects / empty strings all return false; +// any truthy primitive returns true. Used to decide whether the link +// should carry an `fm` blob at all. +function hasShareableFinalMaskValue(value: unknown): boolean { + if (value == null) return false; + if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue); + if (typeof value === 'object') { + return Object.values(value as Record).some(hasShareableFinalMaskValue); + } + if (typeof value === 'string') return value.length > 0; + return true; +} + +function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string { + if (!finalmask) return ''; + return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : ''; +} + +function applyFinalMaskToObj( + finalmask: FinalMaskStreamSettings | undefined, + obj: Record, +): void { + const payload = serializeFinalMask(finalmask); + if (payload.length > 0) obj.fm = payload; +} + +function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string { + if (Array.isArray(value)) return value.filter(Boolean).join(','); + return ''; +} + +function applyExternalProxyTLSObj( + externalProxy: ExternalProxyEntry | null | undefined, + obj: Record, + 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) obj.sni = sni; + if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint; + const alpn = externalProxyAlpn(externalProxy.alpn); + if (alpn.length > 0) obj.alpn = alpn; +} + +export interface GenVmessLinkInput { + inbound: Inbound; + address: string; + port?: number; + forceTls?: ForceTls; + remark?: string; + clientId: string; + security?: VmessSecurity; + externalProxy?: ExternalProxyEntry | null; +} + +// VMess share link: `vmess://` followed by base64-encoded JSON. The JSON +// schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound +// is not vmess so dispatcher code can fall through cleanly. +export function genVmessLink(input: GenVmessLinkInput): string { + const { + inbound, + address, + port = inbound.port, + forceTls = 'same', + remark = '', + clientId, + security, + externalProxy = null, + } = input; + + if (inbound.protocol !== 'vmess') return ''; + + const stream = inbound.streamSettings; + if (!stream) return ''; + + const tls = forceTls === 'same' ? stream.security : forceTls; + const obj: Record = { + v: '2', + ps: remark, + add: address, + port, + id: clientId, + scy: security, + net: stream.network, + tls, + }; + + if (stream.network === 'tcp') { + const tcp = stream.tcpSettings; + const header = tcp.header; + if (header) { + obj.type = header.type; + if (header.type === 'http') { + const request = header.request; + if (request) { + obj.path = request.path.join(','); + const host = getHeaderValue(request.headers, 'host'); + if (host) obj.host = host; + } + } + } else { + obj.type = 'none'; + } + } else if (stream.network === 'kcp') { + const kcp = stream.kcpSettings; + obj.mtu = kcp.mtu; + obj.tti = kcp.tti; + } else if (stream.network === 'ws') { + const ws = stream.wsSettings; + obj.path = ws.path; + obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'); + } else if (stream.network === 'grpc') { + const grpc = stream.grpcSettings; + obj.path = grpc.serviceName; + obj.authority = grpc.authority; + if (grpc.multiMode) obj.type = 'multi'; + } else if (stream.network === 'httpupgrade') { + const hu = stream.httpupgradeSettings; + obj.path = hu.path; + obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'); + } else if (stream.network === 'xhttp') { + const xhttp = stream.xhttpSettings; + obj.path = xhttp.path; + obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp); + obj.type = xhttp.mode; + applyXhttpExtraToObj(xhttp, obj); + } + + applyFinalMaskToObj(stream.finalmask, obj); + + if (tls === 'tls' && stream.security === 'tls') { + const tlsSettings = stream.tlsSettings; + if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName; + if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint; + if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(','); + } + + applyExternalProxyTLSObj(externalProxy, obj, tls); + + return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2)); +} diff --git a/frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json b/frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json new file mode 100644 index 00000000..b0c812c4 --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/vmess-tcp-tls.json @@ -0,0 +1,69 @@ +{ + "id": 7, + "up": 0, + "down": 0, + "total": 0, + "remark": "carol-vmess-tcp-tls", + "enable": true, + "expiryTime": 0, + "listen": "", + "port": 8443, + "tag": "inbound-vmess-1", + "sniffing": { + "enabled": true, + "destOverride": ["http", "tls", "quic", "fakedns"], + "metadataOnly": false, + "routeOnly": false, + "ipsExcluded": [], + "domainsExcluded": [] + }, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "11111111-2222-4333-8444-555555555555", + "security": "auto", + "email": "carol@example.test", + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": true, + "tgId": 0, + "subId": "vmess-001", + "comment": "", + "reset": 0 + } + ] + }, + "streamSettings": { + "network": "tcp", + "tcpSettings": { + "header": { "type": "none" } + }, + "security": "tls", + "tlsSettings": { + "serverName": "vmess.example.test", + "minVersion": "1.2", + "maxVersion": "1.3", + "cipherSuites": "", + "rejectUnknownSni": false, + "disableSystemRoot": false, + "enableSessionResumption": false, + "certificates": [ + { + "certificateFile": "/etc/ssl/certs/vmess.crt", + "keyFile": "/etc/ssl/private/vmess.key", + "oneTimeLoading": false, + "usage": "encipherment", + "buildChain": false + } + ], + "alpn": ["h2", "http/1.1"], + "echServerKeys": "", + "settings": { + "fingerprint": "chrome", + "echConfigList": "" + } + } + } +} diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts new file mode 100644 index 00000000..c583a706 --- /dev/null +++ b/frontend/src/test/inbound-link.test.ts @@ -0,0 +1,65 @@ +/// +import { describe, expect, it } from 'vitest'; + +import { genVmessLink } from '@/lib/xray/inbound-link'; +import { Inbound as LegacyInbound } from '@/models/inbound'; +import { InboundSchema } from '@/schemas/api/inbound'; + +// Parity harness for the share-link extraction. For each full inbound +// fixture matching the protocol under test, we: +// 1. Parse with the Zod InboundSchema -> typed input for the new pure fn +// 2. Construct the legacy Inbound class via Inbound.fromJson(fixture) +// 3. Call both link generators with matching args +// 4. Assert the URLs match byte-for-byte +// Drift between the new pure fn and the legacy class method fails the +// test here, before the call sites in pages/ get swapped. + +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 parity', () => { + 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}: matches legacy Inbound.genVmessLink`, () => { + const typed = InboundSchema.parse(raw); + const settings = (raw as { settings: { clients: Array<{ id: string; security?: string }> } }).settings; + const client = settings.clients[0]; + + const address = 'example.test'; + const port = typed.port; + const remark = 'parity-test'; + + const newLink = genVmessLink({ + inbound: typed, + address, + port, + forceTls: 'same', + remark, + clientId: client.id, + security: client.security as never, + externalProxy: null, + }); + + const legacy = LegacyInbound.fromJson(raw); + const legacyLink = legacy.genVmessLink(address, port, 'same', remark, client.id, client.security, null); + + expect(newLink).toBe(legacyLink); + }); + } +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 00000000..07951472 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,8 @@ +// Vitest setup. The frontend's Base64 utility (used by link generators) +// reaches for `window.btoa` directly. Node 16+ ships btoa/atob on +// globalThis, so we just alias `window` to `globalThis` instead of +// pulling in jsdom — keeps the test env light and avoids a new dep. + +if (typeof globalThis.window === 'undefined') { + (globalThis as unknown as { window: typeof globalThis }).window = globalThis; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index c81d8893..8b793976 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -12,5 +12,6 @@ export default defineConfig({ include: ['src/test/**/*.test.ts'], environment: 'node', globals: false, + setupFiles: ['./src/test/setup.ts'], }, });