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:
2026-06-30 15:26:38 +02:00
parent ddeb509d1d
commit 2a7deadc93
11 changed files with 1059 additions and 0 deletions
+95
View File
@@ -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()