Jelajahi Sumber

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".
MHSanaei 8 jam lalu
induk
melakukan
aee45ca3fe

+ 51 - 17
frontend/src/lib/hosts/host-link.ts

@@ -72,36 +72,70 @@ function splitAdvertisedHost(value: string, inboundPort: number): [string, numbe
   return match ? [match[1], Number(match[2])] : [host, inboundPort];
 }
 
-export function withMtprotoHostEndpoints(
-  inbound: Inbound,
-  inboundId: number,
+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[],
-  hostOverride: string,
-  fallbackHostname: string,
-): Inbound {
-  if (inbound.protocol !== 'mtproto') return inbound;
-  const endpoints: ExternalProxyEntry[] = [];
+  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('raw')
+      record.excludeFromSubTypes?.includes(subType)
     ) {
       continue;
     }
     for (const value of record.hosts) {
-      const [dest, port] = splitAdvertisedHost(value, inbound.port);
-      endpoints.push({
-        forceTls: 'same',
-        dest: dest || resolveAddr(inbound, hostOverride, fallbackHostname),
-        port,
-        remark: record.remark || '',
-      });
+      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: endpoints },
+    streamSettings: { ...inbound.streamSettings, externalProxy },
   } as Inbound;
 }

+ 10 - 4
frontend/src/lib/inbounds/label.ts

@@ -12,6 +12,7 @@ export function formatTunnelConfigMeta(
   inbound: { id?: number; tag?: string; remark?: string },
   email?: string,
   totalCount = 1,
+  endpoint = '',
 ): {
   label?: string;
   fileName: string;
@@ -20,13 +21,18 @@ export function formatTunnelConfigMeta(
   const inboundName =
     formatInboundLabel(inbound.tag, inbound.remark) ||
     (inbound.id != null ? `inbound-${inbound.id}` : '');
-  const label = totalCount > 1 ? inboundName : undefined;
-  const suffix = inbound.remark || inbound.tag || (inbound.id != null ? `${inbound.id}` : '');
+  const name = [inboundName, endpoint].filter(Boolean).join(' - ');
+  const label = totalCount > 1 ? name : undefined;
+  const suffix = [
+    inbound.remark || inbound.tag || (inbound.id != null ? `${inbound.id}` : ''),
+    endpoint,
+  ]
+    .filter(Boolean)
+    .join('-');
   const safeSuffix = suffix ? `-${suffix.replace(/[^\w.-]+/g, '_')}` : '';
   const emailPrefix = email || 'client';
   const fileName = `${emailPrefix}${totalCount > 1 ? safeSuffix : ''}.conf`;
-  const qrRemark =
-    totalCount > 1 && inboundName ? [inboundName, email].filter(Boolean).join(' - ') : email || '';
+  const qrRemark = totalCount > 1 && name ? [name, email].filter(Boolean).join(' - ') : email || '';
 
   return { label, fileName, qrRemark };
 }

+ 79 - 59
frontend/src/lib/xray/inbound-link.ts

@@ -1117,44 +1117,43 @@ export interface GenAmneziaWGFanoutInput {
   fallbackHostname: string;
 }
 
-export function genAmneziaWGLinks(input: GenAmneziaWGFanoutInput): string {
+function amneziaWGFanout(
+  input: GenAmneziaWGFanoutInput,
+  render: (input: GenAmneziaWGLinkInput) => string,
+): string[][] {
   const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
-  if (inbound.protocol !== 'amneziawg') return '';
-  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
-  const sep = '-';
+  if (inbound.protocol !== 'amneziawg') return [];
+  const endpoints = tunnelEndpoints(inbound, resolveAddr(inbound, hostOverride, fallbackHostname));
   const settings = inbound.settings as AmneziawgInboundSettings;
   const clients = settings.clients ?? [];
-  return clients
-    .map((c, i) =>
-      genAmneziaWGLink({
+  return clients.map((c, i) =>
+    endpoints.map((e) =>
+      render({
         settings,
-        address: addr,
-        port: inbound.port,
-        remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(c)}`,
+        address: e.address,
+        port: e.port,
+        remark: tunnelPeerRemark(remark, e.remark, i, c),
         peerIndex: i,
       }),
-    )
-    .join('\r\n');
+    ),
+  );
+}
+
+// Per-peer lists with one entry per advertised endpoint (Host), peer-major.
+export function genAmneziaWGPeerLinks(input: GenAmneziaWGFanoutInput): string[][] {
+  return amneziaWGFanout(input, genAmneziaWGLink);
+}
+
+export function genAmneziaWGPeerConfigs(input: GenAmneziaWGFanoutInput): string[][] {
+  return amneziaWGFanout(input, genAmneziaWGConfig);
+}
+
+export function genAmneziaWGLinks(input: GenAmneziaWGFanoutInput): string {
+  return genAmneziaWGPeerLinks(input).flat().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');
+  return genAmneziaWGPeerConfigs(input).flat().join('\r\n');
 }
 
 export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
@@ -1624,46 +1623,67 @@ function wgRenderPeers(settings: WireguardInboundSettings): WireguardInboundPeer
   return settings.peers;
 }
 
-export function genWireguardLinks(input: GenWireguardFanoutInput): string {
+// Hosts reach wireguard/amneziawg as externalProxy entries (withHostEndpoints);
+// with none, every peer is advertised on the inbound's own address.
+function tunnelEndpoints(
+  inbound: Inbound,
+  addr: string,
+): Array<{ address: string; port: number; remark: string }> {
+  const externals = inbound.streamSettings?.externalProxy;
+  if (Array.isArray(externals) && externals.length > 0) {
+    return externals.map((ep) => ({ address: ep.dest, port: ep.port, remark: ep.remark ?? '' }));
+  }
+  return [{ address: addr, port: inbound.port, remark: '' }];
+}
+
+function tunnelPeerRemark(
+  remark: string,
+  endpointRemark: string,
+  index: number,
+  peer: unknown,
+): string {
+  const base = [remark, endpointRemark].filter((x) => x.length > 0).join('-');
+  return `${base}-${index + 1}${wgPeerCommentSuffix(peer)}`;
+}
+
+function wireguardFanout(
+  input: GenWireguardFanoutInput,
+  render: (input: GenWireguardLinkInput) => string,
+): string[][] {
   const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
-  if (inbound.protocol !== 'wireguard') return '';
-  const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
-  const sep = '-';
+  if (inbound.protocol !== 'wireguard') return [];
+  const endpoints = tunnelEndpoints(inbound, resolveAddr(inbound, hostOverride, fallbackHostname));
   const baseSettings = inbound.settings as WireguardInboundSettings;
   const peers = wgRenderPeers(baseSettings);
   const settings: WireguardInboundSettings = { ...baseSettings, peers };
-  return peers
-    .map((p, i) =>
-      genWireguardLink({
+  return peers.map((p, i) =>
+    endpoints.map((e) =>
+      render({
         settings,
-        address: addr,
-        port: inbound.port,
-        remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
+        address: e.address,
+        port: e.port,
+        remark: tunnelPeerRemark(remark, e.remark, i, p),
         peerIndex: i,
       }),
-    )
-    .join('\r\n');
+    ),
+  );
+}
+
+// Per-peer lists with one entry per advertised endpoint (Host), peer-major.
+export function genWireguardPeerLinks(input: GenWireguardFanoutInput): string[][] {
+  return wireguardFanout(input, genWireguardLink);
+}
+
+export function genWireguardPeerConfigs(input: GenWireguardFanoutInput): string[][] {
+  return wireguardFanout(input, genWireguardConfig);
+}
+
+export function genWireguardLinks(input: GenWireguardFanoutInput): string {
+  return genWireguardPeerLinks(input).flat().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');
+  return genWireguardPeerConfigs(input).flat().join('\r\n');
 }
 
 // Peer comments (#5168) are panel-side annotations; when present they ride

+ 40 - 26
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -30,6 +30,8 @@ import {
   findAmneziaWGInbounds,
   isAmneziaWGClient,
 } from './amneziawgConfig';
+import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints';
+import type { HostRecord } from '@/schemas/api/host';
 import './ClientInfoModal.css';
 
 const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -66,9 +68,12 @@ interface ClientInfoModalProps {
   tunnelAllowedIPs?: Record<number, string>;
   isOnline: boolean;
   subSettings?: SubSettings;
+  hosts?: HostRecord[];
   onOpenChange: (open: boolean) => void;
 }
 
+const NO_HOSTS: HostRecord[] = [];
+
 interface ApiMsg<T = unknown> {
   success?: boolean;
   obj?: T;
@@ -97,6 +102,7 @@ export default function ClientInfoModal({
   tunnelAllowedIPs,
   isOnline,
   subSettings = DEFAULT_SUB,
+  hosts = NO_HOSTS,
   onOpenChange,
 }: ClientInfoModalProps) {
   const { datepicker } = useDatepicker();
@@ -187,20 +193,19 @@ export default function ClientInfoModal({
   );
   const wgConfigs = useMemo(() => {
     if (!client || !isWireguardClient(client)) return [];
+    const host = window.location.hostname;
+    const publicHost = subSettings?.publicHost ?? '';
     return wgInbounds
-      .map((ib) => {
+      .flatMap((ib) => {
         const address = tunnelAllowedIPs?.[ib.id] ?? '';
-        const text = buildWireguardClientConfig(
-          client,
-          ib,
-          window.location.hostname,
-          subSettings?.publicHost ?? '',
-          address,
-        );
-        return { inbound: ib, text };
+        return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
+          inbound: ib,
+          endpoint: tunnelEndpointLabel(ep),
+          text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep),
+        }));
       })
       .filter((c) => !!c.text);
-  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
 
   const awgInbounds = useMemo(
     () => findAmneziaWGInbounds(client, inboundsById),
@@ -208,20 +213,19 @@ export default function ClientInfoModal({
   );
   const awgConfigs = useMemo(() => {
     if (!client || !isAmneziaWGClient(client)) return [];
+    const host = window.location.hostname;
+    const publicHost = subSettings?.publicHost ?? '';
     return awgInbounds
-      .map((ib) => {
+      .flatMap((ib) => {
         const address = tunnelAllowedIPs?.[ib.id] ?? '';
-        const text = buildAmneziaWGClientConfig(
-          client,
-          ib,
-          window.location.hostname,
-          subSettings?.publicHost ?? '',
-          address,
-        );
-        return { inbound: ib, text };
+        return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
+          inbound: ib,
+          endpoint: tunnelEndpointLabel(ep),
+          text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep),
+        }));
       })
       .filter((c) => !!c.text);
-  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost, hosts]);
 
   async function copyValue(text: string) {
     if (!text) return;
@@ -795,11 +799,16 @@ export default function ClientInfoModal({
             {wgConfigs.length > 0 && client && (
               <>
                 <Divider>{t('pages.clients.wireguardConfig')}</Divider>
-                {wgConfigs.map(({ inbound, text }) => {
-                  const meta = formatTunnelConfigMeta(inbound, client.email, wgConfigs.length);
+                {wgConfigs.map(({ inbound, endpoint, text }) => {
+                  const meta = formatTunnelConfigMeta(
+                    inbound,
+                    client.email,
+                    wgConfigs.length,
+                    endpoint,
+                  );
                   return (
                     <ConfigBlock
-                      key={`wg-${inbound.id}`}
+                      key={`wg-${inbound.id}-${endpoint}`}
                       label={meta.label || t('pages.clients.config')}
                       text={text}
                       fileName={meta.fileName}
@@ -814,11 +823,16 @@ export default function ClientInfoModal({
             {awgConfigs.length > 0 && client && (
               <>
                 <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
-                {awgConfigs.map(({ inbound, text }) => {
-                  const meta = formatTunnelConfigMeta(inbound, client.email, awgConfigs.length);
+                {awgConfigs.map(({ inbound, endpoint, text }) => {
+                  const meta = formatTunnelConfigMeta(
+                    inbound,
+                    client.email,
+                    awgConfigs.length,
+                    endpoint,
+                  );
                   return (
                     <ConfigBlock
-                      key={`awg-${inbound.id}`}
+                      key={`awg-${inbound.id}-${endpoint}`}
                       label={meta.label || t('pages.clients.config')}
                       text={text}
                       fileName={meta.fileName}

+ 55 - 46
frontend/src/pages/clients/ClientQrModal.tsx

@@ -22,6 +22,8 @@ import {
   isAmneziaWGClient,
 } from './amneziawgConfig';
 import { buildTuicClientConfig, findTuicInbound, isTuicClient } from './tuicConfig';
+import { tunnelConfigEndpoints, tunnelEndpointLabel } from './tunnelEndpoints';
+import type { HostRecord } from '@/schemas/api/host';
 
 interface SubSettings {
   enable: boolean;
@@ -38,9 +40,12 @@ interface ClientQrModalProps {
   inboundsById: Record<number, InboundOption>;
   tunnelAllowedIPs?: Record<number, string>;
   subSettings?: SubSettings;
+  hosts?: HostRecord[];
   onOpenChange: (open: boolean) => void;
 }
 
+const NO_HOSTS: HostRecord[] = [];
+
 interface ApiMsg<T = unknown> {
   success?: boolean;
   obj?: T;
@@ -227,6 +232,7 @@ function ClientQrModalContent({
   inboundsById,
   tunnelAllowedIPs,
   subSettings = DEFAULT_SUB,
+  hosts = NO_HOSTS,
   onOpenChange,
 }: ClientQrModalProps) {
   const { t } = useTranslation();
@@ -326,20 +332,19 @@ function ClientQrModalContent({
   );
   const wgConfigs = useMemo(() => {
     if (!client || !isWireguardClient(client)) return [];
+    const host = window.location.hostname;
+    const publicHost = subSettings.publicHost ?? '';
     return wgInbounds
-      .map((ib) => {
+      .flatMap((ib) => {
         const address = tunnelAllowedIPs?.[ib.id] ?? '';
-        const text = buildWireguardClientConfig(
-          client,
-          ib,
-          window.location.hostname,
-          subSettings.publicHost ?? '',
-          address,
-        );
-        return { inbound: ib, text };
+        return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
+          inbound: ib,
+          endpoint: tunnelEndpointLabel(ep),
+          text: buildWireguardClientConfig(client, ib, host, publicHost, address, ep),
+        }));
       })
       .filter((c) => !!c.text);
-  }, [client, wgInbounds, tunnelAllowedIPs, subSettings.publicHost]);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings.publicHost, hosts]);
 
   const awgInbounds = useMemo(
     () => findAmneziaWGInbounds(client, inboundsById),
@@ -347,38 +352,37 @@ function ClientQrModalContent({
   );
   const awgConfigs = useMemo(() => {
     if (!client || !isAmneziaWGClient(client)) return [];
+    const host = window.location.hostname;
+    const publicHost = subSettings.publicHost ?? '';
     return awgInbounds
-      .map((ib) => {
+      .flatMap((ib) => {
         const address = tunnelAllowedIPs?.[ib.id] ?? '';
-        const text = buildAmneziaWGClientConfig(
-          client,
-          ib,
-          window.location.hostname,
-          subSettings.publicHost ?? '',
-          address,
-        );
-        return { inbound: ib, text };
+        return tunnelConfigEndpoints(ib, hosts, host, publicHost).map((ep) => ({
+          inbound: ib,
+          endpoint: tunnelEndpointLabel(ep),
+          text: buildAmneziaWGClientConfig(client, ib, host, publicHost, address, ep),
+        }));
       })
       .filter((c) => !!c.text);
-  }, [client, awgInbounds, tunnelAllowedIPs, subSettings.publicHost]);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings.publicHost, hosts]);
 
   const tuicInbound = useMemo(() => findTuicInbound(client, inboundsById), [client, inboundsById]);
-  const tuicConfigText = useMemo(() => {
-    if (!client || !tuicInbound || !isTuicClient(client)) return '';
-    return buildTuicClientConfig(
-      client,
-      tuicInbound,
-      window.location.hostname,
-      subSettings.publicHost ?? '',
-    );
-  }, [client, tuicInbound, subSettings.publicHost]);
+  const tuicConfigs = useMemo(() => {
+    if (!client || !tuicInbound || !isTuicClient(client)) return [];
+    const host = window.location.hostname;
+    const publicHost = subSettings.publicHost ?? '';
+    return tunnelConfigEndpoints(tuicInbound, hosts, host, publicHost, 'clash').map((ep) => ({
+      endpoint: tunnelEndpointLabel(ep),
+      text: buildTuicClientConfig(client, tuicInbound, host, publicHost, ep),
+    }));
+  }, [client, tuicInbound, subSettings.publicHost, hosts]);
 
   const hasAnything =
     !!subLink ||
     !!subJsonLink ||
     wgConfigs.length > 0 ||
     awgConfigs.length > 0 ||
-    !!tuicConfigText ||
+    tuicConfigs.length > 0 ||
     links.length > 0;
 
   // The reset runs during render so the effect only carries the request.
@@ -468,8 +472,8 @@ function ClientQrModalContent({
         ),
       });
     });
-    wgConfigs.forEach(({ inbound, text }) => {
-      const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length);
+    wgConfigs.forEach(({ inbound, endpoint, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length, endpoint);
       const label = (
         <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="cyan" style={{ margin: 0 }}>
@@ -479,13 +483,13 @@ function ClientQrModalContent({
         </span>
       );
       out.push({
-        key: `wg-config-${inbound.id}`,
+        key: `wg-config-${inbound.id}-${endpoint}`,
         label,
         children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
       });
     });
-    awgConfigs.forEach(({ inbound, text }) => {
-      const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length);
+    awgConfigs.forEach(({ inbound, endpoint, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length, endpoint);
       const label = (
         <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="purple" style={{ margin: 0 }}>
@@ -495,28 +499,33 @@ function ClientQrModalContent({
         </span>
       );
       out.push({
-        key: `awg-config-${inbound.id}`,
+        key: `awg-config-${inbound.id}-${endpoint}`,
         label,
         children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
       });
     });
-    if (tuicConfigText) {
+    tuicConfigs.forEach(({ endpoint, text }) => {
+      const name = client?.email || 'tuic';
+      const multi = tuicConfigs.length > 1 ? endpoint : '';
       out.push({
-        key: 'tuic-config',
+        key: `tuic-config-${endpoint}`,
         label: (
-          <Tag color="orange" style={{ margin: 0 }}>
-            {t('pages.clients.tuicConfig')}
-          </Tag>
+          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
+            <Tag color="orange" style={{ margin: 0 }}>
+              {t('pages.clients.tuicConfig')}
+            </Tag>
+            {multi && <span style={{ opacity: 0.85, fontSize: 12 }}>{multi}</span>}
+          </span>
         ),
         children: (
           <QrPanel
-            value={tuicConfigText}
-            remark={client?.email || 'tuic'}
-            downloadName={`${client?.email || 'tuic'}.yaml`}
+            value={text}
+            remark={[name, multi].filter(Boolean).join(' - ')}
+            downloadName={`${[name, multi.replace(/[^\w.-]+/g, '_')].filter(Boolean).join('-')}.yaml`}
           />
         ),
       });
-    }
+    });
     return out;
   }, [
     subLink,
@@ -533,7 +542,7 @@ function ClientQrModalContent({
     selectVariant,
     regenerateHappLink,
     openHappSettings,
-    tuicConfigText,
+    tuicConfigs,
     t,
   ]);
 

+ 23 - 6
frontend/src/pages/clients/ClientsPage.tsx

@@ -60,6 +60,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
+import { useHostsQuery } from '@/api/queries/useHostsQuery';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type {
   ClientRecord,
@@ -381,6 +382,15 @@ export default function ClientsPage() {
   // Node list for the Nodes filter; the section only renders when the panel
   // actually manages nodes (#4997).
   const { nodes } = useNodesQuery();
+  // Tunnel configs advertise these Hosts, so an empty list must mean "no hosts"
+  // and not "not loaded yet" — the page gate waits for it.
+  const {
+    hosts,
+    fetched: hostsFetched,
+    fetchError: hostsFetchError,
+    refetch: refetchHosts,
+  } = useHostsQuery();
+  const hostsError = hosts.length > 0 ? '' : hostsFetchError;
 
   const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
   const [formOpen, setFormOpen] = useState(false);
@@ -769,11 +779,11 @@ export default function ClientsPage() {
   const onRefreshClick = useCallback(async () => {
     setRefreshing(true);
     try {
-      await refresh();
+      await Promise.all([refresh(), refetchHosts()]);
     } finally {
       setRefreshing(false);
     }
-  }, [refresh]);
+  }, [refresh, refetchHosts]);
 
   const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
     setTextTitle(opts.title);
@@ -1289,14 +1299,19 @@ export default function ClientsPage() {
 
         <Layout className="content-shell">
           <Layout.Content id="content-layout" className="content-area">
-            <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
-              {!fetched ? (
+            <Spin
+              spinning={!fetched || !hostsFetched}
+              delay={200}
+              description={t('loading')}
+              size="large"
+            >
+              {!fetched || !hostsFetched ? (
                 <div className="loading-spacer" />
-              ) : fetchError ? (
+              ) : fetchError || hostsError ? (
                 <Result
                   status="error"
                   title={t('somethingWentWrong')}
-                  subTitle={fetchError}
+                  subTitle={fetchError || hostsError}
                   extra={
                     <Button type="primary" loading={refreshing} onClick={onRefreshClick}>
                       {t('refresh')}
@@ -1898,6 +1913,7 @@ export default function ClientsPage() {
             tunnelAllowedIPs={viewingTunnelAllowedIPs}
             isOnline={infoClient ? isOnline(infoClient.email) : false}
             subSettings={subSettings}
+            hosts={hosts}
             onOpenChange={setInfoOpen}
           />
         </LazyMount>
@@ -1908,6 +1924,7 @@ export default function ClientsPage() {
             inboundsById={inboundsById}
             tunnelAllowedIPs={viewingTunnelAllowedIPs}
             subSettings={subSettings}
+            hosts={hosts}
             onOpenChange={setQrOpen}
           />
         </LazyMount>

+ 9 - 7
frontend/src/pages/clients/amneziawgConfig.ts

@@ -1,3 +1,4 @@
+import type { HostEndpoint } from '@/lib/hosts/host-link';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
 import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
@@ -44,17 +45,18 @@ export function buildAmneziaWGClientConfig(
   host = window.location.hostname,
   publicHost = '',
   addressOverride = '',
+  hostEndpoint?: HostEndpoint,
 ): string {
   const server = inbound?.awgServer;
-  const endpointHost = resolveShareHost(
-    inbound ?? {},
-    inbound?.nodeAddress ?? '',
-    preferPublicHost(host, publicHost),
-  );
+  const endpointHost =
+    hostEndpoint?.dest ||
+    resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost));
   const address = addressOverride || client.allowedIPs || '10.8.1.2/32';
-  const endpoint = `${endpointHost}:${inbound?.port || ''}`;
+  const endpoint = `${endpointHost}:${hostEndpoint?.port || inbound?.port || ''}`;
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
-  const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
+  const remark = [inboundName, hostEndpoint?.remark, client.email, client.comment]
+    .filter(Boolean)
+    .join(' - ');
 
   // These land unescaped in [Interface]; a newline here would inject a
   // config line (e.g. a rogue PostUp) into the downloaded .conf.

+ 14 - 10
frontend/src/pages/clients/tuicConfig.ts

@@ -1,3 +1,4 @@
+import type { HostEndpoint } from '@/lib/hosts/host-link';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
@@ -21,21 +22,23 @@ export function buildTuicClientConfig(
   inbound: InboundOption | undefined,
   host = window.location.hostname,
   publicHost = '',
+  hostEndpoint?: HostEndpoint,
 ): string {
-  const endpointHost = resolveShareHost(
-    inbound ?? {},
-    inbound?.nodeAddress ?? '',
-    preferPublicHost(host, publicHost),
-  );
+  const endpointHost =
+    hostEndpoint?.dest ||
+    resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost));
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
-  const remark = [inboundName, client.email].filter(Boolean).join(' - ') || 'tuic-client';
+  const remark =
+    [inboundName, hostEndpoint?.remark, client.email].filter(Boolean).join(' - ') || 'tuic-client';
 
+  // A Host's SNI/ALPN/insecure override the inbound's, as the backend buildTuicProxy does.
   const tuicServer = inbound?.tuicServer;
-  const alpn =
-    Array.isArray(tuicServer?.alpn) && tuicServer.alpn.length > 0
+  const alpn = hostEndpoint?.alpn?.length
+    ? hostEndpoint.alpn
+    : Array.isArray(tuicServer?.alpn) && tuicServer.alpn.length > 0
       ? tuicServer.alpn
       : ['h3', 'spdy/3.1'];
-  const sni = tuicServer?.sni || endpointHost;
+  const sni = hostEndpoint?.sni || tuicServer?.sni || endpointHost;
   const cc = tuicServer?.congestion_control || 'bbr';
   const udpRelay = tuicServer?.udp_relay_mode || 'native';
   const reduceRtt = tuicServer?.zero_rtt_handshake ?? true;
@@ -49,7 +52,7 @@ export function buildTuicClientConfig(
     `  - name: ${yamlQuote(remark)}`,
     `    type: tuic`,
     `    server: ${endpointHost}`,
-    `    port: ${inbound?.port || 8443}`,
+    `    port: ${hostEndpoint?.port || inbound?.port || 8443}`,
     `    uuid: ${client.uuid || ''}`,
     `    password: ${yamlQuote(client.password || '')}`,
     `    alpn:`,
@@ -59,6 +62,7 @@ export function buildTuicClientConfig(
     `    udp-relay-mode: ${udpRelay}`,
     `    reduce-rtt: ${reduceRtt}`,
   ];
+  if (hostEndpoint?.allowInsecure) lines.push('    skip-cert-verify: true');
 
   return lines.join('\n');
 }

+ 27 - 0
frontend/src/pages/clients/tunnelEndpoints.ts

@@ -0,0 +1,27 @@
+import { hostEndpointsFor, type HostEndpoint } from '@/lib/hosts/host-link';
+import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
+import type { InboundOption } from '@/hooks/useClients';
+import type { HostRecord } from '@/schemas/api/host';
+
+// One client config per Host the subscription of that format advertises for the
+// inbound; `undefined` stands for the inbound's own address when no Host applies.
+export function tunnelConfigEndpoints(
+  inbound: InboundOption,
+  hosts: HostRecord[],
+  host: string,
+  publicHost: string,
+  subType: 'raw' | 'clash' = 'raw',
+): (HostEndpoint | undefined)[] {
+  const defaultDest = resolveShareHost(
+    inbound,
+    inbound.nodeAddress ?? '',
+    preferPublicHost(host, publicHost),
+  );
+  const endpoints = hostEndpointsFor(hosts, inbound.id, inbound.port ?? 0, defaultDest, subType);
+  return endpoints.length > 0 ? endpoints : [undefined];
+}
+
+// A Host group shares one remark across its addresses, so only dest:port is unique.
+export function tunnelEndpointLabel(endpoint: HostEndpoint | undefined): string {
+  return endpoint ? `${endpoint.dest}:${endpoint.port}` : '';
+}

+ 9 - 7
frontend/src/pages/clients/wireguardConfig.ts

@@ -1,3 +1,4 @@
+import type { HostEndpoint } from '@/lib/hosts/host-link';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
@@ -28,16 +29,17 @@ export function buildWireguardClientConfig(
   host = window.location.hostname,
   publicHost = '',
   addressOverride = '',
+  hostEndpoint?: HostEndpoint,
 ): string {
-  const endpointHost = resolveShareHost(
-    inbound ?? {},
-    inbound?.nodeAddress ?? '',
-    preferPublicHost(host, publicHost),
-  );
+  const endpointHost =
+    hostEndpoint?.dest ||
+    resolveShareHost(inbound ?? {}, inbound?.nodeAddress ?? '', preferPublicHost(host, publicHost));
   const address = addressOverride || client.allowedIPs || '10.0.0.2/32';
-  const endpoint = `${endpointHost}:${inbound?.port || ''}`;
+  const endpoint = `${endpointHost}:${hostEndpoint?.port || inbound?.port || ''}`;
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
-  const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
+  const remark = [inboundName, hostEndpoint?.remark, client.email, client.comment]
+    .filter(Boolean)
+    .join(' - ');
   const lines = [
     '[Interface]',
     `PrivateKey = ${client.privateKey || client.password || ''}`,

+ 2 - 2
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -39,7 +39,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useHostsQuery } from '@/api/queries/useHostsQuery';
-import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import { withHostEndpoints } from '@/lib/hosts/host-link';
 import AppSidebar from '@/layouts/AppSidebar';
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
 import type { TextModalTab } from '@/components/feedback/TextModal';
@@ -340,7 +340,7 @@ export default function InboundsPage() {
       const hostOverride = hostOverrideFor(dbInbound);
       const fallbackHostname = preferPublicHost(window.location.hostname, subSettings.publicHost);
       const genInput = {
-        inbound: withMtprotoHostEndpoints(
+        inbound: withHostEndpoints(
           inboundFromDb(projected),
           dbInbound.id,
           hosts,

+ 114 - 101
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -10,14 +10,14 @@ import { InfinityIcon } from '@/components/ui';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import {
   genAllLinks,
-  genAmneziaWGConfigs,
-  genAmneziaWGLinks,
-  genWireguardConfigs,
-  genWireguardLinks,
+  genAmneziaWGPeerConfigs,
+  genAmneziaWGPeerLinks,
+  genWireguardPeerConfigs,
+  genWireguardPeerLinks,
   preferPublicHost,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
-import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import { withHostEndpoints } from '@/lib/hosts/host-link';
 
 import {
   buildInboundInfo,
@@ -25,6 +25,7 @@ import {
   downloadText,
   formatIpInfo,
   hasShareLink,
+  peerConfFileName,
   statsColor,
 } from './helpers';
 import type { ClientSetting, ClientStats, InboundInfo, InboundInfoModalProps } from './types';
@@ -53,10 +54,10 @@ export default function InboundInfoModal({
   const [clientSettings, setClientSettings] = useState<ClientSetting | null>(null);
   const [clientStats, setClientStats] = useState<ClientStats | null>(null);
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
-  const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
-  const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
-  const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
-  const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
+  const [wireguardConfigs, setWireguardConfigs] = useState<string[][]>([]);
+  const [wireguardLinks, setWireguardLinks] = useState<string[][]>([]);
+  const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[][]>([]);
+  const [amneziawgLinks, setAmneziawgLinks] = useState<string[][]>([]);
   const [subLink, setSubLink] = useState('');
   const [subJsonLink, setSubJsonLink] = useState('');
   const [refreshing, setRefreshing] = useState(false);
@@ -145,7 +146,7 @@ export default function InboundInfoModal({
       window.location.hostname,
       subSettings?.publicHost ?? '',
     );
-    const inboundForLinks = withMtprotoHostEndpoints(
+    const inboundForLinks = withHostEndpoints(
       inboundFromDb(dbInbound),
       dbInbound.id,
       hosts,
@@ -154,40 +155,40 @@ export default function InboundInfoModal({
     );
     if (info.protocol === Protocols.WIREGUARD) {
       setWireguardConfigs(
-        genWireguardConfigs({
+        genWireguardPeerConfigs({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setWireguardLinks(
-        genWireguardLinks({
+        genWireguardPeerLinks({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setAmneziawgConfigs([]);
       setAmneziawgLinks([]);
       setLinks([]);
     } else if (info.protocol === Protocols.AMNEZIAWG) {
       setAmneziawgConfigs(
-        genAmneziaWGConfigs({
+        genAmneziaWGPeerConfigs({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setAmneziawgLinks(
-        genAmneziaWGLinks({
+        genAmneziaWGPeerLinks({
           inbound: inboundForLinks,
           remark: dbInbound.remark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setWireguardConfigs([]);
       setWireguardLinks([]);
@@ -1189,49 +1190,55 @@ export default function InboundInfoModal({
                     </dd>
                   </div>
                 </dl>
-                {wireguardConfigs[idx] && (
-                  <div className="link-panel">
-                    <div className="link-panel-header">
-                      <Tag color="green">
-                        {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}
-                      </Tag>
-                      <Tooltip title={t('copy')}>
-                        <Button
-                          size="small"
-                          icon={<CopyOutlined />}
-                          aria-label={t('copy')}
-                          onClick={() => copyText(wireguardConfigs[idx], t)}
-                        />
-                      </Tooltip>
-                      <Tooltip title={t('download')}>
-                        <Button
-                          size="small"
-                          icon={<DownloadOutlined />}
-                          aria-label={t('download')}
-                          onClick={() =>
-                            downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)
-                          }
-                        />
-                      </Tooltip>
-                    </div>
-                    <code className="link-panel-text">{wireguardConfigs[idx]}</code>
-                  </div>
+                {(wireguardConfigs[idx] ?? []).map(
+                  (cfg, j, all) =>
+                    cfg && (
+                      <div key={`wg-cfg-${j}`} className="link-panel">
+                        <div className="link-panel-header">
+                          <Tag color="green">
+                            {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}
+                          </Tag>
+                          <Tooltip title={t('copy')}>
+                            <Button
+                              size="small"
+                              icon={<CopyOutlined />}
+                              aria-label={t('copy')}
+                              onClick={() => copyText(cfg, t)}
+                            />
+                          </Tooltip>
+                          <Tooltip title={t('download')}>
+                            <Button
+                              size="small"
+                              icon={<DownloadOutlined />}
+                              aria-label={t('download')}
+                              onClick={() =>
+                                downloadText(cfg, peerConfFileName(idx, j, all.length))
+                              }
+                            />
+                          </Tooltip>
+                        </div>
+                        <code className="link-panel-text">{cfg}</code>
+                      </div>
+                    ),
                 )}
-                {wireguardLinks[idx] && (
-                  <div className="link-panel">
-                    <div className="link-panel-header">
-                      <Tag color="green">Peer {idx + 1} link</Tag>
-                      <Tooltip title={t('copy')}>
-                        <Button
-                          size="small"
-                          icon={<CopyOutlined />}
-                          aria-label={t('copy')}
-                          onClick={() => copyText(wireguardLinks[idx], t)}
-                        />
-                      </Tooltip>
-                    </div>
-                    <code className="link-panel-text">{wireguardLinks[idx]}</code>
-                  </div>
+                {(wireguardLinks[idx] ?? []).map(
+                  (link, j) =>
+                    link && (
+                      <div key={`wg-link-${j}`} className="link-panel">
+                        <div className="link-panel-header">
+                          <Tag color="green">Peer {idx + 1} link</Tag>
+                          <Tooltip title={t('copy')}>
+                            <Button
+                              size="small"
+                              icon={<CopyOutlined />}
+                              aria-label={t('copy')}
+                              onClick={() => copyText(link, t)}
+                            />
+                          </Tooltip>
+                        </div>
+                        <code className="link-panel-text">{link}</code>
+                      </div>
+                    ),
                 )}
               </Fragment>
             ))}
@@ -1241,49 +1248,55 @@ export default function InboundInfoModal({
       {inbound?.protocol === Protocols.AMNEZIAWG && amneziawgConfigs.length > 0 && (
         <>
           <Divider>{t('pages.inbounds.copyLink')}</Divider>
-          {amneziawgConfigs.map((cfg, idx) => (
+          {amneziawgConfigs.map((peerConfigs, idx) => (
             <Fragment key={idx}>
-              {cfg && (
-                <div className="link-panel">
-                  <div className="link-panel-header">
-                    <Tag color="green">
-                      {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}
-                    </Tag>
-                    <Tooltip title={t('copy')}>
-                      <Button
-                        size="small"
-                        icon={<CopyOutlined />}
-                        aria-label={t('copy')}
-                        onClick={() => copyText(cfg, t)}
-                      />
-                    </Tooltip>
-                    <Tooltip title={t('download')}>
-                      <Button
-                        size="small"
-                        icon={<DownloadOutlined />}
-                        aria-label={t('download')}
-                        onClick={() => downloadText(cfg, `peer-${idx + 1}.conf`)}
-                      />
-                    </Tooltip>
-                  </div>
-                  <code className="link-panel-text">{cfg}</code>
-                </div>
+              {peerConfigs.map(
+                (cfg, j, all) =>
+                  cfg && (
+                    <div key={`awg-cfg-${j}`} className="link-panel">
+                      <div className="link-panel-header">
+                        <Tag color="green">
+                          {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}
+                        </Tag>
+                        <Tooltip title={t('copy')}>
+                          <Button
+                            size="small"
+                            icon={<CopyOutlined />}
+                            aria-label={t('copy')}
+                            onClick={() => copyText(cfg, t)}
+                          />
+                        </Tooltip>
+                        <Tooltip title={t('download')}>
+                          <Button
+                            size="small"
+                            icon={<DownloadOutlined />}
+                            aria-label={t('download')}
+                            onClick={() => downloadText(cfg, peerConfFileName(idx, j, all.length))}
+                          />
+                        </Tooltip>
+                      </div>
+                      <code className="link-panel-text">{cfg}</code>
+                    </div>
+                  ),
               )}
-              {amneziawgLinks[idx] && (
-                <div className="link-panel">
-                  <div className="link-panel-header">
-                    <Tag color="green">Peer {idx + 1} link</Tag>
-                    <Tooltip title={t('copy')}>
-                      <Button
-                        size="small"
-                        icon={<CopyOutlined />}
-                        aria-label={t('copy')}
-                        onClick={() => copyText(amneziawgLinks[idx], t)}
-                      />
-                    </Tooltip>
-                  </div>
-                  <code className="link-panel-text">{amneziawgLinks[idx]}</code>
-                </div>
+              {(amneziawgLinks[idx] ?? []).map(
+                (link, j) =>
+                  link && (
+                    <div key={`awg-link-${j}`} className="link-panel">
+                      <div className="link-panel-header">
+                        <Tag color="green">Peer {idx + 1} link</Tag>
+                        <Tooltip title={t('copy')}>
+                          <Button
+                            size="small"
+                            icon={<CopyOutlined />}
+                            aria-label={t('copy')}
+                            onClick={() => copyText(link, t)}
+                          />
+                        </Tooltip>
+                      </div>
+                      <code className="link-panel-text">{link}</code>
+                    </div>
+                  ),
               )}
             </Fragment>
           ))}

+ 6 - 0
frontend/src/pages/inbounds/info/helpers.ts

@@ -162,6 +162,12 @@ export function downloadText(content: string, filename: string) {
   FileManager.downloadTextFile(content, filename);
 }
 
+// One file per advertised Host, so a peer behind two Hosts gets two names.
+export function peerConfFileName(peerIndex: number, endpointIndex: number, endpointCount: number) {
+  const suffix = endpointCount > 1 ? `-${endpointIndex + 1}` : '';
+  return `peer-${peerIndex + 1}${suffix}.conf`;
+}
+
 export function statsColor(stats: ClientStats, trafficDiff: number) {
   return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
 }

+ 41 - 48
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -6,17 +6,18 @@ import type { CollapseProps } from 'antd';
 import { Protocols } from '@/schemas/primitives';
 import {
   genAllLinks,
-  genAmneziaWGConfigs,
-  genAmneziaWGLinks,
-  genWireguardConfigs,
-  genWireguardLinks,
+  genAmneziaWGPeerConfigs,
+  genAmneziaWGPeerLinks,
+  genWireguardPeerConfigs,
+  genWireguardPeerLinks,
   isPostQuantumLink,
   preferPublicHost,
 } from '@/lib/xray/inbound-link';
 import { inboundFromDb, type DbInboundLike } from '@/lib/xray/inbound-from-db';
-import { withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import { withHostEndpoints } from '@/lib/hosts/host-link';
 import type { HostRecord } from '@/schemas/api/host';
 import QrPanel from './QrPanel';
+import { peerConfFileName } from '../info/helpers';
 import type { SubSettings } from '../useInbounds';
 
 interface ClientSetting {
@@ -56,10 +57,10 @@ export default function QrCodeModal({
 }: QrCodeModalProps) {
   const { t } = useTranslation();
   const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
-  const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
-  const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
-  const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
-  const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
+  const [wireguardConfigs, setWireguardConfigs] = useState<string[][]>([]);
+  const [wireguardLinks, setWireguardLinks] = useState<string[][]>([]);
+  const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[][]>([]);
+  const [amneziawgLinks, setAmneziawgLinks] = useState<string[][]>([]);
   const [subLink, setSubLink] = useState('');
   const [subJsonLink, setSubJsonLink] = useState('');
   const [activeKey, setActiveKey] = useState<string[]>([]);
@@ -88,7 +89,7 @@ export default function QrCodeModal({
       window.location.hostname,
       subSettings?.publicHost ?? '',
     );
-    const inbound = withMtprotoHostEndpoints(
+    const inbound = withHostEndpoints(
       inboundFromDb(dbInbound),
       dbInbound.id,
       hosts,
@@ -100,20 +101,20 @@ export default function QrCodeModal({
         ? `${dbInbound.remark}-${client.email}`
         : dbInbound.remark || '';
       setWireguardConfigs(
-        genWireguardConfigs({
+        genWireguardPeerConfigs({
           inbound,
           remark: peerRemark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setWireguardLinks(
-        genWireguardLinks({
+        genWireguardPeerLinks({
           inbound,
           remark: peerRemark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setAmneziawgConfigs([]);
       setAmneziawgLinks([]);
@@ -123,20 +124,20 @@ export default function QrCodeModal({
         ? `${dbInbound.remark}-${client.email}`
         : dbInbound.remark || '';
       setAmneziawgConfigs(
-        genAmneziaWGConfigs({
+        genAmneziaWGPeerConfigs({
           inbound,
           remark: peerRemark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setAmneziawgLinks(
-        genAmneziaWGLinks({
+        genAmneziaWGPeerLinks({
           inbound,
           remark: peerRemark,
           hostOverride: nodeAddress,
           fallbackHostname,
-        }).split('\r\n'),
+        }),
       );
       setWireguardConfigs([]);
       setWireguardLinks([]);
@@ -183,38 +184,30 @@ export default function QrCodeModal({
     links.forEach((link, idx) => {
       items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
     });
-    wireguardConfigs.forEach((cfg, idx) => {
-      items.push({
-        key: `wc${idx}`,
-        header: `Peer ${idx + 1} config`,
-        value: cfg,
-        downloadName: `peer-${idx + 1}.conf`,
-      });
-      if (wireguardLinks[idx]) {
-        items.push({
-          key: `wl${idx}`,
-          header: `Peer ${idx + 1} link`,
-          value: wireguardLinks[idx],
-          showQr: false,
+    const pushTunnelPeers = (prefix: string, configs: string[][], peerLinks: string[][]) => {
+      configs.forEach((peerConfigs, idx) => {
+        peerConfigs.forEach((cfg, j) => {
+          const multi = peerConfigs.length > 1 ? ` #${j + 1}` : '';
+          items.push({
+            key: `${prefix}c${idx}-${j}`,
+            header: `Peer ${idx + 1} config${multi}`,
+            value: cfg,
+            downloadName: peerConfFileName(idx, j, peerConfigs.length),
+          });
+          const link = peerLinks[idx]?.[j];
+          if (link) {
+            items.push({
+              key: `${prefix}l${idx}-${j}`,
+              header: `Peer ${idx + 1} link${multi}`,
+              value: link,
+              showQr: false,
+            });
+          }
         });
-      }
-    });
-    amneziawgConfigs.forEach((cfg, idx) => {
-      items.push({
-        key: `ac${idx}`,
-        header: `Peer ${idx + 1} config`,
-        value: cfg,
-        downloadName: `peer-${idx + 1}.conf`,
       });
-      if (amneziawgLinks[idx]) {
-        items.push({
-          key: `al${idx}`,
-          header: `Peer ${idx + 1} link`,
-          value: amneziawgLinks[idx],
-          showQr: false,
-        });
-      }
-    });
+    };
+    pushTunnelPeers('w', wireguardConfigs, wireguardLinks);
+    pushTunnelPeers('a', amneziawgConfigs, amneziawgLinks);
     return items;
   }, [
     subLink,

+ 5 - 5
frontend/src/test/host-link.test.ts

@@ -1,7 +1,7 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
-import { hostToExternalProxyEntry, withMtprotoHostEndpoints } from '@/lib/hosts/host-link';
+import { hostToExternalProxyEntry, withHostEndpoints } from '@/lib/hosts/host-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 
 describe('hostToExternalProxyEntry', () => {
@@ -70,7 +70,7 @@ describe('hostToExternalProxyEntry', () => {
   });
 });
 
-describe('withMtprotoHostEndpoints', () => {
+describe('withHostEndpoints', () => {
   const inbound = inboundFromDb({
     protocol: 'mtproto',
     port: 4060,
@@ -81,7 +81,7 @@ describe('withMtprotoHostEndpoints', () => {
   });
 
   it('projects enabled raw Hosts onto MTProto share endpoints', () => {
-    const got = withMtprotoHostEndpoints(
+    const got = withHostEndpoints(
       inbound,
       7,
       [
@@ -103,7 +103,7 @@ describe('withMtprotoHostEndpoints', () => {
   });
 
   it('inherits the inbound address for a port-only Host', () => {
-    const got = withMtprotoHostEndpoints(
+    const got = withHostEndpoints(
       inbound,
       7,
       [{ groupId: 'port-only', inboundIds: [7], hosts: [':8443'], port: 8443 }],
@@ -116,7 +116,7 @@ describe('withMtprotoHostEndpoints', () => {
   });
 
   it('ignores disabled, excluded and unrelated Hosts', () => {
-    const got = withMtprotoHostEndpoints(
+    const got = withHostEndpoints(
       inbound,
       7,
       [

+ 82 - 0
frontend/src/test/multi-tunnel-client-config.test.tsx

@@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router';
 import ClientInfoModal from '@/pages/clients/ClientInfoModal';
 import ClientQrModal from '@/pages/clients/ClientQrModal';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import type { HostRecord } from '@/schemas/api/host';
 import { renderWithProviders } from './test-utils';
 
 const deAwgInbound: InboundOption = {
@@ -182,4 +183,85 @@ describe('Multi-tunnel Client Modals', () => {
     expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
     expect(screen.getByText('FI · Helsinki')).toBeTruthy();
   });
+
+  // The subscription beside these configs advertises the inbound's Hosts, so
+  // the panel-built configs must too — one per Host address.
+  const edgeHosts: HostRecord[] = [
+    {
+      groupId: 'cdn',
+      inboundIds: [201],
+      hosts: ['edge.example.com:443', 'edge2.example.com'],
+      remark: 'CDN',
+    },
+  ];
+  const edgeClient = { ...multiWgClient, inboundIds: [201] } as ClientRecord;
+  const edgeLabels = [
+    'US · New York - edge.example.com:443',
+    'US · New York - edge2.example.com:51820',
+  ];
+
+  it('renders one ConfigBlock per Host in ClientInfoModal', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={edgeClient}
+        inboundsById={{ 201: usWgInbound }}
+        isOnline={false}
+        hosts={edgeHosts}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    for (const label of edgeLabels) expect(screen.getByText(label)).toBeTruthy();
+  });
+
+  it('renders one collapse panel per Host in ClientQrModal', () => {
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/clients']}>
+        <ClientQrModal
+          open
+          client={edgeClient}
+          inboundsById={{ 201: usWgInbound }}
+          hosts={edgeHosts}
+          onOpenChange={() => {}}
+        />
+      </MemoryRouter>,
+    );
+
+    for (const label of edgeLabels) expect(screen.getByText(label)).toBeTruthy();
+  });
+
+  it('builds the TUIC Clash config only from Hosts the Clash subscription serves', () => {
+    const tuicInbound = { id: 301, remark: 'tuic', protocol: 'tuic', port: 8443 } as InboundOption;
+    const tuicClient = {
+      id: 'c4',
+      email: 'TUIC-CLIENT',
+      uuid: 'e79b9107-1607-4e6c-a496-d8f99e4f0dc5',
+      password: 'secret',
+      inboundIds: [301],
+    } as unknown as ClientRecord;
+    const hosts: HostRecord[] = [
+      { groupId: 'clash', inboundIds: [301], hosts: ['clash.example.com:443'] },
+      {
+        groupId: 'raw-only',
+        inboundIds: [301],
+        hosts: ['raw.example.com:443'],
+        excludeFromSubTypes: ['clash'],
+      },
+    ];
+    renderWithProviders(
+      <MemoryRouter initialEntries={['/clients']}>
+        <ClientQrModal
+          open
+          client={tuicClient}
+          inboundsById={{ 301: tuicInbound }}
+          hosts={hosts}
+          onOpenChange={() => {}}
+        />
+      </MemoryRouter>,
+    );
+
+    expect(screen.getAllByText('TUIC config (Clash)')).toHaveLength(1);
+    expect(screen.queryByText('raw.example.com:443')).toBeNull();
+  });
 });

+ 172 - 0
frontend/src/test/tunnel-host-endpoints.test.ts

@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest';
+
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { withHostEndpoints } from '@/lib/hosts/host-link';
+import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
+import { genAmneziaWGPeerConfigs, genWireguardPeerConfigs } from '@/lib/xray/inbound-link';
+import { buildAmneziaWGClientConfig } from '@/pages/clients/amneziawgConfig';
+import { buildTuicClientConfig } from '@/pages/clients/tuicConfig';
+import { tunnelConfigEndpoints } from '@/pages/clients/tunnelEndpoints';
+import { buildWireguardClientConfig } from '@/pages/clients/wireguardConfig';
+import { InboundSchema, type Inbound } from '@/schemas/api/inbound';
+import type { HostRecord } from '@/schemas/api/host';
+
+const PANEL = 'panel.example.com';
+const HOSTS: HostRecord[] = [
+  {
+    groupId: 'cdn',
+    inboundIds: [7],
+    hosts: ['edge.example.com:443', 'edge2.example.com'],
+    remark: 'CDN',
+  },
+];
+
+function endpointLines(configs: string[]): string[] {
+  return configs.map((cfg) => cfg.match(/^Endpoint = (.*)$/m)?.[1] ?? '');
+}
+
+// The panel's own tunnel previews must advertise the same Hosts the
+// subscription does, not the panel address (#6369 did this for MTProto).
+describe('inbounds page tunnel configs follow Hosts', () => {
+  it('renders one WireGuard config per Host for each peer', () => {
+    const inbound = InboundSchema.parse({
+      port: 51820,
+      protocol: 'wireguard',
+      settings: {
+        secretKey: 'iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=',
+        peers: [],
+        clients: [
+          {
+            email: 'alice',
+            privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
+            allowedIPs: ['10.0.0.2/32'],
+          },
+        ],
+      },
+    });
+    const peers = genWireguardPeerConfigs({
+      inbound: withHostEndpoints(inbound, 7, HOSTS, '', PANEL),
+      remark: 'wg',
+      fallbackHostname: PANEL,
+    });
+    expect(peers).toHaveLength(1);
+    expect(endpointLines(peers[0])).toEqual(['edge.example.com:443', 'edge2.example.com:51820']);
+  });
+
+  it('renders one AmneziaWG config per Host for each peer', () => {
+    const inbound = {
+      port: 51821,
+      protocol: 'amneziawg',
+      settings: {
+        server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
+        clients: [{ email: 'alice', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }],
+      },
+      streamSettings: {},
+    } as unknown as Inbound;
+    const peers = genAmneziaWGPeerConfigs({
+      inbound: withHostEndpoints(inbound, 7, HOSTS, '', PANEL),
+      remark: 'awg',
+      fallbackHostname: PANEL,
+    });
+    expect(peers).toHaveLength(1);
+    expect(endpointLines(peers[0])).toEqual(['edge.example.com:443', 'edge2.example.com:51821']);
+  });
+});
+
+describe('clients page tunnel configs follow Hosts', () => {
+  const client = {
+    email: 'alice',
+    privateKey: 'clientPrivKey==',
+    allowedIPs: '10.0.0.2/32',
+    uuid: 'e79b9107-1607-4e6c-a496-d8f99e4f0dc5',
+    password: 'secret',
+  } as unknown as ClientRecord;
+
+  it('advertises each Host in the WireGuard config', () => {
+    const inbound = { id: 7, remark: 'wg', protocol: 'wireguard', port: 51820 } as InboundOption;
+    const configs = tunnelConfigEndpoints(inbound, HOSTS, PANEL, '').map((ep) =>
+      buildWireguardClientConfig(client, inbound, PANEL, '', '', ep),
+    );
+    expect(endpointLines(configs)).toEqual(['edge.example.com:443', 'edge2.example.com:51820']);
+  });
+
+  it('advertises each Host in the AmneziaWG config', () => {
+    const inbound = {
+      id: 7,
+      remark: 'awg',
+      protocol: 'amneziawg',
+      port: 51821,
+      awgServer: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
+    } as unknown as InboundOption;
+    const configs = tunnelConfigEndpoints(inbound, HOSTS, PANEL, '').map((ep) =>
+      buildAmneziaWGClientConfig(client, inbound, PANEL, '', '', ep),
+    );
+    expect(endpointLines(configs)).toEqual(['edge.example.com:443', 'edge2.example.com:51821']);
+  });
+
+  it('keeps the inbound address when no Host applies to it', () => {
+    const inbound = { id: 8, remark: 'wg', protocol: 'wireguard', port: 51820 } as InboundOption;
+    const configs = tunnelConfigEndpoints(inbound, HOSTS, PANEL, '').map((ep) =>
+      buildWireguardClientConfig(client, inbound, PANEL, '', '', ep),
+    );
+    expect(endpointLines(configs)).toEqual([`${PANEL}:51820`]);
+  });
+
+  it('applies a Host SNI, ALPN and insecure flag to the TUIC config', () => {
+    const inbound = {
+      id: 7,
+      remark: 'tuic',
+      protocol: 'tuic',
+      port: 8443,
+      tuicServer: { sni: 'inbound.sni', alpn: ['h3'] },
+    } as unknown as InboundOption;
+    const hosts: HostRecord[] = [
+      {
+        groupId: 'tuic',
+        inboundIds: [7],
+        hosts: ['tuic.example.com:9443'],
+        sni: 'host.sni',
+        alpn: ['h3', 'h2'],
+        allowInsecure: true,
+      },
+    ];
+    const [ep] = tunnelConfigEndpoints(inbound, hosts, PANEL, '');
+    const cfg = buildTuicClientConfig(client, inbound, PANEL, '', ep);
+    expect(cfg).toContain('server: tuic.example.com');
+    expect(cfg).toContain('port: 9443');
+    expect(cfg).toContain('sni: host.sni');
+    expect(cfg).toContain('alpn:\n      - h3\n      - h2\n');
+    expect(cfg).toContain('skip-cert-verify: true');
+  });
+
+  it('skips a Host excluded from Clash in the TUIC Clash config', () => {
+    const inbound = { id: 7, remark: 'tuic', protocol: 'tuic', port: 8443 } as InboundOption;
+    const hosts: HostRecord[] = [
+      {
+        groupId: 'raw-only',
+        inboundIds: [7],
+        hosts: ['raw.example.com:443'],
+        excludeFromSubTypes: ['clash'],
+      },
+    ];
+    const configs = tunnelConfigEndpoints(inbound, hosts, PANEL, '', 'clash').map((ep) =>
+      buildTuicClientConfig(client, inbound, PANEL, '', ep),
+    );
+    expect(configs).toHaveLength(1);
+    expect(configs[0]).toContain(`server: ${PANEL}`);
+  });
+
+  it('names two Hosts of one inbound apart', () => {
+    const inbound = { id: 7, remark: 'wg' };
+    const a = formatTunnelConfigMeta(inbound, 'alice', 2, 'edge.example.com:443');
+    const b = formatTunnelConfigMeta(inbound, 'alice', 2, 'edge2.example.com:51820');
+    expect([a.fileName, b.fileName]).toEqual([
+      'alice-wg-edge.example.com_443.conf',
+      'alice-wg-edge2.example.com_51820.conf',
+    ]);
+    expect([a.label, b.label]).toEqual([
+      'wg - edge.example.com:443',
+      'wg - edge2.example.com:51820',
+    ]);
+  });
+});

+ 18 - 0
internal/sub/endpoint.go

@@ -56,6 +56,24 @@ func (s *SubService) inboundDefaultEndpoint(inbound *model.Inbound) ShareEndpoin
 	}
 }
 
+// advertisedEndpoints is every endpoint a stream-less link (mtproto, wireguard,
+// amneziawg) must fan out over: the externalProxy/Host entries, else the default.
+func (s *SubService) advertisedEndpoints(inbound *model.Inbound) []ShareEndpoint {
+	stream := unmarshalStreamSettings(inbound.StreamSettings)
+	if externalProxies, ok := stream["externalProxy"].([]any); ok {
+		endpoints := make([]ShareEndpoint, 0, len(externalProxies))
+		for _, raw := range externalProxies {
+			if ep, ok := raw.(map[string]any); ok {
+				endpoints = append(endpoints, externalProxyToEndpoint(ep))
+			}
+		}
+		if len(endpoints) > 0 {
+			return endpoints
+		}
+	}
+	return []ShareEndpoint{s.inboundDefaultEndpoint(inbound)}
+}
+
 // applyEndpointTLSParams applies an endpoint's TLS overrides onto a URL-param
 // map. External-proxy endpoints delegate to the unchanged helper; host/default
 // endpoints carry no override yet (Phase 4).

+ 17 - 19
internal/sub/service.go

@@ -936,7 +936,6 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
 	}
 	client := &resolved
 
-	link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
 	params := make(map[string]string)
 	if secretKey != "" {
 		if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
@@ -958,7 +957,13 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
 	if ka := client.KeepAliveSeconds(); ka > 0 {
 		params["keepalive"] = strconv.Itoa(ka)
 	}
-	return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
+	endpoints := s.advertisedEndpoints(inbound)
+	links := make([]string, 0, len(endpoints))
+	for _, e := range endpoints {
+		link := fmt.Sprintf("wireguard://%s@%s", encodeUserinfo(client.PrivateKey), joinHostPort(e.Address, e.Port))
+		links = append(links, buildLinkWithParams(link, params, s.endpointRemark(inbound, email, e.ep, "")))
+	}
+	return strings.Join(links, "\n")
 }
 
 // amneziaWGHeaderOrDefault mirrors the frontend's amneziaWGHLine: AmneziaWG's
@@ -1084,11 +1089,16 @@ func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) stri
 	}
 	client := &resolved
 
-	text := amneziaWGConfigText(server, client, s.resolveInboundAddress(inbound), inbound.Port, s.genRemark(inbound, email, "", ""))
-	if text == "" {
-		return ""
+	endpoints := s.advertisedEndpoints(inbound)
+	links := make([]string, 0, len(endpoints))
+	for _, e := range endpoints {
+		text := amneziaWGConfigText(server, client, e.Address, e.Port, s.endpointRemark(inbound, email, e.ep, ""))
+		if text == "" {
+			continue
+		}
+		links = append(links, "vpn://"+base64.RawURLEncoding.EncodeToString([]byte(text)))
 	}
-	return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
+	return strings.Join(links, "\n")
 }
 
 // genMtprotoLink builds one Telegram link per advertised endpoint with the client's FakeTLS secret.
@@ -1101,19 +1111,7 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
 	if !ok || resolved.Secret == "" {
 		return ""
 	}
-	endpoints := []ShareEndpoint{s.inboundDefaultEndpoint(inbound)}
-	stream := unmarshalStreamSettings(inbound.StreamSettings)
-	if externalProxies, ok := stream["externalProxy"].([]any); ok && len(externalProxies) > 0 {
-		overrides := make([]ShareEndpoint, 0, len(externalProxies))
-		for _, raw := range externalProxies {
-			if ep, ok := raw.(map[string]any); ok {
-				overrides = append(overrides, externalProxyToEndpoint(ep))
-			}
-		}
-		if len(overrides) > 0 {
-			endpoints = overrides
-		}
-	}
+	endpoints := s.advertisedEndpoints(inbound)
 	links := make([]string, 0, len(endpoints))
 	for _, endpoint := range endpoints {
 		links = append(links, buildLinkWithParams("tg://proxy", map[string]string{

+ 97 - 0
internal/sub/service_tunnel_hosts_test.go

@@ -0,0 +1,97 @@
+package sub
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func seedTunnelSubInbound(t *testing.T, protocol model.Protocol, tag, subID, email, settings string, port int) *model.Inbound {
+	t.Helper()
+	db := database.GetDB()
+	ib := &model.Inbound{
+		UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
+		Protocol: protocol, Remark: tag, Settings: settings,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create %s: %v", tag, err)
+	}
+	rec := &model.ClientRecord{Email: email, SubID: subID, Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("link client: %v", err)
+	}
+	return ib
+}
+
+// A Host on a WireGuard inbound must replace the advertised endpoint; the raw
+// generator used to ignore it and always emit the panel's own address.
+func TestGetSubs_WireGuardAdvertisesHostEndpoints(t *testing.T) {
+	initSubDB(t)
+	serverPriv, _ := mustWireguardKeypair(t)
+	clientPriv, _ := mustWireguardKeypair(t)
+	const email, subID = "alice@wg", "sub-wg-hosts"
+	settings := fmt.Sprintf(`{"secretKey":%q,"clients":[{"email":%q,"privateKey":%q,"allowedIPs":["10.0.0.2/32"],"enable":true}]}`,
+		serverPriv, email, clientPriv)
+	ib := seedTunnelSubInbound(t, model.WireGuard, "wg-in", subID, email, settings, 51820)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "CDN-B", Address: "wg2.example.com"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "CDN-A", Address: "wg.example.com", Port: 443})
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	parts := splitLinkLines(strings.Join(links, "\n"))
+	want := []struct{ host, remark string }{
+		{"wg.example.com:443", "wg-in-CDN-A-" + email},
+		{"wg2.example.com:51820", "wg-in-CDN-B-" + email},
+	}
+	if len(parts) != len(want) {
+		t.Fatalf("links = %d, want %d: %v", len(parts), len(want), parts)
+	}
+	for i, w := range want {
+		u := parseWireguardSubLink(t, parts[i])
+		if u.Host != w.host || u.Fragment != w.remark {
+			t.Fatalf("link %d = %s#%s, want %s#%s", i, u.Host, u.Fragment, w.host, w.remark)
+		}
+		if u.User.Username() != clientPriv {
+			t.Fatalf("link %d private key = %q, want the client's", i, u.User.Username())
+		}
+	}
+}
+
+// The AmneziaWG vpn:// payload carries the endpoint inside its .conf text, so a
+// Host must reach the Endpoint line and the remark comment, not only the URL.
+func TestGetSubs_AmneziaWGAdvertisesHostEndpoint(t *testing.T) {
+	initSubDB(t)
+	serverPriv, serverPub := mustWireguardKeypair(t)
+	clientPriv, _ := mustWireguardKeypair(t)
+	const email, subID = "alice@awg", "sub-awg-hosts"
+	settings := fmt.Sprintf(`{"server":{"privateKey":%q,"publicKey":%q,"mtu":1420},"clients":[{"email":%q,"privateKey":%q,"allowedIPs":["10.8.0.2/32"],"enable":true}]}`,
+		serverPriv, serverPub, email, clientPriv)
+	ib := seedTunnelSubInbound(t, model.AmneziaWG, "awg-in", subID, email, settings, 51821)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "CDN", Address: "awg.example.com", Port: 8443})
+
+	links, _, _, _, err := NewSubService("").GetSubs(subID, "sub.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	parts := splitLinkLines(strings.Join(links, "\n"))
+	if len(parts) != 1 {
+		t.Fatalf("links = %d, want 1: %v", len(parts), parts)
+	}
+	conf := decodeAmneziaWGSubLink(t, parts[0])
+	for _, line := range []string{"Endpoint = awg.example.com:8443", "# awg-in-CDN-" + email, "PrivateKey = " + clientPriv} {
+		if !strings.Contains(conf, line) {
+			t.Fatalf("config missing %q\n%s", line, conf)
+		}
+	}
+	if strings.Contains(conf, "203.0.113.5") {
+		t.Fatalf("config still advertises the inbound address\n%s", conf)
+	}
+}