Files
felhom-controller/controller/internal/web/offbox_handlers.go
T
admin 908e4b906a v0.104.0: off-box unit discovery (durable, deployment-independent) + no-silent-success
offbox located each toggled app's recovery unit via AppNamespaceRoot→GetAppDrivePath,
which reads the app's LIVE app.yaml HDD_PATH and silently falls back to systemDataPath
when the app isn't deployed → looked on the wrong drive, backed up nothing, reported
ok/0 (DIAG root cause). Now:

- discoverOffboxUnit/offboxCandidateNSRoots scan the durable storage registry
  (schedulable non-decommissioned paths ∪ systemDataPath) for backups/primary/<app>,
  independent of deploy state; newest-by-manifest-CreatedAt wins on drive churn.
- RunOffboxBackup: runOffboxInternal returns (backedUp, missing, err); 0-of-N toggled →
  hard error + operator alert; partial → ok + new OffboxTarget.LastWarning (shown on
  /backups, preserved across config edit).
- AppNamespaceRoot + primary WRITE paths unchanged.
- 6 non-hollow tests (A-E + edge) + both companion red-proofs run (reverted).
- NOT yet live-validated against the Storage Box (spike creds torn down).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-09 12:32:05 +02:00

160 lines
6.5 KiB
Go

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 operator back to the backups page with a flash (success or error) message.
func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) {
q := "flash"
if isErr {
q = "flash_error"
}
http.Redirect(w, r, "/backups?"+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 NAS 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 NAS 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
}
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)", user, host, repoPath, port, tgt.Enabled)
offboxRedirect(w, r, "A NAS mentési cél elmentve.", 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 NAS-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 NAS mentési cél nincs beállítva.", 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 NAS-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() {
offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true)
return
}
_ = r.ParseForm()
app := strings.TrimSpace(r.FormValue("app"))
if app == "" {
offboxRedirect(w, r, "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() {
offboxRedirect(w, r, "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)
}()
offboxRedirect(w, r, "A NAS-visszaállítás elindult — az állapot itt frissül.", false)
}