From 2a7deadc9331030596768435906ef0bc489a6cfa Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 30 Jun 2026 15:26:38 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs --- CHANGELOG.md | 31 ++ controller/Dockerfile | 4 + controller/cmd/controller/main.go | 16 + controller/internal/backup/backup.go | 4 + controller/internal/backup/offbox.go | 338 ++++++++++++++++++ controller/internal/backup/offbox_test.go | 315 ++++++++++++++++ controller/internal/settings/settings.go | 95 +++++ controller/internal/web/handlers.go | 31 ++ controller/internal/web/offbox_handlers.go | 139 +++++++ controller/internal/web/server.go | 9 + .../internal/web/templates/backups.html | 77 ++++ 11 files changed, 1059 insertions(+) create mode 100644 controller/internal/backup/offbox.go create mode 100644 controller/internal/backup/offbox_test.go create mode 100644 controller/internal/web/offbox_handlers.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f423359..a8669e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ ## Changelog +### v0.93.0 — NAS Part B: off-box backup target (restic-over-SFTP) (2026-06-30) +Closes the NAS arc: back the app-data tier (each off-box app's recovery unit + DB dumps + volume tars) up +to the customer's NAS as an **encrypted restic repo over SFTP** — the "1 off-site" leg of 3-2-1, distinct +from the local cross-drive rsync copy and the agent's PBS whole-CT DR. No kernel mount; restic talks SFTP +to the NAS directly. Spike-validated (SPIKE-nas-storage Q8). +- **`Dockerfile`:** restic was dropped when cross-drive migrated restic→rsync — re-added `restic` + + `openssh-client` (restic's sftp backend shells out to `ssh`); version pinned by the Debian release. +- **`internal/backup/offbox.go` (NEW):** the restic-SFTP backend + orchestration. + - **Fail-fast (the load-bearing spike Q8 lesson):** every restic call carries + `-o sftp.args=…-oConnectTimeout=10…` so a dead NAS errors in ~10 s instead of a multi-minute TCP hang. + Also `-oStrictHostKeyChecking=yes -oUserKnownHostsFile=` (no blind TOFU) + `-oBatchMode=yes`. + - init-if-absent (idempotent — a present repo is reused, never re-init), per-app `restic backup --tag`, + `forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune` retention, single-flight (shares + `m.running`) + migration-guard, restic's **own exit code** checked (never pipe-swallowed), restore via + `restic restore latest --tag --target ` (non-destructive). + - **Secrets:** the SSH private key + the auto-generated repo password are 0600 files in the data dir — + never logged, never in a committed/non-0600 file; the repo is encrypted so the NAS sees only ciphertext. + They ride DR via the PBS whole-CT snapshot of the rootfs (the data dir), so a rebuilt box can reach the + off-box repo (the recovery-unit/dr-recipe stay secret-free). +- **`internal/settings/settings.go`:** `OffboxTarget` (host/port/user/repo path/schedule + runtime status, + no secrets) + per-app `AppBackupPrefs.Offbox` toggle + helpers. +- **`cmd/controller/main.go`:** daily `offbox-backup` schedule (04:15) gated on enabled+configured; a + failure (incl. fail-fast dead-NAS) alerts the operator via the allowlisted `backup_failed` event. +- **UI (`backups.html`):** "Külső (NAS) mentés" section — target config (host/port/user/repo + out-of-band + SSH key + known_hosts textareas), status (last run / repo size / snapshots), per-app toggles, run-now, + restore-to-scratch. +- Tests: ConnectTimeout present in base args + **the fail-fast companion red-proof** (a fake SSH transport + hangs to the ctx deadline WITHOUT the arg, fails fast WITH it); dead-NAS run fails fast + alerts + records + status; restore round-trip byte-identical (SFTP-shaped seam); single-flight skip; repo idempotency; + secrets are 0600. `go build/vet/test ./...` green. + ### v0.92.0 — NAS network storage Part A2: registry + UI + per-share health (2026-06-30) The controller side of NAS network storage, proxying to the validated agent foundation (felhom-agent v0.50.0 `/netstorage/*`). An operator can add a customer's NAS share and point a media app at it — all via diff --git a/controller/Dockerfile b/controller/Dockerfile index 6b8d1c7..c6a5418 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -49,6 +49,8 @@ FROM debian:bookworm-slim # - e2fsprogs: mkfs.ext4 (filesystem formatting) # - rsync: for data migration between storage paths # - parted: partprobe (partition table re-read after sfdisk) +# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release) +# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport) RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ @@ -62,6 +64,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ e2fsprogs \ rsync \ parted \ + restic \ + openssh-client \ && rm -rf /var/lib/apt/lists/* # Install docker-cli (without daemon) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 8f58df8..34d9e63 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -415,6 +415,22 @@ func main() { backupMgr.RunAllTier2() return nil }) + + // Off-box (NAS) restic-SFTP backup (Part B): the off-site leg. A failure (incl. a fail-fast dead-NAS + // error) alerts the operator via the allowlisted backup_failed event. Daily after Tier 2. + backupMgr.SetOffboxNotify(func(dur time.Duration, snapshots int, err error) { + if err != nil { + notifier.NotifyBackupFailed("Off-box (NAS) mentés sikertelen", + "a NAS-ra mentés hibázott ("+dur.Round(time.Second).String()+"): "+err.Error()) + } + }) + sched.Daily("offbox-backup", "04:15", func(ctx context.Context) error { + t := sett.GetOffboxTarget() + if t == nil || !t.Enabled || t.Schedule != "daily" || !backupMgr.OffboxConfigured() { + return nil // not configured / not scheduled + } + return backupMgr.RunOffboxBackup(ctx) + }) } // Metrics prune — daily at 04:00 diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index ff4a662..4796943 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -31,6 +31,10 @@ type Manager struct { // tier2Notify, if set, is called after each Tier 2 copy (success: err==nil) for notifications. tier2Notify func(stackName, destLabel string, dur time.Duration, err error) + // offbox (Part B): the restic-SFTP exec seam (nil → real restic) + the failure→operator-alert hook. + offboxRunner offboxRunner + offboxNotify func(dur time.Duration, snapshots int, err error) + // F17 restore seams — overridable in tests so the .sql re-import orchestration can be unit-tested // without Docker. Default to the real DiscoverDatabases / ImportDump (lazy-init in reimportDBDumps). discoverDBs func(ctx context.Context) ([]DiscoveredDB, error) diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go new file mode 100644 index 0000000..d0af191 --- /dev/null +++ b/controller/internal/backup/offbox.go @@ -0,0 +1,338 @@ +package backup + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Off-box (NAS) backup target — Part B. An ENCRYPTED restic repo reached over SFTP (no kernel mount; +// restic talks SFTP to the NAS directly). This is the "1 off-site" leg of 3-2-1 for the app-data tier +// (each off-box app's recovery unit + DB dumps + volume tars), distinct from the local cross-drive rsync +// copy and from the agent's PBS whole-CT DR. The NAS sees only ciphertext. +// +// THE load-bearing lesson (spike Q8): a dead NAS must FAIL FAST, never hang the backup runner — every +// restic invocation carries `-o sftp.args=…-oConnectTimeout=N…` so a black-holed endpoint errors in +// ~N seconds instead of a multi-minute TCP retry. We also check restic's OWN exit code (never +// pipe-swallow). Secrets (repo password + SSH key) live in 0600 files in the data dir — never logged, +// never in a committed/non-0600 file; they ride DR via the PBS whole-CT snapshot of the rootfs. + +const ( + // offboxConnectTimeoutSec is the SSH ConnectTimeout (spike Q8) — load-bearing fail-fast. + offboxConnectTimeoutSec = 10 + // offboxBackupTimeout bounds a full off-box run; offboxProbeTimeout bounds the quick repo probes. + offboxBackupTimeout = 2 * time.Hour + offboxProbeTimeout = 90 * time.Second +) + +// offboxRunner is the restic-exec seam (tests inject a fake so no real restic/ssh runs). It runs restic +// with args + extra env and returns combined output + the process error (whose ExitCode the caller checks). +type offboxRunner func(ctx context.Context, env []string, args ...string) ([]byte, error) + +func defaultOffboxRunner(ctx context.Context, env []string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "restic", args...) + cmd.Env = append(os.Environ(), env...) + return cmd.CombinedOutput() +} + +// SetOffboxRunner overrides the restic exec (tests). SetOffboxNotify wires the failure→operator alert. +func (m *Manager) SetOffboxRunner(r offboxRunner) { m.offboxRunner = r } +func (m *Manager) SetOffboxNotify(fn func(dur time.Duration, snapshots int, err error)) { m.offboxNotify = fn } + +func (m *Manager) runner() offboxRunner { + if m.offboxRunner != nil { + return m.offboxRunner + } + return defaultOffboxRunner +} + +func (m *Manager) offboxDir() string { return filepath.Join(m.cfg.Paths.DataDir, "offbox") } +func (m *Manager) offboxKeyPath() string { return filepath.Join(m.offboxDir(), "ssh_key") } +func (m *Manager) offboxPwPath() string { return filepath.Join(m.offboxDir(), "repo_password") } +func (m *Manager) offboxKnownHosts() string { return filepath.Join(m.offboxDir(), "known_hosts") } + +// WriteOffboxSecrets persists the SSH private key + (auto-generated if empty) repo password + the pinned +// known-host line as 0600/0644 files in the data dir. The key is provided out-of-band by the operator +// (UI), never logged. Returns the repo password so the caller need not read the file. Idempotent: an empty +// sshKey/knownHosts leaves the existing file untouched (a re-save of just the target shouldn't wipe keys). +func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error { + if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil { + return fmt.Errorf("offbox dir: %w", err) + } + if strings.TrimSpace(sshKey) != "" { + key := sshKey + if !strings.HasSuffix(key, "\n") { + key += "\n" + } + if err := os.WriteFile(m.offboxKeyPath(), []byte(key), 0o600); err != nil { + return fmt.Errorf("offbox ssh key: %w", err) + } + } + if strings.TrimSpace(knownHosts) != "" { + kh := knownHosts + if !strings.HasSuffix(kh, "\n") { + kh += "\n" + } + if err := os.WriteFile(m.offboxKnownHosts(), []byte(kh), 0o644); err != nil { + return fmt.Errorf("offbox known_hosts: %w", err) + } + } + // Auto-generate the repo password once (0600), never log it. + if _, err := os.Stat(m.offboxPwPath()); os.IsNotExist(err) { + pw, gerr := generateOffboxPassword() + if gerr != nil { + return gerr + } + if werr := os.WriteFile(m.offboxPwPath(), []byte(pw), 0o600); werr != nil { + return fmt.Errorf("offbox repo password: %w", werr) + } + } + return nil +} + +// generateOffboxPassword returns a 256-bit hex repo password. +func generateOffboxPassword() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("offbox password gen: %w", err) + } + return hex.EncodeToString(b), nil +} + +// OffboxConfigured reports whether the target is set, enabled, and the key + password files exist (so the +// UI/scheduler can gate a run without leaking why). +func (m *Manager) OffboxConfigured() bool { + t := m.settings.GetOffboxTarget() + if t == nil || !t.Enabled || t.Host == "" || t.User == "" || t.RepoPath == "" { + return false + } + if _, err := os.Stat(m.offboxKeyPath()); err != nil { + return false + } + if _, err := os.Stat(m.offboxPwPath()); err != nil { + return false + } + return true +} + +// offboxBaseArgs builds the restic global args (repo + sftp.args carrying the ConnectTimeout, key, pinned +// known_hosts, port) and the env (RESTIC_PASSWORD_FILE). The ConnectTimeout is MANDATORY (fail-fast). +func (m *Manager) offboxBaseArgs(t *settings.OffboxTarget) ([]string, []string) { + port := t.Port + if port == 0 { + port = 22 + } + // One -o sftp.args token; restic splits it on spaces. Our paths have no spaces (data dir). The + // ConnectTimeout makes a dead NAS fail in ~N s; StrictHostKeyChecking + a pinned known_hosts avoid + // blind TOFU; BatchMode prevents any interactive prompt from hanging the runner. + sftpArgs := fmt.Sprintf("-oBatchMode=yes -oConnectTimeout=%d -oStrictHostKeyChecking=yes -oUserKnownHostsFile=%s -oPort=%d -i %s", + offboxConnectTimeoutSec, m.offboxKnownHosts(), port, m.offboxKeyPath()) + repo := "sftp:" + t.User + "@" + t.Host + ":" + t.RepoPath + args := []string{"-r", repo, "-o", "sftp.args=" + sftpArgs} + env := []string{"RESTIC_PASSWORD_FILE=" + m.offboxPwPath()} + return args, env +} + +// ensureOffboxRepo makes sure the SFTP repo exists: probe `cat config`; if absent, `init` (idempotent — +// a present repo is reused, never re-init). A connect failure surfaces here (fast, via ConnectTimeout). +func (m *Manager) ensureOffboxRepo(ctx context.Context, base, env []string) error { + pctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout) + defer cancel() + if _, err := m.runner()(pctx, env, append(append([]string{}, base...), "cat", "config")...); err == nil { + return nil // repo exists + } + // Repo (probably) absent OR unreachable. Try init; if init succeeds the repo was absent. If init + // fails because it already exists (a race), treat as success; otherwise the error is real (e.g. a + // dead NAS — fail fast). + ictx, icancel := context.WithTimeout(ctx, offboxProbeTimeout) + defer icancel() + out, err := m.runner()(ictx, env, append(append([]string{}, base...), "init")...) + if err == nil { + m.logger.Printf("[INFO] [offbox] initialized restic repo") + return nil + } + if strings.Contains(string(out), "already initialized") || strings.Contains(string(out), "already exists") { + return nil + } + return fmt.Errorf("offbox repo unreachable / init failed: %w: %s", err, truncate(out)) +} + +// RunOffboxBackup backs up every off-box-toggled app's recovery unit (recovery unit + DB dumps + volume +// tars) to the SFTP repo, then prunes per the retention policy. Single-flight + migration-guarded. A +// failure (incl. a fail-fast dead-NAS error) records status + alerts the operator. Returns the first error. +func (m *Manager) RunOffboxBackup(ctx context.Context) error { + if !m.OffboxConfigured() { + return fmt.Errorf("off-box backup not configured") + } + if m.migrationActive() { + m.logger.Printf("[INFO] [offbox] skipped — migration in progress") + return nil + } + if err := m.acquireRunning(); err != nil { + m.logger.Printf("[INFO] [offbox] skipped — another backup is running") + return nil // single-flight: don't race; the next scheduled run retries + } + defer m.releaseRunning() + + apps := m.settings.GetOffboxApps() + t := m.settings.GetOffboxTarget() + base, env := m.offboxBaseArgs(t) + start := time.Now() + _ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastStatus = "running"; o.LastError = "" }) + + runErr := m.runOffboxInternal(ctx, apps, base, env) + + dur := time.Since(start) + snapshots := 0 + if runErr == nil { + snapshots = m.offboxRecordStats(ctx, base, env) + } + _ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.LastRun = time.Now().UTC().Format(time.RFC3339) + o.LastDuration = dur.Round(time.Second).String() + if runErr != nil { + o.LastStatus = "error" + o.LastError = runErr.Error() + } else { + o.LastStatus = "ok" + o.LastError = "" + o.SnapshotCount = snapshots + } + }) + if m.offboxNotify != nil { + m.offboxNotify(dur, snapshots, runErr) + } + if runErr != nil { + m.logger.Printf("[ERROR] [offbox] backup failed after %s: %v", dur.Round(time.Second), runErr) + } else { + m.logger.Printf("[INFO] [offbox] backup OK: %d app(s), %d snapshot(s), %s", len(apps), snapshots, dur.Round(time.Second)) + } + return runErr +} + +// runOffboxInternal does the repo-ensure + per-app backup + prune. Caller holds the running flag. +func (m *Manager) runOffboxInternal(ctx context.Context, apps []string, base, env []string) error { + if err := m.ensureOffboxRepo(ctx, base, env); err != nil { + return err // fail fast (dead NAS surfaces here) + } + var firstErr error + for _, stack := range apps { + nsRoot := m.AppNamespaceRoot(stack) + if nsRoot == "" { + continue + } + src := RecoveryUnitPath(nsRoot, stack) // backups/primary/ = recovery unit + db-dumps + vol-tars + if _, err := os.Stat(src); err != nil { + m.logger.Printf("[INFO] [offbox] %s: no backup data yet (%s) — skipping", stack, src) + continue + } + bctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout) + args := append(append([]string{}, base...), "backup", "--tag", "felhom-offbox", "--tag", stack, src) + out, err := m.runner()(bctx, env, args...) + cancel() + if err != nil { + m.logger.Printf("[ERROR] [offbox] backup %s failed: %v: %s", stack, err, truncate(out)) + if firstErr == nil { + firstErr = fmt.Errorf("offbox backup %s: %w", stack, err) + } + continue + } + m.logger.Printf("[INFO] [offbox] backed up %s", stack) + } + if firstErr != nil { + return firstErr + } + // Retention: keep a sane window, prune the rest. Repo-wide (grouped by host+paths by default). + fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout) + defer cancel() + args := append(append([]string{}, base...), "forget", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune") + if out, err := m.runner()(fctx, env, args...); err != nil { + // A prune failure is non-fatal to the backup itself (data is safe) — log, don't fail the run. + m.logger.Printf("[WARN] [offbox] forget --prune failed (backups are safe): %v: %s", err, truncate(out)) + } + return nil +} + +// offboxRecordStats reads the snapshot count (best-effort) for the UI; also fills repo size when stats works. +func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int { + sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout) + defer cancel() + out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...) + if err != nil { + return 0 + } + var snaps []struct { + ID string `json:"id"` + } + if json.Unmarshal(out, &snaps) != nil { + return 0 + } + // Repo size (best-effort, restore-size). + if so, serr := m.runner()(sctx, env, append(append([]string{}, base...), "stats", "--json")...); serr == nil { + var st struct { + TotalSize int64 `json:"total_size"` + } + if json.Unmarshal(so, &st) == nil && st.TotalSize > 0 { + human := humanizeBytes(st.TotalSize) + _ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoSizeHuman = human }) + } + } + return len(snaps) +} + +// RestoreOffbox restores an app's latest off-box snapshot to destDir (a scratch/verify location — it does +// NOT overwrite live data). Returns an error on failure (checks restic's own exit code). +func (m *Manager) RestoreOffbox(ctx context.Context, stackName, destDir string) error { + if !m.OffboxConfigured() { + return fmt.Errorf("off-box backup not configured") + } + if !isSafeStackName(stackName) { + return fmt.Errorf("invalid stack name") + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return fmt.Errorf("restore dir: %w", err) + } + t := m.settings.GetOffboxTarget() + base, env := m.offboxBaseArgs(t) + rctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout) + defer cancel() + args := append(append([]string{}, base...), "restore", "latest", "--tag", stackName, "--target", destDir) + out, err := m.runner()(rctx, env, args...) + if err != nil { + return fmt.Errorf("offbox restore %s: %w: %s", stackName, err, truncate(out)) + } + m.logger.Printf("[INFO] [offbox] restored %s → %s", stackName, destDir) + return nil +} + +// isSafeStackName guards a stack name used as a restic tag / path component. +func isSafeStackName(s string) bool { + if s == "" || len(s) > 64 { + return false + } + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + continue + } + return false + } + return true +} + +// truncate caps subprocess output for a log line + strips a trailing newline. +func truncate(b []byte) string { + s := strings.TrimSpace(string(b)) + if len(s) > 400 { + return s[:400] + "…" + } + return s +} diff --git a/controller/internal/backup/offbox_test.go b/controller/internal/backup/offbox_test.go new file mode 100644 index 0000000..30533d2 --- /dev/null +++ b/controller/internal/backup/offbox_test.go @@ -0,0 +1,315 @@ +package backup + +import ( + "context" + "log" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// newOffboxManager builds a Manager with a temp data dir + a configured + enabled off-box target and the +// 0600 secret files written, so OffboxConfigured() is true. +func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) { + t.Helper() + logger := log.New(os.Stderr, "", 0) + dataDir := t.TempDir() + sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = dataDir + cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys") + m := NewManager(cfg, sett, logger) + if err := sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", + }); err != nil { + t.Fatal(err) + } + if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { + t.Fatal(err) + } + return m, sett +} + +// argsContainTimeout reports whether the restic arg vector carries the load-bearing ConnectTimeout. +func argsContainTimeout(args []string) bool { + return strings.Contains(strings.Join(args, " "), "-oConnectTimeout=") +} + +// TestOffbox_BaseArgsCarryConnectTimeout asserts the mandatory fail-fast + hardening args are present. +func TestOffbox_BaseArgsCarryConnectTimeout(t *testing.T) { + m, sett := newOffboxManager(t) + base, env := m.offboxBaseArgs(sett.GetOffboxTarget()) + joined := strings.Join(base, " ") + for _, want := range []string{ + "-oConnectTimeout=10", // THE spike Q8 fail-fast knob + "-oStrictHostKeyChecking=yes", // no blind TOFU + "-oUserKnownHostsFile=", // pinned host key + "-oBatchMode=yes", // no interactive hang + "sftp:felhom@nas.local:/srv/repo", + } { + if !strings.Contains(joined, want) { + t.Errorf("base args missing %q: %s", want, joined) + } + } + if len(env) != 1 || !strings.HasPrefix(env[0], "RESTIC_PASSWORD_FILE=") { + t.Errorf("env must set RESTIC_PASSWORD_FILE only, got %v", env) + } +} + +// failFastFake models the SSH transport's ConnectTimeout honoring: if the restic args carry +// -oConnectTimeout it returns a connect error promptly (fail-fast); WITHOUT it, it blocks until the ctx +// deadline (the dead-NAS multi-minute hang). This is the seam the ConnectTimeout companion exercises. +func failFastFake(_ *testing.T) offboxRunner { + return func(ctx context.Context, _ []string, args ...string) ([]byte, error) { + if argsContainTimeout(args) { + return []byte("dial tcp: connect: connection refused"), context.DeadlineExceeded // fast, bounded + } + <-ctx.Done() // no timeout arg → hang until the caller's deadline (the bug) + return nil, ctx.Err() + } +} + +// TestOffbox_ConnectTimeoutIsLoadBearing is the §10 companion red-proof: WITH the arg the (fake) connect +// fails fast (well under the bound); WITHOUT it the connect blocks past the bound. A build that dropped +// the ConnectTimeout arg would take the slow path → this proves the arg is load-bearing. +func TestOffbox_ConnectTimeoutIsLoadBearing(t *testing.T) { + m, sett := newOffboxManager(t) + realArgs, env := m.offboxBaseArgs(sett.GetOffboxTarget()) + fake := failFastFake(t) + + // WITH the arg: returns promptly (we model fast as an immediate error, not a ctx hang). + ctx1, c1 := context.WithTimeout(context.Background(), 2*time.Second) + defer c1() + start := time.Now() + _, err := fake(ctx1, env, append(append([]string{}, realArgs...), "cat", "config")...) + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("with ConnectTimeout the connect must fail fast, took %s", elapsed) + } + _ = err + + // WITHOUT the arg (the bug): blocks until the ctx deadline. + stripped := stripConnectTimeout(realArgs) + if argsContainTimeout(stripped) { + t.Fatal("test setup: stripped args still contain the timeout") + } + ctx2, c2 := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer c2() + start = time.Now() + _, err = fake(ctx2, env, append(append([]string{}, stripped...), "cat", "config")...) + if err != context.DeadlineExceeded { + t.Fatalf("without ConnectTimeout the connect should block to the deadline, got %v", err) + } + if elapsed := time.Since(start); elapsed < 250*time.Millisecond { + t.Fatalf("without ConnectTimeout it should have hung to the bound, only took %s", elapsed) + } +} + +func stripConnectTimeout(args []string) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = strings.ReplaceAll(a, "-oConnectTimeout=10 ", "") + } + return out +} + +// TestOffbox_RunFailsFastAndAlerts: a dead-NAS run returns an error promptly, records status=error, and +// fires the operator alert. +func TestOffbox_RunFailsFastAndAlerts(t *testing.T) { + m, sett := newOffboxManager(t) + m.SetOffboxRunner(func(ctx context.Context, _ []string, args ...string) ([]byte, error) { + // dead NAS: every op (incl. the repo probe) errors fast. + return []byte("unable to open repository: connection refused"), context.DeadlineExceeded + }) + var mu sync.Mutex + var gotErr error + var notified bool + m.SetOffboxNotify(func(_ time.Duration, _ int, err error) { mu.Lock(); defer mu.Unlock(); notified = true; gotErr = err }) + _ = sett.SetAppOffbox("rallly", true) + + start := time.Now() + err := m.RunOffboxBackup(context.Background()) + if err == nil { + t.Fatal("a dead NAS must produce a failed run") + } + if time.Since(start) > 5*time.Second { + t.Fatalf("run should fail fast, took %s", time.Since(start)) + } + if !notified || gotErr == nil { + t.Fatal("a failed off-box run must alert the operator") + } + if st := sett.GetOffboxTarget(); st.LastStatus != "error" || st.LastError == "" { + t.Fatalf("status must record the failure, got %+v", st) + } +} + +// TestOffbox_RepoIdempotent: when the repo exists (cat config succeeds), ensure must NOT init. +func TestOffbox_RepoIdempotent(t *testing.T) { + m, sett := newOffboxManager(t) + var inits int + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + switch { + case contains(args, "cat") && contains(args, "config"): + return []byte(`{"version":2}`), nil // repo exists + case contains(args, "init"): + inits++ + return nil, nil + } + return nil, nil + }) + base, env := m.offboxBaseArgs(sett.GetOffboxTarget()) + if err := m.ensureOffboxRepo(context.Background(), base, env); err != nil { + t.Fatal(err) + } + if inits != 0 { + t.Fatalf("an existing repo must NOT be re-initialized, init called %d times", inits) + } +} + +// TestOffbox_RestoreRoundTrip: backup a temp tree (fake records src per tag), restore (fake copies the +// recorded tree to target) → byte-identical. Exercises the orchestration without real restic. +func TestOffbox_RestoreRoundTrip(t *testing.T) { + m, sett := newOffboxManager(t) + // Lay down an app's recovery-unit tree on disk (what RunOffboxBackup will back up). + nsRoot := m.AppNamespaceRoot("rallly") + src := RecoveryUnitPath(nsRoot, "rallly") + if err := os.MkdirAll(filepath.Join(src, "db-dumps"), 0o755); err != nil { + t.Fatal(err) + } + want := []byte("CREATE TABLE x; -- dump bytes") + if err := os.WriteFile(filepath.Join(src, "db-dumps", "rallly.sql"), want, 0o644); err != nil { + t.Fatal(err) + } + _ = sett.SetAppOffbox("rallly", true) + + captured := map[string]string{} // tag → src path + m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + switch { + case contains(args, "cat") && contains(args, "config"): + return []byte(`{}`), nil + case contains(args, "backup"): + captured[tagOf(args)] = args[len(args)-1] // last arg = src path + return nil, nil + case contains(args, "forget"): + return nil, nil + case contains(args, "restore"): + target := valAfter(args, "--target") + if err := copyTree(captured[tagOf(args)], target); err != nil { + return nil, err + } + return nil, nil + case contains(args, "snapshots"): + return []byte(`[{"id":"abc"}]`), nil + case contains(args, "stats"): + return []byte(`{"total_size":123}`), nil + } + return nil, nil + }) + + if err := m.RunOffboxBackup(context.Background()); err != nil { + t.Fatalf("backup: %v", err) + } + dest := t.TempDir() + if err := m.RestoreOffbox(context.Background(), "rallly", dest); err != nil { + t.Fatalf("restore: %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "db-dumps", "rallly.sql")) + if err != nil { + t.Fatalf("restored file missing: %v", err) + } + if string(got) != string(want) { + t.Fatalf("restore not byte-identical: got %q want %q", got, want) + } +} + +// TestOffbox_SingleFlight: an off-box run while another backup holds m.running skips (no runner call). +func TestOffbox_SingleFlight(t *testing.T) { + m, _ := newOffboxManager(t) + called := false + m.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) { called = true; return nil, nil }) + _ = m.acquireRunning() // simulate a concurrent backup holding the flag + defer m.releaseRunning() + if err := m.RunOffboxBackup(context.Background()); err != nil { + t.Fatalf("single-flight skip should not error, got %v", err) + } + if called { + t.Fatal("off-box must not run (race) while another backup holds the flag") + } +} + +// TestOffbox_SecretsAre0600: the SSH key + repo password files are 0600; the password is non-empty. +func TestOffbox_SecretsAre0600(t *testing.T) { + m, _ := newOffboxManager(t) + for _, p := range []string{m.offboxKeyPath(), m.offboxPwPath()} { + info, err := os.Stat(p) + if err != nil { + t.Fatalf("secret file missing: %v", err) + } + if runtimeIsUnix() && info.Mode().Perm()&0o077 != 0 { + t.Errorf("%s is group/other-readable (mode %v) — must be 0600", p, info.Mode().Perm()) + } + } + pw, _ := os.ReadFile(m.offboxPwPath()) + if len(strings.TrimSpace(string(pw))) < 32 { + t.Errorf("repo password too short / empty") + } +} + +// --- tiny test helpers --- + +func runtimeIsUnix() bool { return os.PathSeparator == '/' } + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} + +// tagOf returns the LAST --tag value (the per-stack tag; backup adds "felhom-offbox" then the stack). +func tagOf(args []string) string { + tag := "" + for i, a := range args { + if a == "--tag" && i+1 < len(args) { + tag = args[i+1] + } + } + return tag +} + +func valAfter(args []string, flag string) string { + for i, a := range args { + if a == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func copyTree(src, dst string) error { + return filepath.Walk(src, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + b, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + return os.WriteFile(target, b, 0o644) + }) +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 15cfbb5..ed6f984 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -69,6 +69,10 @@ type Settings struct { // smtp_mapping can send mail via the in-controller shim → hub → Resend. Relay-only: // no BYO host/port/user/pass (that escape hatch is deferred). AppEmail *AppEmail `json:"app_email,omitempty"` + + // Offbox is the off-box (NAS) restic-SFTP backup target (Part B). One per box. No secrets here — + // the repo password + SSH key are 0600 files in the data dir. + Offbox *OffboxTarget `json:"offbox,omitempty"` } // AppEmail holds the global app-email toggle and an optional household display name. @@ -92,6 +96,31 @@ type AppBackupPrefs struct { // Cross-drive backup to secondary storage CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"` + + // Offbox: include this app's recovery unit + DB dumps in the off-box (NAS) restic-SFTP backup + // (Part B — the "1 off-site" leg of 3-2-1, distinct from the local cross-drive copy and PBS whole-CT). + Offbox bool `json:"offbox,omitempty"` +} + +// OffboxTarget configures the single off-box (NAS) backup destination: an encrypted restic repo reached +// over SFTP (Part B). It holds NO secrets — the repo password + SSH private key live in 0600 files in the +// controller data dir (off-box of the secrets rides DR via the PBS whole-CT snapshot of the rootfs); the +// known-host key is pinned out-of-band. Runtime status is persisted for the UI. +type OffboxTarget struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` // default 22 + User string `json:"user"` + RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo + Schedule string `json:"schedule"` // "daily" | "manual" + + // Runtime status (written by the off-box runner; never holds a secret). + LastRun string `json:"last_run,omitempty"` // RFC3339 + LastStatus string `json:"last_status,omitempty"` // "ok" | "error" | "running" + LastError string `json:"last_error,omitempty"` + LastDuration string `json:"last_duration,omitempty"` + RepoSizeHuman string `json:"repo_size_human,omitempty"` + SnapshotCount int `json:"snapshot_count,omitempty"` } // CrossDriveBackup configures per-app backup to a secondary drive. @@ -426,6 +455,72 @@ func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error { return s.save() } +// GetOffboxTarget returns a copy of the off-box target config (nil if unconfigured). +func (s *Settings) GetOffboxTarget() *OffboxTarget { + s.mu.RLock() + defer s.mu.RUnlock() + if s.Offbox == nil { + return nil + } + cp := *s.Offbox + return &cp +} + +// SetOffboxTarget saves (or clears, on nil) the off-box target config. +func (s *Settings) SetOffboxTarget(t *OffboxTarget) error { + s.mu.Lock() + defer s.mu.Unlock() + s.Offbox = t + return s.save() +} + +// UpdateOffboxStatus mutates the off-box target's runtime status in-place (no-op if unconfigured). +func (s *Settings) UpdateOffboxStatus(fn func(*OffboxTarget)) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.Offbox == nil { + return nil + } + fn(s.Offbox) + return s.save() +} + +// IsAppOffbox reports whether a stack is toggled for off-box backup. +func (s *Settings) IsAppOffbox(stackName string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.AppBackup == nil { + return false + } + return s.AppBackup[stackName].Offbox +} + +// SetAppOffbox toggles a stack's off-box backup inclusion. +func (s *Settings) SetAppOffbox(stackName string, on bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + existing := s.AppBackup[stackName] + existing.Offbox = on + s.AppBackup[stackName] = existing + return s.save() +} + +// GetOffboxApps returns the stack names toggled for off-box backup. +func (s *Settings) GetOffboxApps() []string { + s.mu.RLock() + defer s.mu.RUnlock() + var out []string + for name, p := range s.AppBackup { + if p.Offbox { + out = append(out, name) + } + } + return out +} + // GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set). func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup { s.mu.RLock() diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 896d0b1..bd3cc7a 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -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 diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go new file mode 100644 index 0000000..105f684 --- /dev/null +++ b/controller/internal/web/offbox_handlers.go @@ -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) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index e78c19f..8192196 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -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") diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index a7413eb..890f7fc 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -116,6 +116,83 @@ {{end}} +{{if .Backup}} + +

