Browse Source

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.
MHSanaei 12 hours ago
parent
commit
a329882e0e

+ 13 - 1
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": ""
                     }
                   ]
                 }

+ 40 - 0
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;
+}

+ 79 - 0
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 = (
+    <div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
+      <Tooltip title={t('copy')}>
+        <Button size="small" icon={<CopyOutlined />} onClick={copy} />
+      </Tooltip>
+      <Tooltip title={t('download')}>
+        <Button
+          size="small"
+          icon={<DownloadOutlined />}
+          onClick={() => FileManager.downloadTextFile(text, fileName)}
+        />
+      </Tooltip>
+      {showQr && (
+        <Popover
+          trigger="click"
+          placement="left"
+          destroyOnHidden
+          content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
+        >
+          <Tooltip title={t('pages.clients.qrCode')}>
+            <Button size="small" icon={<QrcodeOutlined />} />
+          </Tooltip>
+        </Popover>
+      )}
+    </div>
+  );
+
+  return (
+    <>
+      {messageContextHolder}
+      <Collapse
+        className="config-block"
+        defaultActiveKey={defaultOpen ? ['cfg'] : []}
+        items={[{
+          key: 'cfg',
+          label: <Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>{label}</Tag>,
+          extra: actions,
+          children: <code className="config-block-text">{text}</code>,
+        }]}
+      />
+    </>
+  );
+}

+ 4 - 1
frontend/src/generated/examples.ts

@@ -406,7 +406,10 @@ export const EXAMPLES: Record<string, unknown> = {
     "remark": "VLESS-443",
     "ssMethod": "",
     "tag": "in-443-tcp",
-    "tlsFlowCapable": true
+    "tlsFlowCapable": true,
+    "wgDns": "",
+    "wgMtu": 0,
+    "wgPublicKey": ""
   },
   "Msg": {
     "msg": "",

+ 9 - 0
frontend/src/generated/schemas.ts

@@ -1828,6 +1828,15 @@ export const SCHEMAS: Record<string, unknown> = {
       "tlsFlowCapable": {
         "example": true,
         "type": "boolean"
+      },
+      "wgDns": {
+        "type": "string"
+      },
+      "wgMtu": {
+        "type": "integer"
+      },
+      "wgPublicKey": {
+        "type": "string"
       }
     },
     "required": [

+ 3 - 0
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 {

+ 3 - 0
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<typeof InboundOptionSchema>;
 

+ 4 - 0
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;

+ 61 - 1
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 {

+ 59 - 38
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<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>

+ 28 - 5
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<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) {

+ 1 - 0
frontend/src/pages/clients/ClientsPage.tsx

@@ -1459,6 +1459,7 @@ export default function ClientsPage() {
           <ClientQrModal
             open={qrOpen}
             client={qrClient}
+            inboundsById={inboundsById}
             subSettings={subSettings}
             onOpenChange={setQrOpen}
           />

+ 44 - 0
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<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');
+}

+ 3 - 0
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -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')}

+ 7 - 2
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -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>

+ 1 - 1
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: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
   }
   items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });

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

+ 27 - 2
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<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

+ 3 - 2
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -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)}
         />
       ),
     })),

+ 16 - 3
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 (
-                          <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>

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

+ 1 - 0
frontend/src/schemas/protocols/inbound/wireguard.ts

@@ -61,6 +61,7 @@ export type WireguardClient = z.infer<typeof WireguardClientSchema>;
 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),

+ 55 - 0
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');
+  });
+});

+ 9 - 0
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, "", ""))
 }
 

+ 34 - 0
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()

+ 2 - 0
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",