From c529a455af31c6def0ce30fe62eeeeb966d46155 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 6 Jul 2026 20:23:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(backup):=20async=20restore=20family=20?= =?UTF-8?q?=E2=80=94=20no=20proxy-timeout=20error=20page=20on=20a=20succee?= =?UTF-8?q?ding=20restore=20(v0.102.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adjudicates F4: /backup/restore, /backup/tier2/restore, /backup/offbox/restore blocked the HTTP request until completion, so through cloudflared's 100s cap a customer got an error page while the restore succeeded (offbox worse — bounded on r.Context(), canceling the SFTP restore mid-flight). Convert all three to the offboxRun async shape: fast-path IsRunning refuse, background goroutine (offbox ctx off r.Context() -> Background+30m), instant redirect. Add mutex- guarded op-status (opstatus.go) + GET /api/backup/restore-status + a 3s-polling backups.html banner (neutral running, red on failure). Restore single-flight unchanged. Tests + red-proof (sync handler blocks indefinitely vs <500ms async). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CHANGELOG.md | 24 +++ controller/internal/api/router.go | 15 ++ controller/internal/backup/backup.go | 7 + controller/internal/backup/opstatus.go | 71 ++++++++ controller/internal/backup/opstatus_test.go | 55 ++++++ controller/internal/web/async_restore_test.go | 157 ++++++++++++++++++ controller/internal/web/handlers.go | 74 +++++---- controller/internal/web/offbox_handlers.go | 26 ++- .../internal/web/templates/backups.html | 41 +++++ 9 files changed, 429 insertions(+), 41 deletions(-) create mode 100644 controller/internal/backup/opstatus.go create mode 100644 controller/internal/backup/opstatus_test.go create mode 100644 controller/internal/web/async_restore_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8654339..6a3ed82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ ## Changelog +### v0.102.0 — async restore family: no more proxy-timeout error page on a succeeding restore (2026-07-06) + +Re-adjudicates campaign **F4** (`felhom.eu/documentation/audits/RERUN-p1p3-2026-07-06.md`): all three +restore surfaces (`/backup/restore`, `/backup/tier2/restore`, `/backup/offbox/restore`) blocked the +HTTP request until completion. Through cloudflared's hard 100s cap + traefik, a real customer got an +**error page while the restore silently succeeded**; the off-box one was worse — it bounded on +`r.Context()`, so a proxy read-timeout **canceled the SFTP restore mid-flight**. + +- **Async family** (mirrors the existing `offboxRunHandler` shape): each handler fast-path refuses a + concurrent op (`IsRunning()` → "Egy mentési/visszaállítási művelet már fut."), then runs the restore + in a **background goroutine** and redirects immediately with a "Visszaállítás elindult…" flash. The + offbox restore's context moved from `r.Context()` to `context.Background()+30m` (fixes the mid-flight + cancel). The restore functions' internal single-flight acquire is unchanged. +- **Op-status surface** (`internal/backup/opstatus.go`): mutex-guarded in-memory current-op + terminal + `last{op,stack,ok,message,finished_at}`, deep-copy getter; new `GET /api/backup/restore-status` + (distinct from `/backup/status`, which proxies the agent's PBS status). In-memory, lost on restart + (same precedent as notification cooldowns). +- **UI** (`backups.html`): a progress banner polls the status every 3s — neutral while running (shown + even on a fresh page load mid-op), success on completion, red **only** on failure. +- Tests: `opstatus_test.go` (begin→running→terminal, deep-copy, failure); `async_restore_test.go` + (handler returns <500ms while the restore parks in a blocking provider + op-status transitions; + double-click refused with no second launch). Red-proof: the pre-fix synchronous handler blocks the + request indefinitely (test killed at 30s) vs <500ms async. + ### v0.101.0 — campaign findings F3 (sync deadline) + F2 evidence gap (agent refusal surfacing) (2026-07-06) From the no-mercy campaign (`felhom.eu/documentation/audits/CAMPAIGN-nomercy-2026-07-06.md`). diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 9120826..999e08f 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -252,6 +252,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/backup/status" && req.Method == http.MethodGet: r.backupStatus(w, req) + // GET /api/backup/restore-status — async restore progress for the backups-page banner (Part B). + // Distinct from /backup/status (which proxies the agent's PBS whole-guest status). + case path == "/backup/restore-status" && req.Method == http.MethodGet: + r.backupRestoreStatus(w, req) + // GET /api/backup/snapshots?stack= — restorable keep-side backups for the restore panel case path == "/backup/snapshots" && req.Method == http.MethodGet: r.backupSnapshots(w, req) @@ -835,6 +840,16 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) } +// backupRestoreStatus (Part B) surfaces the async restore-op progress the backups page polls to drive +// its banner (running: / → terminal last{ok,message}). Display-only; empty when idle. +func (r *Router) backupRestoreStatus(w http.ResponseWriter, _ *http.Request) { + if r.backupMgr == nil { + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"running": false}}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.backupMgr.RestoreStatus()}) +} + // validStackParam reports whether a stack name from a request is a safe single path segment // (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because // api ↔ web would be a circular import). Rejects traversal/escape so the name can never become diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index ed8edb0..7ab2790 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -63,6 +63,13 @@ type Manager struct { lastDBDump *DBDumpStatus running bool + // Restore op-status (Part B, opstatus.go) — display-only async-restore progress, under `mu`. + opRunning bool + opName string + opStack string + opStartedAt time.Time + opLast *RestoreOpResult + // Cached status for page rendering (refreshed periodically) cachedStatus *FullBackupStatus cacheTime time.Time diff --git a/controller/internal/backup/opstatus.go b/controller/internal/backup/opstatus.go new file mode 100644 index 0000000..3ca1ed8 --- /dev/null +++ b/controller/internal/backup/opstatus.go @@ -0,0 +1,71 @@ +package backup + +import "time" + +// Restore op-status (Part B): a lightweight, in-memory surface for the ASYNC restore family so the +// backups page can show a progress banner (running → success/failure) instead of blocking the HTTP +// request until the restore completes. It is display-only and mutex-guarded on the Manager's `mu`; +// it does NOT gate concurrency (that stays the restore functions' internal single-flight acquire). +// In-memory only — lost on a controller restart (same precedent as notification cooldowns); a page +// load mid-op after a restart simply shows no banner. + +// RestoreOpResult is the terminal record of the most recent restore op. +type RestoreOpResult struct { + Op string `json:"op"` // "restore" | "tier2-restore" | "offbox-restore" + Stack string `json:"stack"` + OK bool `json:"ok"` + Message string `json:"message"` + FinishedAt time.Time `json:"finished_at"` +} + +// RestoreOpStatus is the shape served at GET /api/backup/restore-status. +type RestoreOpStatus struct { + Running bool `json:"running"` + Op string `json:"op,omitempty"` + Stack string `json:"stack,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + Last *RestoreOpResult `json:"last,omitempty"` +} + +// BeginRestoreOp marks a restore op in flight (called by the handler just before launching the +// background goroutine). Idempotent enough for display; concurrency is enforced elsewhere. +func (m *Manager) BeginRestoreOp(op, stack string) { + m.mu.Lock() + defer m.mu.Unlock() + m.opRunning = true + m.opName = op + m.opStack = stack + m.opStartedAt = time.Now() +} + +// EndRestoreOp records the terminal result (called from the goroutine on completion, success or +// failure). Message carries the error (failure) or a human note like the scratch path (offbox). +func (m *Manager) EndRestoreOp(ok bool, message string) { + m.mu.Lock() + defer m.mu.Unlock() + m.opLast = &RestoreOpResult{ + Op: m.opName, + Stack: m.opStack, + OK: ok, + Message: message, + FinishedAt: time.Now(), + } + m.opRunning = false +} + +// RestoreStatus returns a deep copy of the current restore op-status for the page/API. +func (m *Manager) RestoreStatus() RestoreOpStatus { + m.mu.Lock() + defer m.mu.Unlock() + st := RestoreOpStatus{ + Running: m.opRunning, + Op: m.opName, + Stack: m.opStack, + StartedAt: m.opStartedAt, + } + if m.opLast != nil { + cp := *m.opLast + st.Last = &cp + } + return st +} diff --git a/controller/internal/backup/opstatus_test.go b/controller/internal/backup/opstatus_test.go new file mode 100644 index 0000000..2f4bce1 --- /dev/null +++ b/controller/internal/backup/opstatus_test.go @@ -0,0 +1,55 @@ +package backup + +import "testing" + +// TestRestoreOpStatus covers the async restore op-status surface (Part B): begin → running, +// end(success/failure) → terminal record, and the deep-copy getter (mutating the returned value must +// not corrupt the Manager's state). +func TestRestoreOpStatus(t *testing.T) { + m := &Manager{} + + // idle + if st := m.RestoreStatus(); st.Running || st.Last != nil { + t.Fatalf("idle status should be empty: %+v", st) + } + + // begin → running with op/stack + m.BeginRestoreOp("restore", "vaultwarden") + st := m.RestoreStatus() + if !st.Running || st.Op != "restore" || st.Stack != "vaultwarden" { + t.Fatalf("running status = %+v, want running restore/vaultwarden", st) + } + if st.StartedAt.IsZero() { + t.Error("StartedAt not stamped") + } + + // end(success) → not running, terminal Last carries the message + op + stack + m.EndRestoreOp(true, "kész") + st = m.RestoreStatus() + if st.Running { + t.Error("still running after EndRestoreOp") + } + if st.Last == nil || !st.Last.OK || st.Last.Message != "kész" || st.Last.Op != "restore" || st.Last.Stack != "vaultwarden" { + t.Fatalf("terminal = %+v, want ok restore/vaultwarden 'kész'", st.Last) + } + + // deep copy: mutating the returned Last must NOT change the Manager's stored record. + st.Last.Message = "MUTATED" + if again := m.RestoreStatus(); again.Last.Message != "kész" { + t.Fatalf("RestoreStatus is not deep-copied: internal message = %q", again.Last.Message) + } + + // failure path → terminal Last.OK false with the error message and the new op. + m.BeginRestoreOp("tier2-restore", "paperless") + if !m.RestoreStatus().Running { + t.Error("second op not running") + } + m.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: boom") + st = m.RestoreStatus() + if st.Running || st.Last.OK || st.Last.Op != "tier2-restore" || st.Last.Stack != "paperless" { + t.Fatalf("failure terminal = %+v", st.Last) + } + if st.Last.Message != "Fájl-visszaállítás sikertelen: boom" { + t.Errorf("failure message = %q", st.Last.Message) + } +} diff --git a/controller/internal/web/async_restore_test.go b/controller/internal/web/async_restore_test.go new file mode 100644 index 0000000..f59e7e3 --- /dev/null +++ b/controller/internal/web/async_restore_test.go @@ -0,0 +1,157 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// blockProvider is a StackDataProvider whose StopStack blocks on a channel — so a Tier-2 restore +// parks mid-flight while the test asserts the HANDLER already returned (async) and that a concurrent +// POST is refused without launching a second restore. +type blockProvider struct { + hdd string + release chan struct{} + stops int32 // atomic: number of StopStack calls == number of restores that actually launched +} + +func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "", false } +func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil } +func (p *blockProvider) GetStackHDDMounts(string) []string { return nil } +func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd } +func (p *blockProvider) GetDockerVolumes(string) []string { return nil } +func (p *blockProvider) StopStack(string) error { + atomic.AddInt32(&p.stops, 1) + <-p.release // park here until the test releases it + return nil +} +func (p *blockProvider) StartStack(string) error { return nil } +func (p *blockProvider) RefreshAndIsRunning(string) bool { return true } +func (p *blockProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) { + return backup.RecoveryInfo{}, false +} +func (p *blockProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } +func (p *blockProvider) RecreateStackFromUnit(string, string, map[string]string) error { + return nil +} + +func newAsyncRestoreServer(t *testing.T) (*Server, *blockProvider, *backup.Manager) { + t.Helper() + tmp := t.TempDir() + live := filepath.Join(tmp, "usb") + dest := filepath.Join(tmp, "flash") + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{live, dest} { + if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: filepath.Base(p)}); err != nil { + t.Fatal(err) + } + } + if err := sett.SetCrossDriveConfig("app", &settings.CrossDriveBackup{ + Enabled: true, Method: "rsync", DestinationPath: dest, LastRun: "2026-07-06T03:30:00Z", LastStatus: "ok", + }); err != nil { + t.Fatal(err) + } + // a recorded Tier-2 copy dir so RestoreTier2Files proceeds to StopStack (where we block). + if err := os.MkdirAll(filepath.Join(dest, "backups", "secondary", "app", "appdata"), 0o755); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = tmp + m := backup.NewManager(cfg, sett, lg) + prov := &blockProvider{hdd: live, release: make(chan struct{})} + m.SetStackProvider(prov) + s := &Server{cfg: cfg, backupMgr: m, logger: lg} + return s, prov, m +} + +func postTier2(s *Server) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/backup/tier2/restore", strings.NewReader("stack_name=app")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.backupTier2RestoreHandler(w, req) + return w +} + +func waitFor(t *testing.T, cond func() bool, msg string) { + t.Helper() + // Budget past waitForHealthy's 3s post-restore settling sleep (+ copier). + for i := 0; i < 700; i++ { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for: %s", msg) +} + +// B1 — the restore handler returns INSTANTLY (302) while the restore runs in the background, and the +// op-status transitions running → terminal. RED-PROOF: on the pre-fix synchronous handler (calling +// RestoreTier2Files inline) this POST blocks in StopStack until the channel is released, so the +// sub-500ms response assertion FAILS. +func TestBackupTier2Restore_AsyncReturnsInstantly(t *testing.T) { + s, prov, m := newAsyncRestoreServer(t) + + start := time.Now() + w := postTier2(s) + elapsed := time.Since(start) + + if elapsed > 500*time.Millisecond { + t.Fatalf("handler blocked %v — restore is not async (the F4 shape)", elapsed) + } + if w.Code != http.StatusFound { + t.Fatalf("want 302, got %d", w.Code) + } + if loc := w.Header().Get("Location"); !strings.Contains(loc, "elindult") { + t.Fatalf("redirect should carry the 'elindult' flash; got %q", loc) + } + // the background restore is now parked in StopStack → op-status shows running. + waitFor(t, func() bool { return m.RestoreStatus().Running }, "restore op running") + st := m.RestoreStatus() + if st.Op != "tier2-restore" || st.Stack != "app" { + t.Fatalf("running status = %+v", st) + } + // release → the restore completes → terminal status. + close(prov.release) + waitFor(t, func() bool { return !m.RestoreStatus().Running && m.RestoreStatus().Last != nil }, "restore terminal") + if got := atomic.LoadInt32(&prov.stops); got != 1 { + t.Fatalf("StopStack called %d times, want exactly 1", got) + } +} + +// B2 — a concurrent restore POST while one runs is REFUSED (fast-path IsRunning) and does NOT launch a +// second restore. RED-PROOF: removing the IsRunning() fast-path lets the second POST launch a second +// goroutine → StopStack count becomes 2. +func TestBackupTier2Restore_DoubleClickRefused(t *testing.T) { + s, prov, m := newAsyncRestoreServer(t) + + // first restore → parks in StopStack (acquires the single-flight running flag). + _ = postTier2(s) + waitFor(t, func() bool { return m.IsRunning() }, "first restore holding the single-flight lock") + + // second restore while the first runs → refused with the "már fut" flash, no second launch. + w2 := postTier2(s) + if loc := w2.Header().Get("Location"); !strings.Contains(loc, "m%C3%A1r+fut") && !strings.Contains(loc, "már fut") { + t.Fatalf("second POST should be refused with 'már fut'; got %q", loc) + } + if got := atomic.LoadInt32(&prov.stops); got != 1 { + t.Fatalf("double-click launched a second restore: StopStack count = %d, want 1", got) + } + + close(prov.release) + waitFor(t, func() bool { return !m.IsRunning() }, "first restore done") +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index e07d1cf..a5e5892 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -871,29 +871,28 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } - - s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) - - start := time.Now() - // Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed on - // an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists). - err := s.backupMgr.RestoreFromRecoveryUnit(stackName) - if err != nil { - s.logger.Printf("[ERROR] [web] Restore failed: %v", err) - if s.isDebug() { - s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start)) - } - errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error()) - http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound) + // Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer + // got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in + // a BACKGROUND goroutine (survives the request; the poll banner shows progress → result). + if s.backupMgr.IsRunning() { + http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) return } - - if s.isDebug() { - s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s completed in %s", stackName, time.Since(start)) - } - - msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").") - http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) + s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) + s.backupMgr.BeginRestoreOp("restore", stackName) + go func() { + start := time.Now() + // Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed + // on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists). + if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil { + s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err) + s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error()) + return + } + s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start)) + s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").") + }() + http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } // backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its @@ -917,21 +916,28 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } - - s.logger.Printf("[WARN] [web] Tier-2 file restore requested: stack=%s from %s", stackName, r.RemoteAddr) - - n, err := s.backupMgr.RestoreTier2Files(stackName) - if err != nil { - s.logger.Printf("[ERROR] [web] Tier-2 file restore failed: %v", err) - http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Fájl-visszaállítás sikertelen: "+err.Error()), http.StatusFound) + // Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine. + if s.backupMgr.IsRunning() { + http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound) return } - - msg := "Nincs hiányzó fájl — minden fájl megvan a helyén." - if n > 0 { - msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n) - } - http.Redirect(w, r, "/backups?flash="+url.QueryEscape(msg), http.StatusFound) + s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr) + s.backupMgr.BeginRestoreOp("tier2-restore", stackName) + go func() { + n, err := s.backupMgr.RestoreTier2Files(stackName) + if err != nil { + s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err) + s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error()) + return + } + msg := "Nincs hiányzó fájl — minden fájl megvan a helyén." + if n > 0 { + msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n) + } + s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n) + s.backupMgr.EndRestoreOp(true, msg) + }() + http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound) } // settingsBaseData is the shared identity block used by every settings-family subpage diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index a132414..4d46dc5 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -134,13 +134,25 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) { offboxRedirect(w, r, "Hiányzó alkalmazás.", true) return } - dest := filepath.Join(s.cfg.Paths.DataDir, "offbox-restore", app) - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) - defer cancel() - if err := s.backupMgr.RestoreOffbox(ctx, app, dest); err != nil { - s.logger.Printf("[ERROR] [web] off-box restore %s: %v", app, err) - offboxRedirect(w, r, "A visszaállítás sikertelen: "+err.Error(), true) + // Part B: fast-path refuse a concurrent op, then run async on a BACKGROUND context. The old code + // bounded on r.Context()+30m — a proxy read-timeout then CANCELED the SFTP restore mid-flight + // (worse than F4: not just an error page, an aborted restore). Background ctx fixes that. + if s.backupMgr.IsRunning() { + offboxRedirect(w, r, "Egy mentési/visszaállítási művelet már fut.", true) return } - offboxRedirect(w, r, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest, false) + dest := filepath.Join(s.cfg.Paths.DataDir, "offbox-restore", app) + s.backupMgr.BeginRestoreOp("offbox-restore", app) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + if err := s.backupMgr.RestoreOffbox(ctx, app, dest); err != nil { + s.logger.Printf("[ERROR] [web] off-box restore %s (async): %v", app, err) + s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error()) + return + } + s.logger.Printf("[INFO] [web] off-box restore %s completed (async) → %s", app, dest) + s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest) + }() + offboxRedirect(w, r, "A NAS-visszaállítás elindult — az állapot itt frissül.", false) } diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 8ebbfdb..0ab7e0d 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -13,6 +13,10 @@
{{.Backup.FlashError}}
{{end}}{{end}} + + + {{if not .Backup}}
🛡
@@ -597,6 +601,43 @@ {{end}}