Files
3x-ui/frontend/src/lib/xray/outbound-link-parser.ts
T
Jay Hemnani ff3f42ee2a fix(link): read fm and the mKCP fields from vmess links (#6809)
#6480 taught the vless/trojan importers to rebuild the mKCP header and
seed and left the vmess importers ignoring fm. The vmess emitter writes
the finalmask as fm, the mKCP header as type and the seed as path, plus
mtu/tti. Both importers dropped all of them, so a vmess mKCP inbound
with a seed, a header or any finalmask UDP mask (salamander, ...) came
back from an outbound subscription, a client external link or the
outbound form's link import as an outbound that cannot connect.

Feed those fields through the same finalmask and kcp helpers the
url-param protocols use, in the Go and TS importers.
2026-10-14 15:34:06 +02:00

868 lines
30 KiB
TypeScript

import { Base64 } from '@/utils';
import { normalizeUdpHop, upgradeLegacyUdpHop } from './udphop-mask';
import { upgradeLegacyXdnsMasks } from './xdns-mask';
// 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.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux and downloadSettings
// round-trip through the `extra` JSON blob. reality shortIds, padding
// obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — 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<string, unknown>;
// XHTTP knob keys grouped by wire type. Used by both the URL query-param
// (vless/trojan) branch and the vmess JSON branch to consistently pull
// the same set of advanced fields when present. Keep order ~stable to
// match the schema's authoring order so diffs read naturally.
const XHTTP_STRING_KEYS = [
'xPaddingBytes',
'xPaddingKey',
'xPaddingHeader',
'xPaddingPlacement',
'xPaddingMethod',
'sessionIDPlacement',
'sessionIDKey',
'sessionIDTable',
'sessionIDLength',
'seqPlacement',
'seqKey',
'uplinkDataPlacement',
'uplinkDataKey',
'scMaxEachPostBytes',
'scMinPostsIntervalMs',
'scStreamUpServerSecs',
'uplinkHTTPMethod',
] as const;
// Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
// Map them onto the renamed keys so old links still import. Mirrors the
// schema-level migrateLegacyXhttp.
const XHTTP_LEGACY_ALIASES: Record<string, string> = {
sessionPlacement: 'sessionIDPlacement',
sessionKey: 'sessionIDKey',
};
const XHTTP_NUMBER_KEYS = [
'scMaxBufferedPosts',
'serverMaxHeaderBytes',
'uplinkChunkSize',
] as const;
const XHTTP_BOOL_KEYS = ['xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader'] as const;
// Nested objects the inbound link bundles into the `extra` JSON blob
// (and vmess JSON carries inline). The outbound form adapter expands
// xmux into the XMUX sub-form (enableXmux) on load.
const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
function asBool(s: string | null): boolean | undefined {
if (s === null) return undefined;
return s === 'true' || s === '1';
}
function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
// Precedence from lowest to highest: stream-init default →
// x_padding_bytes snake_case alias → extra JSON payload →
// explicit camelCase URL param. Apply in that order so each tier
// overwrites the previous when present.
const padBytesAlt = params.get('x_padding_bytes');
if (padBytesAlt !== null && padBytesAlt !== '') {
xhttp.xPaddingBytes = padBytesAlt;
}
// The inbound link bundles advanced xhttp knobs into `extra=<json>`.
// Decode and merge so re-importing a share link round-trips the full
// xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
const extra = params.get('extra');
if (extra) {
try {
const parsed = JSON.parse(extra) as Record<string, unknown>;
applyXhttpStringFromJson(xhttp, parsed);
if (parsed.headers && typeof parsed.headers === 'object') {
xhttp.headers = parsed.headers;
}
} catch {
// malformed extra — silently ignore, the panel can still operate
// on the rest of the link
}
}
for (const k of XHTTP_STRING_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = v;
}
for (const k of XHTTP_NUMBER_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
}
for (const k of XHTTP_BOOL_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = asBool(v);
}
// Fill renamed keys from legacy params only when the new key is absent.
for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
if (xhttp[renamed] === undefined) {
const v = params.get(legacy);
if (v !== null && v !== '') xhttp[renamed] = v;
}
}
}
function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
for (const k of XHTTP_STRING_KEYS) {
if (typeof json[k] === 'string') xhttp[k] = json[k];
}
for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
if (xhttp[renamed] === undefined && typeof json[legacy] === 'string') {
xhttp[renamed] = json[legacy];
}
}
for (const k of XHTTP_NUMBER_KEYS) {
if (typeof json[k] === 'number') xhttp[k] = json[k];
}
for (const k of XHTTP_BOOL_KEYS) {
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
}
for (const k of XHTTP_OBJECT_KEYS) {
const v = json[k];
if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
}
}
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',
};
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': {
const xhttp = stream.xhttpSettings as Raw;
xhttp.host = host;
xhttp.path = path;
if (params.get('mode')) xhttp.mode = params.get('mode');
applyXhttpStringFromParams(xhttp, params);
break;
}
case 'kcp': {
// mtu/tti on kcpSettings; header/seed via applyMkcpLegacyFromShare.
const kcp = stream.kcpSettings as Raw;
const mtu = kcpParamInRange(params.get('mtu'), KCP_MIN_MTU, KCP_MAX_MTU);
if (mtu !== null) kcp.mtu = mtu;
const tti = kcpParamInRange(params.get('tti'), KCP_MIN_TTI, KCP_MAX_TTI);
if (tti !== null) kcp.tti = tti;
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;
}
}
// mKCP bounds mirror xray-core's KCPConfig.Build checks (a value outside them fails
// the whole config load); mtu's ceiling is the int32 that fits its uint32 field.
const KCP_MIN_MTU = 21;
const KCP_MAX_MTU = 0x7fffffff;
const KCP_MIN_TTI = 10;
const KCP_MAX_TTI = 1000;
// Decimal digits only, like the Go importer's strconv.Atoi; anything else keeps
// buildStream's default.
function kcpParamInRange(raw: string | null, min: number, max: number): number | null {
if (raw === null || !/^\d+$/.test(raw)) return null;
const n = Number(raw);
return Number.isSafeInteger(n) && n >= min && n <= max ? n : null;
}
const kcpHeaderTypeToMask: Record<string, string> = {
dns: 'dns',
dtls: 'dtls',
srtp: 'srtp',
utp: 'utp',
'wechat-video': 'wechat',
wireguard: 'wireguard',
};
// The inbound link emits the entire finalmask object as a JSON-encoded
// `fm` query param. Decode and attach to streamSettings so quicParams and the
// tcp+udp masks round-trip on outbound import; a legacy udpHop becomes the mask.
function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
const fm = params.get('fm');
if (fm) {
try {
const parsed = JSON.parse(fm) as Record<string, unknown>;
if (parsed && typeof parsed === 'object') {
sanitizeFinalMaskQuicParams(parsed);
if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next;
stream.finalmask = normalizeUdpHop(upgradeLegacyUdpHop(parsed).next).next;
}
} catch {
// malformed fm — leave streamSettings.finalmask absent
}
}
applyMkcpLegacyFromShare(stream, params);
}
/** Restore headerType/seed into finalmask.udp mkcp-legacy; fm= mkcp-legacy wins. */
function applyMkcpLegacyFromShare(stream: Raw, params: URLSearchParams): void {
let headerType = (params.get('headerType') ?? '').trim();
const seed = params.get('seed') ?? '';
if (headerType === 'none') headerType = '';
if (!headerType && !seed) return;
const network = stream.network;
if (typeof network === 'string' && network && network !== 'kcp') return;
let maskHeader = '';
if (headerType) {
if (!Object.hasOwn(kcpHeaderTypeToMask, headerType)) return;
maskHeader = kcpHeaderTypeToMask[headerType];
}
const finalmask = (stream.finalmask as Raw) ?? {};
const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : [];
if (udp.some((m) => (m as Raw)?.type === 'mkcp-legacy')) return;
// One mask per field, seed first: MkcpLegacy.Build ignores value once header is
// set, and the chain puts the last mask outermost on the wire (header around cipher).
if (seed) udp.push(mkcpLegacyMask('', seed));
if (maskHeader) udp.push(mkcpLegacyMask(maskHeader, ''));
finalmask.udp = udp;
stream.finalmask = finalmask;
}
function mkcpLegacyMask(header: string, value: string): Raw {
return { type: 'mkcp-legacy', settings: { header, value } };
}
function ensureFinalMask(stream: Raw): Raw {
if (!stream.finalmask || typeof stream.finalmask !== 'object') stream.finalmask = {};
return stream.finalmask as Raw;
}
// Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
// password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
const obfs = (params.get('obfs') ?? '').toLowerCase();
const isGecko = obfs === 'gecko';
if (!isGecko && obfs !== 'salamander') return;
const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
if (!password) return;
let packetSize = '';
if (isGecko) {
// Both halves required and numeric, matching the export side; anything
// else is dropped rather than stored as a malformed range.
const minSize = (params.get('minPacketSize') ?? '').trim();
const maxSize = (params.get('maxPacketSize') ?? '').trim();
const min = Number(minSize);
const max = Number(maxSize);
if (
/^\d+$/.test(minSize) &&
/^\d+$/.test(maxSize) &&
Number.isSafeInteger(min) &&
Number.isSafeInteger(max) &&
min >= 1 &&
max >= min &&
max <= 2048
) {
packetSize = `${min}-${max}`;
}
}
const finalmask = ensureFinalMask(stream);
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
const existing = udp.find(
(m) => m && typeof m === 'object' && (m as Raw).type === 'salamander',
) as Raw | undefined;
if (existing) {
const settings = (
existing.settings && typeof existing.settings === 'object'
? existing.settings
: (existing.settings = {})
) as Raw;
if (typeof settings.password !== 'string' || settings.password.length === 0)
settings.password = password;
if (
packetSize !== '' &&
!(typeof settings.packetSize === 'string' && settings.packetSize.length > 0)
)
settings.packetSize = packetSize;
return;
}
const settings: Raw = { password };
if (packetSize !== '') settings.packetSize = packetSize;
finalmask.udp = [...udp, { type: 'salamander', settings }];
}
// Rebuild the UDP port-hopping range from the standard mport param. xray-core
// 26.9.9 replaced finalmask.quicParams.udpHop with a 'udphop' UDP mask, whose
// intervalremote mode is what the old key used to do; an fm= mask wins.
function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
const ports = firstParam(params, 'mport');
if (!ports) return;
const finalmask = ensureFinalMask(stream);
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
if (udp.some((mask) => (mask as Raw | undefined)?.type === 'udphop')) return;
finalmask.udp = [
...udp,
{ type: 'udphop', settings: { mode: 'intervalremote', interval: '5-10', remotePorts: ports } },
];
}
const QUIC_PARAMS_NUMERIC_KEYS = [
'initStreamReceiveWindow',
'maxStreamReceiveWindow',
'initConnectionReceiveWindow',
'maxConnectionReceiveWindow',
'maxIdleTimeout',
'keepAlivePeriod',
'maxIncomingStreams',
] as const;
const DURATION_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3600 };
const QUIC_NUMERIC_MAX = 1e15;
function coerceQuicNumeric(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string') {
const asNumber = Number(value);
if (value.trim() !== '' && Number.isFinite(asNumber)) {
return Math.trunc(asNumber);
}
const duration = /^(-?\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim());
if (duration) {
return Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]);
}
}
return null;
}
function clampQuicNumeric(key: string, n: number): number | null {
if (n < 0 || n > QUIC_NUMERIC_MAX) return null;
if (n === 0) return 0;
if (key === 'keepAlivePeriod') return Math.min(Math.max(n, 2), 60);
if (key === 'maxIdleTimeout') return Math.min(Math.max(n, 4), 120);
if (key === 'maxIncomingStreams') return Math.max(n, 8);
return n;
}
// xray-core rejects the whole config when these quicParams fields are not
// plain integers within its accepted ranges (keepAlivePeriod 0 or 2-60,
// maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8), so coerce
// numeric/duration strings, clamp the ranged fields, and drop anything
// unparseable, negative, or absurdly large (#5783). Mirrors the Go parser in
// internal/util/link/outbound.go.
function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): void {
const raw = parsed.quicParams;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
const quic = raw as Record<string, unknown>;
for (const key of QUIC_PARAMS_NUMERIC_KEYS) {
if (!(key in quic)) continue;
const coerced = coerceQuicNumeric(quic[key]);
const clamped = coerced === null ? null : clampQuicNumeric(key, coerced);
if (clamped === null) {
delete quic[key];
continue;
}
quic[key] = clamped;
}
}
// The panel exports tcp/http obfuscation as the SIP002 obfs-local plugin only,
// so the header it stands for has to be rebuilt before the transport is applied.
function applyObfsLocalPluginParams(params: URLSearchParams): void {
if (params.get('headerType') || params.get('type') === 'http') return;
const parts = (params.get('plugin') ?? '').split(';');
if (parts[0] !== 'obfs-local') return;
let obfs = '';
let host = '';
for (const part of parts.slice(1)) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq);
if (key === 'obfs') obfs = part.slice(eq + 1);
else if (key === 'obfs-host') host = part.slice(eq + 1);
}
if (obfs !== 'http') return;
params.set('type', 'tcp');
params.set('headerType', 'http');
if (host) params.set('host', host);
}
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(',');
tls.echConfigList = params.get('ech') ?? '';
tls.verifyPeerCertByName = params.get('vcn') ?? '';
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
} 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') ?? '';
reality.mldsa65Verify = params.get('pqv') ?? '';
}
}
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<string, unknown>;
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') {
const xhttp = stream.xhttpSettings as Raw;
xhttp.host = json.host ?? '';
xhttp.path = json.path ?? '/';
if (json.mode) xhttp.mode = json.mode;
applyXhttpStringFromJson(xhttp, json);
} else if (network === 'kcp') {
applyTransportParams(
stream,
new URLSearchParams({ mtu: String(json.mtu ?? ''), tti: String(json.tti ?? '') }),
);
}
// The vmess object carries fm as-is and the mKCP header and seed as type and path.
const maskParams = new URLSearchParams();
if (typeof json.fm === 'string') maskParams.set('fm', json.fm);
if (network === 'kcp') {
if (typeof json.type === 'string') maskParams.set('headerType', json.type);
if (typeof json.path === 'string') maskParams.set('seed', json.path);
}
applyFinalMaskParam(stream, maskParams);
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(',');
// The vmess object names the certificate checks the url-param protocols
// pass through applySecurityParams, under the same short names.
if (typeof json.ech === 'string') tls.echConfigList = json.ech;
if (typeof json.vcn === 'string') tls.verifyPeerCertByName = json.vcn;
if (typeof json.pcs === 'string') tls.pinnedPeerCertSha256 = json.pcs;
}
const port = Number(json.port) || 443;
const rawScy = (json.scy as string) || 'auto';
const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
return {
protocol: 'vmess',
tag: typeof json.ps === 'string' ? json.ps : '',
settings: {
vnext: [
{
address: json.add ?? '',
port,
users: [{ id: json.id ?? '', security: userSecurity }],
},
],
},
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);
applyFinalMaskParam(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);
applyFinalMaskParam(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
// Query may carry Xray-native stream params (type/security/sni/alpn/fp)
// emitted by the SS share-link generator — preserve them like trojan/vless.
// 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 queryIndex = linkNoHash.indexOf('?');
const rawQuery = queryIndex >= 0 ? linkNoHash.slice(queryIndex + 1) : '';
const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
const atIndex = core.indexOf('@');
if (atIndex >= 0) {
const rawUserInfo = core.slice('ss://'.length, atIndex);
if (rawUserInfo.includes(':')) {
// SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
// (a literal ':' can't appear in a base64/base64url string).
try {
userInfo = decodeURIComponent(rawUserInfo);
} catch {
userInfo = rawUserInfo;
}
} else {
try {
userInfo = Base64.decode(rawUserInfo);
} catch {
userInfo = rawUserInfo;
}
}
const hostPort = core.slice(atIndex + 1).replace(/\/+$/, '');
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(core.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);
const params = new URLSearchParams(rawQuery);
applyObfsLocalPluginParams(params);
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);
applyFinalMaskParam(stream, params);
return {
protocol: 'shadowsocks',
tag: remark,
settings: {
servers: [{ address: host, port, password, method }],
},
streamSettings: stream,
};
}
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 alpn = params.get('alpn');
const stream: Raw = {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2,
auth,
udpIdleTimeout: 60,
},
tlsSettings: {
serverName: params.get('sni') ?? '',
alpn: alpn ? alpn.split(',') : ['h3'],
fingerprint: params.get('fp') ?? '',
echConfigList: params.get('ech') ?? '',
verifyPeerCertByName: params.get('vcn') ?? '',
pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
},
};
applyFinalMaskParam(stream, params);
applyHysteria2Obfs(stream, params);
applyHysteria2Hop(stream, params);
return {
protocol: 'hysteria',
tag: decodeRemark(url),
settings: { address, port, version: 2 },
streamSettings: stream,
};
}
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 {
const trimmed = link.trim();
if (!trimmed) return null;
return (
parseVmessLink(trimmed) ??
parseVlessLink(trimmed) ??
parseTrojanLink(trimmed) ??
parseShadowsocksLink(trimmed) ??
parseHysteria2Link(trimmed) ??
parseWireguardLink(trimmed)
);
}