controller v0.93.0: NAS Part B off-box backup target (restic-over-SFTP)
Encrypted restic repo over SFTP for the app-data tier (the off-site 3-2-1 leg). A dead NAS fails fast via -oConnectTimeout (spike Q8), never hangs the runner; secrets are 0600 files (ride DR via PBS whole-CT); init-if-absent, retention forget --prune, restore, single-flight, per-app toggle + UI. restic re-added to the image. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
offboxRedirect(w, r, "A(z) "+app+" visszaállítva ide (ellenőrzésre): "+dest, false)
|
||||
}
|
||||
Reference in New Issue
Block a user