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:
@@ -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.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.63"
|
||||
var version = "1.0.64"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
internal/database/migrations/0018_backups.sql
Normal file
37
internal/database/migrations/0018_backups.sql
Normal file
@@ -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/<file>; 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
|
||||
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
|
||||
505
internal/services/backup/backup.go
Normal file
505
internal/services/backup/backup.go
Normal file
@@ -0,0 +1,505 @@
|
||||
// Package backup implementiert Backup + Restore für EdgeGuard.
|
||||
//
|
||||
// Backup-Inhalt (eg-<ts>.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/<relpath> 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)
|
||||
})
|
||||
}
|
||||
@@ -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() {
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -18,6 +18,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/logs': 'nav.logs',
|
||||
'/backups': 'nav.backups',
|
||||
'/license': 'nav.license',
|
||||
'/settings': 'nav.settings',
|
||||
}
|
||||
|
||||
@@ -71,13 +71,14 @@ const NAV: NavSection[] = [
|
||||
items: [
|
||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
|
||||
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
|
||||
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.63'
|
||||
const VERSION = '1.0.64'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall-Log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"backups": "Backups",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
"section": {
|
||||
@@ -621,6 +622,30 @@
|
||||
"cta": "Jetzt aktivieren →",
|
||||
"openPage": "Lizenz-Seite öffnen →"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.",
|
||||
"scopeTitle": "Was wird gesichert?",
|
||||
"scopeDesc": "DB-Dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Konfig-Dateien (haproxy.cfg, nft, …) sind aus der DB regenerierbar und werden NICHT mitgesichert.",
|
||||
"runNow": "Backup jetzt erstellen",
|
||||
"created": "Backup erstellt: {{file}}",
|
||||
"failed": "Backup fehlgeschlagen",
|
||||
"deleted": "Backup gelöscht",
|
||||
"download": "Download",
|
||||
"downloadTooltip": "tar.gz herunterladen",
|
||||
"refreshTooltip": "Liste neu laden",
|
||||
"confirmDelete": "Backup {{file}} wirklich löschen?",
|
||||
"empty": "Noch keine Backups. Klicke „Backup jetzt erstellen\" oder warte den nächsten Auto-Tick ab.",
|
||||
"failedTag": "FEHLER",
|
||||
"col": {
|
||||
"time": "Zeit",
|
||||
"file": "Datei",
|
||||
"kind": "Typ",
|
||||
"status": "Status",
|
||||
"size": "Größe",
|
||||
"duration": "Dauer"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "System-Logs",
|
||||
"intro": "Aggregierter Blick auf alle Service-Journals + audit_log. Multi-Source-Auswahl, Level-Filter, Freitext-Suche, Zeit-Range, Auto-Refresh (5s).",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"backups": "Backups",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
"section": {
|
||||
@@ -621,6 +622,30 @@
|
||||
"cta": "Activate now →",
|
||||
"openPage": "Open license page →"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.",
|
||||
"scopeTitle": "What is backed up?",
|
||||
"scopeDesc": "DB dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Generated configs (haproxy.cfg, nft, …) are reproducible from the DB and are NOT included.",
|
||||
"runNow": "Run backup now",
|
||||
"created": "Backup created: {{file}}",
|
||||
"failed": "Backup failed",
|
||||
"deleted": "Backup deleted",
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download tar.gz",
|
||||
"refreshTooltip": "Reload list",
|
||||
"confirmDelete": "Really delete backup {{file}}?",
|
||||
"empty": "No backups yet. Click “Run backup now” or wait for the next scheduled tick.",
|
||||
"failedTag": "FAILED",
|
||||
"col": {
|
||||
"time": "Time",
|
||||
"file": "File",
|
||||
"kind": "Kind",
|
||||
"status": "Status",
|
||||
"size": "Size",
|
||||
"duration": "Duration"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "System logs",
|
||||
"intro": "Aggregated view across all service journals + audit_log. Multi-source selection, level filter, free-text search, time range, auto-refresh (5s).",
|
||||
|
||||
227
management-ui/src/pages/Backups/index.tsx
Normal file
227
management-ui/src/pages/Backups/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,12 @@ case "$1" in
|
||||
# gehört nur edgeguard.
|
||||
install -d -m 0700 -o "$EG_USER" -g "$EG_USER" /var/lib/edgeguard/acme-account
|
||||
|
||||
# Backup-Verzeichnis: edgeguard-owned damit der scheduler /
|
||||
# API-Prozess schreiben kann. Mode 0750 — Backups enthalten
|
||||
# einen pg_dump der edgeguard-DB + license_key, also nicht
|
||||
# world-readable.
|
||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" /var/backups/edgeguard
|
||||
|
||||
# ── sudoers: HAProxy reload + (later) systemd-networkd reload
|
||||
# Damit edgeguard-api nach einer SSL- oder Netzwerk-Mutation
|
||||
# selbst reloaden kann ohne root zu sein. NOPASSWD ist auf
|
||||
@@ -84,6 +90,9 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart chrony.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart chrony.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
|
||||
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
|
||||
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
|
||||
# Self-Upgrade-Pfad (handlers/system.go → /system/upgrade). Whitelist
|
||||
# nur die exakte Unit-Form, damit edgeguard NICHT beliebige systemd-
|
||||
# Units anlegen darf.
|
||||
|
||||
Reference in New Issue
Block a user