|
@@ -0,0 +1,345 @@
|
|
|
|
|
+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.
|
|
|
|
|
+// Advanced transport fields (xmux, padding obfs, hysteria udphop,
|
|
|
|
|
+// reality short IDs, etc.) are not parsed — the user finishes them
|
|
|
|
|
+// in the form after import. This is intentional: a focused parser
|
|
|
|
|
+// keeps the surface small; 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>;
|
|
|
|
|
+
|
|
|
|
|
+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', scMaxEachPostBytes: '1000000',
|
|
|
|
|
+ };
|
|
|
|
|
+ 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':
|
|
|
|
|
+ (stream.xhttpSettings as Raw).host = host;
|
|
|
|
|
+ (stream.xhttpSettings as Raw).path = path;
|
|
|
|
|
+ if (params.get('mode')) (stream.xhttpSettings as Raw).mode = params.get('mode');
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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(',');
|
|
|
|
|
+ } 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') ?? '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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') {
|
|
|
|
|
+ (stream.xhttpSettings as Raw).host = json.host ?? '';
|
|
|
|
|
+ (stream.xhttpSettings as Raw).path = json.path ?? '/';
|
|
|
|
|
+ if (json.mode) (stream.xhttpSettings as Raw).mode = json.mode;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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;
|
|
|
|
|
+ return {
|
|
|
|
|
+ protocol: 'vmess',
|
|
|
|
|
+ tag: typeof json.ps === 'string' ? json.ps : '',
|
|
|
|
|
+ settings: {
|
|
|
|
|
+ vnext: [{
|
|
|
|
|
+ address: json.add ?? '',
|
|
|
|
|
+ port,
|
|
|
|
|
+ users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
|
|
|
|
|
+ }],
|
|
|
|
|
+ },
|
|
|
|
|
+ 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);
|
|
|
|
|
+ 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);
|
|
|
|
|
+ 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 atIndex = linkNoHash.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);
|
|
|
|
|
+ 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)); }
|
|
|
|
|
+ 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 stream: Raw = {
|
|
|
|
|
+ network: 'hysteria',
|
|
|
|
|
+ security: 'tls',
|
|
|
|
|
+ hysteriaSettings: {
|
|
|
|
|
+ version: 2, auth, congestion: '', up: '0', down: '0',
|
|
|
|
|
+ initStreamReceiveWindow: 8388608, maxStreamReceiveWindow: 8388608,
|
|
|
|
|
+ initConnectionReceiveWindow: 20971520, maxConnectionReceiveWindow: 20971520,
|
|
|
|
|
+ maxIdleTimeout: 30, keepAlivePeriod: 2, disablePathMTUDiscovery: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ tlsSettings: {
|
|
|
|
|
+ serverName: params.get('sni') ?? '',
|
|
|
|
|
+ alpn: ['h3'],
|
|
|
|
|
+ fingerprint: '',
|
|
|
|
|
+ echConfigList: '',
|
|
|
|
|
+ verifyPeerCertByName: '',
|
|
|
|
|
+ pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ return {
|
|
|
|
|
+ protocol: 'hysteria',
|
|
|
|
|
+ tag: decodeRemark(url),
|
|
|
|
|
+ settings: { address, port, version: 2 },
|
|
|
|
|
+ streamSettings: stream,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 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)
|
|
|
|
|
+ );
|
|
|
|
|
+}
|