| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656 |
- 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<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 '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<string, unknown>;
- 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=<json> 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<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;
- }
- }
- 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);
- }
- 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)
- );
- }
|