浏览代码

fix(clients): render all tunnel configs for multi-inbound client (#6346) (#6349)

When a client belongs to multiple AmneziaWG or WireGuard inbounds (e.g. across
remote nodes), findAmneziaWGInbounds and findWireguardInbounds only returned the
first matching inbound. Consequently, ClientInfoModal and ClientQrModal rendered
only one config block, making other inbounds' configs unreachable.

- Add findAmneziaWGInbounds and findWireguardInbounds returning all matching inbounds
- Add formatTunnelConfigMeta helper to unify label, fileName, and qrRemark resolution
- Support addressOverride in buildWireguardClientConfig from tunnelAllowedIPs
- Render all tunnel configs in ClientInfoModal and ClientQrModal with node remarks
- Distinguish download filenames with inbound remark suffix to avoid collisions
- Add component integration tests covering multi-inbound modal rendering
MRVX 8 小时之前
父节点
当前提交
47964afbc5

+ 23 - 0
frontend/src/lib/inbounds/label.ts

@@ -7,3 +7,26 @@ export function formatInboundLabel(tag?: string, remark?: string): string {
   if (remarkText) return remarkText;
   return (tag || '').trim();
 }
+
+export function formatTunnelConfigMeta(
+  inbound: { id?: number; tag?: string; remark?: string },
+  email?: string,
+  totalCount = 1,
+): {
+  label?: string;
+  fileName: string;
+  qrRemark: string;
+} {
+  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 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 || '';
+
+  return { label, fileName, qrRemark };
+}

+ 67 - 41
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -10,7 +10,7 @@ import {
 } from '@ant-design/icons';
 
 import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
-import { formatInboundLabel } from '@/lib/inbounds/label';
+import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useClientHwids } from '@/hooks/useClientHwids';
@@ -22,12 +22,12 @@ import ClientHwidListModal from '@/components/clients/ClientHwidList';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import {
   buildWireguardClientConfig,
-  findWireguardInbound,
+  findWireguardInbounds,
   isWireguardClient,
 } from './wireguardConfig';
 import {
   buildAmneziaWGClientConfig,
-  findAmneziaWGInbound,
+  findAmneziaWGInbounds,
   isAmneziaWGClient,
 } from './amneziawgConfig';
 import './ClientInfoModal.css';
