outbound-link-parser.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. import { Base64 } from '@/utils';
  2. // Focused share-link parser for the OutboundFormModal's link-import
  3. // helper. Each parser returns a wire-shape outbound record (the same
  4. // shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
  5. // null when the input doesn't match.
  6. //
  7. // Scope: address + port + auth + remark, plus the network/security
  8. // fields the common vmess:// / vless:// links carry as query params.
  9. // XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
  10. // scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
  11. // present in either the JSON or URL params. xmux and downloadSettings
  12. // round-trip through the `extra` JSON blob. reality shortIds, padding
  13. // obfs key/header/placement, hysteria udphop are still left
  14. // to the user to fill in after import — the legacy Outbound.fromLink
  15. // was ~250 lines of dense edge-case handling we don't need to
  16. // replicate verbatim for the common phone-to-panel workflow.
  17. type Raw = Record<string, unknown>;
  18. // XHTTP knob keys grouped by wire type. Used by both the URL query-param
  19. // (vless/trojan) branch and the vmess JSON branch to consistently pull
  20. // the same set of advanced fields when present. Keep order ~stable to
  21. // match the schema's authoring order so diffs read naturally.
  22. const XHTTP_STRING_KEYS = [
  23. 'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
  24. 'xPaddingMethod', 'sessionIDPlacement', 'sessionIDKey', 'sessionIDTable',
  25. 'sessionIDLength', 'seqPlacement', 'seqKey', 'uplinkDataPlacement',
  26. 'uplinkDataKey', 'scMaxEachPostBytes', 'scMinPostsIntervalMs',
  27. 'scStreamUpServerSecs', 'uplinkHTTPMethod',
  28. ] as const;
  29. // Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
  30. // Map them onto the renamed keys so old links still import. Mirrors the
  31. // schema-level migrateLegacyXhttp.
  32. const XHTTP_LEGACY_ALIASES: Record<string, string> = {
  33. sessionPlacement: 'sessionIDPlacement',
  34. sessionKey: 'sessionIDKey',
  35. };
  36. const XHTTP_NUMBER_KEYS = [
  37. 'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
  38. ] as const;
  39. const XHTTP_BOOL_KEYS = [
  40. 'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
  41. ] as const;
  42. // Nested objects the inbound link bundles into the `extra` JSON blob
  43. // (and vmess JSON carries inline). The outbound form adapter expands
  44. // xmux into the XMUX sub-form (enableXmux) on load.
  45. const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
  46. function asBool(s: string | null): boolean | undefined {
  47. if (s === null) return undefined;
  48. return s === 'true' || s === '1';
  49. }
  50. function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
  51. // Precedence from lowest to highest: stream-init default →
  52. // x_padding_bytes snake_case alias → extra JSON payload →
  53. // explicit camelCase URL param. Apply in that order so each tier
  54. // overwrites the previous when present.
  55. const padBytesAlt = params.get('x_padding_bytes');
  56. if (padBytesAlt !== null && padBytesAlt !== '') {
  57. xhttp.xPaddingBytes = padBytesAlt;
  58. }
  59. // The inbound link bundles advanced xhttp knobs into `extra=<json>`.
  60. // Decode and merge so re-importing a share link round-trips the full
  61. // xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
  62. const extra = params.get('extra');
  63. if (extra) {
  64. try {
  65. const parsed = JSON.parse(extra) as Record<string, unknown>;
  66. applyXhttpStringFromJson(xhttp, parsed);
  67. if (parsed.headers && typeof parsed.headers === 'object') {
  68. xhttp.headers = parsed.headers;
  69. }
  70. } catch {
  71. // malformed extra — silently ignore, the panel can still operate
  72. // on the rest of the link
  73. }
  74. }
  75. for (const k of XHTTP_STRING_KEYS) {
  76. const v = params.get(k);
  77. if (v !== null && v !== '') xhttp[k] = v;
  78. }
  79. for (const k of XHTTP_NUMBER_KEYS) {
  80. const v = params.get(k);
  81. if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
  82. }
  83. for (const k of XHTTP_BOOL_KEYS) {
  84. const v = params.get(k);
  85. if (v !== null && v !== '') xhttp[k] = asBool(v);
  86. }
  87. // Fill renamed keys from legacy params only when the new key is absent.
  88. for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
  89. if (xhttp[renamed] === undefined) {
  90. const v = params.get(legacy);
  91. if (v !== null && v !== '') xhttp[renamed] = v;
  92. }
  93. }
  94. }
  95. function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
  96. for (const k of XHTTP_STRING_KEYS) {
  97. if (typeof json[k] === 'string') xhttp[k] = json[k];
  98. }
  99. for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
  100. if (xhttp[renamed] === undefined && typeof json[legacy] === 'string') {
  101. xhttp[renamed] = json[legacy];
  102. }
  103. }
  104. for (const k of XHTTP_NUMBER_KEYS) {
  105. if (typeof json[k] === 'number') xhttp[k] = json[k];
  106. }
  107. for (const k of XHTTP_BOOL_KEYS) {
  108. if (typeof json[k] === 'boolean') xhttp[k] = json[k];
  109. }
  110. for (const k of XHTTP_OBJECT_KEYS) {
  111. const v = json[k];
  112. if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
  113. }
  114. }
  115. function buildStream(network: string, security: string): Raw {
  116. const stream: Raw = { network, security };
  117. switch (network) {
  118. case 'tcp':
  119. stream.tcpSettings = { header: { type: 'none' } };
  120. break;
  121. case 'kcp':
  122. stream.kcpSettings = {
  123. mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
  124. cwndMultiplier: 1, maxSendingWindow: 2097152,
  125. };
  126. break;
  127. case 'ws':
  128. stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
  129. break;
  130. case 'grpc':
  131. stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
  132. break;
  133. case 'httpupgrade':
  134. stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
  135. break;
  136. case 'xhttp':
  137. stream.xhttpSettings = {
  138. path: '/', host: '', mode: 'auto', headers: {},
  139. xPaddingBytes: '100-1000',
  140. };
  141. break;
  142. default:
  143. stream.tcpSettings = { header: { type: 'none' } };
  144. }
  145. if (security === 'tls') {
  146. stream.tlsSettings = {
  147. serverName: '', alpn: [], fingerprint: '',
  148. echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
  149. };
  150. } else if (security === 'reality') {
  151. stream.realitySettings = {
  152. publicKey: '', fingerprint: 'chrome', serverName: '',
  153. shortId: '', spiderX: '', mldsa65Verify: '',
  154. };
  155. }
  156. return stream;
  157. }
  158. function applyTransportParams(stream: Raw, params: URLSearchParams): void {
  159. const network = stream.network as string;
  160. const host = params.get('host') ?? '';
  161. const path = params.get('path') ?? '/';
  162. switch (network) {
  163. case 'ws':
  164. (stream.wsSettings as Raw).host = host;
  165. (stream.wsSettings as Raw).path = path;
  166. break;
  167. case 'grpc': {
  168. const grpc = stream.grpcSettings as Raw;
  169. const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
  170. grpc.serviceName = serviceName;
  171. grpc.authority = params.get('authority') ?? '';
  172. grpc.multiMode = params.get('mode') === 'multi';
  173. break;
  174. }
  175. case 'httpupgrade':
  176. (stream.httpupgradeSettings as Raw).host = host;
  177. (stream.httpupgradeSettings as Raw).path = path;
  178. break;
  179. case 'xhttp': {
  180. const xhttp = stream.xhttpSettings as Raw;
  181. xhttp.host = host;
  182. xhttp.path = path;
  183. if (params.get('mode')) xhttp.mode = params.get('mode');
  184. applyXhttpStringFromParams(xhttp, params);
  185. break;
  186. }
  187. case 'tcp':
  188. // vless/trojan TCP HTTP camouflage rides on header=http+host+path
  189. if (params.get('headerType') === 'http' || params.get('type') === 'http') {
  190. (stream.tcpSettings as Raw).header = {
  191. type: 'http',
  192. request: {
  193. version: '1.1',
  194. method: 'GET',
  195. path: path.split(',').filter(Boolean),
  196. headers: host ? { Host: host.split(',').filter(Boolean) } : {},
  197. },
  198. };
  199. }
  200. break;
  201. }
  202. }
  203. // The inbound link emits the entire finalmask object as a JSON-encoded
  204. // `fm` query param. Decode and attach to streamSettings so udpHop /
  205. // quicParams / tcp+udp masks round-trip on outbound import.
  206. function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
  207. const fm = params.get('fm');
  208. if (!fm) return;
  209. try {
  210. const parsed = JSON.parse(fm) as Record<string, unknown>;
  211. if (parsed && typeof parsed === 'object') {
  212. sanitizeFinalMaskQuicParams(parsed);
  213. stream.finalmask = parsed;
  214. }
  215. } catch {
  216. // malformed fm — leave streamSettings.finalmask absent
  217. }
  218. }
  219. function ensureFinalMask(stream: Raw): Raw {
  220. if (!stream.finalmask || typeof stream.finalmask !== 'object') stream.finalmask = {};
  221. return stream.finalmask as Raw;
  222. }
  223. // Rebuild the salamander mask from the standard Hysteria2 obfs pair (every
  224. // non-3x-ui client, and this panel's own generator, speak it instead of the
  225. // private fm=<json> dump). A salamander mask already carrying a password via fm=
  226. // wins; a password-less one is completed rather than left empty.
  227. function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
  228. if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return;
  229. const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
  230. if (!password) return;
  231. const finalmask = ensureFinalMask(stream);
  232. const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
  233. const existing = udp.find((m) => m && typeof m === 'object' && (m as Raw).type === 'salamander') as Raw | undefined;
  234. if (existing) {
  235. const settings = (existing.settings && typeof existing.settings === 'object'
  236. ? existing.settings
  237. : (existing.settings = {})) as Raw;
  238. if (typeof settings.password !== 'string' || settings.password.length === 0) settings.password = password;
  239. return;
  240. }
  241. finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
  242. }
  243. // Rebuild the UDP port-hopping range from the standard mport param, which the
  244. // generator emits as finalmask.quicParams.udpHop.ports. A range already supplied
  245. // via fm= wins; the client-side interval falls back to the panel's default.
  246. function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
  247. const ports = firstParam(params, 'mport');
  248. if (!ports) return;
  249. const finalmask = ensureFinalMask(stream);
  250. const quicParams = (finalmask.quicParams && typeof finalmask.quicParams === 'object'
  251. ? finalmask.quicParams
  252. : (finalmask.quicParams = {})) as Raw;
  253. const existingHop = quicParams.udpHop as Raw | undefined;
  254. if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
  255. quicParams.udpHop = { ports, interval: '5-10' };
  256. }
  257. const QUIC_PARAMS_NUMERIC_KEYS = [
  258. 'initStreamReceiveWindow',
  259. 'maxStreamReceiveWindow',
  260. 'initConnectionReceiveWindow',
  261. 'maxConnectionReceiveWindow',
  262. 'maxIdleTimeout',
  263. 'keepAlivePeriod',
  264. 'maxIncomingStreams',
  265. ] as const;
  266. const DURATION_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3600 };
  267. const QUIC_NUMERIC_MAX = 1e15;
  268. function coerceQuicNumeric(value: unknown): number | null {
  269. if (typeof value === 'number' && Number.isFinite(value)) {
  270. return Math.trunc(value);
  271. }
  272. if (typeof value === 'string') {
  273. const asNumber = Number(value);
  274. if (value.trim() !== '' && Number.isFinite(asNumber)) {
  275. return Math.trunc(asNumber);
  276. }
  277. const duration = /^(-?\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value.trim());
  278. if (duration) {
  279. return Math.trunc(Number(duration[1]) * DURATION_SECONDS[duration[2]]);
  280. }
  281. }
  282. return null;
  283. }
  284. function clampQuicNumeric(key: string, n: number): number | null {
  285. if (n < 0 || n > QUIC_NUMERIC_MAX) return null;
  286. if (n === 0) return 0;
  287. if (key === 'keepAlivePeriod') return Math.min(Math.max(n, 2), 60);
  288. if (key === 'maxIdleTimeout') return Math.min(Math.max(n, 4), 120);
  289. if (key === 'maxIncomingStreams') return Math.max(n, 8);
  290. return n;
  291. }
  292. // xray-core rejects the whole config when these quicParams fields are not
  293. // plain integers within its accepted ranges (keepAlivePeriod 0 or 2-60,
  294. // maxIdleTimeout 0 or 4-120, maxIncomingStreams 0 or >= 8), so coerce
  295. // numeric/duration strings, clamp the ranged fields, and drop anything
  296. // unparseable, negative, or absurdly large (#5783). Mirrors the Go parser in
  297. // internal/util/link/outbound.go.
  298. function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): void {
  299. const raw = parsed.quicParams;
  300. if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
  301. const quic = raw as Record<string, unknown>;
  302. for (const key of QUIC_PARAMS_NUMERIC_KEYS) {
  303. if (!(key in quic)) continue;
  304. const coerced = coerceQuicNumeric(quic[key]);
  305. const clamped = coerced === null ? null : clampQuicNumeric(key, coerced);
  306. if (clamped === null) {
  307. delete quic[key];
  308. continue;
  309. }
  310. quic[key] = clamped;
  311. }
  312. }
  313. function applySecurityParams(stream: Raw, params: URLSearchParams): void {
  314. if (stream.security === 'tls') {
  315. const tls = stream.tlsSettings as Raw;
  316. tls.serverName = params.get('sni') ?? '';
  317. tls.fingerprint = params.get('fp') ?? '';
  318. const alpn = params.get('alpn');
  319. if (alpn) tls.alpn = alpn.split(',');
  320. tls.echConfigList = params.get('ech') ?? '';
  321. tls.verifyPeerCertByName = params.get('vcn') ?? '';
  322. tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
  323. } else if (stream.security === 'reality') {
  324. const reality = stream.realitySettings as Raw;
  325. reality.serverName = params.get('sni') ?? '';
  326. reality.fingerprint = params.get('fp') ?? 'chrome';
  327. reality.publicKey = params.get('pbk') ?? '';
  328. reality.shortId = params.get('sid') ?? '';
  329. reality.spiderX = params.get('spx') ?? '';
  330. reality.mldsa65Verify = params.get('pqv') ?? '';
  331. }
  332. }
  333. function decodeRemark(url: URL): string {
  334. try {
  335. return decodeURIComponent(url.hash.replace(/^#/, ''));
  336. } catch {
  337. return url.hash.replace(/^#/, '');
  338. }
  339. }
  340. export function parseVmessLink(link: string): Raw | null {
  341. if (!link.startsWith('vmess://')) return null;
  342. try {
  343. const decoded = Base64.decode(link.slice('vmess://'.length));
  344. const json = JSON.parse(decoded) as Record<string, unknown>;
  345. const network = (json.net as string) || 'tcp';
  346. const security = json.tls === 'tls' ? 'tls' : 'none';
  347. const stream = buildStream(network, security);
  348. // Map the vmess JSON's net-specific keys onto the stream branch.
  349. if (network === 'tcp' && json.type === 'http') {
  350. (stream.tcpSettings as Raw).header = {
  351. type: 'http',
  352. request: {
  353. version: '1.1', method: 'GET',
  354. path: (json.path as string ?? '/').split(',').filter(Boolean),
  355. headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
  356. },
  357. };
  358. } else if (network === 'ws') {
  359. (stream.wsSettings as Raw).host = json.host ?? '';
  360. (stream.wsSettings as Raw).path = json.path ?? '/';
  361. } else if (network === 'grpc') {
  362. (stream.grpcSettings as Raw).serviceName = json.path ?? '';
  363. (stream.grpcSettings as Raw).authority = json.authority ?? '';
  364. (stream.grpcSettings as Raw).multiMode = json.type === 'multi';
  365. } else if (network === 'httpupgrade') {
  366. (stream.httpupgradeSettings as Raw).host = json.host ?? '';
  367. (stream.httpupgradeSettings as Raw).path = json.path ?? '/';
  368. } else if (network === 'xhttp') {
  369. const xhttp = stream.xhttpSettings as Raw;
  370. xhttp.host = json.host ?? '';
  371. xhttp.path = json.path ?? '/';
  372. if (json.mode) xhttp.mode = json.mode;
  373. applyXhttpStringFromJson(xhttp, json);
  374. }
  375. if (security === 'tls') {
  376. const tls = stream.tlsSettings as Raw;
  377. tls.serverName = json.sni ?? '';
  378. tls.fingerprint = json.fp ?? '';
  379. if (json.alpn) tls.alpn = (json.alpn as string).split(',');
  380. }
  381. const port = Number(json.port) || 443;
  382. const rawScy = (json.scy as string) || 'auto';
  383. const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
  384. return {
  385. protocol: 'vmess',
  386. tag: typeof json.ps === 'string' ? json.ps : '',
  387. settings: {
  388. vnext: [{
  389. address: json.add ?? '',
  390. port,
  391. users: [{ id: json.id ?? '', security: userSecurity }],
  392. }],
  393. },
  394. streamSettings: stream,
  395. };
  396. } catch {
  397. return null;
  398. }
  399. }
  400. function parseUrlLink(link: string, expectedProto: string): URL | null {
  401. try {
  402. const url = new URL(link);
  403. if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
  404. return url;
  405. } catch {
  406. return null;
  407. }
  408. }
  409. export function parseVlessLink(link: string): Raw | null {
  410. const url = parseUrlLink(link, 'vless');
  411. if (!url) return null;
  412. const id = url.username;
  413. const address = url.hostname;
  414. const port = Number(url.port) || 443;
  415. const params = url.searchParams;
  416. const network = params.get('type') ?? 'tcp';
  417. const security = (params.get('security') ?? 'none') as string;
  418. const stream = buildStream(network, security);
  419. applyTransportParams(stream, params);
  420. applySecurityParams(stream, params);
  421. applyFinalMaskParam(stream, params);
  422. return {
  423. protocol: 'vless',
  424. tag: decodeRemark(url),
  425. settings: {
  426. address,
  427. port,
  428. id,
  429. flow: params.get('flow') ?? '',
  430. encryption: params.get('encryption') ?? 'none',
  431. },
  432. streamSettings: stream,
  433. };
  434. }
  435. export function parseTrojanLink(link: string): Raw | null {
  436. const url = parseUrlLink(link, 'trojan');
  437. if (!url) return null;
  438. const password = url.username;
  439. const address = url.hostname;
  440. const port = Number(url.port) || 443;
  441. const params = url.searchParams;
  442. const network = params.get('type') ?? 'tcp';
  443. const security = (params.get('security') ?? 'tls') as string;
  444. const stream = buildStream(network, security);
  445. applyTransportParams(stream, params);
  446. applySecurityParams(stream, params);
  447. applyFinalMaskParam(stream, params);
  448. return {
  449. protocol: 'trojan',
  450. tag: decodeRemark(url),
  451. settings: {
  452. servers: [{ address, port, password }],
  453. },
  454. streamSettings: stream,
  455. };
  456. }
  457. export function parseShadowsocksLink(link: string): Raw | null {
  458. if (!link.startsWith('ss://')) return null;
  459. // Two link shapes coexist:
  460. // modern: ss://base64(method:password)@host:port#remark
  461. // legacy: ss://base64(method:password@host:port)#remark
  462. // Try modern first; fall back to legacy decode of the whole userinfo+host.
  463. let userInfo: string;
  464. let host: string;
  465. let port: number;
  466. let remark = '';
  467. const hashIndex = link.indexOf('#');
  468. const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
  469. if (hashIndex >= 0) {
  470. try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
  471. }
  472. const queryIndex = linkNoHash.indexOf('?');
  473. const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
  474. const atIndex = core.indexOf('@');
  475. if (atIndex >= 0) {
  476. const rawUserInfo = core.slice('ss://'.length, atIndex);
  477. if (rawUserInfo.includes(':')) {
  478. // SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64
  479. // (a literal ':' can't appear in a base64/base64url string).
  480. try { userInfo = decodeURIComponent(rawUserInfo); } catch { userInfo = rawUserInfo; }
  481. } else {
  482. try { userInfo = Base64.decode(rawUserInfo); }
  483. catch { userInfo = rawUserInfo; }
  484. }
  485. const hostPort = core.slice(atIndex + 1);
  486. const colon = hostPort.lastIndexOf(':');
  487. if (colon < 0) return null;
  488. host = hostPort.slice(0, colon);
  489. port = Number(hostPort.slice(colon + 1)) || 443;
  490. } else {
  491. let decoded: string;
  492. try { decoded = Base64.decode(core.slice('ss://'.length)); }
  493. catch { return null; }
  494. const at = decoded.indexOf('@');
  495. if (at < 0) return null;
  496. userInfo = decoded.slice(0, at);
  497. const hostPort = decoded.slice(at + 1);
  498. const colon = hostPort.lastIndexOf(':');
  499. if (colon < 0) return null;
  500. host = hostPort.slice(0, colon);
  501. port = Number(hostPort.slice(colon + 1)) || 443;
  502. }
  503. const sep = userInfo.indexOf(':');
  504. const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
  505. const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
  506. return {
  507. protocol: 'shadowsocks',
  508. tag: remark,
  509. settings: {
  510. servers: [{ address: host, port, password, method }],
  511. },
  512. };
  513. }
  514. export function parseHysteria2Link(link: string): Raw | null {
  515. const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
  516. if (!url) return null;
  517. // hysteria2's auth rides as the URL userinfo. The streamSettings
  518. // network branch is the dedicated 'hysteria' transport — the modal's
  519. // newStreamSlice('hysteria') initializer fills in receive-window
  520. // defaults; we override the user-set fields here.
  521. const auth = url.username;
  522. const address = url.hostname;
  523. const port = Number(url.port) || 443;
  524. const params = url.searchParams;
  525. const alpn = params.get('alpn');
  526. const stream: Raw = {
  527. network: 'hysteria',
  528. security: 'tls',
  529. hysteriaSettings: {
  530. version: 2, auth, udpIdleTimeout: 60,
  531. },
  532. tlsSettings: {
  533. serverName: params.get('sni') ?? '',
  534. alpn: alpn ? alpn.split(',') : ['h3'],
  535. fingerprint: params.get('fp') ?? '',
  536. echConfigList: params.get('ech') ?? '',
  537. verifyPeerCertByName: params.get('vcn') ?? '',
  538. pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
  539. },
  540. };
  541. applyFinalMaskParam(stream, params);
  542. applyHysteria2Obfs(stream, params);
  543. applyHysteria2Hop(stream, params);
  544. return {
  545. protocol: 'hysteria',
  546. tag: decodeRemark(url),
  547. settings: { address, port, version: 2 },
  548. streamSettings: stream,
  549. };
  550. }
  551. function firstParam(params: URLSearchParams, ...keys: string[]): string | null {
  552. for (const k of keys) {
  553. const v = params.get(k);
  554. if (v !== null && v !== '') return v;
  555. }
  556. return null;
  557. }
  558. export function parseWireguardLink(link: string): Raw | null {
  559. const url = parseUrlLink(link, 'wireguard') ?? parseUrlLink(link, 'wg');
  560. if (!url) return null;
  561. let secretKey: string;
  562. try {
  563. secretKey = decodeURIComponent(url.username);
  564. } catch {
  565. secretKey = url.username;
  566. }
  567. const params = url.searchParams;
  568. const host = url.hostname;
  569. const port = url.port;
  570. const endpoint = host ? (port ? `${host}:${port}` : host) : '';
  571. const addressRaw = firstParam(params, 'address', 'ip') ?? '';
  572. const address = addressRaw.split(',').map((s) => s.trim()).filter(Boolean);
  573. const allowedRaw = firstParam(params, 'allowedips', 'allowed_ips');
  574. const allowedIPs = allowedRaw
  575. ? allowedRaw.split(',').map((s) => s.trim()).filter(Boolean)
  576. : ['0.0.0.0/0', '::/0'];
  577. const peer: Raw = {
  578. publicKey: firstParam(params, 'publickey', 'publicKey', 'public_key', 'peerPublicKey') ?? '',
  579. endpoint,
  580. allowedIPs,
  581. };
  582. const psk = firstParam(params, 'presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
  583. if (psk) peer.preSharedKey = psk;
  584. const keepAliveRaw = firstParam(params, 'keepalive', 'persistentkeepalive', 'persistent_keepalive');
  585. if (keepAliveRaw !== null) {
  586. const k = Number(keepAliveRaw);
  587. if (Number.isFinite(k)) peer.keepAlive = k;
  588. }
  589. const settings: Raw = { secretKey, address, peers: [peer] };
  590. const mtuRaw = firstParam(params, 'mtu');
  591. if (mtuRaw !== null) {
  592. const m = Number(mtuRaw);
  593. if (Number.isFinite(m)) settings.mtu = m;
  594. }
  595. const reservedRaw = firstParam(params, 'reserved');
  596. if (reservedRaw) {
  597. const reserved = reservedRaw.split(',')
  598. .map((s) => Number(s.trim()))
  599. .filter((n) => Number.isFinite(n));
  600. if (reserved.length > 0) settings.reserved = reserved;
  601. }
  602. return {
  603. protocol: 'wireguard',
  604. tag: decodeRemark(url),
  605. settings,
  606. };
  607. }
  608. // Dispatcher — first non-null parser wins. Returns null when no parser
  609. // recognizes the link's protocol scheme.
  610. export function parseOutboundLink(link: string): Raw | null {
  611. const trimmed = link.trim();
  612. if (!trimmed) return null;
  613. return (
  614. parseVmessLink(trimmed)
  615. ?? parseVlessLink(trimmed)
  616. ?? parseTrojanLink(trimmed)
  617. ?? parseShadowsocksLink(trimmed)
  618. ?? parseHysteria2Link(trimmed)
  619. ?? parseWireguardLink(trimmed)
  620. );
  621. }