mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
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:
parent
72e01f8c84
commit
927797f3d5
@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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>>(
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user