@@ -180,35 +180,47 @@ export default function ClientInfoModal({
       : '';
 
   const showSubscription = !!(subSettings?.enable && client?.subId);
-  const wgInbound = useMemo(
-    () => findWireguardInbound(client, inboundsById),
+  const wgInbounds = useMemo(
+    () => findWireguardInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const wgConfigText = useMemo(() => {
-    if (!client || !wgInbound || !isWireguardClient(client)) return '';
-    return buildWireguardClientConfig(
-      client,
-      wgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-    );
-  }, [client, wgInbound, subSettings?.publicHost]);
+  const wgConfigs = useMemo(() => {
+    if (!client || !isWireguardClient(client)) return [];
+    return wgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildWireguardClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
-  const awgInbound = useMemo(
-    () => findAmneziaWGInbound(client, inboundsById),
+  const awgInbounds = useMemo(
+    () => findAmneziaWGInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const awgConfigText = useMemo(() => {
-    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
-    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
-    return buildAmneziaWGClientConfig(
-      client,
-      awgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-      address,
-    );
-  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+  const awgConfigs = useMemo(() => {
+    if (!client || !isAmneziaWGClient(client)) return [];
+    return awgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildAmneziaWGClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
   async function copyValue(text: string) {
     if (!text) return;
@@ -779,27 +791,41 @@ export default function ClientInfoModal({
               </>
             )}
 
-            {wgConfigText && client && (
+            {wgConfigs.length > 0 && client && (
               <>
                 <Divider>{t('pages.clients.wireguardConfig')}</Divider>
-                <ConfigBlock
-                  label={t('pages.clients.config')}
-                  text={wgConfigText}
-                  fileName={`${client.email}.conf`}
-                  qrRemark={client.email || 'peer'}
-                />
+                {wgConfigs.map(({ inbound, text }) => {
+                  const meta = formatTunnelConfigMeta(inbound, client.email, wgConfigs.length);
+                  return (
+                    <ConfigBlock
+                      key={`wg-${inbound.id}`}
+                      label={meta.label || t('pages.clients.config')}
+                      text={text}
+                      fileName={meta.fileName}
+                      qrRemark={meta.qrRemark}
+                      tagColor="cyan"
+                    />
+                  );
+                })}
               </>
             )}
 
-            {awgConfigText && client && (
+            {awgConfigs.length > 0 && client && (
               <>
                 <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
-                <ConfigBlock
-                  label={t('pages.clients.config')}
-                  text={awgConfigText}
-                  fileName={`${client.email}.conf`}
-                  qrRemark={client.email || 'peer'}
-                />
+                {awgConfigs.map(({ inbound, text }) => {
+                  const meta = formatTunnelConfigMeta(inbound, client.email, awgConfigs.length);
+                  return (
+                    <ConfigBlock
+                      key={`awg-${inbound.id}`}
+                      label={meta.label || t('pages.clients.config')}
+                      text={text}
+                      fileName={meta.fileName}
+                      qrRemark={meta.qrRemark}
+                      tagColor="purple"
+                    />
+                  );
+                })}
               </>
             )}
           </>

+ 65 - 54
frontend/src/pages/clients/ClientQrModal.tsx

@@ -6,14 +6,15 @@ import { isPostQuantumLink } from '@/lib/xray/inbound-link';
 import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
 import { QrPanel } from '@/pages/inbounds/qr';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
 import {
   buildWireguardClientConfig,
-  findWireguardInbound,
+  findWireguardInbounds,
   isWireguardClient,
 } from './wireguardConfig';
 import {
   buildAmneziaWGClientConfig,
-  findAmneziaWGInbound,
+  findAmneziaWGInbounds,
   isAmneziaWGClient,
 } from './amneziawgConfig';
 
@@ -67,38 +68,50 @@ export default function ClientQrModal({
       ? subSettings.subJsonURI + subId
       : '';
 
-  const wgInbound = useMemo(
-    () => findWireguardInbound(client, inboundsById),
+  const wgInbounds = useMemo(
+    () => findWireguardInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const wgConfigText = useMemo(() => {
-    if (!client || !wgInbound || !isWireguardClient(client)) return '';
-    return buildWireguardClientConfig(
-      client,
-      wgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-    );
-  }, [client, wgInbound, subSettings?.publicHost]);
+  const wgConfigs = useMemo(() => {
+    if (!client || !isWireguardClient(client)) return [];
+    return wgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildWireguardClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
-  const awgInbound = useMemo(
-    () => findAmneziaWGInbound(client, inboundsById),
+  const awgInbounds = useMemo(
+    () => findAmneziaWGInbounds(client, inboundsById),
     [client, inboundsById],
   );
-  const awgConfigText = useMemo(() => {
-    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
-    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
-    return buildAmneziaWGClientConfig(
-      client,
-      awgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-      address,
-    );
-  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+  const awgConfigs = useMemo(() => {
+    if (!client || !isAmneziaWGClient(client)) return [];
+    return awgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildAmneziaWGClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
   const hasAnything =
-    !!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
+    !!subLink || !!subJsonLink || wgConfigs.length > 0 || awgConfigs.length > 0 || links.length > 0;
 
   // The reset runs during render so the effect only carries the request.
   const openSubId = open ? (client?.subId ?? '') : '';
@@ -172,42 +185,40 @@ export default function ClientQrModal({
         ),
       });
     });
-    if (wgConfigText) {
-      out.push({
-        key: 'wg-config',
-        label: (
+    wgConfigs.forEach(({ inbound, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length);
+      const label = (
+        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="cyan" style={{ margin: 0 }}>
             {t('pages.clients.wireguardConfig')}
           </Tag>
-        ),
-        children: (
-          <QrPanel
-            value={wgConfigText}
-            remark={client?.email || 'peer'}
-            downloadName={`${client?.email || 'peer'}.conf`}
-          />
-        ),
-      });
-    }
-    if (awgConfigText) {
+          {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
+        </span>
+      );
       out.push({
-        key: 'awg-config',
-        label: (
+        key: `wg-config-${inbound.id}`,
+        label,
+        children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
+      });
+    });
+    awgConfigs.forEach(({ inbound, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length);
+      const label = (
+        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="purple" style={{ margin: 0 }}>
             {t('pages.clients.amneziaWgConfig')}
           </Tag>
-        ),
-        children: (
-          <QrPanel
-            value={awgConfigText}
-            remark={client?.email || 'peer'}
-            downloadName={`${client?.email || 'peer'}.conf`}
-          />
-        ),
+          {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
+        </span>
+      );
+      out.push({
+        key: `awg-config-${inbound.id}`,
+        label,
+        children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
       });
-    }
+    });
     return out;
-  }, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
+  }, [subLink, subJsonLink, wgConfigs, awgConfigs, links, client?.email, t]);
 
   // Expanding the first panel is a render-time adjustment, not a side effect.
   const firstKey = open && items.length > 0 ? items[0].key : null;

+ 5 - 5
frontend/src/pages/clients/amneziawgConfig.ts

@@ -5,7 +5,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 // AmneziaWG clients are wire-identical to WireGuard clients (same
 // privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
 // model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
-// type can't tell the two protocols apart on its own; findAmneziaWGInbound's
+// type can't tell the two protocols apart on its own; findAmneziaWGInbounds's
 // protocol==='amneziawg' filter below is what actually disambiguates.
 export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
   if (!client) return false;
@@ -18,13 +18,13 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
   );
 }
 
-export function findAmneziaWGInbound(
+export function findAmneziaWGInbounds(
   client: ClientRecord | null | undefined,
   inboundsById: Record<number, InboundOption>,
-): InboundOption | undefined {
+): InboundOption[] {
   return (client?.inboundIds || [])
-    .map((id) => inboundsById[id])
-    .find((ib) => ib?.protocol === 'amneziawg');
+    .map((id) => inboundsById?.[id])
+    .filter((ib): ib is InboundOption => ib?.protocol === 'amneziawg');
 }
 
 // h4Line renders one H magic-header line, matching the Go backend's

+ 6 - 5
frontend/src/pages/clients/wireguardConfig.ts

@@ -13,13 +13,13 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool
   );
 }
 
-export function findWireguardInbound(
+export function findWireguardInbounds(
   client: ClientRecord | null | undefined,
   inboundsById: Record<number, InboundOption>,
-): InboundOption | undefined {
+): InboundOption[] {
   return (client?.inboundIds || [])
-    .map((id) => inboundsById[id])
-    .find((ib) => ib?.protocol === 'wireguard');
+    .map((id) => inboundsById?.[id])
+    .filter((ib): ib is InboundOption => ib?.protocol === 'wireguard');
 }
 
 export function buildWireguardClientConfig(
@@ -27,13 +27,14 @@ export function buildWireguardClientConfig(
   inbound: InboundOption | undefined,
   host = window.location.hostname,
   publicHost = '',
+  addressOverride = '',
 ): string {
   const endpointHost = resolveShareHost(
     inbound ?? {},
     inbound?.nodeAddress ?? '',
     preferPublicHost(host, publicHost),
   );
-  const address = client.allowedIPs || '10.0.0.2/32';
+  const address = addressOverride || client.allowedIPs || '10.0.0.2/32';
   const endpoint = `${endpointHost}:${inbound?.port || ''}`;
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
   const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');

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

@@ -0,0 +1,183 @@
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+
+import ClientInfoModal from '@/pages/clients/ClientInfoModal';
+import ClientQrModal from '@/pages/clients/ClientQrModal';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { renderWithProviders } from './test-utils';
+
+const deAwgInbound: InboundOption = {
+  id: 101,
+  tag: 'awg-de',
+  remark: 'DE · Kelsterbach',
+  port: 52716,
+  protocol: 'amneziawg',
+  nodeAddress: 'de.vpn.example.com',
+  awgServer: {
+    publicKey: 'deServerPublicKey==',
+    primaryDns: '1.1.1.1',
+    secondaryDns: '1.0.0.1',
+    mtu: 1420,
+    jc: 4,
+    jmin: 40,
+    jmax: 100,
+    s1: 30,
+    s2: 90,
+    s3: 0,
+    s4: 0,
+    h1: '123',
+    h2: '456',
+    h3: '789',
+    h4: '101112',
+  },
+};
+
+const fiAwgInbound: InboundOption = {
+  id: 102,
+  tag: 'awg-fi',
+  remark: 'FI · Helsinki',
+  port: 26641,
+  protocol: 'amneziawg',
+  nodeAddress: 'fi.vpn.example.com',
+  awgServer: {
+    publicKey: 'fiServerPublicKey==',
+    primaryDns: '8.8.8.8',
+    secondaryDns: '8.8.4.4',
+    mtu: 1380,
+    jc: 10,
+    jmin: 20,
+    jmax: 80,
+    s1: 25,
+    s2: 50,
+    s3: 0,
+    s4: 0,
+    h1: '999',
+    h2: '888',
+    h3: '777',
+    h4: '666',
+  },
+};
+
+const usWgInbound: InboundOption = {
+  id: 201,
+  tag: 'wg-us',
+  remark: 'US · New York',
+  port: 51820,
+  protocol: 'wireguard',
+  nodeAddress: 'us.vpn.example.com',
+  wgPublicKey: 'usWgServerPublicKey==',
+  wgDns: '1.1.1.1',
+  wgMtu: 1420,
+};
+
+const euWgInbound: InboundOption = {
+  id: 202,
+  tag: 'wg-eu',
+  remark: 'EU · Frankfurt',
+  port: 51821,
+  protocol: 'wireguard',
+  nodeAddress: 'eu.vpn.example.com',
+  wgPublicKey: 'euWgServerPublicKey==',
+  wgDns: '9.9.9.9',
+  wgMtu: 1400,
+};
+
+const multiAwgClient: ClientRecord = {
+  id: 'c1',
+  email: 'NSK-RT-01',
+  privateKey: 'clientPrivateKey==',
+  publicKey: 'clientPublicKey==',
+  preSharedKey: 'clientPsk==',
+  allowedIPs: '10.8.0.2/32',
+  keepAlive: 25,
+  inboundIds: [101, 102],
+  enable: true,
+} as unknown as ClientRecord;
+
+const multiWgClient: ClientRecord = {
+  id: 'c2',
+  email: 'WG-CLIENT',
+  privateKey: 'wgClientPrivateKey==',
+  publicKey: 'wgClientPublicKey==',
+  preSharedKey: 'wgClientPsk==',
+  allowedIPs: '10.0.0.2/32',
+  keepAlive: 25,
+  inboundIds: [201, 202],
+  enable: true,
+} as unknown as ClientRecord;
+
+const singleAwgClient: ClientRecord = {
+  id: 'c3',
+  email: 'SINGLE-CLIENT',
+  privateKey: 'clientPrivateKey==',
+  publicKey: 'clientPublicKey==',
+  allowedIPs: '10.8.0.2/32',
+  inboundIds: [101],
+  enable: true,
+} as unknown as ClientRecord;
+
+describe('Multi-tunnel Client Modals', () => {
+  it('renders distinct labeled ConfigBlocks in ClientInfoModal for multiple AmneziaWG inbounds', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={multiAwgClient}
+        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+        isOnline={false}
+        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getAllByText('DE · Kelsterbach')).toHaveLength(2);
+    expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+    expect(document.querySelectorAll('.config-block')).toHaveLength(2);
+  });
+
+  it('renders distinct labeled ConfigBlocks in ClientInfoModal for multiple WireGuard inbounds', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={multiWgClient}
+        inboundsById={{ 201: usWgInbound, 202: euWgInbound }}
+        isOnline={false}
+        tunnelAllowedIPs={{ 201: '10.0.1.2/32', 202: '10.0.2.2/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getAllByText('US · New York')).toHaveLength(2);
+    expect(screen.getByText('EU · Frankfurt')).toBeTruthy();
+    expect(document.querySelectorAll('.config-block')).toHaveLength(2);
+  });
+
+  it('renders single default-labeled ConfigBlock in ClientInfoModal for single inbound', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={singleAwgClient}
+        inboundsById={{ 101: deAwgInbound }}
+        isOnline={false}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(document.querySelectorAll('.config-block')).toHaveLength(1);
+    expect(screen.getByText('Config')).toBeTruthy();
+  });
+
+  it('renders separate collapse panels in ClientQrModal for multiple AmneziaWG inbounds', () => {
+    renderWithProviders(
+      <ClientQrModal
+        open
+        client={multiAwgClient}
+        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
+    expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+  });
+});