package web import ( "context" "net/http" "net/url" "path/filepath" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // Off-box (NAS) restic-SFTP backup handlers (Part B). Form POSTs that redirect to /backups with a flash. // The SSH private key + known-host line are provided out-of-band by the operator (textareas) and written // to 0600/0644 files by the backup Manager; they are NEVER echoed back, logged, or stored in settings. // offboxRedirect sends the customer back to the Távoli mentés page with a flash (success or // error) message (v0.124.0 IA split: the offbox controls live on /backups/remote; the // restore-to-verify flow redirects to /backups/restore via offboxRedirectTo). func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) { offboxRedirectTo(w, r, "/backups/remote", msg, isErr) } func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, isErr bool) { q := "flash" if isErr { q = "flash_error" } http.Redirect(w, r, page+"?"+q+"="+url.QueryEscape(msg), http.StatusFound) } // offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts. func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil { offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true) return } _ = r.ParseForm() host := strings.TrimSpace(r.FormValue("host")) user := strings.TrimSpace(r.FormValue("user")) repoPath := strings.TrimSpace(r.FormValue("repo_path")) port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) if port == 0 { port = 22 } sshKey := r.FormValue("ssh_key") knownHosts := r.FormValue("known_hosts") if host == "" || user == "" || repoPath == "" { offboxRedirect(w, r, "A cél címe, a felhasználó és a tárhely útvonala kötelező.", true) return } if !strings.HasPrefix(repoPath, "/") { offboxRedirect(w, r, "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", true) return } // Validate BEFORE persisting — host/user/repo flow into the ssh command restic runs; reject anything // that could inject an ssh option (leading '-') or a metacharacter (the security boundary). if err := backup.ValidateOffboxTarget(&settings.OffboxTarget{Host: host, User: user, RepoPath: repoPath, Port: port}); err != nil { offboxRedirect(w, r, "Érvénytelen beállítás: "+err.Error(), true) return } // First-time config requires the SSH key + a pinned known-host line (no blind TOFU). existing := s.backupMgr.OffboxConfigured() if !existing && (strings.TrimSpace(sshKey) == "" || strings.TrimSpace(knownHosts) == "") { offboxRedirect(w, r, "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.", true) return } // Write secrets out-of-band (0600 key/pw, 0644 known_hosts); never logged. if err := s.backupMgr.WriteOffboxSecrets(sshKey, knownHosts); err != nil { s.logger.Printf("[ERROR] [web] offbox secrets: %v", err) offboxRedirect(w, r, "A hitelesítő adatok mentése sikertelen.", true) return } prev := s.settings.GetOffboxTarget() tgt := &settings.OffboxTarget{ Enabled: r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true", Host: host, Port: port, User: user, RepoPath: repoPath, Schedule: "daily", } if prev != nil { // preserve runtime status fields across an edit tgt.LastRun, tgt.LastStatus, tgt.LastError = prev.LastRun, prev.LastStatus, prev.LastError tgt.LastDuration, tgt.RepoSizeHuman, tgt.SnapshotCount = prev.LastDuration, prev.RepoSizeHuman, prev.SnapshotCount tgt.LastWarning = prev.LastWarning tgt.EscrowState = prev.EscrowState } // fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks // it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already // escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push // failure does NOT mark escrowed; it is surfaced (the run gate still protects data). stageErr := "" if tgt.Enabled { if tgt.EscrowState != "escrowed" { tgt.EscrowState = "pending" } if client, cerr := s.agentClient(); cerr != nil { stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra." s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr) } else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil { stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra." s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret } } if err := s.settings.SetOffboxTarget(tgt); err != nil { offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) return } s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState) offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "") } // offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4). // DEPRECATED FALLBACK (SLICE 3): the PRIMARY path is the hub-verified auto-confirm // (report.EscrowAutoConfirmer — flips on a hash match in the report ACK, no operator involved). This // manual endpoint stays for LEGACY blobs recorded before the hash existed (e.g. the demo's) — they have // no restic_pw_sha256 and can never auto-confirm; the operator vouches by hand after a verified ceremony. func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true) return } if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }); err != nil { offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) return } s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled") // Fork-4 hygiene: the staged copy on the agent has served its purpose — wipe it. Best-effort: a wipe // failure is logged LOUDLY but does not fail the confirm (the state flip is the primary effect; a // lingering file is a hygiene gap, not a correctness one — re-confirm retries the wipe). if err := s.wipeStagedEscrow(r.Context()); err != nil { s.logger.Printf("[ERROR] [web] escrow confirmed but the agent-staged secret was NOT wiped (re-confirm to retry): %v", err) } offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.", false) } // wipeStagedEscrow calls the injected seam (tests), else the agent's DELETE /escrow/stage-secret over the // pinned local-API channel (agent >= v0.78.0). func (s *Server) wipeStagedEscrow(ctx context.Context) error { if s.wipeStagedEscrowFn != nil { return s.wipeStagedEscrowFn(ctx) } client, err := s.agentClient() if err != nil { return err } wctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() return client.WipeStagedEscrowSecret(wctx) } // offboxInjectPasswordHandler pre-places a RECOVERED repo password at the offbox password path (fork-4 DR // seam) so a subsequent configure uses it and the existing offsite repo opens. Operator/DR only; the value // is never logged. Body: {password, force?}. func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil { offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true) return } _ = r.ParseForm() pw := r.FormValue("password") force := r.FormValue("force") == "on" || r.FormValue("force") == "true" if strings.TrimSpace(pw) == "" { offboxRedirect(w, r, "A repo jelszó kötelező.", true) return } if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil { offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true) return } s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force) offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false) } // offboxToggleHandler flips an app's off-box inclusion. func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() app := strings.TrimSpace(r.FormValue("app")) on := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true" if app == "" { offboxRedirect(w, r, "Hiányzó alkalmazás.", true) return } if err := s.settings.SetAppOffbox(app, on); err != nil { offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) return } offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false) } // offboxRunHandler triggers an off-box backup now (async — it can run for minutes). func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true) return } // fork-4 atomicity: refuse the run until the repo password is escrowed under R. if !s.backupMgr.OffboxRunnable() { offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true) return } go func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour) defer cancel() if err := s.backupMgr.RunOffboxBackup(ctx); err != nil { s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err) } }() offboxRedirect(w, r, "A távoli mentés elindult (a futás után az állapot frissül).", false) } // offboxRestoreHandler restores an app's off-box data to a scratch dir (non-destructive — does NOT // overwrite live data; the operator inspects the restored files). func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true) return } _ = r.ParseForm() app := strings.TrimSpace(r.FormValue("app")) if app == "" { offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true) return } // 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() { offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true) return } 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) }() offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false) }