Files
3x-ui/frontend/src/lib/hosts/host-link.ts
T
MHSanaei aee45ca3fe fix(hosts): advertise Hosts in every WireGuard, AmneziaWG and TUIC config
Invariant: an inbound's enabled Hosts are the endpoints every client config
for it advertises, whichever surface renders that config.

WireGuard and AmneziaWG broke it. Their raw generators ignored the
externalProxy entries Hosts are injected as and always emitted
resolveInboundAddress, so the raw subscription, the sub page .conf, the
clients links API and "export all links" gave out the panel address while
the JSON and Clash formats of the same inbound used the Host.
advertisedEndpoints now states the fan-out once for mtproto, wireguard and
amneziawg.

The browser-built configs had the same gap. The Clients page WireGuard and
AmneziaWG config blocks and QR panels, and its TUIC Clash config, used the
panel hostname next to server links that already used Hosts; the Inbounds
page peer configs, QR and export ignored them too. withMtprotoHostEndpoints
becomes withHostEndpoints over a shared hostEndpointsFor mirror of the
backend, the tunnel fan-outs render one config per Host, and the clients
page waits for the hosts list the way the inbounds page does, so an empty
list means "no hosts" rather than "not loaded yet".
2026-09-30 15:03:16 +02:00

142 lines
4.7 KiB
TypeScript

import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
import type { HostFormValues, HostRecord } from '@/schemas/api/host';
import type { Inbound } from '@/schemas/api/inbound';
import { resolveAddr } from '@/lib/xray/inbound-link';
// The subset of a host that affects its share link. Mirrors the fields the
// backend's hostToExternalProxyMap reads.
export type HostLinkInput = Pick<
HostFormValues,
| 'security'
| 'address'
| 'port'
| 'remark'
| 'sni'
| 'alpn'
| 'fingerprint'
| 'pinnedPeerCertSha256'
| 'verifyPeerCertByName'
| 'echConfigList'
| 'overrideSniFromAddress'
| 'keepSniBlank'
| 'vlessRoute'
| 'allowInsecure'
>;
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
// share-link preview generators already understand — the frontend mirror of the
// backend's hostToExternalProxyMap. security "reality"/"same" keep the inbound's
// base TLS (forceTls "same"); the preview falls back to port 443 when the host
// inherits the inbound port (port 0).
export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntry {
const forceTls = host.security === 'tls' || host.security === 'none' ? host.security : 'same';
let sni: string | undefined;
if (host.keepSniBlank) {
sni = undefined;
} else if (host.overrideSniFromAddress) {
sni = host.address || undefined;
} else {
sni = host.sni || undefined;
}
return {
forceTls,
dest: host.address || '',
port: host.port && host.port > 0 ? host.port : 443,
remark: host.remark || '',
sni,
fingerprint: host.fingerprint,
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
pinnedPeerCertSha256:
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0
? host.pinnedPeerCertSha256
: undefined,
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
echConfigList: host.echConfigList || undefined,
vlessRoute: host.vlessRoute || undefined,
allowInsecure: host.allowInsecure || undefined,
};
}
function splitAdvertisedHost(value: string, inboundPort: number): [string, number] {
const host = value.trim();
if (host.startsWith('[')) {
const close = host.indexOf(']');
if (close > 0) {
const port = host.slice(close + 1).match(/^:(\d+)$/)?.[1];
return [host.slice(1, close), port ? Number(port) : inboundPort];
}
}
const match = host.match(/^([^:]*):(\d+)$/);
return match ? [match[1], Number(match[2])] : [host, inboundPort];
}
export interface HostEndpoint {
dest: string;
port: number;
remark: string;
sni?: string;
alpn?: string[];
allowInsecure?: boolean;
}
// hostEndpointsFor mirrors the backend hostEndpoints + hostToExternalProxyMap:
// enabled Hosts of that sub type only; a blank address or port inherits the inbound's.
export function hostEndpointsFor(
records: HostRecord[],
inboundId: number,
inboundPort: number,
defaultDest: string,
subType: 'raw' | 'clash' = 'raw',
): HostEndpoint[] {
const endpoints: HostEndpoint[] = [];
for (const record of records) {
if (
record.isDisabled ||
!record.inboundIds.includes(inboundId) ||
record.excludeFromSubTypes?.includes(subType)
) {
continue;
}
for (const value of record.hosts) {
const [address, port] = splitAdvertisedHost(value, inboundPort);
const dest = address || defaultDest;
const endpoint: HostEndpoint = { dest, port, remark: record.remark || '' };
const sni = record.overrideSniFromAddress ? dest : record.sni;
if (!record.keepSniBlank && sni) endpoint.sni = sni;
if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn;
if (record.allowInsecure) endpoint.allowInsecure = true;
endpoints.push(endpoint);
}
}
return endpoints;
}
// Panel-built links of these protocols read Hosts; the rest still show the
// inbound's own address on the inbounds page.
const HOST_LINK_PROTOCOLS: ReadonlySet<string> = new Set(['mtproto', 'wireguard', 'amneziawg']);
export function withHostEndpoints(
inbound: Inbound,
inboundId: number,
records: HostRecord[],
hostOverride: string,
fallbackHostname: string,
): Inbound {
if (!HOST_LINK_PROTOCOLS.has(inbound.protocol)) return inbound;
const defaultDest = resolveAddr(inbound, hostOverride, fallbackHostname);
const endpoints = hostEndpointsFor(records, inboundId, inbound.port, defaultDest);
if (endpoints.length === 0) return inbound;
const externalProxy: ExternalProxyEntry[] = endpoints.map(({ dest, port, remark }) => ({
forceTls: 'same',
dest,
port,
remark,
}));
return {
...inbound,
streamSettings: { ...inbound.streamSettings, externalProxy },
} as Inbound;
}