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:
@@ -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)
|
||||
|
||||
@@ -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/<stack> = 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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