Add SSL certificate management to WebUI config

Introduces backend API endpoints and frontend UI for managing SSL certificates, including viewing status, uploading, and deleting cert/key files. Adds a new SSL configuration tab in the dashboard, allowing users to enable HTTPS by providing PEM-formatted certificate and key, with changes taking effect after restart.
This commit is contained in:
手瓜一十雪 2026-01-30 14:28:47 +08:00
parent 72e01f8c84
commit 927797f3d5
5 changed files with 294 additions and 1 deletions

View File

@ -1,7 +1,9 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { WebUiConfig } from '@/napcat-webui-backend/index'; import { WebUiConfig, webUiPathWrapper } from '@/napcat-webui-backend/index';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { isEmpty } from '@/napcat-webui-backend/src/utils/check'; import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
import { existsSync, promises as fsProm } from 'node:fs';
import { join } from 'node:path';
// 获取WebUI基础配置 // 获取WebUI基础配置
export const GetWebUIConfigHandler: RequestHandler = async (_, res) => { export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
@ -158,3 +160,86 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
return sendError(res, `更新WebUI配置失败: ${msg}`); return sendError(res, `更新WebUI配置失败: ${msg}`);
} }
}; };
// 获取SSL证书状态
export const GetSSLStatusHandler: RequestHandler = async (_, res) => {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
const certExists = existsSync(certPath);
const keyExists = existsSync(keyPath);
let certContent = '';
let keyContent = '';
if (certExists) {
certContent = await fsProm.readFile(certPath, 'utf-8');
}
if (keyExists) {
keyContent = await fsProm.readFile(keyPath, 'utf-8');
}
return sendSuccess(res, {
enabled: certExists && keyExists,
certExists,
keyExists,
certContent,
keyContent,
});
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `获取SSL状态失败: ${msg}`);
}
};
// 保存SSL证书通过文本内容
export const UploadSSLCertHandler: RequestHandler = async (req, res) => {
try {
const { cert, key } = req.body;
if (isEmpty(cert) || isEmpty(key)) {
return sendError(res, 'cert和key内容不能为空');
}
// 简单验证证书格式
if (!cert.includes('-----BEGIN CERTIFICATE-----') || !cert.includes('-----END CERTIFICATE-----')) {
return sendError(res, 'cert格式不正确应为PEM格式的证书');
}
if (!key.includes('-----BEGIN') || !key.includes('KEY-----')) {
return sendError(res, 'key格式不正确应为PEM格式的私钥');
}
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
await fsProm.writeFile(certPath, cert, 'utf-8');
await fsProm.writeFile(keyPath, key, 'utf-8');
return sendSuccess(res, { message: 'SSL证书保存成功重启后生效' });
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `保存SSL证书失败: ${msg}`);
}
};
// 删除SSL证书
export const DeleteSSLCertHandler: RequestHandler = async (_, res) => {
try {
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
if (existsSync(certPath)) {
await fsProm.unlink(certPath);
}
if (existsSync(keyPath)) {
await fsProm.unlink(keyPath);
}
return sendSuccess(res, { message: 'SSL证书已删除重启后生效' });
} catch (error) {
const msg = (error as Error).message;
return sendError(res, `删除SSL证书失败: ${msg}`);
}
};

View File

@ -5,6 +5,9 @@ import {
UpdateDisableWebUIHandler, UpdateDisableWebUIHandler,
UpdateWebUIConfigHandler, UpdateWebUIConfigHandler,
GetClientIPHandler, GetClientIPHandler,
GetSSLStatusHandler,
UploadSSLCertHandler,
DeleteSSLCertHandler,
} from '@/napcat-webui-backend/src/api/WebUIConfig'; } from '@/napcat-webui-backend/src/api/WebUIConfig';
const router: Router = Router(); const router: Router = Router();
@ -24,4 +27,13 @@ router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
// 获取当前客户端IP // 获取当前客户端IP
router.get('/GetClientIP', GetClientIPHandler); router.get('/GetClientIP', GetClientIPHandler);
// 获取SSL证书状态
router.get('/GetSSLStatus', GetSSLStatusHandler);
// 上传SSL证书
router.post('/UploadSSLCert', UploadSSLCertHandler);
// 删除SSL证书
router.post('/DeleteSSLCert', DeleteSSLCertHandler);
export { router as WebUIConfigRouter }; export { router as WebUIConfigRouter };

View File

