feat: add TLS certificate management functionality

- Implemented TLS certificate model and service for managing certificates.
- Added API endpoints for creating, importing, listing, and deleting TLS certificates.
- Enhanced proxy route configuration to support HTTPS with certificate selection.
- Updated frontend to include TLS certificate management UI with manual and file import options.
- Added validation for HTTPS routes to ensure certificates are selected.
- Implemented tests for TLS certificate creation and proxy route validation.
This commit is contained in:
ryan
2026-03-10 10:44:29 +08:00
parent ca2c7f6e27
commit 2cbaf95eae
25 changed files with 1384 additions and 81 deletions
+9
View File
@@ -20,6 +20,7 @@ import ProxyRoute from './pages/ProxyRoute';
import ConfigVersion from './pages/ConfigVersion';
import Node from './pages/Node';
import ApplyLog from './pages/ApplyLog';
import TLSCertificate from './pages/TLSCertificate';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
@@ -108,6 +109,14 @@ function App() {
</PrivateRoute>
}
/>
<Route
path='/tls-certificate'
element={
<PrivateRoute>
<TLSCertificate />
</PrivateRoute>
}
/>
<Route
path='/file'
element={
+6
View File
@@ -44,6 +44,12 @@ const headerButtons = [
icon: 'tasks',
admin: true,
},
{
name: '证书',
to: '/tls-certificate',
icon: 'lock',
admin: true,
},
{
name: '文件',
to: '/file',
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Dropdown,
Form,
Header,
Icon,
@@ -15,11 +16,15 @@ const initialForm = {
domain: '',
origin_url: '',
enabled: true,
enable_https: false,
cert_id: '',
redirect_http: false,
remark: '',
};
const ProxyRoute = () => {
const [routes, setRoutes] = useState([]);
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [form, setForm] = useState(initialForm);
@@ -37,8 +42,19 @@ const ProxyRoute = () => {
setLoading(false);
};
const loadCertificates = async () => {
const res = await API.get('/api/tls-certificates/');
const { success, message, data } = res.data;
if (success) {
setCertificates(data || []);
} else {
showError(message);
}
};
useEffect(() => {
loadRoutes().then();
loadCertificates().then();
}, []);
const resetForm = () => {
@@ -51,6 +67,7 @@ const ProxyRoute = () => {
...form,
domain: form.domain.trim(),
origin_url: form.origin_url.trim(),
cert_id: form.enable_https && form.cert_id ? Number(form.cert_id) : null,
remark: form.remark.trim(),
};
const res = editingId
@@ -95,10 +112,19 @@ const ProxyRoute = () => {
domain: route.domain,
origin_url: route.origin_url,
enabled: route.enabled,
enable_https: route.enable_https || false,
cert_id: route.cert_id || '',
redirect_http: route.redirect_http || false,
remark: route.remark || '',
});
};
const certificateOptions = certificates.map((certificate) => ({
key: certificate.id,
text: `${certificate.name} (${certificate.not_after ? formatDateTime(certificate.not_after) : 'unknown'})`,
value: certificate.id,
}));
return (
<Segment loading={loading}>
<div className='page-toolbar'>
@@ -127,6 +153,33 @@ const ProxyRoute = () => {
onChange={(e, { value }) => setForm({ ...form, origin_url: value })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Checkbox
toggle
label='启用 HTTPS'
checked={form.enable_https}
onChange={(e, { checked }) =>
setForm({
...form,
enable_https: checked,
cert_id: checked ? form.cert_id : '',
redirect_http: checked ? form.redirect_http : false,
})
}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
<Form.Field
control={Dropdown}
selection
clearable
disabled={!form.enable_https}
label='TLS 证书'
placeholder='选择已导入证书'
options={certificateOptions}
value={form.cert_id}
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Field
control={TextArea}
@@ -142,6 +195,14 @@ const ProxyRoute = () => {
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
<Form.Checkbox
toggle
label='HTTP 跳转 HTTPS'
checked={form.redirect_http}
disabled={!form.enable_https}
onChange={(e, { checked }) => setForm({ ...form, redirect_http: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
</Form.Group>
<Button primary type='submit'>
{editingId ? '保存修改' : '新增规则'}
@@ -158,6 +219,7 @@ const ProxyRoute = () => {
<Table.Row>
<Table.HeaderCell>域名</Table.HeaderCell>
<Table.HeaderCell>源站地址</Table.HeaderCell>
<Table.HeaderCell>HTTPS</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
@@ -169,6 +231,13 @@ const ProxyRoute = () => {
<Table.Row key={route.id}>
<Table.Cell>{route.domain}</Table.Cell>
<Table.Cell>{route.origin_url}</Table.Cell>
<Table.Cell>
{route.enable_https ? (
<Label color='blue'>{route.redirect_http ? 'HTTPS + 重定向' : 'HTTPS'}</Label>
) : (
<Label>HTTP</Label>
)}
</Table.Cell>
<Table.Cell>
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
</Table.Cell>
@@ -0,0 +1,221 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Header,
Icon,
Segment,
Table,
TextArea,
} from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const initialManualForm = {
name: '',
cert_pem: '',
key_pem: '',
remark: '',
};
const initialFileForm = {
name: '',
remark: '',
};
const TLSCertificate = () => {
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [manualForm, setManualForm] = useState(initialManualForm);
const [fileForm, setFileForm] = useState(initialFileForm);
const [certFile, setCertFile] = useState(null);
const [keyFile, setKeyFile] = useState(null);
const [submittingManual, setSubmittingManual] = useState(false);
const [submittingFiles, setSubmittingFiles] = useState(false);
const loadCertificates = async () => {
setLoading(true);
const res = await API.get('/api/tls-certificates/');
const { success, message, data } = res.data;
if (success) {
setCertificates(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadCertificates().then();
}, []);
const submitManual = async () => {
setSubmittingManual(true);
const payload = {
name: manualForm.name.trim(),
cert_pem: manualForm.cert_pem.trim(),
key_pem: manualForm.key_pem.trim(),
remark: manualForm.remark.trim(),
};
const res = await API.post('/api/tls-certificates/', payload);
const { success, message } = res.data;
if (success) {
showSuccess('证书已导入');
setManualForm(initialManualForm);
await loadCertificates();
} else {
showError(message);
}
setSubmittingManual(false);
};
const submitFiles = async () => {
if (!certFile || !keyFile) {
showError('请选择证书文件和私钥文件');
return;
}
setSubmittingFiles(true);
const formData = new FormData();
formData.append('name', fileForm.name.trim());
formData.append('remark', fileForm.remark.trim());
formData.append('cert_file', certFile);
formData.append('key_file', keyFile);
const res = await API.post('/api/tls-certificates/import-file', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const { success, message } = res.data;
if (success) {
showSuccess('证书文件已导入');
setFileForm(initialFileForm);
setCertFile(null);
setKeyFile(null);
await loadCertificates();
} else {
showError(message);
}
setSubmittingFiles(false);
};
const deleteCertificate = async (id) => {
const res = await API.delete(`/api/tls-certificates/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess('证书已删除');
await loadCertificates();
} else {
showError(message);
}
};
return (
<Segment loading={loading}>
<Header as='h3'>证书管理</Header>
<p className='page-subtitle'>支持手动粘贴 PEM 导入或直接上传证书文件。</p>
<Form onSubmit={submitManual}>
<Header as='h4'>手动导入</Header>
<Form.Group widths='equal'>
<Form.Input
label='证书名称'
placeholder='example-com'
value={manualForm.name}
onChange={(e, { value }) => setManualForm({ ...manualForm, name: value })}
/>
<Form.Input
label='备注'
placeholder='可选备注'
value={manualForm.remark}
onChange={(e, { value }) => setManualForm({ ...manualForm, remark: value })}
/>
</Form.Group>
<Form.Field
control={TextArea}
label='证书 PEM'
placeholder='-----BEGIN CERTIFICATE-----'
value={manualForm.cert_pem}
onChange={(e, { value }) => setManualForm({ ...manualForm, cert_pem: value })}
style={{ minHeight: 140 }}
/>
<Form.Field
control={TextArea}
label='私钥 PEM'
placeholder='-----BEGIN PRIVATE KEY-----'
value={manualForm.key_pem}
onChange={(e, { value }) => setManualForm({ ...manualForm, key_pem: value })}
style={{ minHeight: 140 }}
/>
<Button primary type='submit' loading={submittingManual}>
导入证书
</Button>
</Form>
<Segment secondary>
<Form onSubmit={submitFiles}>
<Header as='h4'>文件导入</Header>
<Form.Group widths='equal'>
<Form.Input
label='证书名称'
placeholder='wildcard-example'
value={fileForm.name}
onChange={(e, { value }) => setFileForm({ ...fileForm, name: value })}
/>
<Form.Input
label='备注'
placeholder='可选备注'
value={fileForm.remark}
onChange={(e, { value }) => setFileForm({ ...fileForm, remark: value })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Input
type='file'
label='证书文件'
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
/>
<Form.Input
type='file'
label='私钥文件'
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
/>
</Form.Group>
<Button primary type='submit' loading={submittingFiles}>
上传文件
</Button>
</Form>
</Segment>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>名称</Table.HeaderCell>
<Table.HeaderCell>有效期</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{certificates.map((certificate) => (
<Table.Row key={certificate.id}>
<Table.Cell>
<Icon name='lock' />
{certificate.name}
</Table.Cell>
<Table.Cell>
{formatDateTime(certificate.not_before)} ~ {formatDateTime(certificate.not_after)}
</Table.Cell>
<Table.Cell>{certificate.remark || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(certificate.updated_at)}</Table.Cell>
<Table.Cell>
<Button size='small' negative onClick={() => deleteCertificate(certificate.id)}>
删除
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default TLSCertificate;