diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index 98b9f3481..5bdd32a0b 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/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( +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[], + 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(subType) + ) { + continue; + } + for (const value of record.hosts) { + 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 = new Set(['mtproto', 'wireguard', 'amneziawg']); + +export function withHostEndpoints( inbound: Inbound, inboundId: number, records: HostRecord[], hostOverride: string, fallbackHostname: string, ): Inbound { - if (inbound.protocol !== 'mtproto') return inbound; - const endpoints: ExternalProxyEntry[] = []; - for (const record of records) { - if ( - record.isDisabled || - !record.inboundIds.includes(inboundId) || - record.excludeFromSubTypes?.includes('raw') - ) { - 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 || '', - }); - } - } + 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; } diff --git a/frontend/src/lib/inbounds/label.ts b/frontend/src/lib/inbounds/label.ts index 1e56fbef0..916881d53 100644 --- a/frontend/src/lib/inbounds/label.ts +++ b/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 }; } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 431b04419..bf5a62ed3 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/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 diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index a662f0241..7fbedba24 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/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 = { @@ -66,9 +68,12 @@ interface ClientInfoModalProps { tunnelAllowedIPs?: Record; isOnline: boolean; subSettings?: SubSettings; + hosts?: HostRecord[]; onOpenChange: (open: boolean) => void; } +const NO_HOSTS: HostRecord[] = []; + interface ApiMsg { 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 && ( <> {t('pages.clients.wireguardConfig')} - {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 ( 0 && client && ( <> {t('pages.clients.amneziaWgConfig')} - {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 ( ; tunnelAllowedIPs?: Record; subSettings?: SubSettings; + hosts?: HostRecord[]; onOpenChange: (open: boolean) => void; } +const NO_HOSTS: HostRecord[] = []; + interface ApiMsg { 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 = ( @@ -479,13 +483,13 @@ function ClientQrModalContent({ ); out.push({ - key: `wg-config-${inbound.id}`, + key: `wg-config-${inbound.id}-${endpoint}`, label, children: , }); }); - 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 = ( @@ -495,28 +499,33 @@ function ClientQrModalContent({ ); out.push({ - key: `awg-config-${inbound.id}`, + key: `awg-config-${inbound.id}-${endpoint}`, label, children: , }); }); - 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: ( - - {t('pages.clients.tuicConfig')} - + + + {t('pages.clients.tuicConfig')} + + {multi && {multi}} + ), children: ( ), }); - } + }); return out; }, [ subLink, @@ -533,7 +542,7 @@ function ClientQrModalContent({ selectVariant, regenerateHappLink, openHappSettings, - tuicConfigText, + tuicConfigs, t, ]); diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index c53e1aff5..b49feb54d 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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(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() { - - {!fetched ? ( + + {!fetched || !hostsFetched ? (
- ) : fetchError ? ( + ) : fetchError || hostsError ? ( {t('refresh')} @@ -1898,6 +1913,7 @@ export default function ClientsPage() { tunnelAllowedIPs={viewingTunnelAllowedIPs} isOnline={infoClient ? isOnline(infoClient.email) : false} subSettings={subSettings} + hosts={hosts} onOpenChange={setInfoOpen} /> @@ -1908,6 +1924,7 @@ export default function ClientsPage() { inboundsById={inboundsById} tunnelAllowedIPs={viewingTunnelAllowedIPs} subSettings={subSettings} + hosts={hosts} onOpenChange={setQrOpen} /> diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts index 74e5d0919..a97e9ce9c 100644 --- a/frontend/src/pages/clients/amneziawgConfig.ts +++ b/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. diff --git a/frontend/src/pages/clients/tuicConfig.ts b/frontend/src/pages/clients/tuicConfig.ts index 4d51a9738..1eea37dca 100644 --- a/frontend/src/pages/clients/tuicConfig.ts +++ b/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'); } diff --git a/frontend/src/pages/clients/tunnelEndpoints.ts b/frontend/src/pages/clients/tunnelEndpoints.ts new file mode 100644 index 000000000..f59a67b8a --- /dev/null +++ b/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}` : ''; +} diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts index 395e56880..27d0d45d5 100644 --- a/frontend/src/pages/clients/wireguardConfig.ts +++ b/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 || ''}`, diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 705933113..a2bd25896 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/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, diff --git a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx b/frontend/src/pages/inbounds/info/InboundInfoModal.tsx index 0f4b797bd..ec01a6860 100644 --- a/frontend/src/pages/inbounds/info/InboundInfoModal.tsx +++ b/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(null); const [clientStats, setClientStats] = useState(null); const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]); - const [wireguardConfigs, setWireguardConfigs] = useState([]); - const [wireguardLinks, setWireguardLinks] = useState([]); - const [amneziawgConfigs, setAmneziawgConfigs] = useState([]); - const [amneziawgLinks, setAmneziawgLinks] = useState([]); + const [wireguardConfigs, setWireguardConfigs] = useState([]); + const [wireguardLinks, setWireguardLinks] = useState([]); + const [amneziawgConfigs, setAmneziawgConfigs] = useState([]); + const [amneziawgLinks, setAmneziawgLinks] = useState([]); 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({
- {wireguardConfigs[idx] && ( -
-
- - {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })} - - -
- {wireguardConfigs[idx]} -
+ {(wireguardConfigs[idx] ?? []).map( + (cfg, j, all) => + cfg && ( +
+
+ + {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })} + + +
+ {cfg} +
+ ), )} - {wireguardLinks[idx] && ( -
-
- Peer {idx + 1} link - -
- {wireguardLinks[idx]} -
+ {(wireguardLinks[idx] ?? []).map( + (link, j) => + link && ( +
+
+ Peer {idx + 1} link + +
+ {link} +
+ ), )} ))} @@ -1241,49 +1248,55 @@ export default function InboundInfoModal({ {inbound?.protocol === Protocols.AMNEZIAWG && amneziawgConfigs.length > 0 && ( <> {t('pages.inbounds.copyLink')} - {amneziawgConfigs.map((cfg, idx) => ( + {amneziawgConfigs.map((peerConfigs, idx) => ( - {cfg && ( -
-
- - {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })} - - -
- {cfg} -
+ {peerConfigs.map( + (cfg, j, all) => + cfg && ( +
+
+ + {t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })} + + +
+ {cfg} +
+ ), )} - {amneziawgLinks[idx] && ( -
-
- Peer {idx + 1} link - -
- {amneziawgLinks[idx]} -
+ {(amneziawgLinks[idx] ?? []).map( + (link, j) => + link && ( +
+
+ Peer {idx + 1} link + +
+ {link} +
+ ), )}
))} diff --git a/frontend/src/pages/inbounds/info/helpers.ts b/frontend/src/pages/inbounds/info/helpers.ts index 1265ad329..d98bf461c 100644 --- a/frontend/src/pages/inbounds/info/helpers.ts +++ b/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); } diff --git a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx b/frontend/src/pages/inbounds/qr/QrCodeModal.tsx index 05b37b045..3375a699e 100644 --- a/frontend/src/pages/inbounds/qr/QrCodeModal.tsx +++ b/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([]); - const [wireguardLinks, setWireguardLinks] = useState([]); - const [amneziawgConfigs, setAmneziawgConfigs] = useState([]); - const [amneziawgLinks, setAmneziawgLinks] = useState([]); + const [wireguardConfigs, setWireguardConfigs] = useState([]); + const [wireguardLinks, setWireguardLinks] = useState([]); + const [amneziawgConfigs, setAmneziawgConfigs] = useState([]); + const [amneziawgLinks, setAmneziawgLinks] = useState([]); const [subLink, setSubLink] = useState(''); const [subJsonLink, setSubJsonLink] = useState(''); const [activeKey, setActiveKey] = useState([]); @@ -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, diff --git a/frontend/src/test/host-link.test.ts b/frontend/src/test/host-link.test.ts index 59da1d076..9041faf65 100644 --- a/frontend/src/test/host-link.test.ts +++ b/frontend/src/test/host-link.test.ts @@ -1,7 +1,7 @@ /// 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, [ diff --git a/frontend/src/test/multi-tunnel-client-config.test.tsx b/frontend/src/test/multi-tunnel-client-config.test.tsx index e32c324b1..d7b34eebf 100644 --- a/frontend/src/test/multi-tunnel-client-config.test.tsx +++ b/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( + {}} + />, + ); + + for (const label of edgeLabels) expect(screen.getByText(label)).toBeTruthy(); + }); + + it('renders one collapse panel per Host in ClientQrModal', () => { + renderWithProviders( + + {}} + /> + , + ); + + 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( + + {}} + /> + , + ); + + expect(screen.getAllByText('TUIC config (Clash)')).toHaveLength(1); + expect(screen.queryByText('raw.example.com:443')).toBeNull(); + }); }); diff --git a/frontend/src/test/tunnel-host-endpoints.test.ts b/frontend/src/test/tunnel-host-endpoints.test.ts new file mode 100644 index 000000000..8a4955d61 --- /dev/null +++ b/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', + ]); + }); +}); diff --git a/internal/sub/endpoint.go b/internal/sub/endpoint.go index f164a5390..d00640ad5 100644 --- a/internal/sub/endpoint.go +++ b/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). diff --git a/internal/sub/service.go b/internal/sub/service.go index d051e36e5..07043eee9 100644 --- a/internal/sub/service.go +++ b/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{ diff --git a/internal/sub/service_tunnel_hosts_test.go b/internal/sub/service_tunnel_hosts_test.go new file mode 100644 index 000000000..c0a0bf40e --- /dev/null +++ b/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) + } +}