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

116
internal/handlers/backup.go Normal file
View File

@@ -0,0 +1,116 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
)
// BackupHandler exposes:
//
// GET /api/v1/backups — list
// POST /api/v1/backups — trigger manual backup (sync)
// GET /api/v1/backups/:id — single entry
// GET /api/v1/backups/:id/download — sendfile tar.gz
// DELETE /api/v1/backups/:id — delete tar.gz + row
type BackupHandler struct {
Service *backup.Service
Audit *audit.Repo
NodeID string
Version string
}
func NewBackupHandler(s *backup.Service, a *audit.Repo, nodeID, version string) *BackupHandler {
return &BackupHandler{Service: s, Audit: a, NodeID: nodeID, Version: version}
}
func (h *BackupHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/backups")
g.GET("", h.List)
g.POST("", h.Trigger)
g.GET("/:id", h.Get)
g.GET("/:id/download", h.Download)
g.DELETE("/:id", h.Delete)
}
func (h *BackupHandler) List(c *gin.Context) {
out, err := h.Service.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"backups": out})
}
func (h *BackupHandler) Trigger(c *gin.Context) {
// Manual backup läuft synchron — der Operator wartet vor dem
// Knopf. Bei echten Multi-GB-DBs wäre async besser, aber unsere
// edgeguard-DB ist klein (<50 MB typisch).
res, err := h.Service.Run(c.Request.Context(), backup.KindManual, h.Version)
if err != nil {
response.Err(c, http.StatusInternalServerError, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.create",
res.File, gin.H{"size": res.SizeBytes, "sha256": res.SHA256}, h.NodeID)
response.OK(c, gin.H{
"id": res.ID,
"file": res.File,
"size_bytes": res.SizeBytes,
"sha256": res.SHA256,
"db_dump_bytes": res.DBDumpBytes,
"files_bytes": res.FilesBytes,
"started_at": res.StartedAt,
"finished_at": res.FinishedAt,
})
}
func (h *BackupHandler) Get(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
e, _, err := h.Service.Get(c.Request.Context(), id)
if err != nil {
response.NotFound(c, err)
return
}
response.OK(c, e)
}
func (h *BackupHandler) Download(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
e, path, err := h.Service.Get(c.Request.Context(), id)
if err != nil {
response.NotFound(c, err)
return
}
// gin.FileAttachment setzt Content-Disposition + sendet stream.
c.FileAttachment(path, e.File)
}
func (h *BackupHandler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Service.Delete(c.Request.Context(), id); err != nil {
response.Err(c, http.StatusInternalServerError, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
}
// Defensive — falls jemand den Pool fehlerhaft injected.
var _ = errors.New