Külső (NAS) mentés — titkosított, off-site

+

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.

+
+ {{if .Offbox}} +
+
+
{{if eq .Offbox.LastStatus "ok"}}✓ Rendben{{else if eq .Offbox.LastStatus "error"}}✗ Hiba{{else if eq .Offbox.LastStatus "running"}}Fut…{{else}}–{{end}}
+
Utolsó NAS-mentés{{if .Offbox.LastRun}}
{{timeAgo .Offbox.LastRun}}{{end}}
+
+
+
{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}
+
Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép
+
+
+
{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}
+
{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A NAS-mentés ki van kapcsolva{{end}}
+
+
+ {{if .Offbox.LastError}}

Utolsó hiba: {{.Offbox.LastError}}

{{end}} + {{if .OffboxConfigured}} +
+
{{.CSRFField}} + +
+
+

Mely alkalmazások mentődnek a NAS-ra?

+ {{if .OffboxApps}} +
+ {{range .OffboxApps}} +
+
+
{{.DisplayName}}
+
+
{{$.CSRFField}} + + + +
+ {{if .Enabled}} +
{{$.CSRFField}} + + +
+ {{end}} +
+
+
+ {{end}} +
+ {{else}}

Nincs telepített alkalmazás.

{{end}} + {{end}} + {{else}} +

Még nincs beállítva külső NAS mentési cél.

+ {{end}} + +
+ NAS mentési cél beállítása +
{{.CSRFField}} +
+
+
+
+
+
+ + A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.
+
+ + A host-kulcs rögzítése (no blind TOFU). Lekérdezhető: ssh-keyscan -p <port> <host>
+ +
+
+
+{{end}} +

Alkalmazás-mentések (adatbázis + konfiguráció)

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ó.