diff --git a/frontend/src/lib/inbounds/label.ts b/frontend/src/lib/inbounds/label.ts
index 6a5fcb0e3..1e56fbef0 100644
--- a/frontend/src/lib/inbounds/label.ts
+++ b/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 };
+}
diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx
index 52a31ea98..15225cd9e 100644
--- a/frontend/src/pages/clients/ClientInfoModal.tsx
+++ b/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 && (
<>
{t('pages.clients.wireguardConfig')}
-
+ {wgConfigs.map(({ inbound, text }) => {
+ const meta = formatTunnelConfigMeta(inbound, client.email, wgConfigs.length);
+ return (
+
+ );
+ })}
>
)}
- {awgConfigText && client && (
+ {awgConfigs.length > 0 && client && (
<>
{t('pages.clients.amneziaWgConfig')}
-
+ {awgConfigs.map(({ inbound, text }) => {
+ const meta = formatTunnelConfigMeta(inbound, client.email, awgConfigs.length);
+ return (
+
+ );
+ })}
>
)}
>
diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx
index 069167106..13b13f3b4 100644
--- a/frontend/src/pages/clients/ClientQrModal.tsx
+++ b/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 = (
+
{t('pages.clients.wireguardConfig')}
- ),
- children: (
-
- ),
- });
- }
- if (awgConfigText) {
+ {meta.label && {meta.label}}
+
+ );
out.push({
- key: 'awg-config',
- label: (
+ key: `wg-config-${inbound.id}`,
+ label,
+ children: ,
+ });
+ });
+ awgConfigs.forEach(({ inbound, text }) => {
+ const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length);
+ const label = (
+
{t('pages.clients.amneziaWgConfig')}
- ),
- children: (
-
- ),
+ {meta.label && {meta.label}}
+
+ );
+ out.push({
+ key: `awg-config-${inbound.id}`,
+ label,
+ children: ,
});
- }
+ });
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;
diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts
index d92dc242f..cea30b916 100644
--- a/frontend/src/pages/clients/amneziawgConfig.ts
+++ b/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,
-): 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
diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts
index 705813269..395e56880 100644
--- a/frontend/src/pages/clients/wireguardConfig.ts
+++ b/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,
-): 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(' - ');
diff --git a/frontend/src/test/multi-tunnel-client-config.test.tsx b/frontend/src/test/multi-tunnel-client-config.test.tsx
new file mode 100644
index 000000000..41a7113ec
--- /dev/null
+++ b/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(
+ {}}
+ />,
+ );
+
+ 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(
+ {}}
+ />,
+ );
+
+ 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(
+ {}}
+ />,
+ );
+
+ expect(document.querySelectorAll('.config-block')).toHaveLength(1);
+ expect(screen.getByText('Config')).toBeTruthy();
+ });
+
+ it('renders separate collapse panels in ClientQrModal for multiple AmneziaWG inbounds', () => {
+ renderWithProviders(
+ {}}
+ />,
+ );
+
+ expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
+ expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+ });
+});