mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 01:50:58 +00:00
feat(wireguard): client config UX, collapsible config card, configurable DNS
Land the WireGuard client-config UX work on main (the upstream PR #5642 branch could not be pushed to). - Reusable collapsible ConfigBlock (copy/download/QR, actions aligned right) for the client .conf, used by client info and the public sub page. - Correct .conf: canonical PresharedKey casing and DNS sourced from the inbound (configurable per-inbound, default 1.1.1.1, 1.0.0.1). - Configurable per-inbound DNS for WireGuard (schema + form + backend hint via InboundOption.WgDns); inert at the Xray layer. - Public sub page now shows the WireGuard config, rebuilt from the share link; the Go wireguard:// link carries dns/presharedkey/keepalive for completeness. - QR enabled for the wireguard:// link; link rows are compact like other protocols. - Client information order is subscription, copy URL, WireGuard config; the redundant config tab is removed from the add/edit client modal. - Drop the Inbound Information and QR Code row actions for WireGuard inbounds.
This commit is contained in:
@@ -11,6 +11,8 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import { QrPanel } from '@/pages/inbounds/qr';
|
||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
|
||||
import './ClientInfoModal.css';
|
||||
|
||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||
@@ -35,6 +37,7 @@ interface SubSettings {
|
||||
subJsonEnable: boolean;
|
||||
subClashURI: string;
|
||||
subClashEnable: boolean;
|
||||
publicHost?: string;
|
||||
}
|
||||
|
||||
interface ClientInfoModalProps {
|
||||
@@ -58,6 +61,7 @@ const DEFAULT_SUB: SubSettings = {
|
||||
subJsonEnable: false,
|
||||
subClashURI: '',
|
||||
subClashEnable: false,
|
||||
publicHost: '',
|
||||
};
|
||||
|
||||
export default function ClientInfoModal({
|
||||
@@ -132,6 +136,11 @@ export default function ClientInfoModal({
|
||||
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
|
||||
|
||||
const showSubscription = !!(subSettings?.enable && client?.subId);
|
||||
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
|
||||
const wgConfigText = useMemo(() => {
|
||||
if (!client || !isWireguardClient(client)) return '';
|
||||
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
|
||||
}, [client, wgInbound, subSettings?.publicHost]);
|
||||
|
||||
async function copyValue(text: string) {
|
||||
if (!text) return;
|
||||
@@ -350,44 +359,6 @@ export default function ClientInfoModal({
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
||||
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
||||
const qrRemark = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
return (
|
||||
<div key={idx} className="link-row">
|
||||
{parts
|
||||
? <LinkTags parts={parts} />
|
||||
: <Tag className="link-row-tag">LINK</Tag>}
|
||||
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
{canQr && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={link} remark={qrRemark} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showSubscription && subLink && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
@@ -480,6 +451,56 @@ export default function ClientInfoModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
|
||||
const rowTitle = (parts && linkMetaText(parts)) || fallback;
|
||||
const qrRemark = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
return (
|
||||
<div key={idx} className="link-row">
|
||||
{parts
|
||||
? <LinkTags parts={parts} />
|
||||
: <Tag className="link-row-tag">LINK</Tag>}
|
||||
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
|
||||
<div className="link-row-actions">
|
||||
<Tooltip title={t('copy')}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
|
||||
</Tooltip>
|
||||
{canQr && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={<QrPanel value={link} remark={qrRemark} size={220} />}
|
||||
>
|
||||
<Tooltip title={t('pages.clients.qrCode')}>
|
||||
<Button size="small" icon={<QrcodeOutlined />} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{wgConfigText && client && (
|
||||
<>
|
||||
<Divider>{t('pages.clients.wireguardConfig')}</Divider>
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.conf')}
|
||||
text={wgConfigText}
|
||||
fileName={`${client.email}.conf`}
|
||||
qrRemark={client.email || 'peer'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapse, Modal, Spin } from 'antd';
|
||||
import { Collapse, Modal, Spin, Tag } from 'antd';
|
||||
import { HttpUtil } from '@/utils';
|
||||
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 } from '@/hooks/useClients';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
|
||||
|
||||
interface SubSettings {
|
||||
enable: boolean;
|
||||
subURI: string;
|
||||
subJsonURI: string;
|
||||
subJsonEnable: boolean;
|
||||
publicHost?: string;
|
||||
}
|
||||
|
||||
interface ClientQrModalProps {
|
||||
open: boolean;
|
||||
client: ClientRecord | null;
|
||||
inboundsById: Record<number, InboundOption>;
|
||||
subSettings?: SubSettings;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
@@ -26,11 +29,12 @@ interface ApiMsg<T = unknown> {
|
||||
obj?: T;
|
||||
}
|
||||
|
||||
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
|
||||
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, publicHost: '' };
|
||||
|
||||
export default function ClientQrModal({
|
||||
open,
|
||||
client,
|
||||
inboundsById,
|
||||
subSettings = DEFAULT_SUB,
|
||||
onOpenChange,
|
||||
}: ClientQrModalProps) {
|
||||
@@ -49,7 +53,13 @@ export default function ClientQrModal({
|
||||
return subSettings.subJsonURI + client.subId;
|
||||
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
|
||||
|
||||
const hasAnything = !!subLink || !!subJsonLink || links.length > 0;
|
||||
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
|
||||
const wgConfigText = useMemo(() => {
|
||||
if (!client || !isWireguardClient(client)) return '';
|
||||
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
|
||||
}, [client, wgInbound, subSettings?.publicHost]);
|
||||
|
||||
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !client?.subId) {
|
||||
@@ -112,8 +122,21 @@ export default function ClientQrModal({
|
||||
),
|
||||
});
|
||||
});
|
||||
if (wgConfigText) {
|
||||
out.push({
|
||||
key: 'wg-config',
|
||||
label: <Tag color="cyan" style={{ margin: 0 }}>{t('pages.clients.wireguardConfig')}</Tag>,
|
||||
children: (
|
||||
<QrPanel
|
||||
value={wgConfigText}
|
||||
remark={client?.email || 'peer'}
|
||||
downloadName={`${client?.email || 'peer'}.conf`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [subLink, subJsonLink, links, client?.email, t]);
|
||||
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
||||
@@ -1459,6 +1459,7 @@ export default function ClientsPage() {
|
||||
<ClientQrModal
|
||||
open={qrOpen}
|
||||
client={qrClient}
|
||||
inboundsById={inboundsById}
|
||||
subSettings={subSettings}
|
||||
onOpenChange={setQrOpen}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { preferPublicHost } from '@/lib/xray/inbound-link';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
|
||||
export function isWireguardClient(client: ClientRecord | null | undefined): boolean {
|
||||
if (!client) return false;
|
||||
return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive);
|
||||
}
|
||||
|
||||
export function findWireguardInbound(
|
||||
client: ClientRecord | null | undefined,
|
||||
inboundsById: Record<number, InboundOption>,
|
||||
): InboundOption | undefined {
|
||||
return (client?.inboundIds || [])
|
||||
.map((id) => inboundsById[id])
|
||||
.find((ib) => ib?.protocol === 'wireguard');
|
||||
}
|
||||
|
||||
export function buildWireguardClientConfig(
|
||||
client: ClientRecord,
|
||||
inbound: InboundOption | undefined,
|
||||
host = window.location.hostname,
|
||||
publicHost = '',
|
||||
): string {
|
||||
const endpointHost = preferPublicHost(host, publicHost);
|
||||
const address = 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(' - ');
|
||||
const lines = [
|
||||
'[Interface]',
|
||||
`PrivateKey = ${client.privateKey || client.password || ''}`,
|
||||
`Address = ${address}`,
|
||||
`DNS = ${inbound?.wgDns || '1.1.1.1, 1.0.0.1'}`,
|
||||
];
|
||||
if (inbound?.wgMtu && inbound.wgMtu > 0) lines.push(`MTU = ${inbound.wgMtu}`);
|
||||
lines.push('');
|
||||
if (remark) lines.push(`# ${remark}`);
|
||||
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
|
||||
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||
if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
|
||||
<Form.Item name={['settings', 'mtu']} label="MTU">
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
<Form.Item name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
|
||||
<Input placeholder="1.1.1.1, 1.0.0.1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['settings', 'noKernelTun']}
|
||||
label={t('pages.inbounds.info.noKernelTun')}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils } from '@/utils';
|
||||
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, Wireguard } from '@/utils';
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import { InfinityIcon } from '@/components/ui';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
@@ -208,6 +208,11 @@ export default function InboundInfoModal({
|
||||
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
||||
}, [clientStats, clientSettings]);
|
||||
|
||||
const wgPubKey = useMemo(() => {
|
||||
if (!dbInbound?.isWireguard || !inbound?.settings?.secretKey) return '';
|
||||
return Wireguard.generateKeypair(inbound.settings.secretKey as string).publicKey;
|
||||
}, [dbInbound?.isWireguard, inbound?.settings?.secretKey]);
|
||||
|
||||
const formatLastOnline = useCallback(
|
||||
(email: string) => {
|
||||
const ts = lastOnlineMap[email];
|
||||
@@ -791,7 +796,7 @@ export default function InboundInfoModal({
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.xray.wireguard.publicKey')}</dt>
|
||||
<dd><Tag className="value-tag">{inbound.settings.pubKey as string}</Tag></dd>
|
||||
<dd><Tag className="value-tag">{wgPubKey}</Tag></dd>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<dt>{t('pages.inbounds.info.mtu')}</dt>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
|
||||
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} else if (!record.isWireguard) {
|
||||
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
|
||||
}
|
||||
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
||||
|
||||
@@ -81,7 +81,6 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
||||
}
|
||||
|
||||
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||
if (dbInbound.isWireguard) return true;
|
||||
if (dbInbound.isSS) {
|
||||
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import { InfinityIcon } from '@/components/ui';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||
|
||||
import { RowActionsCell } from './RowActions';
|
||||
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||
@@ -51,6 +52,28 @@ export function useInboundColumns({
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
return useMemo(() => {
|
||||
const fallbackClientCount = (record: DBInboundRecord): ClientCountEntry | null => {
|
||||
const settings = coerceInboundJsonField(record.settings) as {
|
||||
clients?: { email?: string; enable?: boolean }[];
|
||||
};
|
||||
const clients = Array.isArray(settings.clients) ? settings.clients : [];
|
||||
if (clients.length === 0) return null;
|
||||
const active = clients
|
||||
.filter((client) => client.email && client.enable !== false)
|
||||
.map((client) => client.email!);
|
||||
const deactive = clients
|
||||
.filter((client) => client.email && client.enable === false)
|
||||
.map((client) => client.email!);
|
||||
return {
|
||||
clients: clients.length,
|
||||
active,
|
||||
deactive,
|
||||
depleted: [],
|
||||
expiring: [],
|
||||
online: [],
|
||||
};
|
||||
};
|
||||
|
||||
const cols: TableColumnType<DBInboundRecord>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
@@ -174,14 +197,14 @@ export function useInboundColumns({
|
||||
align: 'left',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const cc = clientCount[record.id];
|
||||
const cc = clientCount[record.id] || fallbackClientCount(record);
|
||||
if (!cc) return null;
|
||||
return (
|
||||
<>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
|
||||
<TeamOutlined /> {cc.clients}
|
||||
</Tag>
|
||||
{cc.active.length > 0 && (
|
||||
{cc.active.length > 0 ? (
|
||||
<Popover
|
||||
title={t('subscription.active')}
|
||||
content={(
|
||||
@@ -192,6 +215,8 @@ export function useInboundColumns({
|
||||
>
|
||||
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
|
||||
</Popover>
|
||||
) : (
|
||||
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>0</Tag>
|
||||
)}
|
||||
{cc.deactive.length > 0 && (
|
||||
<Popover
|
||||
|
||||
@@ -35,6 +35,7 @@ interface QrItem {
|
||||
header: string;
|
||||
value: string;
|
||||
downloadName?: string;
|
||||
showQr?: boolean;
|
||||
}
|
||||
|
||||
export default function QrCodeModal({
|
||||
@@ -122,7 +123,7 @@ export default function QrCodeModal({
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks[idx]) {
|
||||
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx] });
|
||||
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx], showQr: false });
|
||||
}
|
||||
});
|
||||
return items;
|
||||
@@ -137,7 +138,7 @@ export default function QrCodeModal({
|
||||
value={item.value}
|
||||
remark={item.header}
|
||||
downloadName={item.downloadName || ''}
|
||||
showQr={!isPostQuantumLink(item.value)}
|
||||
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
import { isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import SubUsageSummary from './SubUsageSummary';
|
||||
@@ -423,8 +424,10 @@ export default function SubPage() {
|
||||
const rowTitle = parts?.remark || fallback;
|
||||
const qrLabel = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||
return (
|
||||
<div key={link} className="sub-link-row">
|
||||
<Fragment key={link}>
|
||||
<div className="sub-link-row">
|
||||
{parts
|
||||
? <LinkTags parts={parts} />
|
||||
: <Tag className="sub-link-tag">LINK</Tag>}
|
||||
@@ -468,6 +471,16 @@ export default function SubPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isWireguardLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.wireguardConfig')}
|
||||
text={wireguardConfigFromLink(link, rowTitle)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="cyan"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user