From 1e2845306c12b364eef2bfed667b97486896bbf0 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 00:18:55 +0200 Subject: [PATCH] refactor(frontend): extract genTrojanLink + genShadowsocksLink to lib/xray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and fourth link generators. genTrojanLink mirrors genVlessLink's shape (URLSearchParams + network/security branches + remark hash) minus the encryption/flow VLESS-isms. genShadowsocksLink shares the same query construction but base64-encodes the userinfo portion as method:password or method:settingsPw:clientPw depending on whether SS-2022 is in single-user or multi-user mode. Three reusable helpers move out of the per-protocol functions: - writeNetworkParams: the per-network switch that all param-style links share (tcp http header / kcp mtu+tti / ws path+host / grpc serviceName+authority / httpupgrade / xhttp extras) - writeTlsParams: fingerprint/alpn/ech/sni - writeRealityParams: pbk/sid/spx/pqv (preserves the SNI-omission legacy parity quirk noted in the genVlessLink commit) genVmessLink stays with its inline switch — it builds a JSON obj instead of URLSearchParams and has per-network quirks (kcp emits mtu+tti at the obj root, grpc maps multiMode to obj.type='multi') that don't factor cleanly through the shared writer. Two new full-inbound fixtures (trojan-ws-tls, shadowsocks-tcp-2022) plus matching parity tests bring the suite to 74 tests across 8 files; typecheck + lint clean. --- frontend/src/lib/xray/inbound-link.ts | 170 ++++++++++++++++++ .../__snapshots__/inbound-full.test.ts.snap | 143 +++++++++++++++ .../inbound-full/shadowsocks-tcp-2022.json | 49 +++++ .../fixtures/inbound-full/trojan-ws-tls.json | 73 ++++++++ frontend/src/test/inbound-link.test.ts | 67 ++++++- 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json create mode 100644 frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 9eb4e4fa..d7e8c1e3 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -371,3 +371,173 @@ export function genVlessLink(input: GenVlessLinkInput): string { url.hash = encodeURIComponent(remark); return url.toString(); } + +// Shared network-branch writer used by trojan + shadowsocks links. +// VLESS and VMess don't call this because they have minor per-protocol +// quirks inline (vmess maps `multi` differently into obj.type; vless sets +// encryption=none up-front). +function writeNetworkParams(stream: NonNullable, params: URLSearchParams): void { + 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); + } +} + +function writeTlsParams(stream: NonNullable, params: URLSearchParams): void { + if (stream.security !== 'tls') return; + const tls = stream.tlsSettings; + params.set('fp', tls.settings.fingerprint); + params.set('alpn', tls.alpn.join(',')); + if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList); + if (tls.serverName.length > 0) params.set('sni', tls.serverName); +} + +// Reality query-string writer shared by VLESS and Trojan. Preserves the +// legacy SNI-omission quirk (see genVlessLink for the full story). +function writeRealityParams(stream: NonNullable, params: URLSearchParams): void { + if (stream.security !== 'reality') return; + const reality = stream.realitySettings; + params.set('pbk', reality.settings.publicKey); + params.set('fp', reality.settings.fingerprint); + 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); +} + +export interface GenTrojanLinkInput { + inbound: Inbound; + address: string; + port?: number; + forceTls?: ForceTls; + remark?: string; + clientPassword: string; + externalProxy?: ExternalProxyEntry | null; +} + +// Trojan share link: trojan://@:?#. +// Same query-string shape as VLESS minus the `encryption` and `flow` +// fields. Returns '' if the inbound isn't trojan. +export function genTrojanLink(input: GenTrojanLinkInput): string { + const { + inbound, + address, + port = inbound.port, + forceTls = 'same', + remark = '', + clientPassword, + externalProxy = null, + } = input; + + if (inbound.protocol !== 'trojan') return ''; + const stream = inbound.streamSettings; + if (!stream) return ''; + + const security = forceTls === 'same' ? stream.security : forceTls; + const params = new URLSearchParams(); + params.set('type', stream.network); + + writeNetworkParams(stream, params); + applyFinalMaskToParams(stream.finalmask, params); + + if (security === 'tls') { + params.set('security', 'tls'); + writeTlsParams(stream, params); + applyExternalProxyTLSParams(externalProxy, params, security); + } else if (security === 'reality') { + params.set('security', 'reality'); + writeRealityParams(stream, params); + } else { + params.set('security', 'none'); + } + + const url = new URL(`trojan://${clientPassword}@${address}:${port}`); + for (const [key, value] of params) url.searchParams.set(key, value); + url.hash = encodeURIComponent(remark); + return url.toString(); +} + +export interface GenShadowsocksLinkInput { + inbound: Inbound; + address: string; + port?: number; + forceTls?: ForceTls; + remark?: string; + clientPassword?: string; + externalProxy?: ExternalProxyEntry | null; +} + +// Shadowsocks 2022 share link. The userinfo portion is base64(method:pw) +// for single-user and base64(method:settingsPw:clientPw) for multi-user +// 2022-blake3. Legacy SS (non-2022) leaves the password out of the +// userinfo entirely — matches the legacy class's password-array logic. +// Note: legacy `isSSMultiUser` returns true for everything except +// 2022-blake3-chacha20-poly1305 (a curious classification, but we +// preserve it for byte-stable parity). +export function genShadowsocksLink(input: GenShadowsocksLinkInput): string { + const { + inbound, + address, + port = inbound.port, + forceTls = 'same', + remark = '', + clientPassword = '', + externalProxy = null, + } = input; + + if (inbound.protocol !== 'shadowsocks') return ''; + const stream = inbound.streamSettings; + if (!stream) return ''; + const settings = inbound.settings; + + const security = forceTls === 'same' ? stream.security : forceTls; + const params = new URLSearchParams(); + params.set('type', stream.network); + + writeNetworkParams(stream, params); + applyFinalMaskToParams(stream.finalmask, params); + + if (security === 'tls') { + params.set('security', 'tls'); + writeTlsParams(stream, params); + applyExternalProxyTLSParams(externalProxy, params, security); + } + + const isSS2022 = settings.method.substring(0, 4) === '2022'; + const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305'; + const passwords: string[] = []; + if (isSS2022) passwords.push(settings.password); + if (isSSMultiUser) passwords.push(clientPassword); + + const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true); + const url = new URL(`ss://${userinfo}@${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 520e2d02..d27a7293 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -1,5 +1,148 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably 1`] = ` +{ + "down": 0, + "enable": true, + "expiryTime": 0, + "id": 17, + "listen": "", + "port": 8388, + "protocol": "shadowsocks", + "remark": "frank-ss-tcp-2022", + "settings": { + "clients": [ + { + "comment": "", + "email": "frank@example.test", + "enable": true, + "expiryTime": 0, + "limitIp": 0, + "method": "", + "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==", + "reset": 0, + "subId": "ss-001", + "tgId": 0, + "totalGB": 0, + }, + ], + "ivCheck": false, + "method": "2022-blake3-aes-256-gcm", + "network": "tcp,udp", + "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==", + }, + "sniffing": { + "destOverride": [ + "http", + "tls", + "quic", + "fakedns", + ], + "domainsExcluded": [], + "enabled": true, + "ipsExcluded": [], + "metadataOnly": false, + "routeOnly": false, + }, + "streamSettings": { + "network": "tcp", + "security": "none", + "tcpSettings": { + "header": { + "type": "none", + }, + }, + }, + "tag": "inbound-ss-2022", + "total": 0, + "up": 0, +} +`; + +exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] = ` +{ + "down": 0, + "enable": true, + "expiryTime": 0, + "id": 13, + "listen": "", + "port": 443, + "protocol": "trojan", + "remark": "eve-trojan-ws-tls", + "settings": { + "clients": [ + { + "comment": "", + "email": "eve@example.test", + "enable": true, + "expiryTime": 0, + "limitIp": 0, + "password": "trojan-test-pw-XYZ", + "reset": 0, + "subId": "trj-001", + "tgId": 0, + "totalGB": 0, + }, + ], + "fallbacks": [], + }, + "sniffing": { + "destOverride": [ + "http", + "tls", + "quic", + "fakedns", + ], + "domainsExcluded": [], + "enabled": true, + "ipsExcluded": [], + "metadataOnly": false, + "routeOnly": false, + }, + "streamSettings": { + "network": "ws", + "security": "tls", + "tlsSettings": { + "alpn": [ + "h2", + "http/1.1", + ], + "certificates": [ + { + "buildChain": false, + "certificateFile": "/etc/ssl/certs/trojan.crt", + "keyFile": "/etc/ssl/private/trojan.key", + "oneTimeLoading": false, + "usage": "encipherment", + }, + ], + "cipherSuites": "", + "disableSystemRoot": false, + "echServerKeys": "", + "enableSessionResumption": false, + "maxVersion": "1.3", + "minVersion": "1.2", + "rejectUnknownSni": false, + "serverName": "trojan.example.test", + "settings": { + "echConfigList": "", + "fingerprint": "chrome", + }, + }, + "wsSettings": { + "acceptProxyProtocol": false, + "headers": {}, + "heartbeatPeriod": 0, + "host": "trojan.example.test", + "path": "/trojan", + }, + }, + "tag": "inbound-trojan-ws", + "total": 0, + "up": 0, +} +`; + exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`] = ` { "down": 0, diff --git a/frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json b/frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json new file mode 100644 index 00000000..e13a928c --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/shadowsocks-tcp-2022.json @@ -0,0 +1,49 @@ +{ + "id": 17, + "up": 0, + "down": 0, + "total": 0, + "remark": "frank-ss-tcp-2022", + "enable": true, + "expiryTime": 0, + "listen": "", + "port": 8388, + "tag": "inbound-ss-2022", + "sniffing": { + "enabled": true, + "destOverride": ["http", "tls", "quic", "fakedns"], + "metadataOnly": false, + "routeOnly": false, + "ipsExcluded": [], + "domainsExcluded": [] + }, + "protocol": "shadowsocks", + "settings": { + "method": "2022-blake3-aes-256-gcm", + "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==", + "network": "tcp,udp", + "clients": [ + { + "method": "", + "password": "dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ==", + "email": "frank@example.test", + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": true, + "tgId": 0, + "subId": "ss-001", + "comment": "", + "reset": 0 + } + ], + "ivCheck": false + }, + "streamSettings": { + "network": "tcp", + "tcpSettings": { + "header": { "type": "none" } + }, + "security": "none" + } +} diff --git a/frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json b/frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json new file mode 100644 index 00000000..e6f2048a --- /dev/null +++ b/frontend/src/test/golden/fixtures/inbound-full/trojan-ws-tls.json @@ -0,0 +1,73 @@ +{ + "id": 13, + "up": 0, + "down": 0, + "total": 0, + "remark": "eve-trojan-ws-tls", + "enable": true, + "expiryTime": 0, + "listen": "", + "port": 443, + "tag": "inbound-trojan-ws", + "sniffing": { + "enabled": true, + "destOverride": ["http", "tls", "quic", "fakedns"], + "metadataOnly": false, + "routeOnly": false, + "ipsExcluded": [], + "domainsExcluded": [] + }, + "protocol": "trojan", + "settings": { + "clients": [ + { + "password": "trojan-test-pw-XYZ", + "email": "eve@example.test", + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "enable": true, + "tgId": 0, + "subId": "trj-001", + "comment": "", + "reset": 0 + } + ], + "fallbacks": [] + }, + "streamSettings": { + "network": "ws", + "wsSettings": { + "acceptProxyProtocol": false, + "path": "/trojan", + "host": "trojan.example.test", + "headers": {}, + "heartbeatPeriod": 0 + }, + "security": "tls", + "tlsSettings": { + "serverName": "trojan.example.test", + "minVersion": "1.2", + "maxVersion": "1.3", + "cipherSuites": "", + "rejectUnknownSni": false, + "disableSystemRoot": false, + "enableSessionResumption": false, + "certificates": [ + { + "certificateFile": "/etc/ssl/certs/trojan.crt", + "keyFile": "/etc/ssl/private/trojan.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 index 146a8652..2f3169d9 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 { genVlessLink, genVmessLink } from '@/lib/xray/inbound-link'; +import { genShadowsocksLink, genTrojanLink, genVlessLink, genVmessLink } from '@/lib/xray/inbound-link'; import { Inbound as LegacyInbound } from '@/models/inbound'; import { InboundSchema } from '@/schemas/api/inbound'; @@ -96,3 +96,68 @@ describe('genVlessLink parity', () => { }); } }); + +describe('genTrojanLink parity', () => { + 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}: matches legacy Inbound.genTrojanLink`, () => { + const typed = InboundSchema.parse(raw); + const settings = (raw as { settings: { clients: Array<{ password: string }> } }).settings; + const client = settings.clients[0]; + + const address = 'example.test'; + const port = typed.port; + const remark = 'parity-test'; + + const newLink = genTrojanLink({ + inbound: typed, + address, + port, + forceTls: 'same', + remark, + clientPassword: client.password, + externalProxy: null, + }); + + const legacy = LegacyInbound.fromJson(raw); + const legacyLink = legacy.genTrojanLink(address, port, 'same', remark, client.password, null); + + expect(newLink).toBe(legacyLink); + }); + } +}); + +describe('genShadowsocksLink parity', () => { + 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}: matches legacy Inbound.genSSLink`, () => { + const typed = InboundSchema.parse(raw); + const settings = (raw as { settings: { clients?: Array<{ password: string }> } }).settings; + const client = settings.clients?.[0]; + + const address = 'example.test'; + const port = typed.port; + const remark = 'parity-test'; + const clientPassword = client?.password ?? ''; + + const newLink = genShadowsocksLink({ + inbound: typed, + address, + port, + forceTls: 'same', + remark, + clientPassword, + externalProxy: null, + }); + + const legacy = LegacyInbound.fromJson(raw); + const legacyLink = legacy.genSSLink(address, port, 'same', remark, clientPassword, null); + + expect(newLink).toBe(legacyLink); + }); + } +});