Ver Fonte

fix(outbounds): parse wireguard:// links and fix ss:// query-string port

Add parseWireguardLink to the outbound import dispatcher: maps the secretKey userinfo, peer publicKey/endpoint, address, mtu, reserved, preSharedKey and keepAlive (probing common client aliases). Previously any wireguard:// link fell through to null and showed "Wrong Link!".

Also fix parseShadowsocksLink so a trailing query string (e.g. ?type=tcp) no longer leaks into the host:port slice, which made Number(port) NaN and silently fell back to 443. Strip the query before parsing in both the modern and legacy ss forms.
MHSanaei há 19 horas atrás
pai
commit
12afb862ff

+ 72 - 5
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)
   );
 }

+ 60 - 0
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:[email protected]: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://[email protected]: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();
   });