From 9de527b35f76215a7b516715c8e04b1678135a9c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 13:28:04 +0200 Subject: [PATCH] feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land. --- frontend/src/lib/xray/outbound-link-parser.ts | 345 ++++++++++++++++++ frontend/src/pages/xray/OutboundFormModal.tsx | 32 ++ .../src/test/outbound-link-parser.test.ts | 159 ++++++++ 3 files changed, 536 insertions(+) create mode 100644 frontend/src/lib/xray/outbound-link-parser.ts create mode 100644 frontend/src/test/outbound-link-parser.test.ts diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts new file mode 100644 index 00000000..0326774b --- /dev/null +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -0,0 +1,345 @@ +import { Base64 } from '@/utils'; + +// Focused share-link parser for the OutboundFormModal's link-import +// helper. Each parser returns a wire-shape outbound record (the same +// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or +// null when the input doesn't match. +// +// Scope: address + port + auth + remark, plus the network/security +// fields the common vmess:// / vless:// links carry as query params. +// Advanced transport fields (xmux, padding obfs, hysteria udphop, +// reality short IDs, etc.) are not parsed — the user finishes them +// in the form after import. This is intentional: a focused parser +// keeps the surface small; the legacy Outbound.fromLink was ~250 +// lines of dense edge-case handling we don't need to replicate +// verbatim for the common phone-to-panel workflow. + +type Raw = Record; + +function buildStream(network: string, security: string): Raw { + const stream: Raw = { network, security }; + switch (network) { + case 'tcp': + stream.tcpSettings = { header: { type: 'none' } }; + break; + case 'kcp': + stream.kcpSettings = { + mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20, + cwndMultiplier: 1, maxSendingWindow: 2097152, + }; + break; + case 'ws': + stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 }; + break; + case 'grpc': + stream.grpcSettings = { serviceName: '', authority: '', multiMode: false }; + break; + case 'httpupgrade': + stream.httpupgradeSettings = { path: '/', host: '', headers: {} }; + break; + case 'xhttp': + stream.xhttpSettings = { + path: '/', host: '', mode: 'auto', headers: {}, + xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000', + }; + break; + default: + stream.tcpSettings = { header: { type: 'none' } }; + } + if (security === 'tls') { + stream.tlsSettings = { + serverName: '', alpn: [], fingerprint: '', + echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '', + }; + } else if (security === 'reality') { + stream.realitySettings = { + publicKey: '', fingerprint: 'chrome', serverName: '', + shortId: '', spiderX: '', mldsa65Verify: '', + }; + } + return stream; +} + +function applyTransportParams(stream: Raw, params: URLSearchParams): void { + const network = stream.network as string; + const host = params.get('host') ?? ''; + const path = params.get('path') ?? '/'; + switch (network) { + case 'ws': + (stream.wsSettings as Raw).host = host; + (stream.wsSettings as Raw).path = path; + break; + case 'grpc': { + const grpc = stream.grpcSettings as Raw; + const serviceName = params.get('serviceName') ?? params.get('path') ?? ''; + grpc.serviceName = serviceName; + grpc.authority = params.get('authority') ?? ''; + grpc.multiMode = params.get('mode') === 'multi'; + break; + } + case 'httpupgrade': + (stream.httpupgradeSettings as Raw).host = host; + (stream.httpupgradeSettings as Raw).path = path; + break; + case 'xhttp': + (stream.xhttpSettings as Raw).host = host; + (stream.xhttpSettings as Raw).path = path; + if (params.get('mode')) (stream.xhttpSettings as Raw).mode = params.get('mode'); + break; + case 'tcp': + // vless/trojan TCP HTTP camouflage rides on header=http+host+path + if (params.get('headerType') === 'http' || params.get('type') === 'http') { + (stream.tcpSettings as Raw).header = { + type: 'http', + request: { + version: '1.1', + method: 'GET', + path: path.split(',').filter(Boolean), + headers: host ? { Host: host.split(',').filter(Boolean) } : {}, + }, + }; + } + break; + } +} + +function applySecurityParams(stream: Raw, params: URLSearchParams): void { + if (stream.security === 'tls') { + const tls = stream.tlsSettings as Raw; + tls.serverName = params.get('sni') ?? ''; + tls.fingerprint = params.get('fp') ?? ''; + const alpn = params.get('alpn'); + if (alpn) tls.alpn = alpn.split(','); + } else if (stream.security === 'reality') { + const reality = stream.realitySettings as Raw; + reality.serverName = params.get('sni') ?? ''; + reality.fingerprint = params.get('fp') ?? 'chrome'; + reality.publicKey = params.get('pbk') ?? ''; + reality.shortId = params.get('sid') ?? ''; + reality.spiderX = params.get('spx') ?? ''; + } +} + +function decodeRemark(url: URL): string { + try { + return decodeURIComponent(url.hash.replace(/^#/, '')); + } catch { + return url.hash.replace(/^#/, ''); + } +} + +export function parseVmessLink(link: string): Raw | null { + if (!link.startsWith('vmess://')) return null; + try { + const decoded = Base64.decode(link.slice('vmess://'.length)); + const json = JSON.parse(decoded) as Record; + const network = (json.net as string) || 'tcp'; + const security = json.tls === 'tls' ? 'tls' : 'none'; + const stream = buildStream(network, security); + // Map the vmess JSON's net-specific keys onto the stream branch. + if (network === 'tcp' && json.type === 'http') { + (stream.tcpSettings as Raw).header = { + type: 'http', + request: { + version: '1.1', method: 'GET', + path: (json.path as string ?? '/').split(',').filter(Boolean), + headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {}, + }, + }; + } else if (network === 'ws') { + (stream.wsSettings as Raw).host = json.host ?? ''; + (stream.wsSettings as Raw).path = json.path ?? '/'; + } else if (network === 'grpc') { + (stream.grpcSettings as Raw).serviceName = json.path ?? ''; + (stream.grpcSettings as Raw).authority = json.authority ?? ''; + (stream.grpcSettings as Raw).multiMode = json.type === 'multi'; + } else if (network === 'httpupgrade') { + (stream.httpupgradeSettings as Raw).host = json.host ?? ''; + (stream.httpupgradeSettings as Raw).path = json.path ?? '/'; + } else if (network === 'xhttp') { + (stream.xhttpSettings as Raw).host = json.host ?? ''; + (stream.xhttpSettings as Raw).path = json.path ?? '/'; + if (json.mode) (stream.xhttpSettings as Raw).mode = json.mode; + } + if (security === 'tls') { + const tls = stream.tlsSettings as Raw; + tls.serverName = json.sni ?? ''; + tls.fingerprint = json.fp ?? ''; + if (json.alpn) tls.alpn = (json.alpn as string).split(','); + } + + const port = Number(json.port) || 443; + return { + protocol: 'vmess', + tag: typeof json.ps === 'string' ? json.ps : '', + settings: { + vnext: [{ + address: json.add ?? '', + port, + users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }], + }], + }, + streamSettings: stream, + }; + } catch { + return null; + } +} + +function parseUrlLink(link: string, expectedProto: string): URL | null { + try { + const url = new URL(link); + if (url.protocol.replace(/:$/, '') !== expectedProto) return null; + return url; + } catch { + return null; + } +} + +export function parseVlessLink(link: string): Raw | null { + const url = parseUrlLink(link, 'vless'); + if (!url) return null; + const id = url.username; + const address = url.hostname; + const port = Number(url.port) || 443; + const params = url.searchParams; + const network = params.get('type') ?? 'tcp'; + const security = (params.get('security') ?? 'none') as string; + const stream = buildStream(network, security); + applyTransportParams(stream, params); + applySecurityParams(stream, params); + return { + protocol: 'vless', + tag: decodeRemark(url), + settings: { + address, + port, + id, + flow: params.get('flow') ?? '', + encryption: params.get('encryption') ?? 'none', + }, + streamSettings: stream, + }; +} + +export function parseTrojanLink(link: string): Raw | null { + const url = parseUrlLink(link, 'trojan'); + if (!url) return null; + const password = url.username; + const address = url.hostname; + const port = Number(url.port) || 443; + const params = url.searchParams; + const network = params.get('type') ?? 'tcp'; + const security = (params.get('security') ?? 'tls') as string; + const stream = buildStream(network, security); + applyTransportParams(stream, params); + applySecurityParams(stream, params); + return { + protocol: 'trojan', + tag: decodeRemark(url), + settings: { + servers: [{ address, port, password }], + }, + streamSettings: stream, + }; +} + +export function parseShadowsocksLink(link: string): Raw | null { + if (!link.startsWith('ss://')) return null; + // Two link shapes coexist: + // modern: ss://base64(method:password)@host:port#remark + // legacy: ss://base64(method:password@host:port)#remark + // Try modern first; fall back to legacy decode of the whole userinfo+host. + let userInfo: string; + let host: string; + let port: number; + let remark = ''; + const hashIndex = link.indexOf('#'); + const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link; + if (hashIndex >= 0) { + try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; } + } + const atIndex = linkNoHash.indexOf('@'); + if (atIndex >= 0) { + try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); } + catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); } + const hostPort = linkNoHash.slice(atIndex + 1); + const colon = hostPort.lastIndexOf(':'); + if (colon < 0) return null; + host = hostPort.slice(0, colon); + port = Number(hostPort.slice(colon + 1)) || 443; + } else { + let decoded: string; + try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); } + catch { return null; } + const at = decoded.indexOf('@'); + if (at < 0) return null; + userInfo = decoded.slice(0, at); + const hostPort = decoded.slice(at + 1); + const colon = hostPort.lastIndexOf(':'); + if (colon < 0) return null; + host = hostPort.slice(0, colon); + port = Number(hostPort.slice(colon + 1)) || 443; + } + const sep = userInfo.indexOf(':'); + const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep); + const password = sep < 0 ? userInfo : userInfo.slice(sep + 1); + return { + protocol: 'shadowsocks', + tag: remark, + settings: { + servers: [{ address: host, port, password, method }], + }, + }; +} + +export function parseHysteria2Link(link: string): Raw | null { + const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2'); + if (!url) return null; + // hysteria2's auth rides as the URL userinfo. The streamSettings + // network branch is the dedicated 'hysteria' transport — the modal's + // newStreamSlice('hysteria') initializer fills in receive-window + // defaults; we override the user-set fields here. + const auth = url.username; + const address = url.hostname; + const port = Number(url.port) || 443; + const params = url.searchParams; + const stream: Raw = { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { + version: 2, auth, congestion: '', up: '0', down: '0', + initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608, + initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520, + maxIdleTimeout: 30, keepAlivePeriod: 2, disablePathMTUDiscovery: false, + }, + tlsSettings: { + serverName: params.get('sni') ?? '', + alpn: ['h3'], + fingerprint: '', + echConfigList: '', + verifyPeerCertByName: '', + pinnedPeerCertSha256: params.get('pinSHA256') ?? '', + }, + }; + return { + protocol: 'hysteria', + tag: decodeRemark(url), + settings: { address, port, version: 2 }, + streamSettings: stream, + }; +} + +// Dispatcher — first non-null parser wins. Returns null when no parser +// recognizes the link's protocol scheme. +export function parseOutboundLink(link: string): Raw | null { + const trimmed = link.trim(); + if (!trimmed) return null; + return ( + parseVmessLink(trimmed) + ?? parseVlessLink(trimmed) + ?? parseTrojanLink(trimmed) + ?? parseShadowsocksLink(trimmed) + ?? parseHysteria2Link(trimmed) + ); +} diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx index c8b74be8..75ef1ba4 100644 --- a/frontend/src/pages/xray/OutboundFormModal.tsx +++ b/frontend/src/pages/xray/OutboundFormModal.tsx @@ -23,6 +23,7 @@ import { formValuesToWirePayload, rawOutboundToFormValues, } from '@/lib/xray/outbound-form-adapter'; +import { parseOutboundLink } from '@/lib/xray/outbound-link-parser'; import { OutboundFormBaseSchema, ShadowsocksOutboundFormSettingsSchema, @@ -189,6 +190,30 @@ export default function OutboundFormModal({ const [activeKey, setActiveKey] = useState('1'); const [jsonText, setJsonText] = useState(''); const [jsonDirty, setJsonDirty] = useState(false); + const [linkInput, setLinkInput] = useState(''); + + // Parse a share link (vmess:// / vless:// / trojan:// / ss:// / + // hysteria2://) and replace form state with the result. The current + // tag is preserved when the parsed link doesn't carry one. + function importLink() { + const link = linkInput.trim(); + if (!link) return; + const parsed = parseOutboundLink(link); + if (!parsed) { + messageApi.error('Wrong Link!'); + return; + } + const currentTag = form.getFieldValue('tag') as string | undefined; + if (!parsed.tag && currentTag) parsed.tag = currentTag; + const next = rawOutboundToFormValues(parsed); + form.resetFields(); + form.setFieldsValue(next); + setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2)); + setJsonDirty(false); + setLinkInput(''); + messageApi.success('Link imported successfully'); + setActiveKey('1'); + } const isEdit = outboundProp != null; const title = isEdit @@ -2081,6 +2106,13 @@ export default function OutboundFormModal({ label: 'JSON', children: ( + setLinkInput(e.target.value)} + onSearch={importLink} + /> { diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts new file mode 100644 index 00000000..24ccfeca --- /dev/null +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseOutboundLink, + parseShadowsocksLink, + parseTrojanLink, + parseVlessLink, + parseVmessLink, + parseHysteria2Link, +} from '@/lib/xray/outbound-link-parser'; +import { Base64 } from '@/utils'; + +// Focused acceptance tests for the share-link parsers — one happy-path +// case per protocol family, plus a few common edge cases. The parsers +// produce wire-shape outbound rows; the modal hands them to +// rawOutboundToFormValues to seed Form.useForm. + +describe('parseVmessLink', () => { + it('parses a vmess:// link with ws + tls', () => { + const json = { + v: '2', ps: 'imported-vmess', add: '1.2.3.4', port: 8443, + id: '11111111-2222-4333-8444-555555555555', aid: 0, scy: 'auto', + net: 'ws', host: 'example.com', path: '/ws', + tls: 'tls', sni: 'example.com', fp: 'chrome', alpn: 'h2,http/1.1', + }; + const link = `vmess://${Base64.encode(JSON.stringify(json))}`; + const out = parseVmessLink(link); + expect(out).not.toBeNull(); + expect(out?.protocol).toBe('vmess'); + expect(out?.tag).toBe('imported-vmess'); + const settings = out?.settings as { vnext: Array<{ address: string; port: number; users: Array<{ id: string; security: string }> }> }; + expect(settings.vnext[0].address).toBe('1.2.3.4'); + expect(settings.vnext[0].port).toBe(8443); + expect(settings.vnext[0].users[0].id).toBe('11111111-2222-4333-8444-555555555555'); + const stream = out?.streamSettings as Record; + expect(stream.network).toBe('ws'); + expect(stream.security).toBe('tls'); + expect((stream.wsSettings as Record).path).toBe('/ws'); + expect((stream.tlsSettings as Record).serverName).toBe('example.com'); + expect((stream.tlsSettings as Record).alpn).toEqual(['h2', 'http/1.1']); + }); + + it('returns null for non-vmess links', () => { + expect(parseVmessLink('vless://x@y:1')).toBeNull(); + }); + + it('returns null for malformed base64', () => { + expect(parseVmessLink('vmess://!!!not-base64!!!')).toBeNull(); + }); +}); + +describe('parseVlessLink', () => { + it('parses a vless:// link with reality', () => { + const link + = 'vless://11111111-2222-4333-8444-555555555555@srv.example:443' + + '?type=tcp&security=reality&pbk=pubkey&sid=abcd&fp=chrome&sni=cloudflare.com&flow=xtls-rprx-vision' + + '#imported-vless'; + const out = parseVlessLink(link); + expect(out?.protocol).toBe('vless'); + expect(out?.tag).toBe('imported-vless'); + const settings = out?.settings as { id: string; flow: string; address: string; port: number }; + expect(settings.id).toBe('11111111-2222-4333-8444-555555555555'); + expect(settings.address).toBe('srv.example'); + expect(settings.port).toBe(443); + expect(settings.flow).toBe('xtls-rprx-vision'); + const stream = out?.streamSettings as Record; + expect(stream.security).toBe('reality'); + const reality = stream.realitySettings as Record; + expect(reality.publicKey).toBe('pubkey'); + expect(reality.shortId).toBe('abcd'); + expect(reality.serverName).toBe('cloudflare.com'); + }); +}); + +describe('parseTrojanLink', () => { + it('parses a trojan:// link with ws + tls', () => { + const link = 'trojan://secret-pw@srv.example:8443?type=ws&security=tls&host=example.com&path=/tj&sni=example.com#imported-trojan'; + const out = parseTrojanLink(link); + expect(out?.protocol).toBe('trojan'); + const settings = out?.settings as { servers: Array<{ address: string; port: number; password: string }> }; + expect(settings.servers[0].address).toBe('srv.example'); + expect(settings.servers[0].port).toBe(8443); + expect(settings.servers[0].password).toBe('secret-pw'); + const stream = out?.streamSettings as Record; + expect(stream.network).toBe('ws'); + expect((stream.wsSettings as Record).path).toBe('/tj'); + }); +}); + +describe('parseShadowsocksLink', () => { + it('parses the modern userinfo@host:port form', () => { + // ss://base64(method:password)@host:port#remark + const userinfo = Base64.encode('2022-blake3-aes-128-gcm:supersecret'); + const link = `ss://${userinfo}@1.2.3.4:8388#imported-ss`; + const out = parseShadowsocksLink(link); + expect(out?.protocol).toBe('shadowsocks'); + expect(out?.tag).toBe('imported-ss'); + const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> }; + expect(settings.servers[0].address).toBe('1.2.3.4'); + expect(settings.servers[0].port).toBe(8388); + expect(settings.servers[0].method).toBe('2022-blake3-aes-128-gcm'); + expect(settings.servers[0].password).toBe('supersecret'); + }); + + it('parses the legacy base64-of-whole form', () => { + // ss://base64(method:password@host:port)#remark + const inner = Base64.encode('aes-256-gcm:legacypw@10.0.0.1:1080'); + const link = `ss://${inner}#imported-legacy`; + const out = parseShadowsocksLink(link); + const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> }; + expect(settings.servers[0].address).toBe('10.0.0.1'); + expect(settings.servers[0].port).toBe(1080); + expect(settings.servers[0].method).toBe('aes-256-gcm'); + expect(settings.servers[0].password).toBe('legacypw'); + }); +}); + +describe('parseHysteria2Link', () => { + it('parses a hysteria2:// link with sni', () => { + const link = 'hysteria2://auth-secret@srv.example:443?sni=example.com#imported-hy2'; + const out = parseHysteria2Link(link); + expect(out?.protocol).toBe('hysteria'); + expect(out?.tag).toBe('imported-hy2'); + const settings = out?.settings as { address: string; port: number; version: number }; + expect(settings.address).toBe('srv.example'); + expect(settings.port).toBe(443); + expect(settings.version).toBe(2); + const stream = out?.streamSettings as Record; + const hys = stream.hysteriaSettings as Record; + expect(hys.auth).toBe('auth-secret'); + expect((stream.tlsSettings as Record).serverName).toBe('example.com'); + }); + + it('also accepts hy2:// alias', () => { + const out = parseHysteria2Link('hy2://auth@srv:443?sni=example.com'); + expect(out?.protocol).toBe('hysteria'); + }); +}); + +describe('parseOutboundLink dispatcher', () => { + it('dispatches vmess via base64 JSON', () => { + const json = { v: '2', ps: 'x', add: '1.1.1.1', port: 443, id: '11111111-2222-4333-8444-555555555555', net: 'tcp', tls: 'none' }; + const link = `vmess://${Base64.encode(JSON.stringify(json))}`; + expect(parseOutboundLink(link)?.protocol).toBe('vmess'); + }); + + it('dispatches vless via URL', () => { + expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless'); + }); + + it('returns null for an unknown scheme', () => { + expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(parseOutboundLink('')).toBeNull(); + expect(parseOutboundLink(' ')).toBeNull(); + }); +});