From 571f51ba9a660e751258b875895b8f819f9172bf Mon Sep 17 00:00:00 2001 From: Debian Date: Tue, 12 May 2026 23:08:18 +0200 Subject: [PATCH] feat(backup): pg_dump + state-tarball + daily auto + UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 7 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 33 +- internal/database/migrations/0018_backups.sql | 37 ++ internal/handlers/backup.go | 116 ++++ internal/services/backup/backup.go | 505 ++++++++++++++++++ management-ui/src/App.tsx | 2 + .../src/components/Layout/AppLayout.tsx | 1 + .../src/components/Layout/Sidebar.tsx | 3 +- management-ui/src/i18n/locales/de/common.json | 25 + management-ui/src/i18n/locales/en/common.json | 25 + management-ui/src/pages/Backups/index.tsx | 227 ++++++++ .../debian/edgeguard-api/DEBIAN/postinst | 9 + 14 files changed, 989 insertions(+), 5 deletions(-) create mode 100644 internal/database/migrations/0018_backups.sql create mode 100644 internal/handlers/backup.go create mode 100644 internal/services/backup/backup.go create mode 100644 management-ui/src/pages/Backups/index.tsx diff --git a/VERSION b/VERSION index 32c4ece..e1d2f8b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.63 +1.0.64 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 6540698..bb1121a 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -33,6 +33,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup" dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" @@ -50,7 +51,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.63" +var version = "1.0.64" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -194,6 +195,10 @@ func main() { // /logs (Phase 4): aggregierter Reader für journalctl + audit_log handlers.NewLogsHandler(syslogs.New(auditRepo)).Register(authed) + + // /backups — manueller Trigger + Liste + Download. Scheduled- + // Jobs laufen im edgeguard-scheduler. + handlers.NewBackupHandler(backup.New(pool), auditRepo, nodeID, version).Register(authed) handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed) // Firewall reload: nach jeder Mutation den Renderer neu fahren // (writes ruleset.nft + sudo nft -f). Errors loggen, nicht failen. diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index f9671d4..505805b 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.63" +var version = "1.0.64" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 50869f9..b1698d0 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -18,13 +18,14 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/license" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer" licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.63" +var version = "1.0.64" const ( // renewTickInterval — how often we re-evaluate expiring certs. @@ -39,6 +40,11 @@ const ( // licenseTickInterval — daily re-verify against // license.netcell-it.com. Result lands in the licenses table. licenseTickInterval = 24 * time.Hour + + // backupTickInterval — daily scheduled backup at ~03:00 (Tick + // alignment ist approximativ, weil time.Ticker bei Boot startet). + // Retention: 14 erfolgreiche Backups (default in backup.Service). + backupTickInterval = 24 * time.Hour ) func main() { @@ -74,6 +80,10 @@ func main() { nodeID := os.Getenv("EDGEGUARD_NODE_ID") slog.Info("scheduler: license re-verify enabled", "tick", licenseTickInterval) + backupSvc := backup.New(pool) + slog.Info("scheduler: daily backup enabled", "tick", backupTickInterval, + "dir", backupSvc.BackupDir, "keep_n", backup.DefaultKeepN) + if renewer != nil { runRenewer(ctx, renewer) } @@ -83,6 +93,8 @@ func main() { defer renewTick.Stop() licTick := time.NewTicker(licenseTickInterval) defer licTick.Stop() + backupTick := time.NewTicker(backupTickInterval) + defer backupTick.Stop() for { select { @@ -92,10 +104,29 @@ func main() { } case <-licTick.C: runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID) + case <-backupTick.C: + runBackup(ctx, backupSvc, version) } } } +// runBackup führt einen scheduled Backup aus + prunet alte. Failures +// loggen wir nur — der Tick läuft morgen wieder, kein Notfall. +func runBackup(ctx context.Context, svc *backup.Service, version string) { + res, err := svc.Run(ctx, backup.KindScheduled, version) + if err != nil { + slog.Warn("scheduler: backup failed", "error", err, "file", res.File) + return + } + slog.Info("scheduler: backup done", + "file", res.File, "size", res.SizeBytes, + "db_bytes", res.DBDumpBytes, "files_bytes", res.FilesBytes, + "sha256", res.SHA256) + if err := svc.Prune(ctx, backup.DefaultKeepN); err != nil { + slog.Warn("scheduler: backup prune failed", "error", err) + } +} + // runLicenseVerify performs a single re-verify pass. Empty key = no-op // (box stays in trial), so this is safe to call on every tick. func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeyStore, diff --git a/internal/database/migrations/0018_backups.sql b/internal/database/migrations/0018_backups.sql new file mode 100644 index 0000000..eb7ff19 --- /dev/null +++ b/internal/database/migrations/0018_backups.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin + +-- Backup-History: jede Backup-Operation (manual oder scheduled) wird +-- nach Abschluss hier eingetragen — Operator sieht die Liste in der +-- /backup-UI mit Größe, Dauer, Status. Files liegen auf der lokalen +-- Box unter /var/backups/edgeguard/; nicht zwischen Cluster- +-- Nodes synchronisiert (Backup ist node-local — jeder Node sichert +-- seinen eigenen State). + +CREATE TABLE IF NOT EXISTS backups ( + id BIGSERIAL PRIMARY KEY, + file TEXT NOT NULL, -- Basename, z.B. eg-20260512-153045.tar.gz + size_bytes BIGINT NOT NULL, + sha256 TEXT NOT NULL, + db_dump_bytes BIGINT NOT NULL DEFAULT 0, + files_bytes BIGINT NOT NULL DEFAULT 0, + kind TEXT NOT NULL, -- manual | scheduled + status TEXT NOT NULL, -- success | failed + error TEXT, -- gesetzt bei failed + host TEXT, -- hostname der Box (für Cluster-Sicht später) + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT backups_file_unique UNIQUE (file), + CONSTRAINT backups_kind_check CHECK (kind IN ('manual', 'scheduled')), + CONSTRAINT backups_status_check CHECK (status IN ('success', 'failed')) +); + +CREATE INDEX IF NOT EXISTS idx_backups_started ON backups (started_at DESC); +CREATE INDEX IF NOT EXISTS idx_backups_status ON backups (status); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS backups; +-- +goose StatementEnd diff --git a/internal/handlers/backup.go b/internal/handlers/backup.go new file mode 100644 index 0000000..3399b5b --- /dev/null +++ b/internal/handlers/backup.go @@ -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 diff --git a/internal/services/backup/backup.go b/internal/services/backup/backup.go new file mode 100644 index 0000000..ab49a81 --- /dev/null +++ b/internal/services/backup/backup.go @@ -0,0 +1,505 @@ +// Package backup implementiert Backup + Restore für EdgeGuard. +// +// Backup-Inhalt (eg-.tar.gz): +// +// dump.sql — pg_dump --clean --if-exists --no-owner --no-acl +// der edgeguard-DB (Schema + Daten). Restore via psql. +// files/ — Verbatim-Kopie node-lokaler State-Dateien: +// - setup.json (Setup-Wizard-Ergebnis) +// - license_key (node-lokale Lizenz) +// - .jwt_fingerprint (Session-Signing-Secret) +// - acme-account/ (LE-Account + Privkey) +// manifest.json — Metadaten: version, ts, hostname, sizes. +// +// Node-local: ein Backup deckt nur diesen Node ab. In Phase 3-Cluster +// machen alle Nodes ihre eigenen Backups; Konfig ist eh aus PG +// reproduzierbar. +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DefaultDir ist der Speicherort der Backup-Archive auf der Box. +const DefaultDir = "/var/backups/edgeguard" + +// DefaultStateDir ist /var/lib/edgeguard — alle node-lokalen State- +// Files leben darunter. +const DefaultStateDir = "/var/lib/edgeguard" + +// DefaultKeepN ist die Retention für scheduled-Backups. Operator kann +// das in der UI noch nicht überschreiben — Konvention reicht für v1. +const DefaultKeepN = 14 + +// Kind unterscheidet den Trigger — manual aus UI vs. scheduled aus +// dem 24h-Tick im edgeguard-scheduler. +type Kind string + +const ( + KindManual Kind = "manual" + KindScheduled Kind = "scheduled" +) + +// Result kommt von Run() zurück und landet auch (success oder failed) +// als Row in der backups-Tabelle. +type Result struct { + ID int64 + File string + SizeBytes int64 + SHA256 string + DBDumpBytes int64 + FilesBytes int64 + StartedAt time.Time + FinishedAt time.Time + Error error +} + +// Manifest ist der content von manifest.json im tarball. +type Manifest struct { + Version string `json:"version"` + Kind Kind `json:"kind"` + Hostname string `json:"hostname"` + CreatedAt time.Time `json:"created_at"` + DBDumpBytes int64 `json:"db_dump_bytes"` + FilesBytes int64 `json:"files_bytes"` +} + +// Service bündelt Backup + Restore + Retention. Stateless — alle +// Konfig kommt als Konstruktor-Param + Methode-Param. +type Service struct { + Pool *pgxpool.Pool + BackupDir string + StateDir string + + // PGDumpCmd ist normalerweise "pg_dump" — postinst whitelisted + // `sudo -n -u postgres pg_dump …`. Mit dem Override-Hook können + // Tests einen fake-binary einschleusen. + PGDumpCmd func(ctx context.Context, w io.Writer) (int64, error) + NowFn func() time.Time +} + +func New(pool *pgxpool.Pool) *Service { + return &Service{ + Pool: pool, + BackupDir: DefaultDir, + StateDir: DefaultStateDir, + NowFn: time.Now, + } +} + +// Run führt ein Backup aus. Bei Erfolg: tarball auf Disk + Row in DB. +// Bei Failure: tarball gelöscht, Row mit status=failed. +func (s *Service) Run(ctx context.Context, kind Kind, version string) (*Result, error) { + now := s.NowFn().UTC() + hostname, _ := os.Hostname() + + res := &Result{ + File: fmt.Sprintf("eg-%s.tar.gz", now.Format("20060102-150405")), + StartedAt: now, + } + + if err := os.MkdirAll(s.BackupDir, 0o755); err != nil { + return res, fmt.Errorf("mkdir: %w", err) + } + outPath := filepath.Join(s.BackupDir, res.File) + + // SHA256 berechnen wir während-write parallel via TeeWriter. + f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640) + if err != nil { + return res, fmt.Errorf("create %s: %w", outPath, err) + } + hasher := sha256.New() + mw := io.MultiWriter(f, hasher) + + gz := gzip.NewWriter(mw) + tw := tar.NewWriter(gz) + + // 1) dump.sql — pg_dump streamt direkt rein. + dumpSize, dumpErr := s.writeDump(ctx, tw) + if dumpErr != nil { + _ = tw.Close() + _ = gz.Close() + _ = f.Close() + _ = os.Remove(outPath) + res.Error = dumpErr + res.FinishedAt = s.NowFn().UTC() + s.recordFailure(ctx, res, hostname, kind) + return res, dumpErr + } + res.DBDumpBytes = dumpSize + + // 2) files/ — alles aus /var/lib/edgeguard außer Cache/Tmp. + filesSize, filesErr := s.writeFiles(tw) + if filesErr != nil { + _ = tw.Close() + _ = gz.Close() + _ = f.Close() + _ = os.Remove(outPath) + res.Error = filesErr + res.FinishedAt = s.NowFn().UTC() + s.recordFailure(ctx, res, hostname, kind) + return res, filesErr + } + res.FilesBytes = filesSize + + // 3) manifest.json + man := Manifest{ + Version: version, + Kind: kind, + Hostname: hostname, + CreatedAt: now, + DBDumpBytes: dumpSize, + FilesBytes: filesSize, + } + manBytes, _ := json.MarshalIndent(man, "", " ") + if err := writeTarBlob(tw, "manifest.json", manBytes); err != nil { + _ = tw.Close() + _ = gz.Close() + _ = f.Close() + _ = os.Remove(outPath) + res.Error = err + res.FinishedAt = s.NowFn().UTC() + s.recordFailure(ctx, res, hostname, kind) + return res, err + } + + if err := tw.Close(); err != nil { + _ = gz.Close() + _ = f.Close() + _ = os.Remove(outPath) + res.Error = err + s.recordFailure(ctx, res, hostname, kind) + return res, err + } + if err := gz.Close(); err != nil { + _ = f.Close() + _ = os.Remove(outPath) + res.Error = err + s.recordFailure(ctx, res, hostname, kind) + return res, err + } + if err := f.Sync(); err != nil { + // Nicht fatal — fsync-failure kann passieren bei tmpfs in + // Dev, aber der File ist da. + } + if err := f.Close(); err != nil { + s.recordFailure(ctx, res, hostname, kind) + return res, err + } + + stat, _ := os.Stat(outPath) + if stat != nil { + res.SizeBytes = stat.Size() + } + res.SHA256 = hex.EncodeToString(hasher.Sum(nil)) + res.FinishedAt = s.NowFn().UTC() + + if err := s.recordSuccess(ctx, res, hostname, kind); err != nil { + // DB-Insert failed — File haben wir, aber Operator sieht + // das Backup nicht in der UI. Lassen wir's stehen mit Log- + // Warnung; nächster Scheduled-Run räumt es nicht ab (Retention + // arbeitet nur über DB-Rows). + return res, fmt.Errorf("db record: %w", err) + } + return res, nil +} + +// writeDump pipet pg_dump direkt in den tar-Stream als file "dump.sql". +// pg_dump läuft via `sudo -n -u postgres pg_dump --clean --if-exists +// --no-owner --no-acl edgeguard`. Sudoers-Whitelist in postinst. +func (s *Service) writeDump(ctx context.Context, tw *tar.Writer) (int64, error) { + if s.PGDumpCmd != nil { + var buf bytes + size, err := s.PGDumpCmd(ctx, &buf) + if err != nil { + return 0, err + } + if err := writeTarBlob(tw, "dump.sql", buf.b); err != nil { + return 0, err + } + return size, nil + } + + cmd := exec.CommandContext(ctx, "sudo", "-n", "-u", "postgres", + "/usr/bin/pg_dump", + "--clean", "--if-exists", "--no-owner", "--no-acl", + "edgeguard") + stdout, err := cmd.StdoutPipe() + if err != nil { + return 0, err + } + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("pg_dump start: %w", err) + } + + dump, err := io.ReadAll(stdout) + if err != nil { + _ = cmd.Wait() + return 0, fmt.Errorf("pg_dump read: %w", err) + } + if err := cmd.Wait(); err != nil { + return 0, fmt.Errorf("pg_dump: %w", err) + } + if err := writeTarBlob(tw, "dump.sql", dump); err != nil { + return 0, err + } + return int64(len(dump)), nil +} + +// writeFiles bringt alle relevanten /var/lib/edgeguard-Files unter +// files/ in den tar. Bewusste Liste statt rekursiv-everything, +// damit wir nicht aus Versehen den fs.cache oder Lockfiles backupen. +func (s *Service) writeFiles(tw *tar.Writer) (int64, error) { + var total int64 + candidates := []string{ + "setup.json", + "license_key", + "license.cache", + "trial.json", + ".jwt_fingerprint", + "node.conf", + } + for _, rel := range candidates { + path := filepath.Join(s.StateDir, rel) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue // optional-file, skip + } + return total, fmt.Errorf("read %s: %w", path, err) + } + if err := writeTarBlob(tw, "files/"+rel, data); err != nil { + return total, err + } + total += int64(len(data)) + } + // acme-account/ rekursiv (Multi-File-Dir mit LE-Privkey). + acmeDir := filepath.Join(s.StateDir, "acme-account") + if _, err := os.Stat(acmeDir); err == nil { + err := filepath.Walk(acmeDir, func(p string, info os.FileInfo, werr error) error { + if werr != nil { + return werr + } + if info.IsDir() { + return nil + } + rel, _ := filepath.Rel(s.StateDir, p) + data, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + if werr := writeTarBlob(tw, "files/"+rel, data); werr != nil { + return werr + } + total += int64(len(data)) + return nil + }) + if err != nil { + return total, err + } + } + return total, nil +} + +func writeTarBlob(tw *tar.Writer, name string, data []byte) error { + hdr := &tar.Header{ + Name: name, + Mode: 0o600, + Size: int64(len(data)), + ModTime: time.Now(), + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + _, err := tw.Write(data) + return err +} + +// recordSuccess + recordFailure schreiben einen Eintrag in backups. +func (s *Service) recordSuccess(ctx context.Context, r *Result, host string, kind Kind) error { + if s.Pool == nil { + return nil + } + row := s.Pool.QueryRow(ctx, ` +INSERT INTO backups (file, size_bytes, sha256, db_dump_bytes, files_bytes, + kind, status, host, started_at, finished_at) +VALUES ($1, $2, $3, $4, $5, $6, 'success', $7, $8, $9) +RETURNING id`, + r.File, r.SizeBytes, r.SHA256, r.DBDumpBytes, r.FilesBytes, + string(kind), host, r.StartedAt, r.FinishedAt) + return row.Scan(&r.ID) +} + +func (s *Service) recordFailure(ctx context.Context, r *Result, host string, kind Kind) { + if s.Pool == nil { + return + } + errStr := "" + if r.Error != nil { + errStr = r.Error.Error() + } + _, _ = s.Pool.Exec(ctx, ` +INSERT INTO backups (file, size_bytes, sha256, db_dump_bytes, files_bytes, + kind, status, error, host, started_at, finished_at) +VALUES ($1, 0, '', 0, 0, $2, 'failed', $3, $4, $5, $6) +ON CONFLICT (file) DO NOTHING`, + r.File, string(kind), errStr, host, r.StartedAt, r.FinishedAt) +} + +// Prune löscht erfolgreiche Backups älter als die letzten keepN. Wird +// nach jedem scheduled-Run aufgerufen. Failed-Rows bleiben für die +// History. +func (s *Service) Prune(ctx context.Context, keepN int) error { + if keepN <= 0 { + keepN = DefaultKeepN + } + if s.Pool == nil { + return nil + } + rows, err := s.Pool.Query(ctx, ` +SELECT id, file FROM backups + WHERE status = 'success' + ORDER BY started_at DESC`) + if err != nil { + return err + } + type row struct { + id int64 + file string + } + var all []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.file); err != nil { + rows.Close() + return err + } + all = append(all, r) + } + rows.Close() + if len(all) <= keepN { + return nil + } + expired := all[keepN:] + for _, e := range expired { + _ = os.Remove(filepath.Join(s.BackupDir, e.file)) + _, _ = s.Pool.Exec(ctx, `DELETE FROM backups WHERE id = $1`, e.id) + } + return nil +} + +// List gibt alle Backup-Einträge zurück (newest first). +func (s *Service) List(ctx context.Context) ([]Entry, error) { + if s.Pool == nil { + return nil, nil + } + rows, err := s.Pool.Query(ctx, ` +SELECT id, file, size_bytes, sha256, db_dump_bytes, files_bytes, + kind, status, COALESCE(error, ''), COALESCE(host, ''), + started_at, finished_at +FROM backups +ORDER BY started_at DESC +LIMIT 200`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Entry{} + for rows.Next() { + var e Entry + if err := rows.Scan(&e.ID, &e.File, &e.SizeBytes, &e.SHA256, + &e.DBDumpBytes, &e.FilesBytes, &e.Kind, &e.Status, + &e.Error, &e.Host, &e.StartedAt, &e.FinishedAt); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// Entry mirrort einen DB-Row. +type Entry struct { + ID int64 `json:"id"` + File string `json:"file"` + SizeBytes int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` + DBDumpBytes int64 `json:"db_dump_bytes"` + FilesBytes int64 `json:"files_bytes"` + Kind string `json:"kind"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Host string `json:"host,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` +} + +// Get gibt einen einzelnen Eintrag + den File-Pfad zurück. +func (s *Service) Get(ctx context.Context, id int64) (*Entry, string, error) { + if s.Pool == nil { + return nil, "", errors.New("no pool") + } + var e Entry + err := s.Pool.QueryRow(ctx, ` +SELECT id, file, size_bytes, sha256, db_dump_bytes, files_bytes, + kind, status, COALESCE(error, ''), COALESCE(host, ''), + started_at, finished_at +FROM backups WHERE id = $1`, id).Scan( + &e.ID, &e.File, &e.SizeBytes, &e.SHA256, &e.DBDumpBytes, &e.FilesBytes, + &e.Kind, &e.Status, &e.Error, &e.Host, &e.StartedAt, &e.FinishedAt) + if err != nil { + return nil, "", err + } + return &e, filepath.Join(s.BackupDir, e.File), nil +} + +// Delete entfernt File + DB-Row. +func (s *Service) Delete(ctx context.Context, id int64) error { + _, path, err := s.Get(ctx, id) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + _, err = s.Pool.Exec(ctx, `DELETE FROM backups WHERE id = $1`, id) + return err +} + +// bytes ist ein tiny io.Writer-Stand-in für den PGDumpCmd-Override +// (Tests). Stdlib bytes.Buffer hätte's auch getan, aber das Package +// hat einen anderen import-graph. +type bytes struct{ b []byte } + +func (b *bytes) Write(p []byte) (int, error) { + b.b = append(b.b, p...) + return len(p), nil +} + +// strFold ist ein utility nur zum Defensiv-Check, dass kind ein +// erlaubter Wert ist (für die DB-Constraint). +func strFold(s string) string { return strings.ToLower(strings.TrimSpace(s)) } + +// SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt +// (DB-Query macht's), aber praktisch wenn der Caller eine eigene +// Liste hat. +func SortByDate(es []Entry) { + sort.Slice(es, func(i, j int) bool { + return es[i].StartedAt.After(es[j].StartedAt) + }) +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index d6fc86a..835ae38 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -26,6 +26,7 @@ const DNSPage = lazy(() => import('./pages/DNS')) const NTPPage = lazy(() => import('./pages/NTP')) const ClusterPage = lazy(() => import('./pages/Cluster')) const LogsPage = lazy(() => import('./pages/Logs')) +const BackupsPage = lazy(() => import('./pages/Backups')) const LicensePage = lazy(() => import('./pages/License')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -111,6 +112,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 1f237d5..48982ee 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -18,6 +18,7 @@ const PAGE_TITLES: Record = { '/ip-addresses': 'nav.ipAddresses', '/cluster': 'nav.cluster', '/logs': 'nav.logs', + '/backups': 'nav.backups', '/license': 'nav.license', '/settings': 'nav.settings', } diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index d8e3f30..c23a7b5 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -71,13 +71,14 @@ const NAV: NavSection[] = [ items: [ { path: '/cluster', labelKey: 'nav.cluster', icon: }, { path: '/logs', labelKey: 'nav.logs', icon: }, + { path: '/backups', labelKey: 'nav.backups', icon: }, { path: '/license', labelKey: 'nav.license', icon: }, { path: '/settings', labelKey: 'nav.settings', icon: }, ], }, ] -const VERSION = '1.0.63' +const VERSION = '1.0.64' // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // -