diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index bd1ed0ed..4a1fdbb2 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -356,18 +356,20 @@ export function parseShadowsocksLink(link: string): Raw | null { if (hashIndex >= 0) { try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; } } - const atIndex = linkNoHash.indexOf('@'); + const queryIndex = linkNoHash.indexOf('?'); + const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash; + const atIndex = core.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); + try { userInfo = Base64.decode(core.slice('ss://'.length, atIndex)); } + catch { userInfo = core.slice('ss://'.length, atIndex); } + const hostPort = core.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)); } + try { decoded = Base64.decode(core.slice('ss://'.length)); } catch { return null; } const at = decoded.indexOf('@'); if (at < 0) return null; @@ -424,6 +426,70 @@ export function parseHysteria2Link(link: string): Raw | null { }; } +function firstParam(params: URLSearchParams, ...keys: string[]): string | null { + for (const k of keys) { + const v = params.get(k); + if (v !== null && v !== '') return v; + } + return null; +} + +export function parseWireguardLink(link: string): Raw | null { + const url = parseUrlLink(link, 'wireguard') ?? parseUrlLink(link, 'wg'); + if (!url) return null; + let secretKey: string; + try { + secretKey = decodeURIComponent(url.username); + } catch { + secretKey = url.username; + } + const params = url.searchParams; + const host = url.hostname; + const port = url.port; + const endpoint = host ? (port ? `${host}:${port}` : host) : ''; + + const addressRaw = firstParam(params, 'address', 'ip') ?? ''; + const address = addressRaw.split(',').map((s) => s.trim()).filter(Boolean); + + const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips'); + const allowedIPs = allowedRaw + ? allowedRaw.split(',').map((s) => s.trim()).filter(Boolean) + : ['0.0.0.0/0', '::/0']; + + const peer: Raw = { + publicKey: firstParam(params, 'publickey', 'publicKey', 'public_key', 'peerPublicKey') ?? '', + endpoint, + allowedIPs, + }; + const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk'); + if (psk) peer.preSharedKey = psk; + const keepAliveRaw = firstParam(params, 'keepalive', 'persistentkeepalive', 'persistent_keepalive'); + if (keepAliveRaw !== null) { + const k = Number(keepAliveRaw); + if (Number.isFinite(k)) peer.keepAlive = k; + } + + const settings: Raw = { secretKey, address, peers: [peer] }; + const mtuRaw = firstParam(params, 'mtu'); + if (mtuRaw !== null) { + const m = Number(mtuRaw); + if (Number.isFinite(m)) settings.mtu = m; + } + const reservedRaw = firstParam(params, 'reserved'); + if (reservedRaw) { + const reserved = reservedRaw.split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + if (reserved.length > 0) settings.reserved = reserved; + } + + return { + protocol: 'wireguard', + tag: decodeRemark(url), + settings, + }; +} + // Dispatcher — first non-null parser wins. Returns null when no parser // recognizes the link's protocol scheme. export function parseOutboundLink(link: string): Raw | null { @@ -435,5 +501,6 @@ export function parseOutboundLink(link: string): Raw | null { ?? parseTrojanLink(trimmed) ?? parseShadowsocksLink(trimmed) ?? parseHysteria2Link(trimmed) + ?? parseWireguardLink(trimmed) ); } diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 1b5b5578..6d3c9edb 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -7,6 +7,7 @@ import { parseVlessLink, parseVmessLink, parseHysteria2Link, + parseWireguardLink, } from '@/lib/xray/outbound-link-parser'; import { Base64 } from '@/utils'; @@ -204,6 +205,18 @@ describe('parseShadowsocksLink', () => { expect(settings.servers[0].password).toBe('supersecret'); }); + it('keeps the port when the link carries a query string (2022 two-key password)', () => { + const link = 'ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206LzhsdFZKaU90azE2QmhKZG9WZVRmSkNNUEJlRGhjcmkycTN0dzU1OUZvYz06YUhuTTB6ZnpFaTdRejc5dzlxNWFFWWVQVnpDU0wxaHV4RnZXZFB6OFZHST0@localhost:30757?type=tcp#pahf4urt53'; + const out = parseShadowsocksLink(link); + expect(out?.protocol).toBe('shadowsocks'); + expect(out?.tag).toBe('pahf4urt53'); + const settings = out?.settings as { servers: Array<{ address: string; port: number; method: string; password: string }> }; + expect(settings.servers[0].address).toBe('localhost'); + expect(settings.servers[0].port).toBe(30757); + expect(settings.servers[0].method).toBe('2022-blake3-aes-256-gcm'); + expect(settings.servers[0].password).toBe('/8ltVJiOtk16BhJdoVeTfJCMPBeDhcri2q3tw559Foc=:aHnM0zfzEi7Qz79w9q5aEYePVzCSL1huxFvWdPz8VGI='); + }); + 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'); @@ -306,6 +319,49 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => { }); }); +describe('parseWireguardLink', () => { + it('parses a wireguard:// link with percent-encoded secret and publickey', () => { + const link = 'wireguard://IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U%3D@localhost:22824' + + '?publickey=3CnNsCy74TOlupjaii%2BRFp%2FgDMk5vvUuFD0SNZ%2FGl2s%3D' + + '&address=10.0.0.2%2F32&mtu=1420#-1'; + const out = parseWireguardLink(link); + expect(out?.protocol).toBe('wireguard'); + expect(out?.tag).toBe('-1'); + const settings = out?.settings as { + secretKey: string; address: string[]; mtu: number; + peers: Array<{ publicKey: string; endpoint: string; allowedIPs: string[] }>; + }; + expect(settings.secretKey).toBe('IKeuy2+BNspvMffiC47z16seLIGxGtbDIYiZcbh9C1U='); + expect(settings.address).toEqual(['10.0.0.2/32']); + expect(settings.mtu).toBe(1420); + expect(settings.peers[0].publicKey).toBe('3CnNsCy74TOlupjaii+RFp/gDMk5vvUuFD0SNZ/Gl2s='); + expect(settings.peers[0].endpoint).toBe('localhost:22824'); + expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0', '::/0']); + }); + + it('parses reserved, presharedkey and keepalive aliases', () => { + const link = 'wireguard://privkey@1.2.3.4:51820' + + '?publickey=peerpub&address=10.0.0.2/32,fd00::2/128' + + '&reserved=1,2,3&presharedkey=psk-secret&persistentkeepalive=25' + + '&allowedips=0.0.0.0/0#wg-peer'; + const out = parseWireguardLink(link); + const settings = out?.settings as { + reserved: number[]; + peers: Array<{ preSharedKey: string; keepAlive: number; allowedIPs: string[] }>; + address: string[]; + }; + expect(settings.address).toEqual(['10.0.0.2/32', 'fd00::2/128']); + expect(settings.reserved).toEqual([1, 2, 3]); + expect(settings.peers[0].preSharedKey).toBe('psk-secret'); + expect(settings.peers[0].keepAlive).toBe(25); + expect(settings.peers[0].allowedIPs).toEqual(['0.0.0.0/0']); + }); + + it('returns null for non-wireguard links', () => { + expect(parseWireguardLink('vless://x@y:1')).toBeNull(); + }); +}); + 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' }; @@ -317,6 +373,10 @@ describe('parseOutboundLink dispatcher', () => { expect(parseOutboundLink('vless://uuid@host:443?type=tcp&security=none')?.protocol).toBe('vless'); }); + it('dispatches wireguard via URL', () => { + expect(parseOutboundLink('wireguard://pk@host:22824?publickey=pub&address=10.0.0.2/32')?.protocol).toBe('wireguard'); + }); + it('returns null for an unknown scheme', () => { expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull(); });