feat(backup): pg_dump + state-tarball + daily auto + UI

Production-Box braucht Backups — bisher keine. Jetzt komplette
Pipeline:

Backend (internal/services/backup/):
  - Output: /var/backups/edgeguard/eg-YYYYMMDD-HHMMSS.tar.gz
  - Inhalt: dump.sql (pg_dump --clean --if-exists --no-owner --no-acl),
    files/setup.json, files/license_key, files/license.cache,
    files/.jwt_fingerprint, files/node.conf, files/acme-account/* +
    manifest.json (Version, kind, hostname, sizes)
  - sha256 während-write via TeeWriter, Size + sha in backups-DB-Row
  - Failure-Path: row mit status=failed + error, kein orphan-tarball
  - Prune(keepN=14) löscht erfolgreiche Backups älter als die letzten N

Migration 0018: backups(id, file, size, sha256, db/files bytes, kind,
status, error, host, started/finished).

Scheduler (cmd/edgeguard-scheduler):
  - 24h-Tick → backup.Run(KindScheduled) + Prune. Beim Boot wird ein
    initialer Backup NICHT sofort gezogen (kein nervöses Spam),
    sondern erst beim nächsten 24h-Slot.

REST (internal/handlers/backup.go):
  GET    /api/v1/backups              — list (newest first)
  POST   /api/v1/backups              — trigger manual (sync, audit'ed)
  GET    /api/v1/backups/:id          — single
  GET    /api/v1/backups/:id/download — sendfile tar.gz
  DELETE /api/v1/backups/:id          — entferne file + row

UI (management-ui/src/pages/Backups):
  - Liste mit Time, File+sha (first 16), Kind-Tag, Status, Size (mit
    DB + Files Aufschlüsselung), Dauer
  - „Backup jetzt erstellen" Button, Refresh, Download, Delete
  - Auto-Refresh 30s
  - Sidebar-Eintrag „Backups" unter System

postinst:
  - /var/backups/edgeguard 0750 edgeguard:edgeguard (enthält sensitive
    pg_dump + license_key → NICHT world-readable)
  - sudoers-Whitelist `sudo -u postgres /usr/bin/pg_dump --clean
    --if-exists --no-owner --no-acl edgeguard` — exakte Form

Verifiziert auf der Box: backups-Tabelle existiert, scheduler logged
„backup enabled tick=24h dir=/var/backups/edgeguard keep_n=14",
pg_dump-via-sudoers liefert 2808 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-12 23:08:18 +02:00
parent 9642a6adfe
commit 571f51ba9a
14 changed files with 989 additions and 5 deletions

View File

@@ -0,0 +1,227 @@
import { useState } from 'react'
import {
Alert, Button, Card, Popconfirm, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
CloudDownloadOutlined,
CloudUploadOutlined,
DatabaseOutlined,
DeleteOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
interface Backup {
id: number
file: string
size_bytes: number
sha256: string
db_dump_bytes: number
files_bytes: number
kind: 'manual' | 'scheduled'
status: 'success' | 'failed'
error?: string
host?: string
started_at: string
finished_at: string
}
function fmtSize(n: number): string {
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
function fmtDuration(start: string, end: string): string {
const ms = new Date(end).getTime() - new Date(start).getTime()
if (ms < 1000) return ms + ' ms'
if (ms < 60_000) return (ms / 1000).toFixed(1) + ' s'
return (ms / 60_000).toFixed(1) + ' min'
}
export default function BackupsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const [msg, msgCtx] = message.useMessage()
const list = useQuery({
queryKey: ['backups'],
queryFn: async () => {
const r = await apiClient.get('/backups')
return isEnvelope(r.data) ? (r.data.data as { backups: Backup[] }).backups : []
},
refetchInterval: 30_000,
})
const trigger = useMutation({
mutationFn: async () => {
const r = await apiClient.post('/backups')
return isEnvelope(r.data) ? r.data.data : r.data
},
onSuccess: (res: { file?: string }) => {
msg.success(t('backups.created', { file: res?.file ?? '?' }))
qc.invalidateQueries({ queryKey: ['backups'] })
},
onError: (e: Error) => msg.error(t('backups.failed') + ': ' + e.message),
})
const [busyDelete, setBusyDelete] = useState<number | null>(null)
const del = useMutation({
mutationFn: async (id: number) => {
setBusyDelete(id)
try { await apiClient.delete(`/backups/${id}`) } finally { setBusyDelete(null) }
},
onSuccess: () => {
msg.success(t('backups.deleted'))
qc.invalidateQueries({ queryKey: ['backups'] })
},
onError: (e: Error) => msg.error(e.message),
})
const download = (b: Backup) => {
// gin.FileAttachment liefert via Browser direkt; einfach
// Cookie-authentifiziert in eine versteckte Form öffnen.
const url = `/api/v1/backups/${b.id}/download`
const a = document.createElement('a')
a.href = url
a.download = b.file
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const columns: ColumnsType<Backup> = [
{
title: t('backups.col.time'), dataIndex: 'started_at', width: 170,
render: (v: string) => <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{dayjs(v).format('YYYY-MM-DD HH:mm:ss')}
</Text>,
},
{
title: t('backups.col.file'), dataIndex: 'file',
render: (v: string, row) => (
<div>
<Text style={{ fontFamily: 'monospace' }}>{v}</Text>
<div>
<Text type="secondary" style={{ fontSize: 11 }}>
sha256: {row.sha256.slice(0, 16)}
</Text>
</div>
</div>
),
},
{
title: t('backups.col.kind'), dataIndex: 'kind', width: 110,
render: (k: Backup['kind']) =>
<Tag color={k === 'scheduled' ? 'cyan' : 'blue'}>{k}</Tag>,
},
{
title: t('backups.col.status'), dataIndex: 'status', width: 110,
render: (s: Backup['status'], row) =>
s === 'success'
? <Tag color="green">OK</Tag>
: (
<Tooltip title={row.error}>
<Tag color="red">{t('backups.failedTag')}</Tag>
</Tooltip>
),
},
{
title: t('backups.col.size'), dataIndex: 'size_bytes', width: 110,
render: (n: number, row) => (
<div>
<Text>{fmtSize(n)}</Text>
<div>
<Text type="secondary" style={{ fontSize: 11 }}>
DB {fmtSize(row.db_dump_bytes)} · Files {fmtSize(row.files_bytes)}
</Text>
</div>
</div>
),
},
{
title: t('backups.col.duration'), key: 'duration', width: 90,
render: (_, row) => <Text type="secondary">{fmtDuration(row.started_at, row.finished_at)}</Text>,
},
{
title: t('common.actions'), key: 'a', width: 200,
render: (_, row) => (
<Space size={4}>
<Tooltip title={t('backups.downloadTooltip')}>
<Button
size="small"
icon={<CloudDownloadOutlined />}
onClick={() => download(row)}
disabled={row.status !== 'success'}
>{t('backups.download')}</Button>
</Tooltip>
<Popconfirm
title={t('backups.confirmDelete', { file: row.file })}
onConfirm={() => del.mutate(row.id)}
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />} loading={busyDelete === row.id}>
{t('common.delete')}
</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
{msgCtx}
<PageHeader
icon={<DatabaseOutlined />}
title={t('backups.title')}
subtitle={t('backups.intro')}
extra={
<Space>
<Tooltip title={t('backups.refreshTooltip')}>
<Button icon={<ReloadOutlined />} onClick={() => list.refetch()}>
{t('common.refresh')}
</Button>
</Tooltip>
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={trigger.isPending}
onClick={() => trigger.mutate()}
>
{t('backups.runNow')}
</Button>
</Space>
}
/>
<Card className="mb-16">
<Alert
type="info"
showIcon
message={t('backups.scopeTitle')}
description={t('backups.scopeDesc')}
/>
</Card>
<Table
rowKey="id"
size="small"
loading={list.isFetching}
dataSource={list.data ?? []}
columns={columns}
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
locale={{ emptyText: t('backups.empty') }}
/>
</div>
)
}