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:
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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%' }} />
|
||||
|
||||
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