Files
3x-ui/frontend/src/lib/xray/inbound-link.ts
T
Sanaei d0edbcec81 feat(xray): update xray-core to v26.9.9 and follow the udpHop move
Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in
DockerInit.sh and release.yml in lockstep.

Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a
standalone "udphop" UDP mask with a different shape (mode / interval /
remotePorts / remoteIPs). The old key is gone from QuicParams, and since the
config loader ignores unknown fields it is now silently dropped rather than
rejected — port hopping just stops.

The panel adapts where that key was live:

- Both link importers rebuilt quicParams.udpHop from the standard mport param,
  so an imported hysteria2 link produced an outbound that no longer hops. They
  now emit a udphop mask in intervalremote mode, which is what the old key did.
  The mode is required: UDPHop.Build() rejects an empty or unknown one.
- validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go
  link generator strips the mask from every link and sub, and Zod strips it on
  the next form round trip.
- mport generation (Go and frontend) reads the mask first and keeps reading the
  legacy key, so inbounds stored before the upgrade still advertise their range.

On an inbound the old key was always inert — only hysteria's dialer consumed
it — so nothing regresses server-side and no migration is needed. udphop stays
out of the mask dropdown on purpose: it is client-only in core, which refuses
to wrap a server socket, and that form is shared with the inbound editor.
2026-09-09 01:53:00 +02:00

1605 lines
58 KiB
TypeScript

