fe9266f53f
New report.Trigger (buffered-1 chan + worker; quiet 2s, min spacing 15s, trailing-edge coalescing) generalizes the v0.70.0 geo out-of-band push. One canonical fire closure in main.go; wired: geo save/sync + app deploy/remove/delete (api reportPushNow), escrow recovery-code claim, notification-prefs save, app-email toggle, offsite config + per-app toggle, customer claim (web SetReportTrigger seam, nil-safe, fired only after a successful local commit). 15-min hub-report cycle untouched as the reconciliation backbone; hub.enabled=false stays a strict no-op. Tests: trigger engine (2 red-proofs), seam fires-after-commit-only, nil-seam no-ops.
307 lines
14 KiB
Go
307 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"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
|
|
tgt.RepoSizeBytes = prev.RepoSizeBytes
|
|
tgt.EnlargedBlocked = prev.EnlargedBlocked
|
|
}
|
|
// 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)
|
|
s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
|
|
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"
|
|
o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
|
|
}); 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
|
|
}
|
|
s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
|
|
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 an on-data-drive scratch dir (§7, F-A1;
|
|
// non-destructive — does NOT overwrite live data). mode=unit (default) restores the recovery unit
|
|
// only; mode=full is size-gated and two-step (first POST computes the size + headroom and redirects
|
|
// with a reveal cue; the revealed confirm POSTs mode=full&confirm=1, re-checked at execution).
|
|
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
|
|
}
|
|
mode := strings.TrimSpace(r.FormValue("mode"))
|
|
if mode == "" {
|
|
mode = "unit"
|
|
}
|
|
// Step 1 of the full two-step: compute size + headroom BEFORE any restic restore; on a refusal
|
|
// flash the Hungarian reason, else redirect with the reveal params (size shown before starting).
|
|
if mode == "full" && r.FormValue("confirm") != "1" {
|
|
pctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app)
|
|
if err != nil {
|
|
offboxRedirectTo(w, r, "/backups/restore", err.Error(), true)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/backups/restore?full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound)
|
|
return
|
|
}
|
|
// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
|
|
// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
|
|
if s.backupMgr.IsRunning() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
|
|
return
|
|
}
|
|
full := mode == "full"
|
|
s.backupMgr.BeginRestoreOp("offbox-restore", app)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
if err := s.backupMgr.RestoreOffboxScratch(ctx, app, full); err != nil {
|
|
s.logger.Printf("[ERROR] [web] off-box restore %s (full=%v, async): %v", app, full, err)
|
|
s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
|
|
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok).")
|
|
}()
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false)
|
|
}
|
|
|
|
// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
|
|
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
|
|
func (s *Server) offboxPlaceHandler(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
|
|
}
|
|
if s.backupMgr.IsRunning() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
|
|
return
|
|
}
|
|
s.backupMgr.BeginRestoreOp("offbox-place", app)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
if err := s.backupMgr.PlaceOffsiteRestore(ctx, app); err != nil {
|
|
s.logger.Printf("[ERROR] [web] off-box place %s (async): %v", app, err)
|
|
s.backupMgr.EndRestoreOp(false, "A helyreállítás sikertelen: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
|
|
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
|
|
}()
|
|
offboxRedirectTo(w, r, "/backups/restore", "A helyreállítás elindult — az állapot itt frissül.", false)
|
|
}
|