@ -281,6 +281,35 @@ export default class WebUIManager {
return data.data; return data.data;
} }
// 获取SSL证书状态
public static async getSSLStatus () {
const { data } = await serverRequest.get<ServerResponse<{
enabled: boolean;
certExists: boolean;
keyExists: boolean;
certContent: string;
keyContent: string;
}>>('/WebUIConfig/GetSSLStatus');
return data.data;
}
// 保存SSL证书
public static async saveSSLCert (cert: string, key: string) {
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>(
'/WebUIConfig/UploadSSLCert',
{ cert, key }
);
return data.data;
}
// 删除SSL证书
public static async deleteSSLCert () {
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>(
'/WebUIConfig/DeleteSSLCert'
);
return data.data;
}
// Passkey相关方法 // Passkey相关方法
public static async generatePasskeyRegistrationOptions () { public static async generatePasskeyRegistrationOptions () {
const { data } = await serverRequest.post<ServerResponse<any>>( const { data } = await serverRequest.post<ServerResponse<any>>(

View File

@ -10,6 +10,7 @@ import ChangePasswordCard from './change_password';
import LoginConfigCard from './login'; import LoginConfigCard from './login';
import OneBotConfigCard from './onebot'; import OneBotConfigCard from './onebot';
import ServerConfigCard from './server'; import ServerConfigCard from './server';
import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme'; import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui'; import WebUIConfigCard from './webui';
@ -81,6 +82,11 @@ export default function ConfigPage () {
<ServerConfigCard /> <ServerConfigCard />
</ConfigPageItem> </ConfigPageItem>
</Tab> </Tab>
<Tab title='SSL配置' key='ssl'>
<ConfigPageItem size='sm'>
<SSLConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='WebUI配置' key='webui'> <Tab title='WebUI配置' key='webui'>
<ConfigPageItem> <ConfigPageItem>
<WebUIConfigCard /> <WebUIConfigCard />

View File

@ -0,0 +1,161 @@
import { useRequest } from 'ahooks';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { Button } from '@heroui/button';
import { Textarea } from '@heroui/input';
import PageLoading from '@/components/page_loading';
import WebUIManager from '@/controllers/webui_manager';
const SSLConfigCard = () => {
const {
data: sslData,
loading: sslLoading,
refreshAsync: refreshSSL,
} = useRequest(WebUIManager.getSSLStatus);
const [sslCert, setSslCert] = useState('');
const [sslKey, setSslKey] = useState('');
const [sslSaving, setSslSaving] = useState(false);
useEffect(() => {
if (sslData) {
setSslCert(sslData.certContent || '');
setSslKey(sslData.keyContent || '');
}
}, [sslData]);
const handleSaveSSL = async () => {
if (!sslCert.trim() || !sslKey.trim()) {
toast.error('证书和私钥内容不能为空');
return;
}
setSslSaving(true);
try {
const result = await WebUIManager.saveSSLCert(sslCert, sslKey);
toast.success(result.message || 'SSL证书保存成功');
await refreshSSL();
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存SSL证书失败: ${msg}`);
} finally {
setSslSaving(false);
}
};
const handleDeleteSSL = async () => {
setSslSaving(true);
try {
const result = await WebUIManager.deleteSSLCert();
toast.success(result.message || 'SSL证书已删除');
setSslCert('');
setSslKey('');
await refreshSSL();
} catch (error) {
const msg = (error as Error).message;
toast.error(`删除SSL证书失败: ${msg}`);
} finally {
setSslSaving(false);
}
};
const handleRefresh = async () => {
try {
await refreshSSL();
toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
}
};
if (sslLoading) return <PageLoading loading />;
return (
<>
<title>SSL配置 - NapCat WebUI</title>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>SSL/HTTPS </div>
{sslData?.enabled && (
<span className='px-2 py-0.5 text-xs bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400 rounded-full whitespace-nowrap'>
</span>
)}
</div>
<p className='text-sm text-default-500 px-1'>
SSL证书后重启即可启用HTTPS(cert.pem)(key.pem)
</p>
<div className='p-3 bg-warning-50 dark:bg-warning-900/20 rounded-lg border border-warning-200 dark:border-warning-800'>
<p className='text-sm text-warning-700 dark:text-warning-400'>
<strong></strong>HTTP模式
</p>
</div>
</div>
<div className='flex flex-col gap-4'>
<Textarea
label='证书内容 (cert.pem)'
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
value={sslCert}
onValueChange={setSslCert}
minRows={6}
maxRows={12}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400 font-mono text-sm',
}}
/>
<Textarea
label='私钥内容 (key.pem)'
placeholder={'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'}
value={sslKey}
onValueChange={setSslKey}
minRows={6}
maxRows={12}
classNames={{
inputWrapper:
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
input: 'bg-transparent text-default-700 placeholder:text-default-400 font-mono text-sm',
}}
/>
</div>
<div className='flex gap-2 justify-end'>
<Button
variant='flat'
isLoading={sslSaving || sslLoading}
onPress={handleRefresh}
size='sm'
>
</Button>
{sslData?.enabled && (
<Button
color='danger'
variant='flat'
isLoading={sslSaving || sslLoading}
onPress={handleDeleteSSL}
size='sm'
>
SSL证书
</Button>
)}
<Button
color='primary'
isLoading={sslSaving || sslLoading}
onPress={handleSaveSSL}
size='sm'
>
SSL证书
</Button>
</div>
</div>
</>
);
};
export default SSLConfigCard;