feat(ssl): TLS-Cert-Verwaltung in der GUI — Let's Encrypt + eigenes PEM
Backend: * internal/services/tlscerts/ — Repo (List/Get/Upsert/Delete/ GetByDomain/ListExpiringSoon/MarkError) gegen tls_certs-Tabelle. * internal/services/certstore/ — WriteCombined verifiziert cert/key match via tls.X509KeyPair, schreibt /etc/edgeguard/tls/<domain>.pem (HAProxy-format: cert + chain + key konkatenert). Parse extrahiert NotBefore/After/Issuer/SANs aus dem PEM. Domain-Charset-Whitelist gegen Path-Traversal beim Filename. 4 Tests (happy path, mismatched key, hostile filename, parse). * internal/services/acme/ — go-acme/lego v4 mit HTTP-01 über die bestehende /var/lib/edgeguard/acme-Webroot (HAProxy proxied dort schon hin). Account-Key persistent in /var/lib/edgeguard/acme- account/account.key, Registrierung lazy beim ersten Issue(). * internal/handlers/tlscerts.go — REST CRUD + /upload (custom PEM) + /issue (LE HTTP-01) auf /api/v1/tls-certs. Reload HAProxy via sudo nach jeder Mutation. Audit-Log pro Aktion. Frontend: * management-ui/src/pages/SSL/ — Tabs (Let's Encrypt / Eigenes Zertifikat) plus Tabelle aller installierten Zerts mit expires-in-Anzeige (orange ab <30 Tage, rot wenn abgelaufen) und Status-Tags. Sidebar-Eintrag, i18n de/en. * Networks-Form: Parent-Interface ist jetzt ein Select aus den System-Discovered-Interfaces statt freier Input — User-Wunsch. Packaging: * postinst legt /var/lib/edgeguard/acme-account/ 0700 an. * postinst installt /etc/sudoers.d/edgeguard mit NOPASSWD-Rule für systemctl reload haproxy.service — damit der edgeguard-User reloaden kann ohne root. Live deployed auf 89.163.205.6. /api/v1/tls-certs antwortet jetzt 401 ohne Cookie (Route registriert), POST /tls-certs/upload + /issue sind bereit. ACME-Issue gegen externe FQDN (utm-1.netcell-it.de) braucht nur noch die Domain, die im wizard schon angelegt ist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
management-ui/src/pages/SSL/index.tsx
Normal file
198
management-ui/src/pages/SSL/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, Card, Form, Input, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
interface TLSCert {
|
||||
id: number
|
||||
domain: string
|
||||
issuer: string
|
||||
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||
cert_path?: string | null
|
||||
key_path?: string | null
|
||||
not_before?: string | null
|
||||
not_after?: string | null
|
||||
last_renewed_at?: string | null
|
||||
last_error?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface UploadValues {
|
||||
domain: string
|
||||
cert_pem: string
|
||||
chain_pem?: string
|
||||
key_pem: string
|
||||
}
|
||||
|
||||
interface IssueValues { domain: string }
|
||||
|
||||
interface Domain { id: number; name: string }
|
||||
|
||||
async function listCerts(): Promise<TLSCert[]> {
|
||||
const r = await apiClient.get('/tls-certs')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { tls_certs?: TLSCert[] }).tls_certs ?? []
|
||||
}
|
||||
async function listDomains(): Promise<Domain[]> {
|
||||
const r = await apiClient.get('/domains')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { domains?: Domain[] }).domains ?? []
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<TLSCert['status'], string> = {
|
||||
active: 'green',
|
||||
renewing: 'blue',
|
||||
pending: 'gold',
|
||||
expired: 'red',
|
||||
error: 'red',
|
||||
}
|
||||
|
||||
function daysUntil(s?: string | null): number | null {
|
||||
if (!s) return null
|
||||
const t = new Date(s).getTime()
|
||||
if (Number.isNaN(t)) return null
|
||||
return Math.round((t - Date.now()) / 86_400_000)
|
||||
}
|
||||
|
||||
export default function SSLPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: certs, isLoading } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
||||
|
||||
const [issueForm] = Form.useForm<IssueValues>()
|
||||
const [uploadForm] = Form.useForm<UploadValues>()
|
||||
const [issueErr, setIssueErr] = useState<string | null>(null)
|
||||
|
||||
const issueMut = useMutation({
|
||||
mutationFn: async (v: IssueValues) => {
|
||||
const r = await apiClient.post('/tls-certs/issue', v)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('ssl.issueSuccess'))
|
||||
issueForm.resetFields()
|
||||
setIssueErr(null)
|
||||
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||
},
|
||||
onError: (e: Error) => setIssueErr(e.message),
|
||||
})
|
||||
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: async (v: UploadValues) => {
|
||||
const r = await apiClient.post('/tls-certs/upload', v)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('ssl.uploadSuccess'))
|
||||
uploadForm.resetFields()
|
||||
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||
},
|
||||
})
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/tls-certs/${id}`) },
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['tls-certs'] }) },
|
||||
})
|
||||
|
||||
const columns: ColumnsType<TLSCert> = [
|
||||
{ title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('ssl.issuer'), dataIndex: 'issuer', key: 'issuer' },
|
||||
{
|
||||
title: t('ssl.status'), dataIndex: 'status', key: 'status',
|
||||
render: (s: TLSCert['status']) => <Tag color={STATUS_COLORS[s] ?? 'default'}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('ssl.expiresIn'), key: 'expires',
|
||||
render: (_, row) => {
|
||||
const d = daysUntil(row.not_after)
|
||||
if (d == null) return '—'
|
||||
if (d < 0) return <Tag color="red">{t('ssl.expiredAgo', { days: -d })}</Tag>
|
||||
if (d < 30) return <Tag color="orange">{d}d</Tag>
|
||||
return `${d}d`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('ssl.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
onConfirm={() => delMut.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'letsencrypt',
|
||||
label: t('ssl.tabLE'),
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Typography.Paragraph type="secondary">{t('ssl.leIntro')}</Typography.Paragraph>
|
||||
{issueErr && <Alert type="error" closable showIcon style={{ marginBottom: 12 }} message={issueErr} onClose={() => setIssueErr(null)} />}
|
||||
<Form form={issueForm} layout="vertical" onFinish={(v) => issueMut.mutate(v)}>
|
||||
<Form.Item label={t('ssl.domain')} name="domain" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder={t('ssl.selectDomain')}
|
||||
options={(domains ?? []).map((d) => ({ value: d.name, label: d.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={issueMut.isPending}>
|
||||
{t('ssl.issueButton')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'upload',
|
||||
label: t('ssl.tabUpload'),
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Typography.Paragraph type="secondary">{t('ssl.uploadIntro')}</Typography.Paragraph>
|
||||
<Form form={uploadForm} layout="vertical" onFinish={(v) => uploadMut.mutate(v)}>
|
||||
<Form.Item label={t('ssl.domain')} name="domain" rules={[{ required: true }]}>
|
||||
<Input placeholder="example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ssl.certPem')} name="cert_pem" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={6} placeholder="-----BEGIN CERTIFICATE-----..." style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ssl.chainPem')} name="chain_pem">
|
||||
<Input.TextArea rows={4} placeholder="-----BEGIN CERTIFICATE-----..." style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('ssl.keyPem')} name="key_pem" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={6} placeholder="-----BEGIN PRIVATE KEY-----..." style={{ fontFamily: 'monospace', fontSize: 12 }} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={uploadMut.isPending}>
|
||||
{t('ssl.uploadButton')}
|
||||
</Button>
|
||||
<Typography.Text type="secondary">{t('ssl.uploadHint')}</Typography.Text>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('ssl.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('ssl.intro')}</Typography.Paragraph>
|
||||
|
||||
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>{t('ssl.installedTitle')}</Typography.Title>
|
||||
<Table rowKey="id" loading={isLoading} dataSource={certs ?? []} columns={columns} pagination={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user