mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-22 12:36:07 +00:00
c80e5e276b
The WireGuard client address is stored end-to-end as a string slice (model.AllowedIPs []string, comma-split/joined in the DB), and the UI hint documents comma-separated multi-value input. Three export paths only read index [0], silently dropping every address after the first (e.g. a dual-stack IPv4+IPv6 client never receives its second address): - genWireguardLink share link address param (inbound-link.ts) - genWireguardConfig .conf Address line (inbound-link.ts) - SubService.genWireguardLink raw subscription address (service.go) The sibling JSON and Clash subscriptions already emit the full slice, and WireguardPeerFromClient passes the whole slice into the real Xray peer config, so only the exported text was wrong. Join all entries, matching the model's strings.Join(..., ",") convention for the link params and the ', '-joined AllowedIPs line already used a few lines below in genWireguardConfig. Fixes #6031
1306 lines
48 KiB
TypeScript
1306 lines
48 KiB
TypeScript
import { Base64, Wireguard } from '@/utils';
|
|
|
|
import type { Inbound } from '@/schemas/api/inbound';
|
|
import type { VlessClient } from '@/schemas/protocols/inbound/vless';
|
|
import type { VmessSecurity } from '@/schemas/protocols/shared/vmess';
|
|
import type {
|
|
WireguardInboundPeer,
|
|
WireguardInboundSettings,
|
|
} from '@/schemas/protocols/inbound/wireguard';
|
|
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
|
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
|
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
|
|
|
import { getHeaderValue } from './headers';
|
|
import { canEnableTlsFlow } from './protocol-capabilities';
|
|
import { deriveSpiderX } from './spider-x';
|
|
|
|
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
|
// client overrides and returns a URL (or '' when the protocol doesn't
|
|
// support shareable links). The helpers below were previously static
|
|
// methods on the Inbound class; extracting them removes the
|
|
// XrayCommonClass dependency and lets these run against Zod-parsed data
|
|
// directly.
|
|
|
|
type ForceTls = 'same' | 'tls' | 'none';
|
|
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])?)*$/;
|
|
|
|
// Format a host for interpolation into a URL authority. IPv6 literals are
|
|
// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
|
|
// Any brackets already present are first stripped so the helper is idempotent.
|
|
function formatUrlHost(address: string): string {
|
|
const bare = address.replace(/^\[|\]$/g, '');
|
|
return bare.includes(':') ? `[${bare}]` : bare;
|
|
}
|
|
|
|
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
|
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
|
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
|
return getHeaderValue(xhttp?.headers, 'host');
|
|
}
|
|
|
|
// Pull the bidirectional SplitHTTPConfig fields out of xhttp into a
|
|
// compact extra payload. Server-only fields (noSSEHeader, scMaxBufferedPosts,
|
|
// scStreamUpServerSecs, serverMaxHeaderBytes) are excluded — the client
|
|
// reading the share link wouldn't honor them. Mirrors the legacy
|
|
// Inbound.buildXhttpExtra exactly so the shadow link snapshots line up.
|
|
function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string, unknown> | null {
|
|
if (!xhttp) return null;
|
|
const extra: Record<string, unknown> = {};
|
|
|
|
if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
|
|
extra.mode = xhttp.mode;
|
|
}
|
|
|
|
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
|
extra.xPaddingBytes = xhttp.xPaddingBytes;
|
|
}
|
|
if (xhttp.xPaddingObfsMode === true) {
|
|
extra.xPaddingObfsMode = true;
|
|
for (const k of ['xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement', 'xPaddingMethod'] as const) {
|
|
const v = xhttp[k];
|
|
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
|
}
|
|
}
|
|
|
|
const stringFields = [
|
|
'uplinkHTTPMethod',
|
|
'sessionIDPlacement',
|
|
'sessionIDKey',
|
|
'sessionIDTable',
|
|
'sessionIDLength',
|
|
'seqPlacement',
|
|
'seqKey',
|
|
'uplinkDataPlacement',
|
|
'uplinkDataKey',
|
|
'scMaxEachPostBytes',
|
|
] as const;
|
|
// Values matching xray-core's own defaults stay off the wire — old panels
|
|
// seeded them into every config and the literal values are a DPI
|
|
// fingerprint (#5141). Mirrors the sub service's filter.
|
|
const coreDefaults: Partial<Record<(typeof stringFields)[number], string>> = {
|
|
scMaxEachPostBytes: '1000000',
|
|
};
|
|
for (const k of stringFields) {
|
|
const v = xhttp[k];
|
|
if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
|
|
}
|
|
|
|
// Headers on the wire are a record; emit them as a map upstream's
|
|
// SplitHTTPConfig.headers expects, dropping Host (already on the URL).
|
|
if (xhttp.headers && Object.keys(xhttp.headers).length > 0) {
|
|
const headersMap: Record<string, string> = {};
|
|
for (const [name, value] of Object.entries(xhttp.headers)) {
|
|
if (name.toLowerCase() === 'host') continue;
|
|
headersMap[name] = value;
|
|
}
|
|
if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
|
|
}
|
|
|
|
return Object.keys(extra).length > 0 ? extra : null;
|
|
}
|
|
|
|
function applyXhttpExtraToObj(xhttp: XHttpStreamSettings | undefined, obj: Record<string, unknown>): void {
|
|
if (!xhttp) return;
|
|
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
|
obj.x_padding_bytes = xhttp.xPaddingBytes;
|
|
}
|
|
const extra = buildXhttpExtra(xhttp);
|
|
if (!extra) return;
|
|
for (const [k, v] of Object.entries(extra)) obj[k] = v;
|
|
}
|
|
|
|
// Recursively checks whether a finalmask payload has any non-empty
|
|
// content. Empty arrays / empty objects / empty strings all return false;
|
|
// any truthy primitive returns true. Used to decide whether the link
|
|
// should carry an `fm` blob at all.
|
|
function hasShareableFinalMaskValue(value: unknown): boolean {
|
|
if (value == null) return false;
|
|
if (Array.isArray(value)) return value.some(hasShareableFinalMaskValue);
|
|
if (typeof value === 'object') {
|
|
return Object.values(value as Record<string, unknown>).some(hasShareableFinalMaskValue);
|
|
}
|
|
if (typeof value === 'string') return value.length > 0;
|
|
return true;
|
|
}
|
|
|
|
function serializeFinalMask(finalmask: FinalMaskStreamSettings | undefined): string {
|
|
if (!finalmask) return '';
|
|
return hasShareableFinalMaskValue(finalmask) ? JSON.stringify(finalmask) : '';
|
|
}
|
|
|
|
function applyFinalMaskToObj(
|
|
finalmask: FinalMaskStreamSettings | undefined,
|
|
obj: Record<string, unknown>,
|
|
): void {
|
|
const payload = serializeFinalMask(finalmask);
|
|
if (payload.length > 0) obj.fm = payload;
|
|
}
|
|
|
|
function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
|
|
if (Array.isArray(value)) return value.filter(Boolean).join(',');
|
|
return '';
|
|
}
|
|
|
|
function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): string {
|
|
if (Array.isArray(value)) return value.filter(Boolean).join(',');
|
|
return '';
|
|
}
|
|
|
|
function applyExternalProxyTLSObj(
|
|
externalProxy: ExternalProxyEntry | null | undefined,
|
|
obj: Record<string, unknown>,
|
|
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) obj.sni = sni;
|
|
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
|
const alpn = externalProxyAlpn(externalProxy.alpn);
|
|
if (alpn.length > 0) obj.alpn = alpn;
|
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
|
if (pins.length > 0) obj.pcs = pins;
|
|
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
|
|
obj.vcn = externalProxy.verifyPeerCertByName;
|
|
}
|
|
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
|
|
}
|
|
|
|
export interface GenVmessLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
forceTls?: ForceTls;
|
|
remark?: string;
|
|
clientId: string;
|
|
security?: VmessSecurity;
|
|
externalProxy?: ExternalProxyEntry | null;
|
|
}
|
|
|
|
// VMess share link: `vmess://` followed by base64-encoded JSON. The JSON
|
|
// schema is the v2rayN-compatible "v2" shape. Returns '' if the inbound
|
|
// is not vmess so dispatcher code can fall through cleanly.
|
|
export function genVmessLink(input: GenVmessLinkInput): string {
|
|
const {
|
|
inbound,
|
|
address,
|
|
port = inbound.port,
|
|
forceTls = 'same',
|
|
remark = '',
|
|
clientId,
|
|
security,
|
|
externalProxy = null,
|
|
} = input;
|
|
|
|
if (inbound.protocol !== 'vmess') return '';
|
|
|
|
const stream = inbound.streamSettings;
|
|
if (!stream) return '';
|
|
|
|
const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
|
|
const obj: Record<string, unknown> = {
|
|
v: '2',
|
|
ps: remark,
|
|
add: address,
|
|
port,
|
|
id: clientId,
|
|
scy: security,
|
|
net: stream.network,
|
|
tls,
|
|
};
|
|
|
|
if (stream.network === 'tcp') {
|
|
const tcp = stream.tcpSettings;
|
|
const header = tcp.header;
|
|
if (header) {
|
|
obj.type = header.type;
|
|
if (header.type === 'http') {
|
|
const request = header.request;
|
|
if (request) {
|
|
obj.path = request.path.join(',');
|
|
const host =
|
|
getHeaderValue(header.response?.headers, 'host')
|
|
|| getHeaderValue(request.headers, 'host');
|
|
if (host) obj.host = host;
|
|
}
|
|
}
|
|
} else {
|
|
obj.type = 'none';
|
|
}
|
|
} else if (stream.network === 'kcp') {
|
|
const kcp = stream.kcpSettings;
|
|
obj.mtu = kcp.mtu;
|
|
obj.tti = kcp.tti;
|
|
} else if (stream.network === 'ws') {
|
|
const ws = stream.wsSettings;
|
|
obj.path = ws.path;
|
|
obj.host = ws.host.length > 0 ? ws.host : getHeaderValue(ws.headers, 'host');
|
|
} else if (stream.network === 'grpc') {
|
|
const grpc = stream.grpcSettings;
|
|
obj.path = grpc.serviceName;
|
|
obj.authority = grpc.authority;
|
|
if (grpc.multiMode) obj.type = 'multi';
|
|
} else if (stream.network === 'httpupgrade') {
|
|
const hu = stream.httpupgradeSettings;
|
|
obj.path = hu.path;
|
|
obj.host = hu.host.length > 0 ? hu.host : getHeaderValue(hu.headers, 'host');
|
|
} else if (stream.network === 'xhttp') {
|
|
const xhttp = stream.xhttpSettings;
|
|
obj.path = xhttp.path;
|
|
obj.host = xhttp.host.length > 0 ? xhttp.host : xhttpHostFallback(xhttp);
|
|
obj.type = xhttp.mode;
|
|
applyXhttpExtraToObj(xhttp, obj);
|
|
}
|
|
|
|
applyFinalMaskToObj(stream.finalmask, obj);
|
|
|
|
if (tls === 'tls' && stream.security === 'tls') {
|
|
const tlsSettings = stream.tlsSettings;
|
|
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
|
|
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
|
|
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
|
|
if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
|
|
if (tlsSettings.settings.verifyPeerCertByName.length > 0) {
|
|
obj.vcn = tlsSettings.settings.verifyPeerCertByName;
|
|
}
|
|
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
|
|
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
|
|
}
|
|
}
|
|
|
|
applyExternalProxyTLSObj(externalProxy, obj, tls);
|
|
|
|
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);
|
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
|
if (pins.length > 0) params.set('pcs', pins);
|
|
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
|
|
params.set('vcn', externalProxy.verifyPeerCertByName);
|
|
}
|
|
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
|
|
}
|
|
|
|
export interface GenVlessLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
forceTls?: ForceTls;
|
|
remark?: string;
|
|
clientId: string;
|
|
clientKey?: string;
|
|
flow?: VlessClient['flow'];
|
|
externalProxy?: ExternalProxyEntry | null;
|
|
}
|
|
|
|
// Mirror of the Go applyVlessRoute: bake a single 0-65535 value into the UUID's
|
|
// 3rd group (bytes 6-7), which xray reads as the vless route. Empty/invalid/non-
|
|
// UUID input is returned unchanged.
|
|
export function applyVlessRoute(id: string, route: string | undefined): string {
|
|
const r = (route ?? '').trim();
|
|
if (r === '' || !/^\d{1,5}$/.test(r)) return id;
|
|
const n = Number(r);
|
|
if (n > 65535) return id;
|
|
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
|
|
return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
|
|
}
|
|
|
|
// 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,
|
|
clientKey = '',
|
|
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 ?? 'tcp');
|
|
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(tcp.header.response?.headers, '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 (tls.settings.verifyPeerCertByName.length > 0) {
|
|
params.set('vcn', tls.settings.verifyPeerCertByName);
|
|
}
|
|
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
|
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
|
}
|
|
}
|
|
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);
|
|
|
|
const sni =
|
|
reality.settings.serverName ||
|
|
reality.serverNames?.[0] ||
|
|
reality.target?.split(':')[0];
|
|
|
|
if (sni && sni.length > 0) params.set('sni', sni);
|
|
|
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
|
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
|
if (spx.length > 0) params.set('spx', spx);
|
|
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
|
}
|
|
} else {
|
|
params.set('security', 'none');
|
|
}
|
|
|
|
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
|
|
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
|
|
// vlessFlowAllowed and the form's flow-field gating so panel link, share
|
|
// link and subscription agree.
|
|
if (flow.length > 0 && canEnableTlsFlow({
|
|
protocol: inbound.protocol,
|
|
settings: inbound.settings,
|
|
streamSettings: stream,
|
|
})) {
|
|
params.set('flow', flow);
|
|
}
|
|
|
|
const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
|
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
url.hash = encodeURIComponent(remark);
|
|
return url.toString();
|
|
}
|
|
|
|
// Shared network-branch writer used by trojan + shadowsocks links.
|
|
// VLESS and VMess don't call this because they have minor per-protocol
|
|
// quirks inline (vmess maps `multi` differently into obj.type; vless sets
|
|
// encryption=none up-front).
|
|
function writeNetworkParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
|
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(tcp.header.response?.headers, '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);
|
|
}
|
|
}
|
|
|
|
function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams): void {
|
|
if (stream.security !== 'tls') return;
|
|
const tls = stream.tlsSettings;
|
|
params.set('fp', tls.settings.fingerprint);
|
|
params.set('alpn', tls.alpn.join(','));
|
|
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
|
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
|
if (tls.settings.verifyPeerCertByName.length > 0) {
|
|
params.set('vcn', tls.settings.verifyPeerCertByName);
|
|
}
|
|
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
|
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
|
|
}
|
|
}
|
|
|
|
// Reality query-string writer shared by VLESS and Trojan. Preserves the
|
|
// legacy SNI-omission quirk (see genVlessLink for the full story).
|
|
function writeRealityParams(stream: NonNullable<Inbound['streamSettings']>, params: URLSearchParams, clientKey: string): void {
|
|
if (stream.security !== 'reality') return;
|
|
const reality = stream.realitySettings;
|
|
params.set('pbk', reality.settings.publicKey);
|
|
params.set('fp', reality.settings.fingerprint);
|
|
|
|
const sni =
|
|
reality.settings.serverName ||
|
|
reality.serverNames?.[0] ||
|
|
reality.target?.split(':')[0];
|
|
|
|
if (sni && sni.length > 0) params.set('sni', sni);
|
|
|
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
|
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
|
if (spx.length > 0) params.set('spx', spx);
|
|
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
|
}
|
|
|
|
export interface GenTrojanLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
forceTls?: ForceTls;
|
|
remark?: string;
|
|
clientPassword: string;
|
|
clientKey?: string;
|
|
externalProxy?: ExternalProxyEntry | null;
|
|
}
|
|
|
|
// Trojan share link: trojan://<password>@<host>:<port>?<query>#<remark>.
|
|
// Same query-string shape as VLESS minus the `encryption` and `flow`
|
|
// fields. Returns '' if the inbound isn't trojan.
|
|
export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|
const {
|
|
inbound,
|
|
address,
|
|
port = inbound.port,
|
|
forceTls = 'same',
|
|
remark = '',
|
|
clientPassword,
|
|
clientKey = '',
|
|
externalProxy = null,
|
|
} = input;
|
|
|
|
if (inbound.protocol !== 'trojan') return '';
|
|
const stream = inbound.streamSettings;
|
|
if (!stream) return '';
|
|
|
|
const security = forceTls === 'same' ? stream.security : forceTls;
|
|
const params = new URLSearchParams();
|
|
params.set('type', stream.network ?? 'tcp');
|
|
|
|
writeNetworkParams(stream, params);
|
|
applyFinalMaskToParams(stream.finalmask, params);
|
|
|
|
if (security === 'tls') {
|
|
params.set('security', 'tls');
|
|
writeTlsParams(stream, params);
|
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
|
} else if (security === 'reality') {
|
|
params.set('security', 'reality');
|
|
writeRealityParams(stream, params, clientKey);
|
|
} else {
|
|
params.set('security', 'none');
|
|
}
|
|
|
|
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
|
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
url.hash = encodeURIComponent(remark);
|
|
return url.toString();
|
|
}
|
|
|
|
export interface GenShadowsocksLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
forceTls?: ForceTls;
|
|
remark?: string;
|
|
clientPassword?: string;
|
|
externalProxy?: ExternalProxyEntry | null;
|
|
}
|
|
|
|
// Shadowsocks 2022 share link. The userinfo portion is base64(method:pw)
|
|
// for single-user and base64(method:settingsPw:clientPw) for multi-user
|
|
// 2022-blake3. Legacy SS (non-2022) leaves the password out of the
|
|
// userinfo entirely — matches the legacy class's password-array logic.
|
|
// Note: legacy `isSSMultiUser` returns true for everything except
|
|
// 2022-blake3-chacha20-poly1305 (a curious classification, but we
|
|
// preserve it for byte-stable parity).
|
|
export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
|
|
const {
|
|
inbound,
|
|
address,
|
|
port = inbound.port,
|
|
forceTls = 'same',
|
|
remark = '',
|
|
clientPassword = '',
|
|
externalProxy = null,
|
|
} = input;
|
|
|
|
if (inbound.protocol !== 'shadowsocks') return '';
|
|
const stream = inbound.streamSettings;
|
|
if (!stream) return '';
|
|
const settings = inbound.settings;
|
|
|
|
const security = forceTls === 'same' ? stream.security : forceTls;
|
|
const params = new URLSearchParams();
|
|
params.set('type', stream.network ?? 'tcp');
|
|
|
|
writeNetworkParams(stream, params);
|
|
applyFinalMaskToParams(stream.finalmask, params);
|
|
|
|
if (security === 'tls') {
|
|
params.set('security', 'tls');
|
|
writeTlsParams(stream, params);
|
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
|
}
|
|
|
|
// SIP002 clients (v2rayN) ignore type/headerType/host/path and only read
|
|
// `plugin`. Re-encode a TCP http header as obfs-local so they build a
|
|
// matching tcp/http outbound (v2rayN forces request path "/").
|
|
if ((stream.network ?? 'tcp') === 'tcp' && params.get('headerType') === 'http') {
|
|
const host = params.get('host') ?? '';
|
|
params.delete('type');
|
|
params.delete('headerType');
|
|
params.delete('host');
|
|
params.delete('path');
|
|
params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`);
|
|
}
|
|
|
|
const isSS2022 = settings.method.substring(0, 4) === '2022';
|
|
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
|
|
const passwords: string[] = [];
|
|
if (isSS2022) passwords.push(settings.password);
|
|
if (isSSMultiUser) passwords.push(clientPassword);
|
|
|
|
if (isSS2022) {
|
|
// SIP022 (2022-blake3-*) forbids base64 userinfo: method and each key are
|
|
// percent-encoded, joined by literal ':' separators. Built by hand because
|
|
// `new URL` would re-encode the inner key separator to %3A.
|
|
const userinfo = [settings.method, ...passwords].map(encodeURIComponent).join(':');
|
|
let link = `ss://${userinfo}@${formatUrlHost(address)}:${port}`;
|
|
const query = params.toString();
|
|
if (query) link += `?${query}`;
|
|
link += `#${encodeURIComponent(remark)}`;
|
|
return link;
|
|
}
|
|
|
|
// SIP002 userinfo is base64(method:pw).
|
|
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
|
|
const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
|
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
url.hash = encodeURIComponent(remark);
|
|
return url.toString();
|
|
}
|
|
|
|
export interface GenHysteriaLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
remark?: string;
|
|
clientAuth: string;
|
|
externalProxy?: ExternalProxyEntry | null;
|
|
}
|
|
|
|
// Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core
|
|
// clients hex-decode it and crash on a base64 value. The panel stores pins as
|
|
// base64 (xray-core's native TLS format / the generate button) or hex, either
|
|
// bare or colon-separated as `openssl x509 -fingerprint -sha256` emits it. Each
|
|
// entry is coerced to bare hex. Values that are neither a 32-byte hex nor a
|
|
// 32-byte base64 SHA-256 pass through unchanged.
|
|
function hysteriaPinHex(pin: string): string {
|
|
const stripped = pin.trim().replace(/:/g, '');
|
|
if (/^[0-9a-fA-F]{64}$/.test(stripped)) return stripped.toLowerCase();
|
|
try {
|
|
const binary = atob(pin.trim().replace(/-/g, '+').replace(/_/g, '/'));
|
|
if (binary.length !== 32) return pin;
|
|
let hex = '';
|
|
for (let i = 0; i < binary.length; i++) {
|
|
hex += binary.charCodeAt(i).toString(16).padStart(2, '0');
|
|
}
|
|
return hex;
|
|
} catch {
|
|
return pin;
|
|
}
|
|
}
|
|
|
|
// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
|
|
// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
|
|
// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
|
|
// password from finalmask.udp[type=salamander] when present; the broader
|
|
// finalmask payload still rides under `fm` like the other links.
|
|
//
|
|
// Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
|
|
// which isn't a field on TlsStreamSettings.Settings — the guard is always
|
|
// false. We omit the `insecure` param here to stay byte-stable.
|
|
export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
|
const {
|
|
inbound,
|
|
address,
|
|
port = inbound.port,
|
|
remark = '',
|
|
clientAuth,
|
|
externalProxy = null,
|
|
} = input;
|
|
|
|
if (inbound.protocol !== 'hysteria') return '';
|
|
const stream = inbound.streamSettings;
|
|
if (!stream || stream.security !== 'tls') return '';
|
|
|
|
const settings = inbound.settings;
|
|
const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
|
|
|
|
const params = new URLSearchParams();
|
|
params.set('security', 'tls');
|
|
const tls = stream.tlsSettings;
|
|
if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
|
|
if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
|
|
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
|
|
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
|
|
if (tls.settings.verifyPeerCertByName.length > 0) {
|
|
params.set('vcn', tls.settings.verifyPeerCertByName);
|
|
}
|
|
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
|
params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(','));
|
|
}
|
|
// An external-proxy entry can pin a different endpoint's certificate.
|
|
// Hysteria carries it as hex `pinSHA256` (not the `pcs` other protocols
|
|
// use), so coerce each entry through hysteriaPinHex like the main pin.
|
|
if (Array.isArray(externalProxy?.pinnedPeerCertSha256)) {
|
|
const epPins = externalProxy.pinnedPeerCertSha256.filter(Boolean).map(hysteriaPinHex);
|
|
if (epPins.length > 0) params.set('pinSHA256', epPins.join(','));
|
|
}
|
|
|
|
const udpMasks = stream.finalmask?.udp;
|
|
if (Array.isArray(udpMasks)) {
|
|
const salamander = udpMasks.find((m) => m?.type === 'salamander');
|
|
const obfsPassword = salamander?.settings?.password;
|
|
if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
|
|
params.set('obfs', 'salamander');
|
|
params.set('obfs-password', obfsPassword);
|
|
}
|
|
}
|
|
|
|
applyFinalMaskToParams(stream.finalmask, params);
|
|
|
|
const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
|
|
if (hopPorts.length > 0) {
|
|
params.set('mport', hopPorts);
|
|
}
|
|
|
|
const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`);
|
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
|
url.hash = encodeURIComponent(remark);
|
|
return url.toString();
|
|
}
|
|
|
|
export interface GenMtprotoLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
clientSecret?: string;
|
|
}
|
|
|
|
// Builds a per-client Telegram proxy deep link for an mtproto inbound from the
|
|
// client's own FakeTLS secret. No remark fragment is added: Telegram proxy deep
|
|
// links have no name field, and a trailing "#remark" gets folded into the last
|
|
// query value by lenient parsers, breaking the server address. The panel shows
|
|
// the remark separately from the link.
|
|
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
|
|
const { inbound, address, port = inbound.port, clientSecret = '' } = input;
|
|
if (inbound.protocol !== 'mtproto') return '';
|
|
if (clientSecret.length === 0) return '';
|
|
const url = new URL('tg://proxy');
|
|
url.searchParams.set('server', address);
|
|
url.searchParams.set('port', String(port));
|
|
url.searchParams.set('secret', clientSecret);
|
|
return url.toString();
|
|
}
|
|
|
|
export interface GenWireguardLinkInput {
|
|
settings: WireguardInboundSettings;
|
|
address: string;
|
|
port: number;
|
|
remark?: string;
|
|
peerIndex: number;
|
|
}
|
|
|
|
// Wireguard share link: wireguard://<peerPrivKey>@<host>:<port>
|
|
// ?publickey=<serverPub>&address=<peerAllowedIP>&mtu=<mtu>#<remark>
|
|
// pubKey is derived from the server's secretKey via Wireguard.generateKeypair
|
|
// at call time (Zod's schema stores secretKey only — pubKey isn't on the
|
|
// wire). Returns '' when the peer index is out of bounds.
|
|
export function genWireguardLink(input: GenWireguardLinkInput): string {
|
|
const { settings, address, port, remark = '', peerIndex } = input;
|
|
const peer = settings.peers[peerIndex];
|
|
if (!peer) return '';
|
|
|
|
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
|
|
url.username = peer.privateKey ?? '';
|
|
|
|
const pubKey = settings.secretKey.length > 0
|
|
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
|
: '';
|
|
if (pubKey.length > 0) url.searchParams.set('publickey', pubKey);
|
|
if (peer.allowedIPs.length > 0) {
|
|
url.searchParams.set('address', peer.allowedIPs.join(','));
|
|
}
|
|
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
|
url.searchParams.set('mtu', String(settings.mtu));
|
|
}
|
|
|
|
url.hash = encodeURIComponent(remark);
|
|
return url.toString();
|
|
}
|
|
|
|
// Plain-text WireGuard client config (.conf format). Mirrors the legacy
|
|
// getWireguardTxt — same DNS defaults (1.1.1.1, 1.0.0.1), MTU optional,
|
|
// presharedKey + keepAlive only emitted when present on the peer. The
|
|
// final newline structure follows the legacy: no newline after Endpoint,
|
|
// optional preSharedKey appended with leading \n, keepAlive appended
|
|
// with leading \n AND trailing \n.
|
|
export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
|
const { settings, address, port, remark = '', peerIndex } = input;
|
|
const peer = settings.peers[peerIndex];
|
|
if (!peer) return '';
|
|
|
|
const pubKey = settings.secretKey.length > 0
|
|
? Wireguard.generateKeypair(settings.secretKey).publicKey
|
|
: '';
|
|
|
|
let txt = `[Interface]\n`;
|
|
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
|
txt += `Address = ${peer.allowedIPs.join(', ')}\n`;
|
|
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
|
|
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
|
txt += `MTU = ${settings.mtu}\n`;
|
|
}
|
|
txt += `\n# ${remark}\n`;
|
|
txt += `[Peer]\n`;
|
|
txt += `PublicKey = ${pubKey}\n`;
|
|
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
|
|
txt += `Endpoint = ${address}:${port}`;
|
|
if (peer.preSharedKey && peer.preSharedKey.length > 0) {
|
|
txt += `\nPresharedKey = ${peer.preSharedKey}`;
|
|
}
|
|
if (typeof peer.keepAlive === 'number' && peer.keepAlive > 0) {
|
|
txt += `\nPersistentKeepalive = ${peer.keepAlive}\n`;
|
|
}
|
|
return txt;
|
|
}
|
|
|
|
export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(link);
|
|
} catch {
|
|
return '';
|
|
}
|
|
const scheme = url.protocol.replace(/:$/, '');
|
|
if (scheme !== 'wireguard' && scheme !== 'wg') return '';
|
|
|
|
const params = url.searchParams;
|
|
const pick = (...keys: string[]): string => {
|
|
for (const k of keys) {
|
|
const v = params.get(k);
|
|
if (v) return v;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
let privateKey: string;
|
|
try {
|
|
privateKey = decodeURIComponent(url.username);
|
|
} catch {
|
|
privateKey = url.username;
|
|
}
|
|
const host = url.hostname;
|
|
const endpoint = host ? (url.port ? `${host}:${url.port}` : host) : '';
|
|
const address = pick('address', 'ip') || '10.0.0.2/32';
|
|
const publicKey = pick('publickey', 'publicKey', 'public_key', 'peerPublicKey');
|
|
const dns = pick('dns') || '1.1.1.1, 1.0.0.1';
|
|
const mtu = pick('mtu');
|
|
const psk = pick('presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
|
|
const keepAlive = pick('keepalive', 'persistentkeepalive', 'persistent_keepalive');
|
|
const allowedIPs = pick('allowedips', 'allowed_ips') || '0.0.0.0/0, ::/0';
|
|
|
|
let remark = fallbackRemark;
|
|
try {
|
|
const decoded = decodeURIComponent(url.hash.replace(/^#/, ''));
|
|
if (decoded) remark = decoded;
|
|
} catch {
|
|
const raw = url.hash.replace(/^#/, '');
|
|
if (raw) remark = raw;
|
|
}
|
|
|
|
const lines = [
|
|
'[Interface]',
|
|
`PrivateKey = ${privateKey}`,
|
|
`Address = ${address}`,
|
|
`DNS = ${dns}`,
|
|
];
|
|
if (mtu && Number(mtu) > 0) lines.push(`MTU = ${mtu}`);
|
|
lines.push('');
|
|
if (remark) lines.push(`# ${remark}`);
|
|
lines.push('[Peer]', `PublicKey = ${publicKey}`);
|
|
if (psk) lines.push(`PresharedKey = ${psk}`);
|
|
lines.push(`AllowedIPs = ${allowedIPs}`, `Endpoint = ${endpoint}`);
|
|
if (keepAlive && Number(keepAlive) > 0) lines.push(`PersistentKeepalive = ${keepAlive}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export type { WireguardInboundPeer };
|
|
|
|
function isUnixSocketListen(listen: string): boolean {
|
|
return listen.startsWith('/') || listen.startsWith('@');
|
|
}
|
|
|
|
function normalizeShareHost(host: string): string {
|
|
const h = host.trim();
|
|
if (
|
|
h.length === 0
|
|
|| h.includes('://')
|
|
|| h.startsWith('//')
|
|
|| /[/?#@]/.test(h)
|
|
) {
|
|
return '';
|
|
}
|
|
if (h.startsWith('[')) {
|
|
if (!h.endsWith(']')) return '';
|
|
try {
|
|
return new URL(`http://${h}`).hostname;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
if (h.includes(':')) {
|
|
try {
|
|
return new URL(`http://[${h}]`).hostname;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
return SHARE_HOSTNAME_RE.test(h) ? h : '';
|
|
}
|
|
|
|
function isShareableHost(host: string): boolean {
|
|
const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase();
|
|
if (h.length === 0) return false;
|
|
if (h === '0.0.0.0' || h === '::' || h === '::0') return false;
|
|
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false;
|
|
return true;
|
|
}
|
|
|
|
function shareableListenFrom(listen: string): string {
|
|
const trimmed = listen.trim();
|
|
return trimmed.length > 0 && !isUnixSocketListen(trimmed) && isShareableHost(trimmed)
|
|
? normalizeShareHost(trimmed)
|
|
: '';
|
|
}
|
|
|
|
type ShareAddrStrategy = 'node' | 'listen' | 'custom';
|
|
|
|
function normalizeShareAddrStrategy(strategy: string | undefined): ShareAddrStrategy {
|
|
return strategy === 'listen' || strategy === 'custom' ? strategy : 'node';
|
|
}
|
|
|
|
// ShareHostFields is the subset of an inbound resolveShareHost needs, so callers
|
|
// holding only a lightweight projection (e.g. the clients page InboundOption)
|
|
// can pick the same host as the full-inbound share/QR path.
|
|
export interface ShareHostFields {
|
|
listen?: string;
|
|
shareAddr?: string;
|
|
shareAddrStrategy?: string;
|
|
}
|
|
|
|
// resolveShareHost picks the host that goes into share/QR links, the browser-side
|
|
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
|
|
// address (empty for this panel's own inbounds); fallbackHostname is the
|
|
// already-resolved panel/public host used as the last resort — kept verbatim when
|
|
// it fails normalization (e.g. an underscore intranet hostname) so the last
|
|
// resort never degrades to an empty host.
|
|
export function resolveShareHost(
|
|
fields: ShareHostFields,
|
|
hostOverride: string,
|
|
fallbackHostname: string,
|
|
): string {
|
|
const nodeAddr = normalizeShareHost(hostOverride);
|
|
const listenAddr = shareableListenFrom(fields.listen ?? '');
|
|
const customAddr = normalizeShareHost(fields.shareAddr ?? '');
|
|
const fallbackAddr = normalizeShareHost(fallbackHostname) || fallbackHostname.trim();
|
|
switch (normalizeShareAddrStrategy(fields.shareAddrStrategy)) {
|
|
case 'listen':
|
|
return listenAddr || nodeAddr || fallbackAddr;
|
|
case 'custom':
|
|
return customAddr || nodeAddr || listenAddr || fallbackAddr;
|
|
default:
|
|
return nodeAddr || listenAddr || fallbackAddr;
|
|
}
|
|
}
|
|
|
|
// Orchestrators.
|
|
// resolveAddr picks the host that goes into share/QR links. The default
|
|
// `node` strategy keeps the previous node-address-first behavior for
|
|
// node-managed inbounds; other strategies let a row prefer its listen address
|
|
// or a custom endpoint.
|
|
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
|
|
return resolveShareHost(inbound, hostOverride, fallbackHostname);
|
|
}
|
|
|
|
// A loopback browser host means the panel was reached through a tunnel (e.g.
|
|
// SSH-forwarded 127.0.0.1/localhost), so it can never be a shareable link host.
|
|
function isLoopbackHost(host: string): boolean {
|
|
const h = host.trim().replace(/^\[|\]$/g, '').toLowerCase();
|
|
return h === 'localhost' || h === '::1' || h.startsWith('127.');
|
|
}
|
|
|
|
// preferPublicHost is the browser-side analog of the backend's
|
|
// configuredPublicHost: when the panel is reached on a loopback host, prefer a
|
|
// configured public host (Sub/Web Domain) for share/QR links instead of leaking
|
|
// localhost. An explicit per-inbound listen or node override still wins, since
|
|
// resolveAddr only reaches the fallbackHostname after those.
|
|
export function preferPublicHost(browserHost: string, publicHost: string): string {
|
|
return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
|
|
}
|
|
|
|
// Returns the client array for protocols that have one. SS returns its
|
|
// clients only in 2022-blake3 multi-user mode (matches the legacy
|
|
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
|
|
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
|
|
// clients, and any protocol without a clients array.
|
|
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; secret?: string; email?: string; subId?: string };
|
|
|
|
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
|
// keys on — subscription id first, unique email as the fallback.
|
|
function clientSubKey(client: ClientShape): string {
|
|
return client.subId || client.email || '';
|
|
}
|
|
|
|
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
|
switch (inbound.protocol) {
|
|
case 'vmess':
|
|
return (inbound.settings.clients ?? []) as ClientShape[];
|
|
case 'vless':
|
|
return (inbound.settings.clients ?? []) as ClientShape[];
|
|
case 'trojan':
|
|
return (inbound.settings.clients ?? []) as ClientShape[];
|
|
case 'hysteria':
|
|
return (inbound.settings.clients ?? []) as ClientShape[];
|
|
case 'mtproto':
|
|
return (inbound.settings.clients ?? []) as ClientShape[];
|
|
case 'shadowsocks': {
|
|
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
|
|
return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface GenLinkInput {
|
|
inbound: Inbound;
|
|
address: string;
|
|
port?: number;
|
|
forceTls?: ForceTls;
|
|
remark?: string;
|
|
client: ClientShape;
|
|
externalProxy?: ExternalProxyEntry | null;
|
|
}
|
|
|
|
// Per-protocol dispatcher matching the legacy `genLink` switch. Returns
|
|
// '' for protocols that don't have client-based share links (wireguard
|
|
// goes through genWireguardLinks/Configs separately, http/mixed/tunnel
|
|
// don't have share URLs).
|
|
export function genLink(input: GenLinkInput): string {
|
|
const { inbound, address, port = inbound.port, forceTls = 'same', remark = '', client, externalProxy = null } = input;
|
|
switch (inbound.protocol) {
|
|
case 'vmess':
|
|
return genVmessLink({
|
|
inbound, address, port, forceTls, remark,
|
|
clientId: client.id ?? '',
|
|
security: client.security,
|
|
externalProxy,
|
|
});
|
|
case 'vless':
|
|
return genVlessLink({
|
|
inbound, address, port, forceTls, remark,
|
|
clientId: client.id ?? '',
|
|
clientKey: clientSubKey(client),
|
|
flow: client.flow,
|
|
externalProxy,
|
|
});
|
|
case 'shadowsocks': {
|
|
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
|
|
return genShadowsocksLink({
|
|
inbound, address, port, forceTls, remark,
|
|
clientPassword: isMultiUser ? (client.password ?? '') : '',
|
|
externalProxy,
|
|
});
|
|
}
|
|
case 'trojan':
|
|
return genTrojanLink({
|
|
inbound, address, port, forceTls, remark,
|
|
clientPassword: client.password ?? '',
|
|
clientKey: clientSubKey(client),
|
|
externalProxy,
|
|
});
|
|
case 'hysteria':
|
|
return genHysteriaLink({
|
|
inbound, address, port, remark,
|
|
clientAuth: client.auth ?? '',
|
|
externalProxy,
|
|
});
|
|
case 'mtproto':
|
|
return genMtprotoLink({ inbound, address, port, clientSecret: client.secret ?? '' });
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export interface GenAllLinksEntry {
|
|
remark: string;
|
|
link: string;
|
|
}
|
|
|
|
export interface GenAllLinksInput {
|
|
inbound: Inbound;
|
|
remark?: string;
|
|
client: ClientShape;
|
|
hostOverride?: string;
|
|
fallbackHostname: string;
|
|
}
|
|
|
|
// Fans out a single client's link per externalProxy entry, or just one link
|
|
// when there are no external proxies. The panel copy/QR remark is the inbound
|
|
// remark plus the externalProxy remark, dash-joined (the configurable
|
|
// subscription remark model was removed; subscription output uses the template).
|
|
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
|
|
const {
|
|
inbound,
|
|
remark = '',
|
|
client,
|
|
hostOverride = '',
|
|
fallbackHostname,
|
|
} = input;
|
|
|
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
|
const port = inbound.port;
|
|
|
|
const composeRemark = (proxyRemark: string): string =>
|
|
[remark, proxyRemark].filter((x) => x.length > 0).join('-');
|
|
|
|
const externals = inbound.streamSettings?.externalProxy;
|
|
if (!externals || externals.length === 0) {
|
|
const r = composeRemark('');
|
|
return [{ remark: r, link: genLink({ inbound, address: addr, port, forceTls: 'same', remark: r, client }) }];
|
|
}
|
|
return externals.map((ep) => {
|
|
const r = composeRemark(ep.remark);
|
|
return {
|
|
remark: r,
|
|
link: genLink({
|
|
inbound,
|
|
address: ep.dest,
|
|
port: ep.port,
|
|
forceTls: ep.forceTls,
|
|
remark: r,
|
|
client,
|
|
externalProxy: ep,
|
|
}),
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface GenInboundLinksInput {
|
|
inbound: Inbound;
|
|
remark?: string;
|
|
hostOverride?: string;
|
|
fallbackHostname: string;
|
|
}
|
|
|
|
// Top-level entrypoint that produces the full \r\n-joined block a user
|
|
// pastes into a client. Iterates per-client for protocols with clients,
|
|
// falls back to a single SS link for single-user 2022-blake3-chacha20,
|
|
// and emits per-peer .conf blocks for wireguard. Returns '' for the
|
|
// other clientless protocols (http, mixed, tunnel).
|
|
export function genInboundLinks(input: GenInboundLinksInput): string {
|
|
const {
|
|
inbound,
|
|
remark = '',
|
|
hostOverride = '',
|
|
fallbackHostname,
|
|
} = input;
|
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
|
const clients = getInboundClients(inbound);
|
|
if (clients) {
|
|
const links: string[] = [];
|
|
for (const client of clients) {
|
|
const entries = genAllLinks({ inbound, remark, client, hostOverride, fallbackHostname });
|
|
for (const e of entries) links.push(e.link);
|
|
}
|
|
return links.join('\r\n');
|
|
}
|
|
if (inbound.protocol === 'shadowsocks') {
|
|
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
|
|
}
|
|
if (inbound.protocol === 'wireguard') {
|
|
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// Per-peer wireguard fanout. Each peer gets its own link (or .conf
|
|
// block) with an index-suffixed remark, joined by \r\n. Matches the
|
|
// legacy genWireguardLinks / genWireguardConfigs exactly.
|
|
export interface GenWireguardFanoutInput {
|
|
inbound: Inbound;
|
|
remark?: string;
|
|
hostOverride?: string;
|
|
fallbackHostname: string;
|
|
}
|
|
|
|
// WireGuard is multi-client: each client is one accepted peer. The canonical
|
|
// store is settings.clients; legacy single-config inbounds (pre-migration) are
|
|
// still rendered from settings.peers. Both carry the privateKey/allowedIPs/
|
|
// preSharedKey/keepAlive the link and .conf need, so they project to the same
|
|
// peer shape and reuse genWireguardLink/genWireguardConfig unchanged.
|
|
function wgRenderPeers(settings: WireguardInboundSettings): WireguardInboundPeer[] {
|
|
const clients = settings.clients ?? [];
|
|
if (clients.length > 0) {
|
|
return clients.map((c) => ({ ...c, publicKey: c.publicKey ?? '' }));
|
|
}
|
|
return settings.peers;
|
|
}
|
|
|
|
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
|
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
|
if (inbound.protocol !== 'wireguard') return '';
|
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
|
const sep = '-';
|
|
const baseSettings = inbound.settings as WireguardInboundSettings;
|
|
const peers = wgRenderPeers(baseSettings);
|
|
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
|
return peers
|
|
.map((p, i) => genWireguardLink({
|
|
settings,
|
|
address: addr,
|
|
port: inbound.port,
|
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
|
peerIndex: i,
|
|
}))
|
|
.join('\r\n');
|
|
}
|
|
|
|
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
|
|
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
|
|
if (inbound.protocol !== 'wireguard') return '';
|
|
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
|
|
const sep = '-';
|
|
const baseSettings = inbound.settings as WireguardInboundSettings;
|
|
const peers = wgRenderPeers(baseSettings);
|
|
const settings: WireguardInboundSettings = { ...baseSettings, peers };
|
|
return peers
|
|
.map((p, i) => genWireguardConfig({
|
|
settings,
|
|
address: addr,
|
|
port: inbound.port,
|
|
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
|
|
peerIndex: i,
|
|
}))
|
|
.join('\r\n');
|
|
}
|
|
|
|
// Peer comments (#5168) are panel-side annotations; when present they ride
|
|
// along in the share remark so the device is identifiable in client apps.
|
|
function wgPeerCommentSuffix(peer: unknown): string {
|
|
const comment = (peer as { comment?: unknown })?.comment;
|
|
return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
|
|
}
|
|
|
|
export function isPostQuantumLink(link: string): boolean {
|
|
if (/[?&]pqv=/.test(link)) return true;
|
|
if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
|
|
if (link.includes('ML-KEM-768')) return true;
|
|
return false;
|
|
}
|