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. // 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; // 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 = { 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=`. // 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; 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): 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 '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; } } // The inbound link emits the entire finalmask object as a JSON-encoded // `fm` query param. Decode and attach to streamSettings so udpHop / // quicParams / tcp+udp masks round-trip on outbound import. function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { const fm = params.get('fm'); if (!fm) return; try { const parsed = JSON.parse(fm) as Record; if (parsed && typeof parsed === 'object') { sanitizeFinalMaskQuicParams(parsed); stream.finalmask = parsed; } } catch { // malformed fm — leave streamSettings.finalmask absent } } 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 (every // non-3x-ui client, and this panel's own generator, speak it instead of the // private fm= dump). A salamander mask already carrying a password via fm= // wins; a password-less one is completed rather than left empty. function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void { if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return; const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword'); if (!password) return; 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; return; } finalmask.udp = [...udp, { type: 'salamander', settings: { password } }]; } // Rebuild the UDP port-hopping range from the standard mport param, which the // generator emits as finalmask.quicParams.udpHop.ports. A range already supplied // via fm= wins; the client-side interval falls back to the panel's default. function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void { const ports = firstParam(params, 'mport'); if (!ports) return; const finalmask = ensureFinalMask(stream); const quicParams = (finalmask.quicParams && typeof finalmask.quicParams === 'object' ? finalmask.quicParams : (finalmask.quicParams = {})) as Raw; const existingHop = quicParams.udpHop as Raw | undefined; if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return; quicParams.udpHop = { ports, interval: '5-10' }; } const QUIC_PARAMS_NUMERIC_KEYS = [ 'initStreamReceiveWindow', 'maxStreamReceiveWindow', 'initConnectionReceiveWindow', 'maxConnectionReceiveWindow', 'maxIdleTimeout', 'keepAlivePeriod', 'maxIncomingStreams', ] as const; const DURATION_SECONDS: Record = { 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): void { const raw = parsed.quicParams; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; const quic = raw as Record; 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; } } 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; 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); } 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; 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 // 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 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); 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); 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 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) ); }