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

@@ -18,6 +18,7 @@ const BackendsPage = lazy(() => import('./pages/Backends'))
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
const NetworksPage = lazy(() => import('./pages/Networks'))
const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
const SSLPage = lazy(() => import('./pages/SSL'))
const ClusterPage = lazy(() => import('./pages/Cluster'))
const SettingsPage = lazy(() => import('./pages/Settings'))
@@ -95,6 +96,7 @@ export default function App() {
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/networks" element={<NetworksPage />} />
<Route path="/ip-addresses" element={<IPAddressesPage />} />
<Route path="/ssl" element={<SSLPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -8,6 +8,7 @@ import {
DatabaseOutlined,
GlobalOutlined,
NodeIndexOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -48,6 +49,7 @@ const NAV: NavSection[] = [
items: [
{ path: '/networks', labelKey: 'nav.networks', icon: <ClusterOutlined /> },
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
],
},
{

View File

@@ -138,6 +138,31 @@
"joinedAt": "Beigetreten",
"self": "diese Node"
},
"ssl": {
"title": "SSL-Zertifikate",
"intro": "TLS-Zertifikate verwalten — entweder per Let's Encrypt automatisch ausstellen oder eigene PEMs hochladen. HAProxy lädt nach jeder Änderung automatisch neu.",
"tabLE": "Let's Encrypt",
"tabUpload": "Eigenes Zertifikat",
"leIntro": "Domain wählen, Issue klicken — EdgeGuard löst HTTP-01 über die ACME-Webroot, schreibt das PEM nach /etc/edgeguard/tls/ und reloaded HAProxy.",
"uploadIntro": "Eigenes Zertifikat hochladen. Format: PEM-encoded. Cert + optional Chain + Private Key. EdgeGuard prüft die Cert/Key-Übereinstimmung vor dem Schreiben.",
"uploadHint": "Tipp: bei Let's-Encrypt-Renewals nicht hier hochladen — den LE-Tab nutzen.",
"domain": "Domain",
"selectDomain": "Domain wählen",
"issuer": "Issuer",
"status": "Status",
"expiresIn": "Gültig noch",
"expiredAgo": "abgelaufen vor {{days}} Tagen",
"actions": "Aktionen",
"issueButton": "Zertifikat anfordern",
"uploadButton": "Hochladen",
"issueSuccess": "Zertifikat ausgestellt + installiert.",
"uploadSuccess": "Zertifikat hochgeladen + installiert.",
"deleteConfirm": "Zertifikat für {{domain}} löschen? HAProxy fällt für diese Domain auf das Default-Cert zurück.",
"installedTitle": "Installierte Zertifikate",
"certPem": "Zertifikat (PEM)",
"chainPem": "Chain (PEM, optional)",
"keyPem": "Private Key (PEM)"
},
"settings": {
"title": "Einstellungen",
"intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.",

View File

@@ -31,6 +31,7 @@
"name": "Name",
"type": "Type",
"parent": "Parent interface",
"selectParent": "Select parent",
"vlan": "VLAN",
"vlanId": "VLAN ID",
"role": "Role",
@@ -138,6 +139,31 @@
"joinedAt": "Joined",
"self": "this node"
},
"ssl": {
"title": "SSL certificates",
"intro": "Manage TLS certs — let EdgeGuard issue them via Let's Encrypt or upload your own PEM. HAProxy reloads automatically after each change.",
"tabLE": "Let's Encrypt",
"tabUpload": "Custom certificate",
"leIntro": "Pick a domain, click Issue — EdgeGuard solves HTTP-01 over the ACME webroot, writes the PEM into /etc/edgeguard/tls/, and reloads HAProxy.",
"uploadIntro": "Upload your own certificate. Format: PEM-encoded. Cert + optional chain + private key. EdgeGuard validates cert/key match before writing.",
"uploadHint": "Tip: for Let's Encrypt renewals don't upload here — use the LE tab.",
"domain": "Domain",
"selectDomain": "Select domain",
"issuer": "Issuer",
"status": "Status",
"expiresIn": "Expires in",
"expiredAgo": "expired {{days}} days ago",
"actions": "Actions",
"issueButton": "Issue certificate",
"uploadButton": "Upload",
"issueSuccess": "Certificate issued + installed.",
"uploadSuccess": "Certificate uploaded + installed.",
"deleteConfirm": "Delete certificate for {{domain}}? HAProxy falls back to the default cert for this domain.",
"installedTitle": "Installed certificates",
"certPem": "Certificate (PEM)",
"chainPem": "Chain (PEM, optional)",
"keyPem": "Private key (PEM)"
},
"settings": {
"title": "Settings",
"intro": "System information and setup status. Editable values come in a later release.",

View File

@@ -185,7 +185,13 @@ export default function NetworksPage() {
{({ getFieldValue }) => getFieldValue('type') === 'vlan' ? (
<>
<Form.Item label={t('networks.parent')} name="parent" rules={[{ required: true }]}>
<Input placeholder="eth0" />
<Select
placeholder={t('networks.selectParent')}
showSearch
options={(sys ?? [])
.filter((i) => i.ifname !== 'lo')
.map((i) => ({ value: i.ifname, label: i.ifname }))}
/>
</Form.Item>
<Form.Item label={t('networks.vlanId')} name="vlan_id" rules={[{ required: true }]}>
<InputNumber min={1} max={4094} style={{ width: '100%' }} />

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