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:
116
internal/handlers/backup.go
Normal file
116
internal/handlers/backup.go
Normal 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
|
||||
Reference in New Issue
Block a user