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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user