v0.76.0: campaign-#3 hardening (settings .bak recovery, restore stack_name validation, quiesce marker quarantine)

S1: corrupt settings.json recovers from .bak / safe-defaults+preserve, no crash-loop.
F2: validStackName gates restore + export handlers (reject /,\,..,NUL traversal).
S3: corrupt quiesce marker logged + quarantined, not silently dropped.
Tests T-S1/F2/S3 + red-proofs. Agent/hub untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:34:33 +02:00
parent 141d51bf19
commit b0dd13154b
10 changed files with 305 additions and 2 deletions
+28 -1
View File
@@ -21,6 +21,10 @@ type Settings struct {
log *log.Logger `json:"-"`
debug bool `json:"-"`
// LoadWarning is set (non-empty, Hungarian) when Load recovered from a corrupt settings.json —
// surfaced to the dashboard as a persistent banner. Not persisted.
LoadWarning string `json:"-"`
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
@@ -198,7 +202,24 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
}
if err := json.Unmarshal(data, s); err != nil {
return nil, fmt.Errorf("parsing settings file: %w", err)
// CORRUPT primary — never crash-loop. Recover from the last-known-good .bak; failing that,
// preserve the corrupt file for forensics and start on safe defaults (recoverable: an empty
// PasswordHash falls back to controller.yaml; the storage registry re-discovers on startup).
logger.Printf("[ERROR] [settings] primary settings corrupt (%v) — attempting recovery from .bak", err)
if bak, berr := os.ReadFile(path + ".bak"); berr == nil {
s2 := &Settings{path: path, log: logger}
if json.Unmarshal(bak, s2) == nil {
logger.Printf("[WARN] [settings] recovered settings from .bak; re-promoting to primary")
_ = os.WriteFile(path, bak, 0644) // best-effort promote
s2.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból"
s2.migrateResticToRsync()
return s2, nil
}
}
corrupt := fmt.Sprintf("%s.corrupt-%d", path, time.Now().Unix())
_ = os.Rename(path, corrupt)
logger.Printf("[ERROR] [settings] settings unrecoverable — preserved as %s; starting with safe defaults", corrupt)
return &Settings{path: path, log: logger, LoadWarning: "settings.json sérült és helyreállíthatatlan — alapértelmezett beállítások"}, nil
}
logger.Printf("[INFO] [settings] Loaded settings from %s", path)
@@ -266,6 +287,12 @@ func (s *Settings) save() error {
return fmt.Errorf("renaming settings file: %w", err)
}
// last-known-good: written AFTER the primary rename succeeds, so .bak only ever holds settings that
// parsed + saved cleanly. Best-effort — a failed .bak must NOT fail the save.
if err := os.WriteFile(s.path+".bak", data, 0644); err != nil && s.log != nil {
s.log.Printf("[WARN] [settings] could not write .bak: %v", err)
}
if s.debug {
s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
}