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