diff --git a/VERSION b/VERSION index e1d2f8b..7b8d6b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.64 +1.0.65 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index bb1121a..3b2621d 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -51,7 +51,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.64" +var version = "1.0.65" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 505805b..c164e32 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.64" +var version = "1.0.65" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index b1698d0..a4fce3d 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -25,7 +25,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.64" +var version = "1.0.65" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/backup.go b/internal/handlers/backup.go index 3399b5b..a47ae08 100644 --- a/internal/handlers/backup.go +++ b/internal/handlers/backup.go @@ -36,9 +36,33 @@ func (h *BackupHandler) Register(rg *gin.RouterGroup) { g.POST("", h.Trigger) g.GET("/:id", h.Get) g.GET("/:id/download", h.Download) + g.POST("/:id/restore", h.Restore) g.DELETE("/:id", h.Delete) } +// Restore startet einen Restore aus einem vorhandenen Backup. Endpoint +// returnt sofort 202 Accepted — der eigentliche Restore läuft in einer +// transient systemd-Unit; die UI pollt /healthz für die Restart- +// Detection. Massive Audit-Trail, weil das ein destruktiver Eingriff +// in den live-DB-State ist. +func (h *BackupHandler) Restore(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + e, err := h.Service.Restore(c.Request.Context(), id) + if err != nil { + response.Err(c, http.StatusInternalServerError, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.restore", + e.File, gin.H{"id": id, "sha256": e.SHA256}, h.NodeID) + c.JSON(http.StatusAccepted, response.Envelope{ + Data: gin.H{"status": "restoring", "file": e.File, "id": id}, + Message: "Restore gestartet", + }) +} + func (h *BackupHandler) List(c *gin.Context) { out, err := h.Service.List(c.Request.Context()) if err != nil { diff --git a/internal/services/backup/backup.go b/internal/services/backup/backup.go index ab49a81..c71d3d1 100644 --- a/internal/services/backup/backup.go +++ b/internal/services/backup/backup.go @@ -468,6 +468,102 @@ FROM backups WHERE id = $1`, id).Scan( return &e, filepath.Join(s.BackupDir, e.File), nil } +// Restore startet einen full-system-restore aus einem vorhandenen +// Backup-Tarball. Läuft analog `/system/upgrade`-Pattern: wir +// schreiben /var/lib/edgeguard/restore.sh und dispatchen es per +// `sudo systemd-run --unit=edgeguard-restore.service`. Das Skript +// stoppt edgeguard-api+scheduler, kopiert die files/, restored den +// DB-Dump als postgres, re-rendert die Configs und startet die +// Services neu. +// +// Returnt sofort nach dem Dispatch (asynchron) — der eigentliche +// Restore läuft im Hintergrund. UI pollt /healthz für die +// Version-Flip-Detection (analog Upgrade). +func (s *Service) Restore(ctx context.Context, id int64) (*Entry, error) { + e, path, err := s.Get(ctx, id) + if err != nil { + return nil, fmt.Errorf("backup not found: %w", err) + } + if e.Status != "success" { + return nil, fmt.Errorf("backup is in status %q — cannot restore", e.Status) + } + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("backup file missing on disk: %w", err) + } + + const scriptPath = "/var/lib/edgeguard/restore.sh" + script := fmt.Sprintf(`#!/bin/bash +# Generated by edgeguard-api — restore from %s +set -e +sleep 2 # let API return 202 first +TARBALL=%q +TMP=/var/lib/edgeguard/restore-tmp +echo "[restore] extract $TARBALL → $TMP" +rm -rf "$TMP" +mkdir -p "$TMP" +tar -xzf "$TARBALL" -C "$TMP" + +# 1) Restore node-local state files BEFORE the DB swap so a crash +# mid-restore leaves the box in a state where the next API-start +# sees the new keys/setup. DB will be partial but recoverable. +echo "[restore] state files" +for f in setup.json license_key license.cache trial.json .jwt_fingerprint node.conf; do + if [ -f "$TMP/files/$f" ]; then + cp -a "$TMP/files/$f" /var/lib/edgeguard/ + fi +done +if [ -d "$TMP/files/acme-account" ]; then + mkdir -p /var/lib/edgeguard/acme-account + cp -a "$TMP/files/acme-account/." /var/lib/edgeguard/acme-account/ +fi +chown -R edgeguard:edgeguard /var/lib/edgeguard/setup.json \ + /var/lib/edgeguard/license_key /var/lib/edgeguard/license.cache \ + /var/lib/edgeguard/trial.json /var/lib/edgeguard/.jwt_fingerprint \ + /var/lib/edgeguard/node.conf /var/lib/edgeguard/acme-account 2>/dev/null || true + +# 2) Stop API+scheduler so psql can DROP/CREATE tables without active +# connections fighting the dump-restore. +echo "[restore] stop services" +systemctl stop edgeguard-api edgeguard-scheduler + +# 3) Apply DB dump. pg_dump --clean emits DROP TABLE IF EXISTS so +# we don't need to wipe the schema manually. +echo "[restore] psql -f dump.sql" +sudo -u postgres /usr/bin/psql --quiet -d edgeguard -f "$TMP/dump.sql" + +# 4) Re-render configs from the freshly restored DB. Each renderer +# triggers its own service reload — haproxy, nft, etc. so the +# user-visible state matches DB-state immediately. +echo "[restore] render-config" +sudo -u edgeguard /usr/bin/edgeguard-ctl render-config || true + +# 5) Restart edgeguard-api so the UI's /healthz poll sees version- +# flip / fresh connection. Scheduler comes back automatically. +echo "[restore] start services" +systemctl start edgeguard-api edgeguard-scheduler + +rm -rf "$TMP" "$0" +echo "[restore] complete" +`, e.File, path) + + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + return nil, fmt.Errorf("write %s: %w", scriptPath, err) + } + + const unitName = "edgeguard-restore.service" + _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", + "reset-failed", unitName).Run() + cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", + "--unit="+unitName, + "--description=EdgeGuard self-restore", + "--collect", + "bash", scriptPath) + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("systemd-run: %w", err) + } + return e, nil +} + // Delete entfernt File + DB-Row. func (s *Service) Delete(ctx context.Context, id int64) error { _, path, err := s.Get(ctx, id) diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index c23a7b5..6f5a54c 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -78,7 +78,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.64' +const VERSION = '1.0.65' // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // -