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