mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-31 10:14:15 +00:00
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.
This commit is contained in:
parent
cb7af04cd3
commit
12afb862ff
2 changed files with 132 additions and 5 deletions
|
|
@ -356,18 +356,20 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
||||||
if (hashIndex >= 0) {
|
if (hashIndex >= 0) {
|
||||||
try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
|
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) {
|
if (atIndex >= 0) {
|
||||||
try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
|
try { userInfo = Base64.decode(core.slice('ss://'.length, atIndex)); }
|
||||||
catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
|
catch { userInfo = core.slice('ss://'.length, atIndex); }
|
||||||
const hostPort = linkNoHash.slice(atIndex + 1);
|
const hostPort = core.slice(atIndex + 1);
|
||||||
const colon = hostPort.lastIndexOf(':');
|
const colon = hostPort.lastIndexOf(':');
|
||||||
if (colon < 0) return null;
|
if (colon < 0) return null;
|
||||||
host = hostPort.slice(0, colon);
|
host = hostPort.slice(0, colon);
|
||||||
port = Number(hostPort.slice(colon + 1)) || 443;
|
port = Number(hostPort.slice(colon + 1)) || 443;
|
||||||
} else {
|
} else {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
|
try { decoded = Base64.decode(core.slice('ss://'.length)); }
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
const at = decoded.indexOf('@');
|
const at = decoded.indexOf('@');
|
||||||
if (at < 0) return null;
|
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
|
// Dispatcher — first non-null parser wins. Returns null when no parser
|
||||||
// recognizes the link's protocol scheme.
|
// recognizes the link's protocol scheme.
|
||||||
export function parseOutboundLink(link: string): Raw | null {
|
export function parseOutboundLink(link: string): Raw | null {
|
||||||
|
|
@ -435,5 +501,6 @@ export function parseOutboundLink(link: string): Raw | null {
|
||||||
?? parseTrojanLink(trimmed)
|
?? parseTrojanLink(trimmed)
|
||||||
?? parseShadowsocksLink(trimmed)
|
?? parseShadowsocksLink(trimmed)
|
||||||
?? parseHysteria2Link(trimmed)
|
?? parseHysteria2Link(trimmed)
|
||||||
|
?? parseWireguardLink(trimmed)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
parseVlessLink,
|
parseVlessLink,
|
||||||
parseVmessLink,
|
parseVmessLink,
|
||||||
parseHysteria2Link,
|
parseHysteria2Link,
|
||||||
|
parseWireguardLink,
|
||||||
} from '@/lib/xray/outbound-link-parser';
|
} from '@/lib/xray/outbound-link-parser';
|
||||||
import { Base64 } from '@/utils';
|
import { Base64 } from '@/utils';
|
||||||
|
|
||||||
|
|
@ -204,6 +205,18 @@ describe('parseShadowsocksLink', () => {
|
||||||
expect(settings.servers[0].password).toBe('supersecret');
|
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', () => {
|
it('parses the legacy base64-of-whole form', () => {
|
||||||
// ss://base64(method:password@host:port)#remark
|
// ss://base64(method:password@host:port)#remark
|
||||||
const inner = Base64.encode('aes-256-gcm:legacypw@10.0.0.1:1080');
|
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', () => {
|
describe('parseOutboundLink dispatcher', () => {
|
||||||
it('dispatches vmess via base64 JSON', () => {
|
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 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');
|
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', () => {
|
it('returns null for an unknown scheme', () => {
|
||||||
expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
|
expect(parseOutboundLink('socks5://user:pass@host:1080')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue