1
0

2 کامیت‌ها 8c023d13dc ... 99047c0a63

نویسنده SHA1 پیام تاریخ
  MHSanaei 99047c0a63 fix(frontend): keep the given file name on mobile downloads 8 ساعت پیش
  MHSanaei aee45ca3fe fix(hosts): advertise Hosts in every WireGuard, AmneziaWG and TUIC config 8 ساعت پیش

+ 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,

+ 35 - 0
frontend/src/test/file-download.test.tsx

@@ -0,0 +1,35 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { FileManager } from '@/utils';
+
+const { createObjectURL, revokeObjectURL } = URL;
+
+afterEach(() => {
+  URL.createObjectURL = createObjectURL;
+  URL.revokeObjectURL = revokeObjectURL;
+  vi.restoreAllMocks();
+});
+
+describe('FileManager.downloadTextFile', () => {
+  // Android's MediaStore appends the blob type's own extension when the name's
+  // extension maps elsewhere, so a text/plain peer.conf lands as peer.conf.txt.
+  it('hands the browser an untyped blob so a mobile save keeps the given name', () => {
+    const blobs: Blob[] = [];
+    URL.createObjectURL = vi.fn((blob: Blob) => {
+      blobs.push(blob);
+      return 'blob:download';
+    });
+    URL.revokeObjectURL = vi.fn();
+    let savedAs = '';
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
+      this: HTMLAnchorElement,
+    ) {
+      savedAs = this.download;
+    });
+
+    FileManager.downloadTextFile('[Interface]\n', 'alice.conf');
+
+    expect(savedAs).toBe('alice.conf');
+    expect(blobs.map((blob) => blob.type)).toEqual(['application/octet-stream']);
+  });
+});

+ 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',
+    ]);
+  });
+});

+ 3 - 1
frontend/src/utils/index.ts

@@ -967,10 +967,12 @@ export class LanguageManager {
 }
 
 export class FileManager {
+  // Typed text/plain, Android's MediaStore saves peer.conf as peer.conf.txt;
+  // octet-stream carries no extension of its own, so the given name is kept.
   static downloadTextFile(
     content: BlobPart,
     filename: string = 'file.txt',
-    options: BlobPropertyBag = { type: 'text/plain' },
+    options: BlobPropertyBag = { type: 'application/octet-stream' },
   ): void {
     const link = window.document.createElement('a');
     link.download = filename;

+ 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)
+	}
+}