|
|
@@ -1,6 +1,7 @@
|
|
|
import { Base64 } from '@/utils';
|
|
|
|
|
|
import type { Inbound } from '@/schemas/api/inbound';
|
|
|
+import type { VlessClient } from '@/schemas/protocols/inbound/vless';
|
|
|
import type { VmessSecurity } from '@/schemas/protocols/inbound/vmess';
|
|
|
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
|
|
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
|
|
@@ -224,3 +225,149 @@ export function genVmessLink(input: GenVmessLinkInput): string {
|
|
|
|
|
|
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
|
|
|
}
|
|
|
+
|
|
|
+// Param-style helpers (vless/trojan/ss/hysteria links). These mirror the
|
|
|
+// legacy applyXhttpExtraToParams / applyFinalMaskToParams /
|
|
|
+// applyExternalProxyTLSParams but write to a URLSearchParams instance
|
|
|
+// directly. Number values get coerced via .toString() on set — same as
|
|
|
+// what URLSearchParams does internally so the resulting URL bytes match.
|
|
|
+
|
|
|
+function applyXhttpExtraToParams(xhttp: XHttpStreamSettings | undefined, params: URLSearchParams): void {
|
|
|
+ if (!xhttp) return;
|
|
|
+ params.set('path', xhttp.path);
|
|
|
+ const host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
|
|
+ params.set('host', host);
|
|
|
+ params.set('mode', xhttp.mode);
|
|
|
+ if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
|
|
+ params.set('x_padding_bytes', xhttp.xPaddingBytes);
|
|
|
+ }
|
|
|
+ const extra = buildXhttpExtra(xhttp);
|
|
|
+ if (extra) params.set('extra', JSON.stringify(extra));
|
|
|
+}
|
|
|
+
|
|
|
+function applyFinalMaskToParams(finalmask: FinalMaskStreamSettings | undefined, params: URLSearchParams): void {
|
|
|
+ const payload = serializeFinalMask(finalmask);
|
|
|
+ if (payload.length > 0) params.set('fm', payload);
|
|
|
+}
|
|
|
+
|
|
|
+function applyExternalProxyTLSParams(
|
|
|
+ externalProxy: ExternalProxyEntry | null | undefined,
|
|
|
+ params: URLSearchParams,
|
|
|
+ security: string,
|
|
|
+): void {
|
|
|
+ if (!externalProxy || security !== 'tls') return;
|
|
|
+ const sni = externalProxy.sni && externalProxy.sni.length > 0 ? externalProxy.sni : externalProxy.dest;
|
|
|
+ if (sni && sni.length > 0) params.set('sni', sni);
|
|
|
+ if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
|
|
+ const alpn = externalProxyAlpn(externalProxy.alpn);
|
|
|
+ if (alpn.length > 0) params.set('alpn', alpn);
|
|
|
+}
|
|
|
+
|
|
|
+export interface GenVlessLinkInput {
|
|
|
+ inbound: Inbound;
|
|
|
+ address: string;
|
|
|
+ port?: number;
|
|
|
+ forceTls?: ForceTls;
|
|
|
+ remark?: string;
|
|
|
+ clientId: string;
|
|
|
+ flow?: VlessClient['flow'];
|
|
|
+ externalProxy?: ExternalProxyEntry | null;
|
|
|
+}
|
|
|
+
|
|
|
+// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
|
|
+// query carries network type, encryption, network-specific knobs, and
|
|
|
+// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
|
|
+// pbk/sid/spx). Returns '' if the inbound isn't vless.
|
|
|
+export function genVlessLink(input: GenVlessLinkInput): string {
|
|
|
+ const {
|
|
|
+ inbound,
|
|
|
+ address,
|
|
|
+ port = inbound.port,
|
|
|
+ forceTls = 'same',
|
|
|
+ remark = '',
|
|
|
+ clientId,
|
|
|
+ flow = '',
|
|
|
+ externalProxy = null,
|
|
|
+ } = input;
|
|
|
+
|
|
|
+ if (inbound.protocol !== 'vless') return '';
|
|
|
+ const stream = inbound.streamSettings;
|
|
|
+ if (!stream) return '';
|
|
|
+
|
|
|
+ const security = forceTls === 'same' ? stream.security : forceTls;
|
|
|
+ const params = new URLSearchParams();
|
|
|
+ params.set('type', stream.network);
|
|
|
+ params.set('encryption', inbound.settings.encryption);
|
|
|
+
|
|
|
+ if (stream.network === 'tcp') {
|
|
|
+ const tcp = stream.tcpSettings;
|
|
|
+ if (tcp.header?.type === 'http') {
|
|
|
+ const request = tcp.header.request;
|
|
|
+ if (request) {
|
|
|
+ params.set('path', request.path.join(','));
|
|
|
+ const host = getHeaderValue(request.headers, 'host');
|
|
|
+ if (host) params.set('host', host);
|
|
|
+ params.set('headerType', 'http');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (stream.network === 'kcp') {
|
|
|
+ const kcp = stream.kcpSettings;
|
|
|
+ params.set('mtu', String(kcp.mtu));
|
|
|
+ params.set('tti', String(kcp.tti));
|
|
|
+ } else if (stream.network === 'ws') {
|
|
|
+ const ws = stream.wsSettings;
|
|
|
+ params.set('path', ws.path);
|
|
|
+ params.set('host', ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host'));
|
|
|
+ } else if (stream.network === 'grpc') {
|
|
|
+ const grpc = stream.grpcSettings;
|
|
|
+ params.set('serviceName', grpc.serviceName);
|
|
|
+ params.set('authority', grpc.authority);
|
|
|
+ if (grpc.multiMode) params.set('mode', 'multi');
|
|
|
+ } else if (stream.network === 'httpupgrade') {
|
|
|
+ const hu = stream.httpupgradeSettings;
|
|
|
+ params.set('path', hu.path);
|
|
|
+ params.set('host', hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host'));
|
|
|
+ } else if (stream.network === 'xhttp') {
|
|
|
+ applyXhttpExtraToParams(stream.xhttpSettings, params);
|
|
|
+ }
|
|
|
+
|
|
|
+ applyFinalMaskToParams(stream.finalmask, params);
|
|
|
+
|
|
|
+ if (security === 'tls') {
|
|
|
+ params.set('security', 'tls');
|
|
|
+ if (stream.security === 'tls') {
|
|
|
+ const tls = stream.tlsSettings;
|
|
|
+ params.set('fp', tls.settings.fingerprint);
|
|
|
+ params.set('alpn', tls.alpn.join(','));
|
|
|
+ if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
|
|
+ if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
|
|
+ if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
|
|
+ }
|
|
|
+ applyExternalProxyTLSParams(externalProxy, params, security);
|
|
|
+ } else if (security === 'reality') {
|
|
|
+ params.set('security', 'reality');
|
|
|
+ if (stream.security === 'reality') {
|
|
|
+ const reality = stream.realitySettings;
|
|
|
+ params.set('pbk', reality.settings.publicKey);
|
|
|
+ params.set('fp', reality.settings.fingerprint);
|
|
|
+ // Legacy parity quirk: the old class stored realitySettings.serverNames
|
|
|
+ // as a comma-joined string and gated SNI on `!ObjectUtil.isArrEmpty(s)`
|
|
|
+ // — which returns true for any string, so SNI was never written into
|
|
|
+ // Reality share links. Existing deployed clients rely on receiving
|
|
|
+ // the SNI from realitySettings.target instead; we keep the omission
|
|
|
+ // here so this extraction stays byte-stable with the legacy URL.
|
|
|
+ // Fixing the bug is a separate intentional commit.
|
|
|
+ if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
|
|
+ if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
|
|
|
+ if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
|
|
+ if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ params.set('security', 'none');
|
|
|
+ }
|
|
|
+
|
|
|
+ const url = new URL(`vless://${clientId}@${address}:${port}`);
|
|
|
+ for (const [key, value] of params) url.searchParams.set(key, value);
|
|
|
+ url.hash = encodeURIComponent(remark);
|
|
|
+ return url.toString();
|
|
|
+}
|