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:
@@ -635,6 +635,11 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
dbDumpTotalBytes += f.Size
|
||||
}
|
||||
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
||||
|
||||
// Off-box (NAS) restic-SFTP backup (Part B): the target status + per-app off-box toggles.
|
||||
data["Offbox"] = s.settings.GetOffboxTarget()
|
||||
data["OffboxConfigured"] = s.backupMgr.OffboxConfigured()
|
||||
data["OffboxApps"] = s.buildOffboxApps()
|
||||
} else {
|
||||
data["Backup"] = nil
|
||||
}
|
||||
@@ -642,6 +647,32 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.executeTemplate(w, r, "backups", data)
|
||||
}
|
||||
|
||||
// OffboxAppRow is one deployed app's off-box toggle state for the backups page.
|
||||
type OffboxAppRow struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// buildOffboxApps lists deployed, non-protected apps with their off-box toggle state.
|
||||
func (s *Server) buildOffboxApps() []OffboxAppRow {
|
||||
var out []OffboxAppRow
|
||||
if s.stackMgr == nil {
|
||||
return out
|
||||
}
|
||||
for _, st := range s.stackMgr.GetStacks() {
|
||||
if !st.Deployed || st.Protected {
|
||||
continue
|
||||
}
|
||||
dn := st.Meta.DisplayName
|
||||
if dn == "" {
|
||||
dn = st.Name
|
||||
}
|
||||
out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Enabled: s.settings.IsAppOffbox(st.Name)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||||
type AppBackupRow struct {
|
||||
StackName string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -286,6 +286,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.storageWizardPageHandler(w, r, "storage_attach")
|
||||
case path == "/backup/restore" && r.Method == http.MethodPost:
|
||||
s.backupRestoreHandler(w, r)
|
||||
// Off-box (NAS) restic-SFTP backup (Part B)
|
||||
case path == "/backup/offbox/config" && r.Method == http.MethodPost:
|
||||
s.offboxConfigHandler(w, r)
|
||||
case path == "/backup/offbox/toggle" && r.Method == http.MethodPost:
|
||||
s.offboxToggleHandler(w, r)
|
||||
case path == "/backup/offbox/run" && r.Method == http.MethodPost:
|
||||
s.offboxRunHandler(w, r)
|
||||
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
|
||||
s.offboxRestoreHandler(w, r)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/export")
|
||||
|
||||
@@ -116,6 +116,83 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Backup}}
|
||||
<!-- Off-box (NAS) backup — Part B: encrypted restic repo over SFTP (the off-site 3-2-1 leg). -->
|
||||
<h3 class="backup-tier-divider">Külső (NAS) mentés — titkosított, off-site</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">Az alkalmazások adatainak + adatbázis-kiírásainak titkosított mentése a saját NAS-odra (restic, SFTP-n). A NAS csak titkosított adatot lát. Ez a 3-2-1 „1 off-site" lába — független a helyi másodpéldánytól és a teljes rendszermentéstől.</p>
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
{{if .Offbox}}
|
||||
<div class="stats-grid backup-page-cards">
|
||||
<div class="stat-card {{if eq .Offbox.LastStatus "ok"}}stat-running{{else if eq .Offbox.LastStatus "error"}}stat-stopped{{end}}">
|
||||
<div class="stat-value" style="font-size:1.15rem">{{if eq .Offbox.LastStatus "ok"}}✓ Rendben{{else if eq .Offbox.LastStatus "error"}}✗ Hiba{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Utolsó NAS-mentés{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgo .Offbox.LastRun}}</span>{{end}}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.15rem">{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.05rem">{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}</div>
|
||||
<div class="stat-label">{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A NAS-mentés ki van kapcsolva{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--danger,#c0392b)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
|
||||
{{if .OffboxConfigured}}
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<form method="POST" action="/backup/offbox/run" style="display:inline">{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-sm btn-primary">NAS-mentés most</button>
|
||||
</form>
|
||||
</div>
|
||||
<h4 style="margin-top:1.25rem">Mely alkalmazások mentődnek a NAS-ra?</h4>
|
||||
{{if .OffboxApps}}
|
||||
<div class="storage-paths-list">
|
||||
{{range .OffboxApps}}
|
||||
<div class="storage-path-item">
|
||||
<div class="storage-path-header">
|
||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
||||
<div class="storage-path-actions">
|
||||
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
|
||||
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}NAS-mentés kikapcsolása{{else}}NAS-mentés bekapcsolása{{end}}</button>
|
||||
</form>
|
||||
{{if .Enabled}}
|
||||
<form method="POST" action="/backup/offbox/restore" style="display:inline" onsubmit="return confirm('Visszaállítja a(z) {{.DisplayName}} adatait a NAS-ról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.')">{{$.CSRFField}}
|
||||
<input type="hidden" name="app" value="{{.Name}}">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Visszaállítás (ellenőrzéshez)</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<p class="form-hint">Nincs telepített alkalmazás.</p>{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="form-hint">Még nincs beállítva külső NAS mentési cél.</p>
|
||||
{{end}}
|
||||
|
||||
<details style="margin-top:1rem">
|
||||
<summary class="btn btn-xs btn-primary" style="cursor:pointer;display:inline-block">NAS mentési cél beállítása</summary>
|
||||
<form method="POST" action="/backup/offbox/config" style="margin-top:.75rem;padding:1rem;border:1px solid var(--border,#ddd);border-radius:6px;max-width:560px">{{.CSRFField}}
|
||||
<div class="form-row"><label>NAS címe (IP vagy hosztnév)</label><input type="text" name="host" class="form-input" value="{{if .Offbox}}{{.Offbox.Host}}{{end}}" placeholder="pl. 192.168.0.10"></div>
|
||||
<div class="form-row"><label>SSH port</label><input type="number" name="port" class="form-input" value="{{if .Offbox}}{{.Offbox.Port}}{{else}}22{{end}}"></div>
|
||||
<div class="form-row"><label>Felhasználó</label><input type="text" name="user" class="form-input" value="{{if .Offbox}}{{.Offbox.User}}{{end}}" placeholder="pl. felhom"></div>
|
||||
<div class="form-row"><label>Tároló útvonala a NAS-on</label><input type="text" name="repo_path" class="form-input" value="{{if .Offbox}}{{.Offbox.RepoPath}}{{end}}" placeholder="pl. /volume1/felhom-backup/repo"></div>
|
||||
<div class="form-row"><label><input type="checkbox" name="enabled" {{if .Offbox}}{{if .Offbox.Enabled}}checked{{end}}{{else}}checked{{end}}> Napi automatikus NAS-mentés engedélyezve</label></div>
|
||||
<div class="form-row"><label>SSH privát kulcs {{if .OffboxConfigured}}(üresen hagyva változatlan){{end}}</label>
|
||||
<textarea name="ssh_key" class="form-input" rows="4" autocomplete="off" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||||
<span class="form-hint">A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.</span></div>
|
||||
<div class="form-row"><label>NAS ismert-host sora (known_hosts) {{if .OffboxConfigured}}(üresen hagyva változatlan){{end}}</label>
|
||||
<textarea name="known_hosts" class="form-input" rows="2" autocomplete="off" placeholder="nas.local ssh-ed25519 AAAA…"></textarea>
|
||||
<span class="form-hint">A host-kulcs rögzítése (no blind TOFU). Lekérdezhető: <span class="mono">ssh-keyscan -p <port> <host></span></span></div>
|
||||
<button type="submit" class="btn btn-sm btn-primary" style="margin-top:.5rem">Mentés</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h3 class="backup-tier-divider">Alkalmazás-mentések (adatbázis + konfiguráció)</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">Az egyes alkalmazások részletes, granulált mentése — adatbázis-kiírások, beállítások és alkalmazás-fájlok. A fenti teljes mentéstől függetlenül, alkalmazásonként visszaállítható.</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user