inbound-link.ts 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124
  1. import { Base64, Wireguard } from '@/utils';
  2. import type { Inbound } from '@/schemas/api/inbound';
  3. import type { VlessClient } from '@/schemas/protocols/inbound/vless';
  4. import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
  5. import type {
  6. WireguardInboundPeer,
  7. WireguardInboundSettings,
  8. } from '@/schemas/protocols/inbound/wireguard';
  9. import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
  10. import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
  11. import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
  12. import { getHeaderValue } from './headers';
  13. import { canEnableTlsFlow } from './protocol-capabilities';
  14. // Share-link generators. Each per-protocol fn takes a typed inbound plus
  15. // client overrides and returns a URL (or '' when the protocol doesn't
  16. // support shareable links). The helpers below were previously static
  17. // methods on the Inbound class; extracting them removes the
  18. // XrayCommonClass dependency and lets these run against Zod-parsed data
  19. // directly.
  20. type ForceTls = 'same' | 'tls' | 'none';
  21. const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
  22. // Format a host for interpolation into a URL authority. IPv6 literals are
  23. // wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
  24. // Any brackets already present are first stripped so the helper is idempotent.
  25. function formatUrlHost(address: string): string {
  26. const bare = address.replace(/^\[|\]$/g, '');
  27. return bare.includes(':') ? `[${bare}]` : bare;
  28. }
  29. // xHTTP headers ship as Record<string, string> on the wire (Zod schema)
  30. // rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
  31. function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
  32. return getHeaderValue(xhttp?.headers, 'host');
  33. }
  34. // Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
  35. // compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
  36. // scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
  37. // reading the share link wouldn't honor them. Mirrors the legacy
  38. // Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
  39. function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
  40. if (!xhttp) return null;
  41. const extra: Record<string, unknown> = {};
  42. if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
  43. extra.xPaddingBytes = xhttp.xPaddingBytes;
  44. }
  45. if (xhttp.xPaddingObfsMode === true) {
  46. extra.xPaddingObfsMode = true;
  47. for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
  48. const v = xhttp[k];
  49. if (typeof v === 'string' && v.length > 0) extra[k] = v;
  50. }
  51. }
  52. const stringFields = [
  53. 'uplinkHTTPMethod',
  54. 'sessionPlacement',
  55. 'sessionKey',
  56. 'seqPlacement',
  57. 'seqKey',
  58. 'uplinkDataPlacement',
  59. 'uplinkDataKey',
  60. 'scMaxEachPostBytes',
  61. ] as const;
  62. // Values matching xray-core's own defaults stay off the wire — old panels
  63. // seeded them into every config and the literal values are a DPI
  64. // fingerprint (#5141). Mirrors the sub service's filter.
  65. const coreDefaults: Partial<Record<(typeof stringFields)[number], string>> = {
  66. scMaxEachPostBytes: '1000000',
  67. };
  68. for (const k of stringFields) {
  69. const v = xhttp[k];
  70. if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
  71. }
  72. // Headers on the wire are a record; emit them as a map upstream's
  73. // SplitHTTPConfig.headers expects, dropping Host (already on the URL).
  74. if (xhttp.headers && Object.keys(xhttp.headers).length > 0) {
  75. const headersMap: Record<string, string> = {};
  76. for (const [name, value] of Object.entries(xhttp.headers)) {
  77. if (name.toLowerCase() === 'host') continue;
  78. headersMap[name] = value;
  79. }
  80. if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
  81. }
  82. return Object.keys(extra).length > 0 ? extra : null;
  83. }
  84. function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
  85. if (!xhttp) return;
  86. if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
  87. obj.x_padding_bytes = xhttp.xPaddingBytes;
  88. }
  89. const extra = buildXhttpExtra(xhttp);
  90. if (!extra) return;
  91. for (const [k, v] of Object.entries(extra)) obj[k] = v;
  92. }
  93. // Recursively checks whether a finalmask payload has any non-empty
  94. // content. Empty arrays / empty objects / empty strings all return false;
  95. // any truthy primitive returns true. Used to decide whether the link
  96. // should carry an `fm` blob at all.
  97. function hasShareableFinalMaskValue(value: unknown): boolean {
  98. if (value == null) return false;
  99. if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue);
  100. if (typeof value === 'object') {
  101. return Object.values(value as Record<string, unknown>).some(hasShareableFinalMaskValue);
  102. }
  103. if (typeof value === 'string') return value.length > 0;
  104. return true;
  105. }
  106. function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
  107. if (!finalmask) return '';
  108. return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : '';
  109. }
  110. function applyFinalMaskToObj(
  111. finalmask: FinalMaskStreamSettings | undefined,
  112. obj: Record<string, unknown>,
  113. ): void {
  114. const payload = serializeFinalMask(finalmask);
  115. if (payload.length > 0) obj.fm = payload;
  116. }
  117. function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
  118. if (Array.isArray(value)) return value.filter(Boolean).join(',');
  119. return '';
  120. }
  121. function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): string {
  122. if (Array.isArray(value)) return value.filter(Boolean).join(',');
  123. return '';
  124. }
  125. function applyExternalProxyTLSObj(
  126. externalProxy: ExternalProxyEntry | null | undefined,
  127. obj: Record<string, unknown>,
  128. security: string,
  129. ): void {
  130. if (!externalProxy || security !== 'tls') return;
  131. const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
  132. if (sni && sni.length > 0) obj.sni = sni;
  133. if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
  134. const alpn = externalProxyAlpn(externalProxy.alpn);
  135. if (alpn.length > 0) obj.alpn = alpn;
  136. const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
  137. if (pins.length > 0) obj.pcs = pins;
  138. if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
  139. }
  140. export interface GenVmessLinkInput {
  141. inbound: Inbound;
  142. address: string;
  143. port?: number;
  144. forceTls?: ForceTls;
  145. remark?: string;
  146. clientId: string;
  147. security?: VmessSecurity;
  148. externalProxy?: ExternalProxyEntry | null;
  149. }
  150. // VMess share link: `vmess://` followed by base64-encoded JSON. The JSON
  151. // schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound
  152. // is not vmess so dispatcher code can fall through cleanly.
  153. export function genVmessLink(input: GenVmessLinkInput): string {
  154. const {
  155. inbound,
  156. address,
  157. port = inbound.port,
  158. forceTls = 'same',
  159. remark = '',
  160. clientId,
  161. security,
  162. externalProxy = null,
  163. } = input;
  164. if (inbound.protocol !== 'vmess') return '';
  165. const stream = inbound.streamSettings;
  166. if (!stream) return '';
  167. const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
  168. const obj: Record<string, unknown> = {
  169. v: '2',
  170. ps: remark,
  171. add: address,
  172. port,
  173. id: clientId,
  174. scy: security,
  175. net: stream.network,
  176. tls,
  177. };
  178. if (stream.network === 'tcp') {
  179. const tcp = stream.tcpSettings;
  180. const header = tcp.header;
  181. if (header) {
  182. obj.type = header.type;
  183. if (header.type === 'http') {
  184. const request = header.request;
  185. if (request) {
  186. obj.path = request.path.join(',');
  187. const host =
  188. getHeaderValue(header.response?.headers, 'host')
  189. || getHeaderValue(request.headers, 'host');
  190. if (host) obj.host = host;
  191. }
  192. }
  193. } else {
  194. obj.type = 'none';
  195. }
  196. } else if (stream.network === 'kcp') {
  197. const kcp = stream.kcpSettings;
  198. obj.mtu = kcp.mtu;
  199. obj.tti = kcp.tti;
  200. } else if (stream.network === 'ws') {
  201. const ws = stream.wsSettings;
  202. obj.path = ws.path;
  203. obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host');
  204. } else if (stream.network === 'grpc') {
  205. const grpc = stream.grpcSettings;
  206. obj.path = grpc.serviceName;
  207. obj.authority = grpc.authority;
  208. if (grpc.multiMode) obj.type = 'multi';
  209. } else if (stream.network === 'httpupgrade') {
  210. const hu = stream.httpupgradeSettings;
  211. obj.path = hu.path;
  212. obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host');
  213. } else if (stream.network === 'xhttp') {
  214. const xhttp = stream.xhttpSettings;
  215. obj.path = xhttp.path;
  216. obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
  217. obj.type = xhttp.mode;
  218. applyXhttpExtraToObj(xhttp, obj);
  219. }
  220. applyFinalMaskToObj(stream.finalmask, obj);
  221. if (tls === 'tls' && stream.security === 'tls') {
  222. const tlsSettings = stream.tlsSettings;
  223. if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
  224. if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
  225. if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
  226. if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
  227. if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
  228. obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
  229. }
  230. }
  231. applyExternalProxyTLSObj(externalProxy, obj, tls);
  232. return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
  233. }
  234. // Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
  235. // legacy applyXhttpExtraToParams / applyFinalMaskToParams /
  236. // applyExternalProxyTLSParams but write to a URLSearchParams instance
  237. // directly. Number values get coerced via .toString() on set — same as
  238. // what URLSearchParams does internally so the resulting URL bytes match.
  239. function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
  240. if (!xhttp) return;
  241. params.set('path', xhttp.path);
  242. const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
  243. params.set('host', host);
  244. params.set('mode', xhttp.mode);
  245. if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
  246. params.set('x_padding_bytes', xhttp.xPaddingBytes);
  247. }
  248. const extra = buildXhttpExtra(xhttp);
  249. if (extra) params.set('extra', JSON.stringify(extra));
  250. }
  251. function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
  252. const payload = serializeFinalMask(finalmask);
  253. if (payload.length > 0) params.set('fm', payload);
  254. }
  255. function applyExternalProxyTLSParams(
  256. externalProxy: ExternalProxyEntry | null | undefined,
  257. params: URLSearchParams,
  258. security: string,
  259. ): void {
  260. if (!externalProxy || security !== 'tls') return;
  261. const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
  262. if (sni && sni.length > 0) params.set('sni', sni);
  263. if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
  264. const alpn = externalProxyAlpn(externalProxy.alpn);
  265. if (alpn.length > 0) params.set('alpn', alpn);
  266. const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
  267. if (pins.length > 0) params.set('pcs', pins);
  268. if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
  269. }
  270. export interface GenVlessLinkInput {
  271. inbound: Inbound;
  272. address: string;
  273. port?: number;
  274. forceTls?: ForceTls;
  275. remark?: string;
  276. clientId: string;
  277. flow?: VlessClient['flow'];
  278. externalProxy?: ExternalProxyEntry | null;
  279. }
  280. // VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
  281. // query carries network type, encryption, network-specific knobs, and
  282. // security-specific knobs (TLS fingerprint/alpn/sni or Reality
  283. // pbk/sid/spx). Returns '' if the inbound isn't vless.
  284. export function genVlessLink(input: GenVlessLinkInput): string {
  285. const {
  286. inbound,
  287. address,
  288. port = inbound.port,
  289. forceTls = 'same',
  290. remark = '',
  291. clientId,
  292. flow = '',
  293. externalProxy = null,
  294. } = input;
  295. if (inbound.protocol !== 'vless') return '';
  296. const stream = inbound.streamSettings;
  297. if (!stream) return '';
  298. const security = forceTls === 'same' ? stream.security : forceTls;
  299. const params = new URLSearchParams();
  300. params.set('type', stream.network ?? 'tcp');
  301. params.set('encryption', inbound.settings.encryption);
  302. if (stream.network === 'tcp') {
  303. const tcp = stream.tcpSettings;
  304. if (tcp.header?.type === 'http') {
  305. const request = tcp.header.request;
  306. if (request) {
  307. params.set('path', request.path.join(','));
  308. const host =
  309. getHeaderValue(tcp.header.response?.headers, 'host')
  310. || getHeaderValue(request.headers, 'host');
  311. if (host) params.set('host', host);
  312. params.set('headerType', 'http');
  313. }
  314. }
  315. } else if (stream.network === 'kcp') {
  316. const kcp = stream.kcpSettings;
  317. params.set('mtu', String(kcp.mtu));
  318. params.set('tti', String(kcp.tti));
  319. } else if (stream.network === 'ws') {
  320. const ws = stream.wsSettings;
  321. params.set('path', ws.path);
  322. params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
  323. } else if (stream.network === 'grpc') {
  324. const grpc = stream.grpcSettings;
  325. params.set('serviceName', grpc.serviceName);
  326. params.set('authority', grpc.authority);
  327. if (grpc.multiMode) params.set('mode', 'multi');
  328. } else if (stream.network === 'httpupgrade') {
  329. const hu = stream.httpupgradeSettings;
  330. params.set('path', hu.path);
  331. params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
  332. } else if (stream.network === 'xhttp') {
  333. applyXhttpExtraToParams(stream.xhttpSettings, params);
  334. }
  335. applyFinalMaskToParams(stream.finalmask, params);
  336. if (security === 'tls') {
  337. params.set('security', 'tls');
  338. if (stream.security === 'tls') {
  339. const tls = stream.tlsSettings;
  340. params.set('fp', tls.settings.fingerprint);
  341. params.set('alpn', tls.alpn.join(','));
  342. if (tls.serverName.length > 0) params.set('sni', tls.serverName);
  343. if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
  344. if (tls.settings.pinnedPeerCertSha256.length > 0) {
  345. params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
  346. }
  347. }
  348. applyExternalProxyTLSParams(externalProxy, params, security);
  349. } else if (security === 'reality') {
  350. params.set('security', 'reality');
  351. if (stream.security === 'reality') {
  352. const reality = stream.realitySettings;
  353. params.set('pbk', reality.settings.publicKey);
  354. params.set('fp', reality.settings.fingerprint);
  355. const sni =
  356. reality.settings.serverName ||
  357. reality.serverNames?.[0] ||
  358. reality.target?.split(':')[0];
  359. if (sni && sni.length > 0) params.set('sni', sni);
  360. if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
  361. if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
  362. if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
  363. }
  364. } else {
  365. params.set('security', 'none');
  366. }
  367. // XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
  368. // VLESS-level encryption stands in for transport TLS). Mirrors the backend's
  369. // vlessFlowAllowed and the form's flow-field gating so panel link, share
  370. // link and subscription agree.
  371. if (flow.length > 0 && canEnableTlsFlow({
  372. protocol: inbound.protocol,
  373. settings: inbound.settings,
  374. streamSettings: stream,
  375. })) {
  376. params.set('flow', flow);
  377. }
  378. const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
  379. for (const [key, value] of params) url.searchParams.set(key, value);
  380. url.hash = encodeURIComponent(remark);
  381. return url.toString();
  382. }
  383. // Shared network-branch writer used by trojan + shadowsocks links.
  384. // VLESS and VMess don't call this because they have minor per-protocol
  385. // quirks inline (vmess maps `multi` differently into obj.type; vless sets
  386. // encryption=none up-front).
  387. function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
  388. if (stream.network === 'tcp') {
  389. const tcp = stream.tcpSettings;
  390. if (tcp.header?.type === 'http') {
  391. const request = tcp.header.request;
  392. if (request) {
  393. params.set('path', request.path.join(','));
  394. const host =
  395. getHeaderValue(tcp.header.response?.headers, 'host')
  396. || getHeaderValue(request.headers, 'host');
  397. if (host) params.set('host', host);
  398. params.set('headerType', 'http');
  399. }
  400. }
  401. } else if (stream.network === 'kcp') {
  402. const kcp = stream.kcpSettings;
  403. params.set('mtu', String(kcp.mtu));
  404. params.set('tti', String(kcp.tti));
  405. } else if (stream.network === 'ws') {
  406. const ws = stream.wsSettings;
  407. params.set('path', ws.path);
  408. params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
  409. } else if (stream.network === 'grpc') {
  410. const grpc = stream.grpcSettings;
  411. params.set('serviceName', grpc.serviceName);
  412. params.set('authority', grpc.authority);
  413. if (grpc.multiMode) params.set('mode', 'multi');
  414. } else if (stream.network === 'httpupgrade') {
  415. const hu = stream.httpupgradeSettings;
  416. params.set('path', hu.path);
  417. params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
  418. } else if (stream.network === 'xhttp') {
  419. applyXhttpExtraToParams(stream.xhttpSettings, params);
  420. }
  421. }
  422. function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
  423. if (stream.security !== 'tls') return;
  424. const tls = stream.tlsSettings;
  425. params.set('fp', tls.settings.fingerprint);
  426. params.set('alpn', tls.alpn.join(','));
  427. if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
  428. if (tls.serverName.length > 0) params.set('sni', tls.serverName);
  429. if (tls.settings.pinnedPeerCertSha256.length > 0) {
  430. params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
  431. }
  432. }
  433. // Reality query-string writer shared by VLESS and Trojan. Preserves the
  434. // legacy SNI-omission quirk (see genVlessLink for the full story).
  435. function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
  436. if (stream.security !== 'reality') return;
  437. const reality = stream.realitySettings;
  438. params.set('pbk', reality.settings.publicKey);
  439. params.set('fp', reality.settings.fingerprint);
  440. const sni =
  441. reality.settings.serverName ||
  442. reality.serverNames?.[0] ||
  443. reality.target?.split(':')[0];
  444. if (sni && sni.length > 0) params.set('sni', sni);
  445. if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
  446. if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
  447. if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
  448. }
  449. export interface GenTrojanLinkInput {
  450. inbound: Inbound;
  451. address: string;
  452. port?: number;
  453. forceTls?: ForceTls;
  454. remark?: string;
  455. clientPassword: string;
  456. externalProxy?: ExternalProxyEntry | null;
  457. }
  458. // Trojan share link: trojan://<password>@<host>:<port>?<query>#<remark>.
  459. // Same query-string shape as VLESS minus the `encryption` and `flow`
  460. // fields. Returns '' if the inbound isn't trojan.
  461. export function genTrojanLink(input: GenTrojanLinkInput): string {
  462. const {
  463. inbound,
  464. address,
  465. port = inbound.port,
  466. forceTls = 'same',
  467. remark = '',
  468. clientPassword,
  469. externalProxy = null,
  470. } = input;
  471. if (inbound.protocol !== 'trojan') return '';
  472. const stream = inbound.streamSettings;
  473. if (!stream) return '';
  474. const security = forceTls === 'same' ? stream.security : forceTls;
  475. const params = new URLSearchParams();
  476. params.set('type', stream.network ?? 'tcp');
  477. writeNetworkParams(stream, params);
  478. applyFinalMaskToParams(stream.finalmask, params);
  479. if (security === 'tls') {
  480. params.set('security', 'tls');
  481. writeTlsParams(stream, params);
  482. applyExternalProxyTLSParams(externalProxy, params, security);
  483. } else if (security === 'reality') {
  484. params.set('security', 'reality');
  485. writeRealityParams(stream, params);
  486. } else {
  487. params.set('security', 'none');
  488. }
  489. const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
  490. for (const [key, value] of params) url.searchParams.set(key, value);
  491. url.hash = encodeURIComponent(remark);
  492. return url.toString();
  493. }
  494. export interface GenShadowsocksLinkInput {
  495. inbound: Inbound;
  496. address: string;
  497. port?: number;
  498. forceTls?: ForceTls;
  499. remark?: string;
  500. clientPassword?: string;
  501. externalProxy?: ExternalProxyEntry | null;
  502. }
  503. // Shadowsocks 2022 share link. The userinfo portion is base64(method:pw)
  504. // for single-user and base64(method:settingsPw:clientPw) for multi-user
  505. // 2022-blake3. Legacy SS (non-2022) leaves the password out of the
  506. // userinfo entirely — matches the legacy class's password-array logic.
  507. // Note: legacy `isSSMultiUser` returns true for everything except
  508. // 2022-blake3-chacha20-poly1305 (a curious classification, but we
  509. // preserve it for byte-stable parity).
  510. export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
  511. const {
  512. inbound,
  513. address,
  514. port = inbound.port,
  515. forceTls = 'same',
  516. remark = '',
  517. clientPassword = '',
  518. externalProxy = null,
  519. } = input;
  520. if (inbound.protocol !== 'shadowsocks') return '';
  521. const stream = inbound.streamSettings;
  522. if (!stream) return '';
  523. const settings = inbound.settings;
  524. const security = forceTls === 'same' ? stream.security : forceTls;
  525. const params = new URLSearchParams();
  526. params.set('type', stream.network ?? 'tcp');
  527. writeNetworkParams(stream, params);
  528. applyFinalMaskToParams(stream.finalmask, params);
  529. if (security === 'tls') {
  530. params.set('security', 'tls');
  531. writeTlsParams(stream, params);
  532. applyExternalProxyTLSParams(externalProxy, params, security);
  533. }
  534. const isSS2022 = settings.method.substring(0, 4) === '2022';
  535. const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
  536. const passwords: string[] = [];
  537. if (isSS2022) passwords.push(settings.password);
  538. if (isSSMultiUser) passwords.push(clientPassword);
  539. const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
  540. const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
  541. for (const [key, value] of params) url.searchParams.set(key, value);
  542. url.hash = encodeURIComponent(remark);
  543. return url.toString();
  544. }
  545. export interface GenHysteriaLinkInput {
  546. inbound: Inbound;
  547. address: string;
  548. port?: number;
  549. remark?: string;
  550. clientAuth: string;
  551. externalProxy?: ExternalProxyEntry | null;
  552. }
  553. // Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core
  554. // clients hex-decode it and crash on a base64 value. The panel stores pins as
  555. // base64 (xray-core's native TLS format / the generate button) or hex, either
  556. // bare or colon-separated as `openssl x509 -fingerprint -sha256` emits it. Each
  557. // entry is coerced to bare hex. Values that are neither a 32-byte hex nor a
  558. // 32-byte base64 SHA-256 pass through unchanged.
  559. function hysteriaPinHex(pin: string): string {
  560. const stripped = pin.trim().replace(/:/g, '');
  561. if (/^[0-9a-fA-F]{64}$/.test(stripped)) return stripped.toLowerCase();
  562. try {
  563. const binary = atob(pin.trim().replace(/-/g, '+').replace(/_/g, '/'));
  564. if (binary.length !== 32) return pin;
  565. let hex = '';
  566. for (let i = 0; i < binary.length; i++) {
  567. hex += binary.charCodeAt(i).toString(16).padStart(2, '0');
  568. }
  569. return hex;
  570. } catch {
  571. return pin;
  572. }
  573. }
  574. // Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
  575. // The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
  576. // AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
  577. // password from finalmask.udp[type=salamander] when present; the broader
  578. // finalmask payload still rides under `fm` like the other links.
  579. //
  580. // Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
  581. // which isn't a field on TlsStreamSettings.Settings — the guard is always
  582. // false. We omit the `insecure` param here to stay byte-stable.
  583. export function genHysteriaLink(input: GenHysteriaLinkInput): string {
  584. const {
  585. inbound,
  586. address,
  587. port = inbound.port,
  588. remark = '',
  589. clientAuth,
  590. externalProxy = null,
  591. } = input;
  592. if (inbound.protocol !== 'hysteria') return '';
  593. const stream = inbound.streamSettings;
  594. if (!stream || stream.security !== 'tls') return '';
  595. const settings = inbound.settings;
  596. const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
  597. const params = new URLSearchParams();
  598. params.set('security', 'tls');
  599. const tls = stream.tlsSettings;
  600. if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
  601. if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
  602. if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
  603. if (tls.serverName.length > 0) params.set('sni', tls.serverName);
  604. if (tls.settings.pinnedPeerCertSha256.length > 0) {
  605. params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(','));
  606. }
  607. // An external-proxy entry can pin a different endpoint's certificate.
  608. // Hysteria carries it as hex `pinSHA256` (not the `pcs` other protocols
  609. // use), so coerce each entry through hysteriaPinHex like the main pin.
  610. if (Array.isArray(externalProxy?.pinnedPeerCertSha256)) {
  611. const epPins = externalProxy.pinnedPeerCertSha256.filter(Boolean).map(hysteriaPinHex);
  612. if (epPins.length > 0) params.set('pinSHA256', epPins.join(','));
  613. }
  614. const udpMasks = stream.finalmask?.udp;
  615. if (Array.isArray(udpMasks)) {
  616. const salamander = udpMasks.find((m) => m?.type === 'salamander');
  617. const obfsPassword = salamander?.settings?.password;
  618. if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
  619. params.set('obfs', 'salamander');
  620. params.set('obfs-password', obfsPassword);
  621. }
  622. }
  623. applyFinalMaskToParams(stream.finalmask, params);
  624. const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
  625. if (hopPorts.length > 0) {
  626. params.set('mport', hopPorts);
  627. }
  628. const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`);
  629. for (const [key, value] of params) url.searchParams.set(key, value);
  630. url.hash = encodeURIComponent(remark);
  631. return url.toString();
  632. }
  633. export interface GenMtprotoLinkInput {
  634. inbound: Inbound;
  635. address: string;
  636. port?: number;
  637. }
  638. // Builds a Telegram proxy deep link for an mtproto inbound:
  639. export function genMtprotoLink(input: GenMtprotoLinkInput): string {
  640. const { inbound, address, port = inbound.port } = input;
  641. if (inbound.protocol !== 'mtproto') return '';
  642. const secret = inbound.settings.secret ?? '';
  643. if (secret.length === 0) return '';
  644. const url = new URL('tg://proxy');
  645. url.searchParams.set('server', address);
  646. url.searchParams.set('port', String(port));
  647. url.searchParams.set('secret', secret);
  648. return url.toString();
  649. }
  650. export interface GenWireguardLinkInput {
  651. settings: WireguardInboundSettings;
  652. address: string;
  653. port: number;
  654. remark?: string;
  655. peerIndex: number;
  656. }
  657. // Wireguard share link: wireguard://<peerPrivKey>@<host>:<port>
  658. // ?publickey=<serverPub>&address=<peerAllowedIP>&mtu=<mtu>#<remark>
  659. // pubKey is derived from the server's secretKey via Wireguard.generateKeypair
  660. // at call time (Zod's schema stores secretKey only — pubKey isn't on the
  661. // wire). Returns '' when the peer index is out of bounds.
  662. export function genWireguardLink(input: GenWireguardLinkInput): string {
  663. const { settings, address, port, remark = '', peerIndex } = input;
  664. const peer = settings.peers[peerIndex];
  665. if (!peer) return '';
  666. const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
  667. url.username = peer.privateKey ?? '';
  668. const pubKey = settings.secretKey.length > 0
  669. ? Wireguard.generateKeypair(settings.secretKey).publicKey
  670. : '';
  671. if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
  672. if (peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
  673. url.searchParams.set('address', peer.allowedIPs[0]);
  674. }
  675. if (typeof settings.mtu === 'number' && settings.mtu > 0) {
  676. url.searchParams.set('mtu', String(settings.mtu));
  677. }
  678. url.hash = encodeURIComponent(remark);
  679. return url.toString();
  680. }
  681. // Plain-text WireGuard client config (.conf format). Mirrors the legacy
  682. // getWireguardTxt — same DNS defaults (1.1.1.1, 1.0.0.1), MTU optional,
  683. // presharedKey + keepAlive only emitted when present on the peer. The
  684. // final newline structure follows the legacy: no newline after Endpoint,
  685. // optional preSharedKey appended with leading \n, keepAlive appended
  686. // with leading \n AND trailing \n.
  687. export function genWireguardConfig(input: GenWireguardLinkInput): string {
  688. const { settings, address, port, remark = '', peerIndex } = input;
  689. const peer = settings.peers[peerIndex];
  690. if (!peer) return '';
  691. const pubKey = settings.secretKey.length > 0
  692. ? Wireguard.generateKeypair(settings.secretKey).publicKey
  693. : '';
  694. let txt = `[Interface]\n`;
  695. txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
  696. txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
  697. txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
  698. if (typeof settings.mtu === 'number' && settings.mtu > 0) {
  699. txt += `MTU = ${settings.mtu}\n`;
  700. }
  701. txt += `\n# ${remark}\n`;
  702. txt += `[Peer]\n`;
  703. txt += `PublicKey = ${pubKey}\n`;
  704. txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
  705. txt += `Endpoint = ${address}:${port}`;
  706. if (peer.preSharedKey && peer.preSharedKey.length > 0) {
  707. txt += `\nPresharedKey = ${peer.preSharedKey}`;
  708. }
  709. if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
  710. txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
  711. }
  712. return txt;
  713. }
  714. export type { WireguardInboundPeer };
  715. function isUnixSocketListen(listen: string): boolean {
  716. return listen.startsWith('/') || listen.startsWith('@');
  717. }
  718. function normalizeShareHost(host: string): string {
  719. const h = host.trim();
  720. if (
  721. h.length === 0
  722. || h.includes('://')
  723. || h.startsWith('//')
  724. || /[/?#@]/.test(h)
  725. ) {
  726. return '';
  727. }
  728. if (h.startsWith('[')) {
  729. if (!h.endsWith(']')) return '';
  730. try {
  731. return new URL(`http://${h}`).hostname;
  732. } catch {
  733. return '';
  734. }
  735. }
  736. if (h.includes(':')) {
  737. try {
  738. return new URL(`http://[${h}]`).hostname;
  739. } catch {
  740. return '';
  741. }
  742. }
  743. return SHARE_HOSTNAME_RE.test(h) ? h : '';
  744. }
  745. function isShareableHost(host: string): boolean {
  746. const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase();
  747. if (h.length === 0) return false;
  748. if (h === '0.0.0.0' || h === '::' || h === '::0') return false;
  749. if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false;
  750. return true;
  751. }
  752. function shareableListen(inbound: Inbound): string {
  753. const listen = inbound.listen.trim();
  754. return listen.length > 0 && !isUnixSocketListen(listen) && isShareableHost(listen)
  755. ? normalizeShareHost(listen)
  756. : '';
  757. }
  758. type ShareAddrStrategy = 'node' | 'listen' | 'custom';
  759. function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy {
  760. const strategy = inbound.shareAddrStrategy;
  761. return strategy === 'listen' || strategy === 'custom'
  762. ? strategy
  763. : 'node';
  764. }
  765. // Orchestrators.
  766. // resolveAddr picks the host that goes into share/QR links. The default
  767. // `node` strategy keeps the previous node-address-first behavior for
  768. // node-managed inbounds; other strategies let a row prefer its listen address
  769. // or a custom endpoint.
  770. export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
  771. const nodeAddr = normalizeShareHost(hostOverride);
  772. const listenAddr = shareableListen(inbound);
  773. const customAddr = normalizeShareHost(inbound.shareAddr ?? '');
  774. const fallbackAddr = normalizeShareHost(fallbackHostname);
  775. switch (shareAddrStrategy(inbound)) {
  776. case 'listen':
  777. return listenAddr || nodeAddr || fallbackAddr;
  778. case 'custom':
  779. return customAddr || nodeAddr || listenAddr || fallbackAddr;
  780. default:
  781. return nodeAddr || listenAddr || fallbackAddr;
  782. }
  783. }
  784. // A loopback browser host means the panel was reached through a tunnel (e.g.
  785. // SSH-forwarded 127.0.0.1/localhost), so it can never be a shareable link host.
  786. function isLoopbackHost(host: string): boolean {
  787. const h = host.trim().replace(/^\[|\]$/g, '').toLowerCase();
  788. return h === 'localhost' || h === '::1' || h.startsWith('127.');
  789. }
  790. // preferPublicHost is the browser-side analog of the backend's
  791. // configuredPublicHost: when the panel is reached on a loopback host, prefer a
  792. // configured public host (Sub/Web Domain) for share/QR links instead of leaking
  793. // localhost. An explicit per-inbound listen or node override still wins, since
  794. // resolveAddr only reaches the fallbackHostname after those.
  795. export function preferPublicHost(browserHost: string, publicHost: string): string {
  796. return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
  797. }
  798. // Returns the client array for protocols that have one. SS returns its
  799. // clients only in 2022-blake3 multi-user mode (matches the legacy
  800. // `this.clients` getter, which used isSSMultiUser to gate). Returns null
  801. // for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
  802. // clients, and any protocol without a clients array.
  803. type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string };
  804. export function getInboundClients(inbound: Inbound): ClientShape[] | null {
  805. switch (inbound.protocol) {
  806. case 'vmess':
  807. return (inbound.settings.clients ?? []) as ClientShape[];
  808. case 'vless':
  809. return (inbound.settings.clients ?? []) as ClientShape[];
  810. case 'trojan':
  811. return (inbound.settings.clients ?? []) as ClientShape[];
  812. case 'hysteria':
  813. return (inbound.settings.clients ?? []) as ClientShape[];
  814. case 'shadowsocks': {
  815. const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
  816. return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
  817. }
  818. default:
  819. return null;
  820. }
  821. }
  822. export interface GenLinkInput {
  823. inbound: Inbound;
  824. address: string;
  825. port?: number;
  826. forceTls?: ForceTls;
  827. remark?: string;
  828. client: ClientShape;
  829. externalProxy?: ExternalProxyEntry | null;
  830. }
  831. // Per-protocol dispatcher matching the legacy `genLink` switch. Returns
  832. // '' for protocols that don't have client-based share links (wireguard
  833. // goes through genWireguardLinks/Configs separately, http/mixed/tunnel
  834. // don't have share URLs).
  835. export function genLink(input: GenLinkInput): string {
  836. const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
  837. switch (inbound.protocol) {
  838. case 'vmess':
  839. return genVmessLink({
  840. inbound, address, port, forceTls, remark,
  841. clientId: client.id ?? '',
  842. security: client.security,
  843. externalProxy,
  844. });
  845. case 'vless':
  846. return genVlessLink({
  847. inbound, address, port, forceTls, remark,
  848. clientId: client.id ?? '',
  849. flow: client.flow,
  850. externalProxy,
  851. });
  852. case 'shadowsocks': {
  853. const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
  854. return genShadowsocksLink({
  855. inbound, address, port, forceTls, remark,
  856. clientPassword: isMultiUser ? (client.password ?? '') : '',
  857. externalProxy,
  858. });
  859. }
  860. case 'trojan':
  861. return genTrojanLink({
  862. inbound, address, port, forceTls, remark,
  863. clientPassword: client.password ?? '',
  864. externalProxy,
  865. });
  866. case 'hysteria':
  867. return genHysteriaLink({
  868. inbound, address, port, remark,
  869. clientAuth: client.auth ?? '',
  870. externalProxy,
  871. });
  872. case 'mtproto':
  873. return genMtprotoLink({ inbound, address, port });
  874. default:
  875. return '';
  876. }
  877. }
  878. export interface GenAllLinksEntry {
  879. remark: string;
  880. link: string;
  881. }
  882. export interface GenAllLinksInput {
  883. inbound: Inbound;
  884. remark?: string;
  885. client: ClientShape;
  886. hostOverride?: string;
  887. fallbackHostname: string;
  888. }
  889. // Fans out a single client's link per externalProxy entry, or just one link
  890. // when there are no external proxies. The panel copy/QR remark is the inbound
  891. // remark plus the externalProxy remark, dash-joined (the configurable
  892. // subscription remark model was removed; subscription output uses the template).
  893. export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
  894. const {
  895. inbound,
  896. remark = '',
  897. client,
  898. hostOverride = '',
  899. fallbackHostname,
  900. } = input;
  901. const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
  902. const port = inbound.port;
  903. const composeRemark = (proxyRemark: string): string =>
  904. [remark, proxyRemark].filter((x) => x.length > 0).join('-');
  905. const externals = inbound.streamSettings?.externalProxy;
  906. if (!externals || externals.length === 0) {
  907. const r = composeRemark('');
  908. return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
  909. }
  910. return externals.map((ep) => {
  911. const r = composeRemark(ep.remark);
  912. return {
  913. remark: r,
  914. link: genLink({
  915. inbound,
  916. address: ep.dest,
  917. port: ep.port,
  918. forceTls: ep.forceTls,
  919. remark: r,
  920. client,
  921. externalProxy: ep,
  922. }),
  923. };
  924. });
  925. }
  926. export interface GenInboundLinksInput {
  927. inbound: Inbound;
  928. remark?: string;
  929. hostOverride?: string;
  930. fallbackHostname: string;
  931. }
  932. // Top-level entrypoint that produces the full \r\n-joined block a user
  933. // pastes into a client. Iterates per-client for protocols with clients,
  934. // falls back to a single SS link for single-user 2022-blake3-chacha20,
  935. // and emits per-peer .conf blocks for wireguard. Returns '' for the
  936. // other clientless protocols (http, mixed, tunnel).
  937. export function genInboundLinks(input: GenInboundLinksInput): string {
  938. const {
  939. inbound,
  940. remark = '',
  941. hostOverride = '',
  942. fallbackHostname,
  943. } = input;
  944. const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
  945. const clients = getInboundClients(inbound);
  946. if (clients) {
  947. const links: string[] = [];
  948. for (const client of clients) {
  949. const entries = genAllLinks({ inbound, remark, client, hostOverride, fallbackHostname });
  950. for (const e of entries) links.push(e.link);
  951. }
  952. return links.join('\r\n');
  953. }
  954. if (inbound.protocol === 'shadowsocks') {
  955. return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
  956. }
  957. if (inbound.protocol === 'wireguard') {
  958. return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
  959. }
  960. return '';
  961. }
  962. // Per-peer wireguard fanout. Each peer gets its own link (or .conf
  963. // block) with an index-suffixed remark, joined by \r\n. Matches the
  964. // legacy genWireguardLinks / genWireguardConfigs exactly.
  965. export interface GenWireguardFanoutInput {
  966. inbound: Inbound;
  967. remark?: string;
  968. hostOverride?: string;
  969. fallbackHostname: string;
  970. }
  971. export function genWireguardLinks(input: GenWireguardFanoutInput): string {
  972. const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
  973. if (inbound.protocol !== 'wireguard') return '';
  974. const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
  975. const sep = '-';
  976. return inbound.settings.peers
  977. .map((p, i) => genWireguardLink({
  978. settings: inbound.settings as WireguardInboundSettings,
  979. address: addr,
  980. port: inbound.port,
  981. remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
  982. peerIndex: i,
  983. }))
  984. .join('\r\n');
  985. }
  986. export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
  987. const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
  988. if (inbound.protocol !== 'wireguard') return '';
  989. const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
  990. const sep = '-';
  991. return inbound.settings.peers
  992. .map((p, i) => genWireguardConfig({
  993. settings: inbound.settings as WireguardInboundSettings,
  994. address: addr,
  995. port: inbound.port,
  996. remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
  997. peerIndex: i,
  998. }))
  999. .join('\r\n');
  1000. }
  1001. // Peer comments (#5168) are panel-side annotations; when present they ride
  1002. // along in the share remark so the device is identifiable in client apps.
  1003. function wgPeerCommentSuffix(peer: unknown): string {
  1004. const comment = (peer as { comment?: unknown })?.comment;
  1005. return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
  1006. }
  1007. export function isPostQuantumLink(link: string): boolean {
  1008. if (/[?&]pqv=/.test(link)) return true;
  1009. if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
  1010. if (link.includes('ML-KEM-768')) return true;
  1011. return false;
  1012. }