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:
Debian
2026-05-09 21:49:14 +02:00
parent 4f6b7b34fc
commit e096531df2
15 changed files with 1161 additions and 14 deletions

View 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>
)
}