mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 01:47:15 +00:00
feat(wireguard): client config UX, collapsible config card, configurable DNS
Land the WireGuard client-config UX work on main (the upstream PR #5642 branch could not be pushed to). - Reusable collapsible ConfigBlock (copy/download/QR, actions aligned right) for the client .conf, used by client info and the public sub page. - Correct .conf: canonical PresharedKey casing and DNS sourced from the inbound (configurable per-inbound, default 1.1.1.1, 1.0.0.1). - Configurable per-inbound DNS for WireGuard (schema + form + backend hint via InboundOption.WgDns); inert at the Xray layer. - Public sub page now shows the WireGuard config, rebuilt from the share link; the Go wireguard:// link carries dns/presharedkey/keepalive for completeness. - QR enabled for the wireguard:// link; link rows are compact like other protocols. - Client information order is subscription, copy URL, WireGuard config; the redundant config tab is removed from the add/edit client modal. - Drop the Inbound Information and QR Code row actions for WireGuard inbounds.
This commit is contained in:
@@ -1854,6 +1854,15 @@
|
|||||||
"tlsFlowCapable": {
|
"tlsFlowCapable": {
|
||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"wgDns": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"wgMtu": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"wgPublicKey": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -2751,7 +2760,10 @@
|
|||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
"ssMethod": "",
|
"ssMethod": "",
|
||||||
"tag": "in-443-tcp",
|
"tag": "in-443-tcp",
|
||||||
"tlsFlowCapable": true
|
"tlsFlowCapable": true,
|
||||||
|
"wgDns": "",
|
||||||
|
"wgMtu": 0,
|
||||||
|
"wgPublicKey": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -406,7 +406,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
"ssMethod": "",
|
"ssMethod": "",
|
||||||
"tag": "in-443-tcp",
|
"tag": "in-443-tcp",
|
||||||
"tlsFlowCapable": true
|
"tlsFlowCapable": true,
|
||||||
|
"wgDns": "",
|
||||||
|
"wgMtu": 0,
|
||||||
|
"wgPublicKey": ""
|
||||||
},
|
},
|
||||||
"Msg": {
|
"Msg": {
|
||||||
"msg": "",
|
"msg": "",
|
||||||
|
|||||||
@@ -1828,6 +1828,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tlsFlowCapable": {
|
"tlsFlowCapable": {
|
||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"wgDns": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"wgMtu": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"wgPublicKey": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -401,6 +401,9 @@ export interface InboundOption {
|
|||||||
ssMethod: string;
|
ssMethod: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
tlsFlowCapable: boolean;
|
tlsFlowCapable: boolean;
|
||||||
|
wgDns?: string;
|
||||||
|
wgMtu?: number;
|
||||||
|
wgPublicKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Msg {
|
export interface Msg {
|
||||||
|
|||||||
@@ -428,6 +428,9 @@ export const InboundOptionSchema = z.object({
|
|||||||
ssMethod: z.string(),
|
ssMethod: z.string(),
|
||||||
tag: z.string(),
|
tag: z.string(),
|
||||||
tlsFlowCapable: z.boolean(),
|
tlsFlowCapable: z.boolean(),
|
||||||
|
wgDns: z.string().optional(),
|
||||||
|
wgMtu: z.number().int().optional(),
|
||||||
|
wgPublicKey: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface SubSettings {
|
|||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
|
publicHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientQueryParams {
|
export interface ClientQueryParams {
|
||||||
@@ -240,6 +241,7 @@ export function useClients() {
|
|||||||
subJsonEnable: !!defaults.subJsonEnable,
|
subJsonEnable: !!defaults.subJsonEnable,
|
||||||
subClashURI: (defaults.subClashURI as string) || '',
|
subClashURI: (defaults.subClashURI as string) || '',
|
||||||
subClashEnable: !!defaults.subClashEnable,
|
subClashEnable: !!defaults.subClashEnable,
|
||||||
|
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
||||||
}), [
|
}), [
|
||||||
defaults.subEnable,
|
defaults.subEnable,
|
||||||
defaults.subURI,
|
defaults.subURI,
|
||||||
@@ -247,6 +249,8 @@ export function useClients() {
|
|||||||
defaults.subJsonEnable,
|
defaults.subJsonEnable,
|
||||||
defaults.subClashURI,
|
defaults.subClashURI,
|
||||||
defaults.subClashEnable,
|
defaults.subClashEnable,
|
||||||
|
defaults.subDomain,
|
||||||
|
defaults.webDomain,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||||
|
|||||||
@@ -828,7 +828,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
|||||||
let txt = `[Interface]\n`;
|
let txt = `[Interface]\n`;
|
||||||
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
|
||||||
txt += `Address = ${peer.allowedIPs[0] ?? ''}\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) {
|
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
|
||||||
txt += `MTU = ${settings.mtu}\n`;
|
txt += `MTU = ${settings.mtu}\n`;
|
||||||
}
|
}
|
||||||
@@ -846,6 +846,66 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
|
|||||||
return txt;
|
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 };
|
export type { WireguardInboundPeer };
|
||||||
|
|
||||||
function isUnixSocketListen(listen: string): boolean {
|
function isUnixSocketListen(listen: string): boolean {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
|||||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||||
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
||||||
import { QrPanel } from '@/pages/inbounds/qr';
|
import { QrPanel } from '@/pages/inbounds/qr';
|
||||||
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||||
|
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
|
||||||
import './ClientInfoModal.css';
|
import './ClientInfoModal.css';
|
||||||
|
|
||||||
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
|
||||||
@@ -35,6 +37,7 @@ interface SubSettings {
|
|||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
subClashURI: string;
|
subClashURI: string;
|
||||||
subClashEnable: boolean;
|
subClashEnable: boolean;
|
||||||
|
publicHost?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientInfoModalProps {
|
interface ClientInfoModalProps {
|
||||||
@@ -58,6 +61,7 @@ const DEFAULT_SUB: SubSettings = {
|
|||||||
subJsonEnable: false,
|
subJsonEnable: false,
|
||||||
subClashURI: '',
|
subClashURI: '',
|
||||||
subClashEnable: false,
|
subClashEnable: false,
|
||||||
|
publicHost: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientInfoModal({
|
export default function ClientInfoModal({
|
||||||
@@ -132,6 +136,11 @@ export default function ClientInfoModal({
|
|||||||
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
|
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
|
||||||
|
|
||||||
const showSubscription = !!(subSettings?.enable && client?.subId);
|
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) {
|
async function copyValue(text: string) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -350,44 +359,6 @@ export default function ClientInfoModal({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 && (
|
{showSubscription && subLink && (
|
||||||
<>
|
<>
|
||||||
<Divider>{t('subscription.title')}</Divider>
|
<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>
|
</Modal>
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Collapse, Modal, Spin } from 'antd';
|
import { Collapse, Modal, Spin, Tag } from 'antd';
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||||
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
|
||||||
import { QrPanel } from '@/pages/inbounds/qr';
|
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 {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
subJsonURI: string;
|
||||||
subJsonEnable: boolean;
|
subJsonEnable: boolean;
|
||||||
|
publicHost?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientQrModalProps {
|
interface ClientQrModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
client: ClientRecord | null;
|
client: ClientRecord | null;
|
||||||
|
inboundsById: Record<number, InboundOption>;
|
||||||
subSettings?: SubSettings;
|
subSettings?: SubSettings;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -26,11 +29,12 @@ interface ApiMsg<T = unknown> {
|
|||||||
obj?: T;
|
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({
|
export default function ClientQrModal({
|
||||||
open,
|
open,
|
||||||
client,
|
client,
|
||||||
|
inboundsById,
|
||||||
subSettings = DEFAULT_SUB,
|
subSettings = DEFAULT_SUB,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClientQrModalProps) {
|
}: ClientQrModalProps) {
|
||||||
@@ -49,7 +53,13 @@ export default function ClientQrModal({
|
|||||||
return subSettings.subJsonURI + client.subId;
|
return subSettings.subJsonURI + client.subId;
|
||||||
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open || !client?.subId) {
|
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;
|
return out;
|
||||||
}, [subLink, subJsonLink, links, client?.email, t]);
|
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|||||||
@@ -1459,6 +1459,7 @@ export default function ClientsPage() {
|
|||||||
<ClientQrModal
|
<ClientQrModal
|
||||||
open={qrOpen}
|
open={qrOpen}
|
||||||
client={qrClient}
|
client={qrClient}
|
||||||
|
inboundsById={inboundsById}
|
||||||
subSettings={subSettings}
|
subSettings={subSettings}
|
||||||
onOpenChange={setQrOpen}
|
onOpenChange={setQrOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||||
|
import { preferPublicHost } from '@/lib/xray/inbound-link';
|
||||||
|
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||||
|
|
||||||
|
export function isWireguardClient(client: ClientRecord | null | undefined): boolean {
|
||||||
|
if (!client) return false;
|
||||||
|
return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findWireguardInbound(
|
||||||
|
client: ClientRecord | null | undefined,
|
||||||
|
inboundsById: Record<number, InboundOption>,
|
||||||
|
): InboundOption | undefined {
|
||||||
|
return (client?.inboundIds || [])
|
||||||
|
.map((id) => inboundsById[id])
|
||||||
|
.find((ib) => ib?.protocol === 'wireguard');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWireguardClientConfig(
|
||||||
|
client: ClientRecord,
|
||||||
|
inbound: InboundOption | undefined,
|
||||||
|
host = window.location.hostname,
|
||||||
|
publicHost = '',
|
||||||
|
): string {
|
||||||
|
const endpointHost = preferPublicHost(host, publicHost);
|
||||||
|
const address = client.allowedIPs || '10.0.0.2/32';
|
||||||
|
const endpoint = `${endpointHost}:${inbound?.port || ''}`;
|
||||||
|
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
|
||||||
|
const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
|
||||||
|
const lines = [
|
||||||
|
'[Interface]',
|
||||||
|
`PrivateKey = ${client.privateKey || client.password || ''}`,
|
||||||
|
`Address = ${address}`,
|
||||||
|
`DNS = ${inbound?.wgDns || '1.1.1.1, 1.0.0.1'}`,
|
||||||
|
];
|
||||||
|
if (inbound?.wgMtu && inbound.wgMtu > 0) lines.push(`MTU = ${inbound.wgMtu}`);
|
||||||
|
lines.push('');
|
||||||
|
if (remark) lines.push(`# ${remark}`);
|
||||||
|
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
|
||||||
|
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
|
||||||
|
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
|
||||||
|
if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
|
|||||||
<Form.Item name={['settings', 'mtu']} label="MTU">
|
<Form.Item name={['settings', 'mtu']} label="MTU">
|
||||||
<InputNumber />
|
<InputNumber />
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
name={['settings', 'noKernelTun']}
|
name={['settings', 'noKernelTun']}
|
||||||
label={t('pages.inbounds.info.noKernelTun')}
|
label={t('pages.inbounds.info.noKernelTun')}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
|
||||||
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
|
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 { Protocols } from '@/schemas/primitives';
|
||||||
import { InfinityIcon } from '@/components/ui';
|
import { InfinityIcon } from '@/components/ui';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
@@ -208,6 +208,11 @@ export default function InboundInfoModal({
|
|||||||
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
|
||||||
}, [clientStats, clientSettings]);
|
}, [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(
|
const formatLastOnline = useCallback(
|
||||||
(email: string) => {
|
(email: string) => {
|
||||||
const ts = lastOnlineMap[email];
|
const ts = lastOnlineMap[email];
|
||||||
@@ -791,7 +796,7 @@ export default function InboundInfoModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<dt>{t('pages.xray.wireguard.publicKey')}</dt>
|
<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>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<dt>{t('pages.inbounds.info.mtu')}</dt>
|
<dt>{t('pages.inbounds.info.mtu')}</dt>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
|
|||||||
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
|
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: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
|
||||||
}
|
}
|
||||||
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||||
if (dbInbound.isWireguard) return true;
|
|
||||||
if (dbInbound.isSS) {
|
if (dbInbound.isSS) {
|
||||||
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
|
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
|||||||
import { InfinityIcon } from '@/components/ui';
|
import { InfinityIcon } from '@/components/ui';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||||
|
|
||||||
import { RowActionsCell } from './RowActions';
|
import { RowActionsCell } from './RowActions';
|
||||||
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||||
@@ -51,6 +52,28 @@ export function useInboundColumns({
|
|||||||
const { datepicker } = useDatepicker();
|
const { datepicker } = useDatepicker();
|
||||||
|
|
||||||
return useMemo(() => {
|
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>[] = [
|
const cols: TableColumnType<DBInboundRecord>[] = [
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: 'ID',
|
||||||
@@ -174,14 +197,14 @@ export function useInboundColumns({
|
|||||||
align: 'left',
|
align: 'left',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const cc = clientCount[record.id];
|
const cc = clientCount[record.id] || fallbackClientCount(record);
|
||||||
if (!cc) return null;
|
if (!cc) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
|
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
|
||||||
<TeamOutlined /> {cc.clients}
|
<TeamOutlined /> {cc.clients}
|
||||||
</Tag>
|
</Tag>
|
||||||
{cc.active.length > 0 && (
|
{cc.active.length > 0 ? (
|
||||||
<Popover
|
<Popover
|
||||||
title={t('subscription.active')}
|
title={t('subscription.active')}
|
||||||
content={(
|
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>
|
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>0</Tag>
|
||||||
)}
|
)}
|
||||||
{cc.deactive.length > 0 && (
|
{cc.deactive.length > 0 && (
|
||||||
<Popover
|
<Popover
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface QrItem {
|
|||||||
header: string;
|
header: string;
|
||||||
value: string;
|
value: string;
|
||||||
downloadName?: string;
|
downloadName?: string;
|
||||||
|
showQr?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QrCodeModal({
|
export default function QrCodeModal({
|
||||||
@@ -122,7 +123,7 @@ export default function QrCodeModal({
|
|||||||
downloadName: `peer-${idx + 1}.conf`,
|
downloadName: `peer-${idx + 1}.conf`,
|
||||||
});
|
});
|
||||||
if (wireguardLinks[idx]) {
|
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;
|
return items;
|
||||||
@@ -137,7 +138,7 @@ export default function QrCodeModal({
|
|||||||
value={item.value}
|
value={item.value}
|
||||||
remark={item.header}
|
remark={item.header}
|
||||||
downloadName={item.downloadName || ''}
|
downloadName={item.downloadName || ''}
|
||||||
showQr={!isPostQuantumLink(item.value)}
|
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -31,8 +31,9 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
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 { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||||
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import SubUsageSummary from './SubUsageSummary';
|
import SubUsageSummary from './SubUsageSummary';
|
||||||
@@ -423,8 +424,10 @@ export default function SubPage() {
|
|||||||
const rowTitle = parts?.remark || fallback;
|
const rowTitle = parts?.remark || fallback;
|
||||||
const qrLabel = parts?.remark || rowTitle;
|
const qrLabel = parts?.remark || rowTitle;
|
||||||
const canQr = !isPostQuantumLink(link);
|
const canQr = !isPostQuantumLink(link);
|
||||||
|
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||||
return (
|
return (
|
||||||
<div key={link} className="sub-link-row">
|
<Fragment key={link}>
|
||||||
|
<div className="sub-link-row">
|
||||||
{parts
|
{parts
|
||||||
? <LinkTags parts={parts} />
|
? <LinkTags parts={parts} />
|
||||||
: <Tag className="sub-link-tag">LINK</Tag>}
|
: <Tag className="sub-link-tag">LINK</Tag>}
|
||||||
@@ -468,6 +471,16 @@ export default function SubPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isWireguardLink && (
|
||||||
|
<ConfigBlock
|
||||||
|
label={t('pages.clients.wireguardConfig')}
|
||||||
|
text={wireguardConfigFromLink(link, rowTitle)}
|
||||||
|
fileName={`${rowTitle || 'peer'}.conf`}
|
||||||
|
qrRemark={rowTitle}
|
||||||
|
tagColor="cyan"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const InboundOptionSchema = z.object({
|
|||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
tlsFlowCapable: z.boolean().optional(),
|
tlsFlowCapable: z.boolean().optional(),
|
||||||
ssMethod: z.string().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).
|
// Hosting node id; absent/null for this panel's own inbounds (#4997).
|
||||||
nodeId: z.number().nullable().optional(),
|
nodeId: z.number().nullable().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export type WireguardClient = z.infer<typeof WireguardClientSchema>;
|
|||||||
export const WireguardInboundSettingsSchema = z.object({
|
export const WireguardInboundSettingsSchema = z.object({
|
||||||
mtu: optionalClearedInt(z.number().int().min(1)),
|
mtu: optionalClearedInt(z.number().int().min(1)),
|
||||||
secretKey: z.string().min(1),
|
secretKey: z.string().min(1),
|
||||||
|
dns: z.string().optional(),
|
||||||
peers: z.array(WireguardInboundPeerSchema).default([]),
|
peers: z.array(WireguardInboundPeerSchema).default([]),
|
||||||
clients: z.array(WireguardClientSchema).default([]),
|
clients: z.array(WireguardClientSchema).default([]),
|
||||||
noKernelTun: z.boolean().default(false),
|
noKernelTun: z.boolean().default(false),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -545,6 +545,15 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
|
|||||||
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
|
if mtu, ok := settings["mtu"].(float64); ok && mtu > 0 {
|
||||||
params["mtu"] = strconv.Itoa(int(mtu))
|
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, "", ""))
|
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
"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/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
"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"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -298,6 +299,9 @@ type InboundOption struct {
|
|||||||
Port int `json:"port" example:"443"`
|
Port int `json:"port" example:"443"`
|
||||||
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
|
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
|
||||||
SsMethod string `json:"ssMethod"`
|
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
|
// Hosting node; nil for this panel's own inbounds. Lets the clients
|
||||||
// page map a node filter onto inbound IDs (#4997).
|
// page map a node filter onto inbound IDs (#4997).
|
||||||
NodeId *int `json:"nodeId,omitempty"`
|
NodeId *int `json:"nodeId,omitempty"`
|
||||||
@@ -325,6 +329,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
|||||||
}
|
}
|
||||||
out := make([]InboundOption, 0, len(rows))
|
out := make([]InboundOption, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
|
||||||
out = append(out, InboundOption{
|
out = append(out, InboundOption{
|
||||||
Id: r.Id,
|
Id: r.Id,
|
||||||
Remark: r.Remark,
|
Remark: r.Remark,
|
||||||
@@ -333,12 +338,41 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
|||||||
Port: r.Port,
|
Port: r.Port,
|
||||||
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
|
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
|
||||||
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
|
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
|
||||||
|
WgPublicKey: wgPublicKey,
|
||||||
|
WgMtu: wgMtu,
|
||||||
|
WgDns: wgDns,
|
||||||
NodeId: r.NodeId,
|
NodeId: r.NodeId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
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.
|
// GetAllInbounds retrieves all inbounds with client stats.
|
||||||
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
|
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|||||||
@@ -718,6 +718,8 @@
|
|||||||
"tabBasics": "Basics",
|
"tabBasics": "Basics",
|
||||||
"tabCredentials": "Credentials",
|
"tabCredentials": "Credentials",
|
||||||
"tabLinks": "Links",
|
"tabLinks": "Links",
|
||||||
|
"wireguardConfig": "WireGuard config",
|
||||||
|
"conf": "CONF",
|
||||||
"linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
|
"linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
|
||||||
"addExternalLink": "Add External Link",
|
"addExternalLink": "Add External Link",
|
||||||
"addExternalSubscription": "Add External Subscription",
|
"addExternalSubscription": "Add External Subscription",
|
||||||
|
|||||||
Reference in New Issue
Block a user