diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json
index fa64351ea..819998dfa 100644
--- a/frontend/public/openapi.json
+++ b/frontend/public/openapi.json
@@ -1854,6 +1854,15 @@
"tlsFlowCapable": {
"example": true,
"type": "boolean"
+ },
+ "wgDns": {
+ "type": "string"
+ },
+ "wgMtu": {
+ "type": "integer"
+ },
+ "wgPublicKey": {
+ "type": "string"
}
},
"required": [
@@ -2751,7 +2760,10 @@
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
- "tlsFlowCapable": true
+ "tlsFlowCapable": true,
+ "wgDns": "",
+ "wgMtu": 0,
+ "wgPublicKey": ""
}
]
}
diff --git a/frontend/src/components/clients/ConfigBlock.css b/frontend/src/components/clients/ConfigBlock.css
new file mode 100644
index 000000000..c0b4193df
--- /dev/null
+++ b/frontend/src/components/clients/ConfigBlock.css
@@ -0,0 +1,40 @@
+.config-block {
+ margin-bottom: 10px;
+}
+
+.config-block .ant-collapse-header {
+ align-items: center !important;
+ padding: 8px 12px !important;
+}
+
+.config-block .ant-collapse-header-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.config-block .ant-collapse-extra {
+ display: flex;
+ align-items: center;
+}
+
+.config-block-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.config-block .ant-collapse-content-box {
+ padding: 8px !important;
+}
+
+.config-block-text {
+ display: block;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 11px;
+ word-break: break-all;
+ white-space: pre-wrap;
+ padding: 6px 8px;
+ background: var(--ant-color-fill-tertiary);
+ border-radius: 4px;
+ user-select: all;
+}
diff --git a/frontend/src/components/clients/ConfigBlock.tsx b/frontend/src/components/clients/ConfigBlock.tsx
new file mode 100644
index 000000000..db97cfa8b
--- /dev/null
+++ b/frontend/src/components/clients/ConfigBlock.tsx
@@ -0,0 +1,79 @@
+import type { MouseEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Collapse, Popover, Tag, Tooltip, message } from 'antd';
+import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
+
+import { ClipboardManager, FileManager } from '@/utils';
+import { QrPanel } from '@/pages/inbounds/qr';
+import './ConfigBlock.css';
+
+interface ConfigBlockProps {
+ label: string;
+ text: string;
+ fileName: string;
+ qrRemark?: string;
+ showQr?: boolean;
+ tagColor?: string;
+ defaultOpen?: boolean;
+}
+
+export default function ConfigBlock({
+ label,
+ text,
+ fileName,
+ qrRemark = '',
+ showQr = true,
+ tagColor = 'gold',
+ defaultOpen = false,
+}: ConfigBlockProps) {
+ const { t } = useTranslation();
+ const [messageApi, messageContextHolder] = message.useMessage();
+
+ async function copy() {
+ const ok = await ClipboardManager.copyText(text);
+ if (ok) messageApi.success(t('copied'));
+ }
+
+ const actions = (
+
e.stopPropagation()}>
+
+ } onClick={copy} />
+
+
+ }
+ onClick={() => FileManager.downloadTextFile(text, fileName)}
+ />
+
+ {showQr && (
+
}
+ >
+
+ } />
+
+
+ )}
+
+ );
+
+ return (
+ <>
+ {messageContextHolder}
+ {label},
+ extra: actions,
+ children: {text},
+ }]}
+ />
+ >
+ );
+}
diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts
index 24eb26370..7febe65cc 100644
--- a/frontend/src/generated/examples.ts
+++ b/frontend/src/generated/examples.ts
@@ -406,7 +406,10 @@ export const EXAMPLES: Record = {
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
- "tlsFlowCapable": true
+ "tlsFlowCapable": true,
+ "wgDns": "",
+ "wgMtu": 0,
+ "wgPublicKey": ""
},
"Msg": {
"msg": "",
diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts
index ac28804f9..6a95b66a2 100644
--- a/frontend/src/generated/schemas.ts
+++ b/frontend/src/generated/schemas.ts
@@ -1828,6 +1828,15 @@ export const SCHEMAS: Record = {
"tlsFlowCapable": {
"example": true,
"type": "boolean"
+ },
+ "wgDns": {
+ "type": "string"
+ },
+ "wgMtu": {
+ "type": "integer"
+ },
+ "wgPublicKey": {
+ "type": "string"
}
},
"required": [
diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts
index 87bdb7827..110924f13 100644
--- a/frontend/src/generated/types.ts
+++ b/frontend/src/generated/types.ts
@@ -401,6 +401,9 @@ export interface InboundOption {
ssMethod: string;
tag: string;
tlsFlowCapable: boolean;
+ wgDns?: string;
+ wgMtu?: number;
+ wgPublicKey?: string;
}
export interface Msg {
diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts
index 6cc9a8434..74a25af5a 100644
--- a/frontend/src/generated/zod.ts
+++ b/frontend/src/generated/zod.ts
@@ -428,6 +428,9 @@ export const InboundOptionSchema = z.object({
ssMethod: z.string(),
tag: z.string(),
tlsFlowCapable: z.boolean(),
+ wgDns: z.string().optional(),
+ wgMtu: z.number().int().optional(),
+ wgPublicKey: z.string().optional(),
});
export type InboundOption = z.infer;
diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts
index 21575a847..639aab18e 100644
--- a/frontend/src/hooks/useClients.ts
+++ b/frontend/src/hooks/useClients.ts
@@ -47,6 +47,7 @@ interface SubSettings {
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
+ publicHost: string;
}
export interface ClientQueryParams {
@@ -240,6 +241,7 @@ export function useClients() {
subJsonEnable: !!defaults.subJsonEnable,
subClashURI: (defaults.subClashURI as string) || '',
subClashEnable: !!defaults.subClashEnable,
+ publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
}), [
defaults.subEnable,
defaults.subURI,
@@ -247,6 +249,8 @@ export function useClients() {
defaults.subJsonEnable,
defaults.subClashURI,
defaults.subClashEnable,
+ defaults.subDomain,
+ defaults.webDomain,
]);
const ipLimitEnable = !!defaults.ipLimitEnable;
diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts
index ae0b4ceb7..2734de0da 100644
--- a/frontend/src/lib/xray/inbound-link.ts
+++ b/frontend/src/lib/xray/inbound-link.ts
@@ -828,7 +828,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
let txt = `[Interface]\n`;
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
- txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
+ txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
txt += `MTU = ${settings.mtu}\n`;
}
@@ -846,6 +846,66 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
return txt;
}
+export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
+ let url: URL;
+ try {
+ url = new URL(link);
+ } catch {
+ return '';
+ }
+ const scheme = url.protocol.replace(/:$/, '');
+ if (scheme !== 'wireguard' && scheme !== 'wg') return '';
+
+ const params = url.searchParams;
+ const pick = (...keys: string[]): string => {
+ for (const k of keys) {
+ const v = params.get(k);
+ if (v) return v;
+ }
+ return '';
+ };
+
+ let privateKey: string;
+ try {
+ privateKey = decodeURIComponent(url.username);
+ } catch {
+ privateKey = url.username;
+ }
+ const host = url.hostname;
+ const endpoint = host ? (url.port ? `${host}:${url.port}` : host) : '';
+ const address = pick('address', 'ip') || '10.0.0.2/32';
+ const publicKey = pick('publickey', 'publicKey', 'public_key', 'peerPublicKey');
+ const dns = pick('dns') || '1.1.1.1, 1.0.0.1';
+ const mtu = pick('mtu');
+ const psk = pick('presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
+ const keepAlive = pick('keepalive', 'persistentkeepalive', 'persistent_keepalive');
+ const allowedIPs = pick('allowedips', 'allowed_ips') || '0.0.0.0/0, ::/0';
+
+ let remark = fallbackRemark;
+ try {
+ const decoded = decodeURIComponent(url.hash.replace(/^#/, ''));
+ if (decoded) remark = decoded;
+ } catch {
+ const raw = url.hash.replace(/^#/, '');
+ if (raw) remark = raw;
+ }
+
+ const lines = [
+ '[Interface]',
+ `PrivateKey = ${privateKey}`,
+ `Address = ${address}`,
+ `DNS = ${dns}`,
+ ];
+ if (mtu && Number(mtu) > 0) lines.push(`MTU = ${mtu}`);
+ lines.push('');
+ if (remark) lines.push(`# ${remark}`);
+ lines.push('[Peer]', `PublicKey = ${publicKey}`);
+ if (psk) lines.push(`PresharedKey = ${psk}`);
+ lines.push(`AllowedIPs = ${allowedIPs}`, `Endpoint = ${endpoint}`);
+ if (keepAlive && Number(keepAlive) > 0) lines.push(`PersistentKeepalive = ${keepAlive}`);
+ return lines.join('\n');
+}
+
export type { WireguardInboundPeer };
function isUnixSocketListen(listen: string): boolean {
diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx
index b755a571f..0b5cbb4cc 100644
--- a/frontend/src/pages/clients/ClientInfoModal.tsx
+++ b/frontend/src/pages/clients/ClientInfoModal.tsx
@@ -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 = {
@@ -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({
- {links.length > 0 && (
- <>
- {t('pages.inbounds.copyLink')}
- {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 (
-
- {parts
- ?
- :
LINK}
-
{rowTitle}
-
-
- } onClick={() => copyValue(link)} />
-
- {canQr && (
-
}
- >
-
- } />
-
-
- )}
-
-
- );
- })}
- >
- )}
-
{showSubscription && subLink && (
<>
{t('subscription.title')}
@@ -480,6 +451,56 @@ export default function ClientInfoModal({
)}
>
)}
+
+ {links.length > 0 && (
+ <>
+ {t('pages.inbounds.copyLink')}
+ {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 (
+
+ {parts
+ ?
+ :
LINK}
+
{rowTitle}
+
+
+ } onClick={() => copyValue(link)} />
+
+ {canQr && (
+
}
+ >
+
+ } />
+
+
+ )}
+
+
+ );
+ })}
+ >
+ )}
+
+ {wgConfigText && client && (
+ <>
+ {t('pages.clients.wireguardConfig')}
+
+ >
+ )}
>
)}
diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx
index 0b90ba7d3..0063a3522 100644
--- a/frontend/src/pages/clients/ClientQrModal.tsx
+++ b/frontend/src/pages/clients/ClientQrModal.tsx
@@ -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;
subSettings?: SubSettings;
onOpenChange: (open: boolean) => void;
}
@@ -26,11 +29,12 @@ interface ApiMsg {
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: {t('pages.clients.wireguardConfig')},
+ children: (
+
+ ),
+ });
+ }
return out;
- }, [subLink, subJsonLink, links, client?.email, t]);
+ }, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
useEffect(() => {
if (!open) {
diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx
index bbd1333c9..5545fa4aa 100644
--- a/frontend/src/pages/clients/ClientsPage.tsx
+++ b/frontend/src/pages/clients/ClientsPage.tsx
@@ -1459,6 +1459,7 @@ export default function ClientsPage() {
diff --git a/frontend/src/pages/clients/wireguardConfig.ts b/frontend/src/pages/clients/wireguardConfig.ts
new file mode 100644
index 000000000..53541d19f
--- /dev/null
+++ b/frontend/src/pages/clients/wireguardConfig.ts
@@ -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,
+): 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');
+}
diff --git a/frontend/src/pages/inbounds/form/protocols/wireguard.tsx b/frontend/src/pages/inbounds/form/protocols/wireguard.tsx
index 559cf7e58..0aafb3551 100644
--- a/frontend/src/pages/inbounds/form/protocols/wireguard.tsx
+++ b/frontend/src/pages/inbounds/form/protocols/wireguard.tsx
@@ -25,6 +25,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
+
+
+
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({
{t('pages.xray.wireguard.publicKey')}
- {inbound.settings.pubKey as string}
+ {wgPubKey}
{t('pages.inbounds.info.mtu')}
diff --git a/frontend/src/pages/inbounds/list/RowActions.tsx b/frontend/src/pages/inbounds/list/RowActions.tsx
index 205b8a49d..827e8b9ef 100644
--- a/frontend/src/pages/inbounds/list/RowActions.tsx
+++ b/frontend/src/pages/inbounds/list/RowActions.tsx
@@ -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:
, label: t('pages.inbounds.inboundInfo') });
}
items.push({ key: 'clipboard', icon:
, label: t('pages.inbounds.exportInbound') });
diff --git a/frontend/src/pages/inbounds/list/helpers.ts b/frontend/src/pages/inbounds/list/helpers.ts
index 7e50c4bbf..b654b4c12 100644
--- a/frontend/src/pages/inbounds/list/helpers.ts
+++ b/frontend/src/pages/inbounds/list/helpers.ts
@@ -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) });
}
diff --git a/frontend/src/pages/inbounds/list/useInboundColumns.tsx b/frontend/src/pages/inbounds/list/useInboundColumns.tsx
index 4bb02275a..c25d8a456 100644
--- a/frontend/src/pages/inbounds/list/useInboundColumns.tsx
+++ b/frontend/src/pages/inbounds/list/useInboundColumns.tsx
@@ -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
[] = [
{
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 (
<>
{cc.clients}
- {cc.active.length > 0 && (
+ {cc.active.length > 0 ? (
{cc.active.length}
+ ) : (
+ 0
)}
{cc.deactive.length > 0 && (
),
})),
diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx
index 7679dd6e5..a7467c023 100644
--- a/frontend/src/pages/sub/SubPage.tsx
+++ b/frontend/src/pages/sub/SubPage.tsx
@@ -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 (
-
+
+
{parts
?
: LINK}
@@ -468,6 +471,16 @@ export default function SubPage() {
)}
+ {isWireguardLink && (
+
+ )}
+
);
})}
diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts
index ee46681d4..6e91e4f32 100644
--- a/frontend/src/schemas/client.ts
+++ b/frontend/src/schemas/client.ts
@@ -49,6 +49,9 @@ export const InboundOptionSchema = z.object({
port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(),
+ wgPublicKey: z.string().optional(),
+ wgMtu: z.number().optional(),
+ wgDns: z.string().optional(),
// Hosting node id; absent/null for this panel's own inbounds (#4997).
nodeId: z.number().nullable().optional(),
}).loose();
diff --git a/frontend/src/schemas/protocols/inbound/wireguard.ts b/frontend/src/schemas/protocols/inbound/wireguard.ts
index effa98f1f..033716619 100644
--- a/frontend/src/schemas/protocols/inbound/wireguard.ts
+++ b/frontend/src/schemas/protocols/inbound/wireguard.ts
@@ -61,6 +61,7 @@ export type WireguardClient = z.infer;
export const WireguardInboundSettingsSchema = z.object({
mtu: optionalClearedInt(z.number().int().min(1)),
secretKey: z.string().min(1),
+ dns: z.string().optional(),
peers: z.array(WireguardInboundPeerSchema).default([]),
clients: z.array(WireguardClientSchema).default([]),
noKernelTun: z.boolean().default(false),
diff --git a/frontend/src/test/wireguard-client-config.test.ts b/frontend/src/test/wireguard-client-config.test.ts
new file mode 100644
index 000000000..84399d2cf
--- /dev/null
+++ b/frontend/src/test/wireguard-client-config.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildWireguardClientConfig } from '@/pages/clients/wireguardConfig';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+
+const client: ClientRecord = {
+ email: 'alice',
+ privateKey: 'QGVlb2dXc1ZTWGw0ZXBzZndsWmtMaUM5MUlNYjBHWFdYbz0=',
+ allowedIPs: '10.0.0.2/32',
+ preSharedKey: 'cHNrLXZhbHVlLWZvci13aXJlZ3VhcmQtdGVzdC1jYXNlIQ==',
+ keepAlive: 25,
+ inboundIds: [90],
+};
+
+const inbound: InboundOption = {
+ id: 90,
+ tag: 'in-51820-udp',
+ remark: 'wg-mc',
+ protocol: 'wireguard',
+ port: 51820,
+ wgPublicKey: 'DGSYIcEKAUkA7HhzGSjxLZuV67BR3LeyU0BMLJzNVHQ=',
+ wgMtu: 1420,
+};
+
+describe('buildWireguardClientConfig', () => {
+ it('emits the canonical PresharedKey key, not PreSharedKey', () => {
+ const cfg = buildWireguardClientConfig(client, inbound, 'example.com', '');
+ expect(cfg).toContain(`PresharedKey = ${client.preSharedKey}`);
+ expect(cfg).not.toContain('PreSharedKey =');
+ });
+
+ it('defaults DNS to 1.1.1.1, 1.0.0.1 when the inbound sets none', () => {
+ const cfg = buildWireguardClientConfig(client, inbound, 'example.com', '');
+ expect(cfg).toContain('DNS = 1.1.1.1, 1.0.0.1');
+ });
+
+ it('uses the inbound DNS override when present', () => {
+ const cfg = buildWireguardClientConfig(client, { ...inbound, wgDns: '9.9.9.9' }, 'example.com', '');
+ expect(cfg).toContain('DNS = 9.9.9.9');
+ expect(cfg).not.toContain('DNS = 1.1.1.1, 1.0.0.1');
+ });
+
+ it('builds the endpoint from host, port, MTU and server public key', () => {
+ const cfg = buildWireguardClientConfig(client, inbound, 'example.com', '');
+ expect(cfg).toContain('Endpoint = example.com:51820');
+ expect(cfg).toContain('MTU = 1420');
+ expect(cfg).toContain(`PublicKey = ${inbound.wgPublicKey}`);
+ expect(cfg).toContain('PersistentKeepalive = 25');
+ });
+
+ it('omits the PresharedKey line when the client has no preshared key', () => {
+ const cfg = buildWireguardClientConfig({ ...client, preSharedKey: undefined }, inbound, 'example.com', '');
+ expect(cfg).not.toContain('PresharedKey');
+ });
+});
diff --git a/internal/sub/service.go b/internal/sub/service.go
index 3e40f4e81..fcbfe7bcb 100644
--- a/internal/sub/service.go
+++ b/internal/sub/service.go
@@ -545,6 +545,15 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
params["mtu"] = strconv.Itoa(int(mtu))
}
+ if dns, ok := settings["dns"].(string); ok && dns != "" {
+ params["dns"] = dns
+ }
+ if client.PreSharedKey != "" {
+ params["presharedkey"] = client.PreSharedKey
+ }
+ if client.KeepAlive > 0 {
+ params["keepalive"] = strconv.Itoa(client.KeepAlive)
+ }
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go
index 2c6d86b8b..6bd0cfb21 100644
--- a/internal/web/service/inbound.go
+++ b/internal/web/service/inbound.go
@@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+ wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
@@ -298,6 +299,9 @@ type InboundOption struct {
Port int `json:"port" example:"443"`
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
SsMethod string `json:"ssMethod"`
+ WgPublicKey string `json:"wgPublicKey,omitempty"`
+ WgMtu int `json:"wgMtu,omitempty"`
+ WgDns string `json:"wgDns,omitempty"`
// Hosting node; nil for this panel's own inbounds. Lets the clients
// page map a node filter onto inbound IDs (#4997).
NodeId *int `json:"nodeId,omitempty"`
@@ -325,6 +329,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
}
out := make([]InboundOption, 0, len(rows))
for _, r := range rows {
+ wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
out = append(out, InboundOption{
Id: r.Id,
Remark: r.Remark,
@@ -333,12 +338,41 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
+ WgPublicKey: wgPublicKey,
+ WgMtu: wgMtu,
+ WgDns: wgDns,
NodeId: r.NodeId,
})
}
return out, nil
}
+func inboundWireguardHints(protocol string, settings string) (string, int, string) {
+ if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
+ return "", 0, ""
+ }
+ var parsed struct {
+ PublicKey string `json:"publicKey"`
+ PubKey string `json:"pubKey"`
+ SecretKey string `json:"secretKey"`
+ MTU int `json:"mtu"`
+ DNS string `json:"dns"`
+ }
+ if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+ return "", 0, ""
+ }
+ publicKey := parsed.PublicKey
+ if publicKey == "" {
+ publicKey = parsed.PubKey
+ }
+ if publicKey == "" && parsed.SecretKey != "" {
+ if derived, err := wgutil.PublicKeyFromPrivate(parsed.SecretKey); err == nil {
+ publicKey = derived
+ }
+ }
+ return publicKey, parsed.MTU, parsed.DNS
+}
+
// GetAllInbounds retrieves all inbounds with client stats.
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
db := database.GetDB()
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index ea1fd4538..da22372ec 100644
--- a/internal/web/translation/en-US.json
+++ b/internal/web/translation/en-US.json
@@ -718,6 +718,8 @@
"tabBasics": "Basics",
"tabCredentials": "Credentials",
"tabLinks": "Links",
+ "wireguardConfig": "WireGuard config",
+ "conf": "CONF",
"linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
"addExternalLink": "Add External Link",
"addExternalSubscription": "Add External Subscription",