import { Base64, Wireguard } from '@/utils';
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
import type { Inbound } from '@/schemas/api/inbound';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
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 { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
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.
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;
}
// xray-core #6258 renamed these fields, but older clients still read the
// legacy names from share-link extra. Emit both names so one link works
// across old and new clients while the stored panel config stays canonical.
if (typeof extra.sessionIDPlacement === 'string') {
extra.sessionPlacement = extra.sessionIDPlacement;
}
if (typeof extra.sessionIDKey === 'string') {
extra.sessionKey = extra.sessionIDKey;
}
// 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;
if (tls.settings.fingerprint.length > 0) 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;
if (tls.settings.fingerprint.length > 0) 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;
}
}
// Hysteria2 hop range advertised as `mport`. xray-core 26.9.9 moved hopping
// from finalmask.quicParams.udpHop to a 'udphop' UDP mask; inbounds stored
// before the upgrade still carry the old key.
function udpHopPorts(stream: NonNullable<Inbound['streamSettings']>): string {
for (const mask of stream.finalmask?.udp ?? []) {
if (mask.type !== 'udphop') continue;
const ports = mask.settings?.remotePorts;
if (typeof ports === 'string' && ports.trim().length > 0) return ports.trim();
}
return stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
}
// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
// The scheme is always hysteria2 — xray-core builds version 2 only, so the
// settings schema pins it there and the subscription server emits the same
// scheme. 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 scheme = 'hysteria2';
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) {
// packetSize (Gecko mode) exports via v2rayN's native fields; the
// experimental fm=<json> dump breaks mihomo and other strict clients.
const range = parseGeckoPacketSize(salamander?.settings?.packetSize);
if (range) {
params.set('obfs', 'gecko');
params.set('minPacketSize', String(range.min));
params.set('maxPacketSize', String(range.max));
} else {
params.set('obfs', 'salamander');
}
params.set('obfs-password', obfsPassword);
}
}
const hopPorts = udpHopPorts(stream);
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;
}
// Shared input shape for both the per-client vpn:// link and .conf
// builders below — settings.clients (not a peers array; unlike WireGuard,
// AmneziaWG was multi-client from day one, so there's no legacy format).
export interface GenAmneziaWGLinkInput {
settings: AmneziawgInboundSettings;
address: string;
port: number;
remark?: string;
peerIndex: number;
}
function amneziaWGHLine(key: string, value: string | undefined, fallback: string): string {
return `${key} = ${value && value.trim() !== '' ? value : fallback}`;
}
// Base64url (RFC 4648 §5), no padding — matches the real AmneziaVPN app's
// own Qt::Base64UrlEncoding | Qt::OmitTrailingEquals framing for vpn:// links.
function toBase64Url(text: string): string {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// AmneziaWG share link: vpn://<base64url .conf text>, matching the real
// AmneziaVPN app's own share-link scheme. The app's import path base64url-
// decodes, best-effort qUncompresses (falls back to the raw bytes when the
// input isn't qCompress-framed, which plain text never is), then parses the
// result as a flat bag of "Key = Value" lines regardless of which
// [Interface]/[Peer] section they came from — so wrapping the same .conf
// text genAmneziaWGConfig already produces is sufficient; no JSON schema or
// compression needs replicating. Confirmed against the app's own source
// (importController.cpp's checkConfigFormat/extractWireGuardConfig).
export function genAmneziaWGLink(input: GenAmneziaWGLinkInput): string {
const cfgText = genAmneziaWGConfig(input);
if (!cfgText) return '';
return `vpn://${toBase64Url(cfgText)}`;
}
// Plain-text AmneziaWG client config (.conf format). Mirrors
// genWireguardConfig, plus the obfuscation lines every AmneziaWG client must
// share with the server (see internal/amneziawg.writeObfuscation on the Go
// side).
export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
const { settings, address, port, remark = '', peerIndex } = input;
const client = settings.clients[peerIndex];
if (!client) return '';
const server = settings.server;
// These land unescaped in the .conf; a newline would inject a config line
// (e.g. a rogue PostUp) — same guard as the panel's other two emitters.
for (const v of [
client.privateKey ?? '',
server.primaryDns ?? '',
server.secondaryDns ?? '',
remark,
]) {
if (/[\r\n]/.test(v)) return '';
}
let txt = `[Interface]\n`;
txt += `PrivateKey = ${client.privateKey ?? ''}\n`;
txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`;
const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== '');
if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`;
txt += `MTU = ${effectiveMtu(server.mtu, server.s4)}\n`;
txt += `Jc = ${server.jc}\n`;
txt += `Jmin = ${server.jmin}\n`;
txt += `Jmax = ${server.jmax}\n`;
txt += `S1 = ${server.s1}\n`;
txt += `S2 = ${server.s2}\n`;
if (server.s3) txt += `S3 = ${server.s3}\n`;
if (server.s4) txt += `S4 = ${server.s4}\n`;
txt += `${amneziaWGHLine('H1', server.h1, '1')}\n`;
txt += `${amneziaWGHLine('H2', server.h2, '2')}\n`;
txt += `${amneziaWGHLine('H3', server.h3, '3')}\n`;
txt += `${amneziaWGHLine('H4', server.h4, '4')}\n`;
if (server.i1) txt += `I1 = ${server.i1}\n`;
if (server.i2) txt += `I2 = ${server.i2}\n`;
if (server.i3) txt += `I3 = ${server.i3}\n`;
if (server.i4) txt += `I4 = ${server.i4}\n`;
if (server.i5) txt += `I5 = ${server.i5}\n`;
const optional31: Array<[string, string | undefined]> = [
['HeaderProtectionKey', server.headerProtectionKey],
['ContentPaddingAddition', server.contentPaddingAddition],
['RekeyAfterTime', server.rekeyAfterTime],
['RekeyTimeout', server.rekeyTimeout],
['RejectAfterTime', server.rejectAfterTime],
['KeepaliveTimeout', server.keepaliveTimeout],
['MaxHandshakeAttempts', server.maxHandshakeAttempts],
];
for (const [key, value] of optional31) {
if (value && value.trim() !== '') txt += `${key} = ${value}\n`;
}
if (server.randomTrailers) txt += `RandomTrailers = on\n`;
if (server.disableCookies) txt += `DisableCookies = on\n`;
// Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
// emitters (amneziaWGConfigText in Go, buildAmneziaWGClientConfig); all three
// are independent implementations and must not drift apart.
txt += `\n# ${remark}\n`;
txt += `[Peer]\n`;
txt += `PublicKey = ${server.publicKey ?? ''}\n`;
if (client.preSharedKey && client.preSharedKey.length > 0) {
txt += `PresharedKey = ${client.preSharedKey}\n`;
}
txt += `AllowedIPs = 0.0.0.0/0, ::/0\n`;
txt += `Endpoint = ${address}:${port}`;
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
txt += `\nPersistentKeepalive = ${client.keepAlive}`;
}
return txt;
}
export interface GenAmneziaWGFanoutInput {
inbound: Inbound;
remark?: string;
hostOverride?: string;
fallbackHostname: string;
}
export function genAmneziaWGLinks(input: GenAmneziaWGFanoutInput): string {
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'amneziawg') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = '-';
const settings = inbound.settings as AmneziawgInboundSettings;
const clients = settings.clients ?? [];
return clients
.map((c, i) =>
genAmneziaWGLink({
settings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
peerIndex: i,
}),
)
.join('\r\n');
}
export function genAmneziaWGConfigs(input: GenAmneziaWGFanoutInput): string {
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'amneziawg') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = '-';
const settings = inbound.settings as AmneziawgInboundSettings;
const clients = settings.clients ?? [];
return clients
.map((c, i) =>
genAmneziaWGConfig({
settings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
peerIndex: i,
}),
)
.join('\r\n');
}
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');
}
// Reverse of toBase64Url above -- recovers a vpn:// link's plain .conf
// payload for display/copy/download/QR, the AmneziaWG counterpart of
// wireguardConfigFromLink. Simpler than that function: a vpn:// link's
// payload already *is* the .conf text (see genAmneziaWGLink's own doc
// comment), so there's nothing to reconstruct from query params -- just
// decode. Mirrors link-label.tsx's own private fromBase64Url (used there
// only to pull the remark/port back out for the tag label); duplicated
// rather than imported since both are tiny, self-contained, and each
// file already owns the matching encode or decode half of this pair.
function fromBase64Url(value: string): string {
const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
export function amneziawgConfigFromLink(link: string): string {
const trimmed = link.trim();
if (!trimmed.startsWith('vpn://')) return '';
try {
return fromBase64Url(trimmed.slice('vpn://'.length));
} catch {
return '';
}
}
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 and amneziawg. 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 });
}
if (inbound.protocol === 'amneziawg') {
return genAmneziaWGConfigs({ 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;
}