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>
117 lines
3.1 KiB
Go
117 lines
3.1 KiB
Go
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
|