mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 22:26:38 +08:00
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:
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user