outbound-link-parser.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. import { Base64 } from '@/utils';
  2. import { normalizeUdpHop, upgradeLegacyUdpHop } from './udphop-mask';
  3. import { upgradeLegacyXdnsMasks } from './xdns-mask';
  4. // Focused share-link parser for the OutboundFormModal's link-import
  5. // helper. Each parser returns a wire-shape outbound record (the same
  6. // shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
  7. // null when the input doesn't match.
  8. //
  9. // Scope: address + port + auth + remark, plus the network/security
  10. // fields the common vmess:// / vless:// links carry as query params.
  11. // XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
  12. // scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
  13. // present in either the JSON or URL params. xmux and downloadSettings
  14. // round-trip through the `extra` JSON blob. reality shortIds, padding
  15. // obfs key/header/placement, hysteria udphop are still left
  16. // to the user to fill in after import — the legacy Outbound.fromLink
  17. // was ~250 lines of dense edge-case handling we don't need to
  18. // replicate verbatim for the common phone-to-panel workflow.
  19. type Raw = Record<string, unknown>;
  20. // XHTTP knob keys grouped by wire type. Used by both the URL query-param
  21. // (vless/trojan) branch and the vmess JSON branch to consistently pull
  22. // the same set of advanced fields when present. Keep order ~stable to
  23. // match the schema's authoring order so diffs read naturally.
  24. const XHTTP_STRING_KEYS = [
  25. 'xPaddingBytes',
  26. 'xPaddingKey',
  27. 'xPaddingHeader',
  28. 'xPaddingPlacement',
  29. 'xPaddingMethod',
  30. 'sessionIDPlacement',
  31. 'sessionIDKey',
  32. 'sessionIDTable',
  33. 'sessionIDLength',
  34. 'seqPlacement',
  35. 'seqKey',
  36. 'uplinkDataPlacement',
  37. 'uplinkDataKey',
  38. 'scMaxEachPostBytes',
  39. 'scMinPostsIntervalMs',
  40. 'scStreamUpServerSecs',
  41. 'uplinkHTTPMethod',
  42. ] as const;
  43. // Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
  44. // Map them onto the renamed keys so old links still import. Mirrors the
  45. // schema-level migrateLegacyXhttp.
  46. const XHTTP_LEGACY_ALIASES: Record<string, string> = {
  47. sessionPlacement: 'sessionIDPlacement',
  48. sessionKey: 'sessionIDKey',
  49. };
  50. const XHTTP_NUMBER_KEYS = [
  51. 'scMaxBufferedPosts',
  52. 'serverMaxHeaderBytes',
  53. 'uplinkChunkSize',
  54. ] as const;
  55. const XHTTP_BOOL_KEYS = ['xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader'] as const;
  56. // Nested objects the inbound link bundles into the `extra` JSON blob
  57. // (and vmess JSON carries inline). The outbound form adapter expands
  58. // xmux into the XMUX sub-form (enableXmux) on load.
  59. const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
  60. function asBool(s: string | null): boolean | undefined {
  61. if (s === null) return undefined;
  62. return s === 'true' || s === '1';
  63. }
  64. function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
  65. // Precedence from lowest to highest: stream-init default →
  66. // x_padding_bytes snake_case alias → extra JSON payload →
  67. // explicit camelCase URL param. Apply in that order so each tier
  68. // overwrites the previous when present.
  69. const padBytesAlt = params.get('x_padding_bytes');
  70. if (padBytesAlt !== null && padBytesAlt !== '') {
  71. xhttp.xPaddingBytes = padBytesAlt;
  72. }
  73. // The inbound link bundles advanced xhttp knobs into `extra=<json>`.
  74. // Decode and merge so re-importing a share link round-trips the full
  75. // xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
  76. const extra = params.get('extra');
  77. if (extra) {
  78. try {
  79. const parsed = JSON.parse(extra) as Record<string, unknown>;
  80. applyXhttpStringFromJson(xhttp, parsed);
  81. if (parsed.headers && typeof parsed.headers === 'object') {
  82. xhttp.headers = parsed.headers;
  83. }
  84. } catch {
  85. // malformed extra — silently ignore, the panel can still operate
  86. // on the rest of the link
  87. }
  88. }
  89. for (const k of XHTTP_STRING_KEYS) {
  90. const v = params.get(k);
  91. if (v !== null && v !== '') xhttp[k] = v;
  92. }
  93. for (const k of XHTTP_NUMBER_KEYS) {
  94. const v = params.get(k);
  95. if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
  96. }
  97. for (const k of XHTTP_BOOL_KEYS) {
  98. const v = params.get(k);
  99. if (v !== null && v !== '') xhttp[k] = asBool(v);
  100. }
  101. // Fill renamed keys from legacy params only when the new key is absent.
  102. for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
  103. if (xhttp[renamed] === undefined) {
  104. const v = params.get(legacy);
  105. if (v !== null && v !== '') xhttp[renamed] = v;
  106. }
  107. }
  108. }
  109. function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
  110. for (const k of XHTTP_STRING_KEYS) {
  111. if (typeof json[k] === 'string') xhttp[k] = json[k];
  112. }
  113. for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
  114. if (xhttp[renamed] === undefined && typeof json[legacy] === 'string') {
  115. xhttp[renamed] = json[legacy];
  116. }
  117. }
  118. for (const k of XHTTP_NUMBER_KEYS) {
  119. if (typeof json[k] === 'number') xhttp[k] = json[k];
  120. }
  121. for (const k of XHTTP_BOOL_KEYS) {
  122. if (typeof json[k] === 'boolean') xhttp[k] = json[k];
  123. }
  124. for (const k of XHTTP_OBJECT_KEYS) {
  125. const v = json[k];
  126. if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
  127. }
  128. }
  129. function buildStream(network: string, security: string): Raw {
  130. const stream: Raw = { network, security };
  131. switch (network) {
  132. case 'tcp':
  133. stream.tcpSettings = { header: { type: 'none' } };
  134. break;
  135. case 'kcp':
  136. stream.kcpSettings = {
  137. mtu: 1350,
  138. tti: 20,
  139. uplinkCapacity: 5,
  140. downlinkCapacity: 20,
  141. cwndMultiplier: 1,
  142. maxSendingWindow: 2097152,
  143. };
  144. break;
  145. case 'ws':
  146. stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
  147. break;
  148. case 'grpc':
  149. stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
  150. break;
  151. case 'httpupgrade':
  152. stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
  153. break;
  154. case 'xhttp':
  155. stream.xhttpSettings = {
  156. path: '/',
  157. host: '',
  158. mode: 'auto',
  159. headers: {},
  160. xPaddingBytes: '100-1000',
  161. };
  162. break;
  163. default:
  164. stream.tcpSettings = { header: { type: 'none' } };
  165. }
  166. if (security === 'tls') {
  167. stream.tlsSettings = {
  168. serverName: '',
  169. alpn: [],
  170. fingerprint: '',
  171. echConfigList: '',
  172. verifyPeerCertByName: '',
  173. pinnedPeerCertSha256: '',
  174. };
  175. } else if (security === 'reality') {
  176. stream.realitySettings = {
  177. publicKey: '',
  178. fingerprint: 'chrome',
  179. serverName: '',
  180. shortId: '',
  181. spiderX: '',
  182. mldsa65Verify: '',
  183. };
  184. }
  185. return stream;
  186. }
  187. function applyTransportParams(stream: Raw, params: URLSearchParams): void {
  188. const network = stream.network as string;
  189. const host = params.get('host') ?? '';
  190. const path = params.get('path') ?? '/';
  191. switch (network) {
  192. case 'ws':
  193. (stream.wsSettings as Raw).host = host;
  194. (stream.wsSettings as Raw).path = path;
  195. break;
  196. case 'grpc': {
  197. const grpc = stream.grpcSettings as Raw;
  198. const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
  199. grpc.serviceName = serviceName;
  200. grpc.authority = params.get('authority') ?? '';
  201. grpc.multiMode = params.get('mode') === 'multi';
  202. break;
  203. }
  204. case 'httpupgrade':
  205. (stream.httpupgradeSettings as Raw).host = host;
  206. (stream.httpupgradeSettings as Raw).path = path;
  207. break;
  208. case 'xhttp': {
  209. const xhttp = stream.xhttpSettings as Raw;
  210. xhttp.host = host;
  211. xhttp.path = path;
  212. if (params.get('mode')) xhttp.mode = params.get('mode');
  213. applyXhttpStringFromParams(xhttp, params);
  214. break;
  215. }
  216. case 'kcp': {
  217. // mtu/tti on kcpSettings; header/seed via applyMkcpLegacyFromShare.
  218. const kcp = stream.kcpSettings as Raw;
  219. const mtu = kcpParamInRange(params.get('mtu'), KCP_MIN_MTU, KCP_MAX_MTU);
  220. if (mtu !== null) kcp.mtu = mtu;
  221. const tti = kcpParamInRange(params.get('tti'), KCP_MIN_TTI, KCP_MAX_TTI);
  222. if (tti !== null) kcp.tti = tti;
  223. break;
  224. }
  225. case 'tcp':
  226. // vless/trojan TCP HTTP camouflage rides on header=http+host+path
  227. if (params.get('headerType') === 'http' || params.get('type') === 'http') {
  228. (stream.tcpSettings as Raw).header = {
  229. type: 'http',
  230. request: {
  231. version: '1.1',
  232. method: 'GET',
  233. path: path.split(',').filter(Boolean),
  234. headers: host ? { Host: host.split(',').filter(Boolean) } : {},
  235. },
  236. };
  237. }
  238. break;
  239. }
  240. }
  241. // mKCP bounds mirror xray-core's KCPConfig.Build checks (a value outside them fails
  242. // the whole config load); mtu's ceiling is the int32 that fits its uint32 field.
  243. const KCP_MIN_MTU = 21;
  244. const KCP_MAX_MTU = 0x7fffffff;
  245. const KCP_MIN_TTI = 10;
  246. const KCP_MAX_TTI = 1000;
  247. // Decimal digits only, like the Go importer's strconv.Atoi; anything else keeps
  248. // buildStream's default.
  249. function kcpParamInRange(raw: string | null, min: number, max: number): number | null {
  250. if (raw === null || !/^\d+$/.test(raw)) return null;
  251. const n = Number(raw);
  252. return Number.isSafeInteger(n) && n >= min && n <= max ? n : null;
  253. }
  254. const kcpHeaderTypeToMask: Record<string, string> = {
  255. dns: 'dns',
  256. dtls: 'dtls',
  257. srtp: 'srtp',
  258. utp: 'utp',
  259. 'wechat-video': 'wechat',
  260. wireguard: 'wireguard',
  261. };
  262. // The inbound link emits the entire finalmask object as a JSON-encoded
  263. // `fm` query param. Decode and attach to streamSettings so quicParams and the
  264. // tcp+udp masks round-trip on outbound import; a legacy udpHop becomes the mask.
  265. function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
  266. const fm = params.get('fm');
  267. if (fm) {
  268. try {
  269. const parsed = JSON.parse(fm) as Record<string, unknown>;
  270. if (parsed && typeof parsed === 'object') {
  271. sanitizeFinalMaskQuicParams(parsed);
  272. if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next;
  273. stream.finalmask = normalizeUdpHop(upgradeLegacyUdpHop(parsed).next).next;
  274. }
  275. } catch {
  276. // malformed fm — leave streamSettings.finalmask absent
  277. }
  278. }
  279. applyMkcpLegacyFromShare(stream, params);
  280. }
  281. /** Restore headerType/seed into finalmask.udp mkcp-legacy; fm= mkcp-legacy wins. */
  282. function applyMkcpLegacyFromShare(stream: Raw, params: URLSearchParams): void {
  283. let headerType = (params.get('headerType') ?? '').trim();
  284. const seed = params.get('seed') ?? '';
  285. if (headerType === 'none') headerType = '';
  286. if (!headerType && !seed) return;
  287. const network = stream.network;
  288. if (typeof network === 'string' && network && network !== 'kcp') return;
  289. let maskHeader = '';
  290. if (headerType) {
  291. if (!Object.hasOwn(kcpHeaderTypeToMask, headerType)) return;
  292. maskHeader = kcpHeaderTypeToMask[headerType];
  293. }
  294. const finalmask = (stream.finalmask as Raw) ?? {};
  295. const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : [];
  296. if (udp.some((m) => (m as Raw)?.type === 'mkcp-legacy')) return;
  297. // One mask per field, seed first: MkcpLegacy.Build ignores value once header is
  298. // set, and the chain puts the last mask outermost on the wire (header around cipher).
  299. if (seed) udp.push(mkcpLegacyMask('', seed));
  300. if (maskHeader) udp.push(mkcpLegacyMask(maskHeader, ''));
  301. finalmask.udp = udp;
  302. stream.finalmask = finalmask;
  303. }
  304. function mkcpLegacyMask(header: string, value: string): Raw {
  305. return { type: 'mkcp-legacy', settings: { header, value } };
  306. }
  307. function ensureFinalMask(stream: Raw): Raw {
  308. if (!stream.finalmask || typeof stream.finalmask !== 'object') stream.finalmask = {};
  309. return stream.finalmask as Raw;
  310. }
  311. // Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
  312. // password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
  313. function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
  314. const obfs = (params.get('obfs') ?? '').toLowerCase();
  315. const isGecko = obfs === 'gecko';
  316. if (!isGecko && obfs !== 'salamander') return;
  317. const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
  318. if (!password) return;
  319. let packetSize = '';
  320. if (isGecko) {
  321. // Both halves required and numeric, matching the export side; anything
  322. // else is dropped rather than stored as a malformed range.
  323. const minSize = (params.get('minPacketSize') ?? '').trim();
  324. const maxSize = (params.get('maxPacketSize') ?? '').trim();
  325. const min = Number(minSize);
  326. const max = Number(maxSize);
  327. if (
  328. /^\d+$/.test(minSize) &&
  329. /^\d+$/.test(maxSize) &&
  330. Number.isSafeInteger(min) &&
  331. Number.isSafeInteger(max) &&
  332. min >= 1 &&
  333. max >= min &&
  334. max <= 2048
  335. ) {
  336. packetSize = `${min}-${max}`;
  337. }
  338. }
  339. const finalmask = ensureFinalMask(stream);
  340. const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
  341. const existing = udp.find(
  342. (m) => m && typeof m === 'object' && (m as Raw).type === 'salamander',
  343. ) as Raw | undefined;
  344. if (existing) {
  345. const settings = (
  346. existing.settings && typeof existing.settings === 'object'
  347. ? existing.settings
  348. : (existing.settings = {})
  349. ) as Raw;
  350. if (typeof settings.password !== 'string' || settings.password.length === 0)
  351. settings.password = password;
  352. if (
  353. packetSize !== '' &&
  354. !(typeof settings.packetSize === 'string' && settings.packetSize.length > 0)
  355. )
  356. settings.packetSize = packetSize;
  357. return;
  358. }
  359. const settings: Raw = { password };
  360. if (packetSize !== '') settings.packetSize = packetSize;
  361. finalmask.udp = [...udp, { type: 'salamander', settings }];
  362. }
  363. // Rebuild the UDP port-hopping range from the standard mport param. xray-core
  364. // 26.9.9 replaced finalmask.quicParams.udpHop with a 'udphop' UDP mask, whose
  365. // intervalremote mode is what the old key used to do; an fm= mask wins.
  366. function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
  367. const ports = firstParam(params, 'mport');
  368. if (!ports) return;
  369. const finalmask = ensureFinalMask(stream);
  370. const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
  371. if (udp.some((mask) => (mask as Raw | undefined)?.type === 'udphop')) return;
  372. finalmask.udp = [
  373. ...udp,
  374. { type: 'udphop', settings: { mode: 'intervalremote', interval: '5-10', remotePorts: ports } },
  375. ];
  376. }
  377. const QUIC_PARAMS_NUMERIC_KEYS = [
  378. 'initStreamReceiveWindow',
  379. 'maxStreamReceiveWindow',
  380. 'initConnectionReceiveWindow',
  381. 'maxConnectionReceiveWindow',
  382. 'maxIdleTimeout',
  383. 'keepAlivePeriod',
  384. 'maxIncomingStreams',
  385. ] as const;
  386. const DURATION_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3600 };
  387. const QUIC_NUMERIC_MAX = 1e15;
  388. function coerceQuicNumeric(value: unknown): number | null {
  389. if (typeof value === 'number' && Number.isFinite(value)) {
  390. return Math.trunc(value);
  391. }
  392. if (typeof value === 'string') {
  393. const asNumber = Number(value);
  394. if (value.trim() !== '' && Number.isFinite(asNumber)) {
  395. return Math.trunc(asNumber);
  396. }
  397. const duration = /^(-?\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim());
  398. if (duration) {
  399. return Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]);
  400. }
  401. }
  402. return null;
  403. }
  404. function clampQuicNumeric(key: string, n: number): number | null {
  405. if (n < 0 || n > QUIC_NUMERIC_MAX) return null;
  406. if (n === 0) return 0;
  407. if (key === 'keepAlivePeriod') return Math.min(Math.max(n, 2), 60);
  408. if (key === 'maxIdleTimeout') return Math.min(Math.max(n, 4), 120);
  409. if (key === 'maxIncomingStreams') return Math.max(n, 8);
  410. return n;
  411. }
  412. // xray-core rejects the whole config when these quicParams fields are not
  413. // plain integers within its accepted ranges (keepAlivePeriod 0 or 2-60,
  414. // maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8), so coerce
  415. // numeric/duration strings, clamp the ranged fields, and drop anything
  416. // unparseable, negative, or absurdly large (#5783). Mirrors the Go parser in
  417. // internal/util/link/outbound.go.
  418. function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): void {
  419. const raw = parsed.quicParams;
  420. if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
  421. const quic = raw as Record<string, unknown>;
  422. for (const key of QUIC_PARAMS_NUMERIC_KEYS) {
  423. if (!(key in quic)) continue;
  424. const coerced = coerceQuicNumeric(quic[key]);
  425. const clamped = coerced === null ? null : clampQuicNumeric(key, coerced);
  426. if (clamped === null) {
  427. delete quic[key];
  428. continue;
  429. }
  430. quic[key] = clamped;
  431. }
  432. }
  433. // The panel exports tcp/http obfuscation as the SIP002 obfs-local plugin only,
  434. // so the header it stands for has to be rebuilt before the transport is applied.
  435. function applyObfsLocalPluginParams(params: URLSearchParams): void {
  436. if (params.get('headerType') || params.get('type') === 'http') return;
  437. const parts = (params.get('plugin') ?? '').split(';');
  438. if (parts[0] !== 'obfs-local') return;
  439. let obfs = '';
  440. let host = '';
  441. for (const part of parts.slice(1)) {
  442. const eq = part.indexOf('=');
  443. if (eq < 0) continue;
  444. const key = part.slice(0, eq);
  445. if (key === 'obfs') obfs = part.slice(eq + 1);
  446. else if (key === 'obfs-host') host = part.slice(eq + 1);
  447. }
  448. if (obfs !== 'http') return;
  449. params.set('type', 'tcp');
  450. params.set('headerType', 'http');
  451. if (host) params.set('host', host);
  452. }
  453. function applySecurityParams(stream: Raw, params: URLSearchParams): void {
  454. if (stream.security === 'tls') {
  455. const tls = stream.tlsSettings as Raw;
  456. tls.serverName = params.get('sni') ?? '';
  457. tls.fingerprint = params.get('fp') ?? '';
  458. const alpn = params.get('alpn');
  459. if (alpn) tls.alpn = alpn.split(',');
  460. tls.echConfigList = params.get('ech') ?? '';
  461. tls.verifyPeerCertByName = params.get('vcn') ?? '';
  462. tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
  463. } else if (stream.security === 'reality') {
  464. const reality = stream.realitySettings as Raw;
  465. reality.serverName = params.get('sni') ?? '';
  466. reality.fingerprint = params.get('fp') ?? 'chrome';
  467. reality.publicKey = params.get('pbk') ?? '';
  468. reality.shortId = params.get('sid') ?? '';
  469. reality.spiderX = params.get('spx') ?? '';
  470. reality.mldsa65Verify = params.get('pqv') ?? '';
  471. }
  472. }
  473. function decodeRemark(url: URL): string {
  474. try {
  475. return decodeURIComponent(url.hash.replace(/^#/, ''));
  476. } catch {
  477. return url.hash.replace(/^#/, '');
  478. }
  479. }
  480. export function parseVmessLink(link: string): Raw | null {
  481. if (!link.startsWith('vmess://')) return null;
  482. try {
  483. const decoded = Base64.decode(link.slice('vmess://'.length));
  484. const json = JSON.parse(decoded) as Record<string, unknown>;
  485. const network = (json.net as string) || 'tcp';
  486. const security = json.tls === 'tls' ? 'tls' : 'none';
  487. const stream = buildStream(network, security);
  488. // Map the vmess JSON's net-specific keys onto the stream branch.
  489. if (network === 'tcp' && json.type === 'http') {
  490. (stream.tcpSettings as Raw).header = {
  491. type: 'http',
  492. request: {
  493. version: '1.1',
  494. method: 'GET',
  495. path: ((json.path as string) ?? '/').split(',').filter(Boolean),
  496. headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
  497. },
  498. };
  499. } else if (network === 'ws') {
  500. (stream.wsSettings as Raw).host = json.host ?? '';
  501. (stream.wsSettings as Raw).path = json.path ?? '/';
  502. } else if (network === 'grpc') {
  503. (stream.grpcSettings as Raw).serviceName = json.path ?? '';
  504. (stream.grpcSettings as Raw).authority = json.authority ?? '';
  505. (stream.grpcSettings as Raw).multiMode = json.type === 'multi';
  506. } else if (network === 'httpupgrade') {
  507. (stream.httpupgradeSettings as Raw).host = json.host ?? '';
  508. (stream.httpupgradeSettings as Raw).path = json.path ?? '/';
  509. } else if (network === 'xhttp') {
  510. const xhttp = stream.xhttpSettings as Raw;
  511. xhttp.host = json.host ?? '';
  512. xhttp.path = json.path ?? '/';
  513. if (json.mode) xhttp.mode = json.mode;
  514. applyXhttpStringFromJson(xhttp, json);
  515. } else if (network === 'kcp') {
  516. applyTransportParams(
  517. stream,
  518. new URLSearchParams({ mtu: String(json.mtu ?? ''), tti: String(json.tti ?? '') }),
  519. );
  520. }
  521. // The vmess object carries fm as-is and the mKCP header and seed as type and path.
  522. const maskParams = new URLSearchParams();
  523. if (typeof json.fm === 'string') maskParams.set('fm', json.fm);
  524. if (network === 'kcp') {
  525. if (typeof json.type === 'string') maskParams.set('headerType', json.type);
  526. if (typeof json.path === 'string') maskParams.set('seed', json.path);
  527. }
  528. applyFinalMaskParam(stream, maskParams);
  529. if (security === 'tls') {
  530. const tls = stream.tlsSettings as Raw;
  531. tls.serverName = json.sni ?? '';
  532. tls.fingerprint = json.fp ?? '';
  533. if (json.alpn) tls.alpn = (json.alpn as string).split(',');
  534. // The vmess object names the certificate checks the url-param protocols
  535. // pass through applySecurityParams, under the same short names.
  536. if (typeof json.ech === 'string') tls.echConfigList = json.ech;
  537. if (typeof json.vcn === 'string') tls.verifyPeerCertByName = json.vcn;
  538. if (typeof json.pcs === 'string') tls.pinnedPeerCertSha256 = json.pcs;
  539. }
  540. const port = Number(json.port) || 443;
  541. const rawScy = (json.scy as string) || 'auto';
  542. const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
  543. return {
  544. protocol: 'vmess',
  545. tag: typeof json.ps === 'string' ? json.ps : '',
  546. settings: {
  547. vnext: [
  548. {
  549. address: json.add ?? '',
  550. port,
  551. users: [{ id: json.id ?? '', security: userSecurity }],
  552. },
  553. ],
  554. },
  555. streamSettings: stream,
  556. };
  557. } catch {
  558. return null;
  559. }
  560. }
  561. function parseUrlLink(link: string, expectedProto: string): URL | null {
  562. try {
  563. const url = new URL(link);
  564. if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
  565. return url;
  566. } catch {
  567. return null;
  568. }
  569. }
  570. export function parseVlessLink(link: string): Raw | null {
  571. const url = parseUrlLink(link, 'vless');
  572. if (!url) return null;
  573. const id = url.username;
  574. const address = url.hostname;
  575. const port = Number(url.port) || 443;
  576. const params = url.searchParams;
  577. const network = params.get('type') ?? 'tcp';
  578. const security = (params.get('security') ?? 'none') as string;
  579. const stream = buildStream(network, security);
  580. applyTransportParams(stream, params);
  581. applySecurityParams(stream, params);
  582. applyFinalMaskParam(stream, params);
  583. return {
  584. protocol: 'vless',
  585. tag: decodeRemark(url),
  586. settings: {
  587. address,
  588. port,
  589. id,
  590. flow: params.get('flow') ?? '',
  591. encryption: params.get('encryption') ?? 'none',
  592. },
  593. streamSettings: stream,
  594. };
  595. }
  596. export function parseTrojanLink(link: string): Raw | null {
  597. const url = parseUrlLink(link, 'trojan');
  598. if (!url) return null;
  599. const password = url.username;
  600. const address = url.hostname;
  601. const port = Number(url.port) || 443;
  602. const params = url.searchParams;
  603. const network = params.get('type') ?? 'tcp';
  604. const security = (params.get('security') ?? 'tls') as string;
  605. const stream = buildStream(network, security);
  606. applyTransportParams(stream, params);
  607. applySecurityParams(stream, params);
  608. applyFinalMaskParam(stream, params);
  609. return {
  610. protocol: 'trojan',
  611. tag: decodeRemark(url),
  612. settings: {
  613. servers: [{ address, port, password }],
  614. },
  615. streamSettings: stream,
  616. };
  617. }
  618. export function parseShadowsocksLink(link: string): Raw | null {
  619. if (!link.startsWith('ss://')) return null;
  620. // Two link shapes coexist:
  621. // modern: ss://base64(method:password)@host:port#remark
  622. // legacy: ss://base64(method:password@host:port)#remark
  623. // Query may carry Xray-native stream params (type/security/sni/alpn/fp)
  624. // emitted by the SS share-link generator — preserve them like trojan/vless.
  625. // Try modern first; fall back to legacy decode of the whole userinfo+host.
  626. let userInfo: string;
  627. let host: string;
  628. let port: number;
  629. let remark = '';
  630. const hashIndex = link.indexOf('#');
  631. const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
  632. if (hashIndex >= 0) {
  633. try {
  634. remark = decodeURIComponent(link.slice(hashIndex + 1));
  635. } catch {
  636. remark = '';
  637. }
  638. }
  639. const queryIndex = linkNoHash.indexOf('?');
  640. const rawQuery = queryIndex >= 0 ? linkNoHash.slice(queryIndex + 1) : '';
  641. const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
  642. const atIndex = core.indexOf('@');
  643. if (atIndex >= 0) {
  644. const rawUserInfo = core.slice('ss://'.length, atIndex);
  645. if (rawUserInfo.includes(':')) {
  646. // SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
  647. // (a literal ':' can't appear in a base64/base64url string).
  648. try {
  649. userInfo = decodeURIComponent(rawUserInfo);
  650. } catch {
  651. userInfo = rawUserInfo;
  652. }
  653. } else {
  654. try {
  655. userInfo = Base64.decode(rawUserInfo);
  656. } catch {
  657. userInfo = rawUserInfo;
  658. }
  659. }
  660. const hostPort = core.slice(atIndex + 1).replace(/\/+$/, '');
  661. const colon = hostPort.lastIndexOf(':');
  662. if (colon < 0) return null;
  663. host = hostPort.slice(0, colon);
  664. port = Number(hostPort.slice(colon + 1)) || 443;
  665. } else {
  666. let decoded: string;
  667. try {
  668. decoded = Base64.decode(core.slice('ss://'.length));
  669. } catch {
  670. return null;
  671. }
  672. const at = decoded.indexOf('@');
  673. if (at < 0) return null;
  674. userInfo = decoded.slice(0, at);
  675. const hostPort = decoded.slice(at + 1);
  676. const colon = hostPort.lastIndexOf(':');
  677. if (colon < 0) return null;
  678. host = hostPort.slice(0, colon);
  679. port = Number(hostPort.slice(colon + 1)) || 443;
  680. }
  681. const sep = userInfo.indexOf(':');
  682. const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
  683. const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
  684. const params = new URLSearchParams(rawQuery);
  685. applyObfsLocalPluginParams(params);
  686. const network = params.get('type') ?? 'tcp';
  687. const security = (params.get('security') ?? 'none') as string;
  688. const stream = buildStream(network, security);
  689. applyTransportParams(stream, params);
  690. applySecurityParams(stream, params);
  691. applyFinalMaskParam(stream, params);
  692. return {
  693. protocol: 'shadowsocks',
  694. tag: remark,
  695. settings: {
  696. servers: [{ address: host, port, password, method }],
  697. },
  698. streamSettings: stream,
  699. };
  700. }
  701. export function parseHysteria2Link(link: string): Raw | null {
  702. const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
  703. if (!url) return null;
  704. // hysteria2's auth rides as the URL userinfo. The streamSettings
  705. // network branch is the dedicated 'hysteria' transport — the modal's
  706. // newStreamSlice('hysteria') initializer fills in receive-window
  707. // defaults; we override the user-set fields here.
  708. const auth = url.username;
  709. const address = url.hostname;
  710. const port = Number(url.port) || 443;
  711. const params = url.searchParams;
  712. const alpn = params.get('alpn');
  713. const stream: Raw = {
  714. network: 'hysteria',
  715. security: 'tls',
  716. hysteriaSettings: {
  717. version: 2,
  718. auth,
  719. udpIdleTimeout: 60,
  720. },
  721. tlsSettings: {
  722. serverName: params.get('sni') ?? '',
  723. alpn: alpn ? alpn.split(',') : ['h3'],
  724. fingerprint: params.get('fp') ?? '',
  725. echConfigList: params.get('ech') ?? '',
  726. verifyPeerCertByName: params.get('vcn') ?? '',
  727. pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
  728. },
  729. };
  730. applyFinalMaskParam(stream, params);
  731. applyHysteria2Obfs(stream, params);
  732. applyHysteria2Hop(stream, params);
  733. return {
  734. protocol: 'hysteria',
  735. tag: decodeRemark(url),
  736. settings: { address, port, version: 2 },
  737. streamSettings: stream,
  738. };
  739. }
  740. function firstParam(params: URLSearchParams, ...keys: string[]): string | null {
  741. for (const k of keys) {
  742. const v = params.get(k);
  743. if (v !== null && v !== '') return v;
  744. }
  745. return null;
  746. }
  747. export function parseWireguardLink(link: string): Raw | null {
  748. const url = parseUrlLink(link, 'wireguard') ?? parseUrlLink(link, 'wg');
  749. if (!url) return null;
  750. let secretKey: string;
  751. try {
  752. secretKey = decodeURIComponent(url.username);
  753. } catch {
  754. secretKey = url.username;
  755. }
  756. const params = url.searchParams;
  757. const host = url.hostname;
  758. const port = url.port;
  759. const endpoint = host ? (port ? `${host}:${port}` : host) : '';
  760. const addressRaw = firstParam(params, 'address', 'ip') ?? '';
  761. const address = addressRaw
  762. .split(',')
  763. .map((s) => s.trim())
  764. .filter(Boolean);
  765. const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips');
  766. const allowedIPs = allowedRaw
  767. ? allowedRaw
  768. .split(',')
  769. .map((s) => s.trim())
  770. .filter(Boolean)
  771. : ['0.0.0.0/0', '::/0'];
  772. const peer: Raw = {
  773. publicKey: firstParam(params, 'publickey', 'publicKey', 'public_key', 'peerPublicKey') ?? '',
  774. endpoint,
  775. allowedIPs,
  776. };
  777. const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
  778. if (psk) peer.preSharedKey = psk;
  779. const keepAliveRaw = firstParam(
  780. params,
  781. 'keepalive',
  782. 'persistentkeepalive',
  783. 'persistent_keepalive',
  784. );
  785. if (keepAliveRaw !== null) {
  786. const k = Number(keepAliveRaw);
  787. if (Number.isFinite(k)) peer.keepAlive = k;
  788. }
  789. const settings: Raw = { secretKey, address, peers: [peer] };
  790. const mtuRaw = firstParam(params, 'mtu');
  791. if (mtuRaw !== null) {
  792. const m = Number(mtuRaw);
  793. if (Number.isFinite(m)) settings.mtu = m;
  794. }
  795. const reservedRaw = firstParam(params, 'reserved');
  796. if (reservedRaw) {
  797. const reserved = reservedRaw
  798. .split(',')
  799. .map((s) => Number(s.trim()))
  800. .filter((n) => Number.isFinite(n));
  801. if (reserved.length > 0) settings.reserved = reserved;
  802. }
  803. return {
  804. protocol: 'wireguard',
  805. tag: decodeRemark(url),
  806. settings,
  807. };
  808. }
  809. // Dispatcher — first non-null parser wins. Returns null when no parser
  810. // recognizes the link's protocol scheme.
  811. export function parseOutboundLink(link: string): Raw | null {
  812. const trimmed = link.trim();
  813. if (!trimmed) return null;
  814. return (
  815. parseVmessLink(trimmed) ??
  816. parseVlessLink(trimmed) ??
  817. parseTrojanLink(trimmed) ??
  818. parseShadowsocksLink(trimmed) ??
  819. parseHysteria2Link(trimmed) ??
  820. parseWireguardLink(trimmed)
  821. );
  822. }