package backup import ( "errors" "fmt" "os" "path/filepath" "sort" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // R-1106 — A RESTORED DATABASE COMES BACK WITH THE PASSWORD IT WAS SAVED WITH (`07` §7.4, operator decision 1A, // 2026-10-16). // // A database engine reads its credential variables (POSTGRES_PASSWORD, MARIADB_ROOT_PASSWORD, …) only when it // initialises an EMPTY data directory. Once a restore has put a data directory back, the only password that opens it // is the one it was created with. Measured 2026-10-16 on demo-hp (the drill in `audits/debian13-restic-2026-10-16/` // part2): Rallly and Grimmory removed, reinstalled (the reinstall generated new passwords), restored from the // off-site copy — the data came back and neither app could open it: Rallly crash-looped `P1000`, Grimmory's replay // and its rollback failed `ERROR 1045` and it was held. // // The rule: for every app.yaml variable that feeds a database service's credential (appbackup.DBCredentialVars), the // value the backup unit carries (D5 — every `type: secret` travels) WINS over the current install's. When the unit // carries none, the current install's value is used only when that install is OLDER than the data being restored // (so it is the value the data was written with); otherwise the restore is REFUSED before anything changes. A // database credential is never generated for restored data (O4 no longer reaches these names). // errRestoreNoDBCredential is the refusal; the sentence is the bundle key's (HU + EN). var errRestoreNoDBCredential = errors.New("restore refused: the backup does not carry the database password") // dbCredPlan is the decision for one restore: the credential values to put into the app's settings (unit values // that differ from — or are absent in — the current install), and every credential name considered. type dbCredPlan struct { Names []string // every variable that feeds a DB credential in the compose that will run FromUnit map[string]string // name → the unit's value, for every name the unit carries KeptLocal []string // names the unit lacks whose CURRENT value predates the data (kept) } // planDBCredentials decides, as a PURE function, which credential each DB variable gets. installedAt is when the // current install was deployed (zero = unknown); dataAt is when the data being restored was captured (zero = // unknown). An unknown time on either side never licenses the current value: unknown is not "older". func planDBCredentials(names []string, unitSecrets, liveSecrets map[string]string, installedAt, dataAt time.Time) (dbCredPlan, error) { p := dbCredPlan{Names: append([]string(nil), names...), FromUnit: map[string]string{}} sort.Strings(p.Names) var missing []string for _, n := range p.Names { if v := unitSecrets[n]; v != "" { p.FromUnit[n] = v continue } live := liveSecrets[n] if live != "" && !installedAt.IsZero() && !dataAt.IsZero() && installedAt.Before(dataAt) { p.KeptLocal = append(p.KeptLocal, n) continue } missing = append(missing, n) } if len(missing) > 0 { return p, fmt.Errorf("%w: %v (the current install is not older than the data, or no value at all)", errRestoreNoDBCredential, missing) } return p, nil } // installedAt reads when the app's CURRENT install was deployed (app.yaml `deployed_at`). Zero when unknown. func (m *Manager) installedAt(stack string) time.Time { info, ok := m.stackProvider.GetStackRecoveryInfo(stack) if !ok || info.StackDir == "" { return time.Time{} } cfg := stacks.LoadAppConfig(info.StackDir) if cfg == nil || cfg.DeployedAt == "" { return time.Time{} } t, err := time.Parse(time.RFC3339, cfg.DeployedAt) if err != nil { return time.Time{} } return t } // dbCredentialPlanFor reads the compose that will RUN (composePath), the unit's carried secrets and the live ones, // and returns the plan — or the refusal, as the household's sentence, with the underlying reason logged. Nothing is // written here. func (m *Manager) dbCredentialPlanFor(stack, composePath string, unitSecrets map[string]string, dataAt time.Time) (dbCredPlan, error) { if !fileExists(composePath) { // A unit without its compose (never written by a current capture; old fixtures and hand-made units): read // the LIVE definition instead — the variable NAMES do not change between installs of one template. live, ok := m.stackProvider.GetStackComposePath(stack) if !ok || live == "" || !fileExists(live) { m.logger.Printf("[WARN] [backup] Restore %s: no compose to read the database password variables from — none checked", stack) return dbCredPlan{FromUnit: map[string]string{}}, nil } composePath = live } names, err := DBCredentialVars(composePath) if err != nil { // "cannot tell" is not "no database password": refuse rather than restore blind. m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: cannot read which variables hold the database password (%s): %v", stack, filepath.Base(composePath), err) return dbCredPlan{}, util.MsgError("err.backup.restore_no_db_password") } if len(names) == 0 { return dbCredPlan{FromUnit: map[string]string{}}, nil } live := m.stackProvider.RecoverStackSecrets(stack, names) plan, perr := planDBCredentials(names, unitSecrets, live, m.installedAt(stack), dataAt) if perr != nil { m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s before any change: %v (R-1106)", stack, perr) return plan, util.MsgError("err.backup.restore_no_db_password") } m.logger.Printf("[INFO] [backup] Restore %s: database password(s) %v come from the backup, %v stay as installed (older than the data) — values not logged (R-1106)", stack, credNames(plan.FromUnit), plan.KeptLocal) return plan, nil } func credNames(mp map[string]string) []string { out := make([]string, 0, len(mp)) for k := range mp { out = append(out, k) } sort.Strings(out) return out } // secretValueSetter is the narrow write the off-site restore needs when the app keeps its own definition: put the // named values into the app's settings (load-then-save, encrypted at rest) and nothing else. An optional interface // so the many test fakes of StackProvider need no stub; a provider without it makes the restore REFUSE when a change // is needed (fail-closed), never proceed with the wrong password. type secretValueSetter interface { SetStackSecretValues(name string, values map[string]string) error } // DBCredentialVars forwards appbackup.DBCredentialVars. func DBCredentialVars(composePath string) ([]string, error) { return appbackup.DBCredentialVars(composePath) } // offsiteCredentialOverlay (R-1106) decides, for an off-site restore that keeps the app's own definition, which // database passwords to write into the app's settings: the snapshot's, for every DB credential whose value differs // from the current install's — but ONLY when the snapshot replays a database service's data VOLUME (that data // directory opens with its own password only). A dump-only snapshot changes nothing. Empty map = nothing to write. func (m *Manager) offsiteCredentialOverlay(stack, scratchUnit, scratchCompose string, man *RecoveryManifest) (map[string]string, error) { composeRun, ok := m.stackProvider.GetStackComposePath(stack) if !ok || composeRun == "" { return nil, nil // no live definition to read — the existing gates below decide (R-47's fail-closed refusal) } vols, err := DBDataVolumesFor(composeRun) if err != nil { m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: cannot read the database volumes of the live definition: %v", stack, err) return nil, util.MsgError("err.backup.restore_no_db_password") } replaysDBVolume := false for _, v := range vols { if fileExists(filepath.Join(scratchUnit, "volume-dumps", stack+"_"+v+".tar")) { replaysDBVolume = true break } } if !replaysDBVolume { return nil, nil } _, unitSecrets := readUnitEnv(filepath.Join(scratchCompose, "app.yaml"), man.PortableSecretEnvVars) plan, err := m.dbCredentialPlanFor(stack, composeRun, unitSecrets, manifestDataTime(man)) if err != nil { return nil, err } live := m.stackProvider.RecoverStackSecrets(stack, plan.Names) ov := map[string]string{} for n, v := range plan.FromUnit { if live[n] != v { ov[n] = v } } if len(ov) > 0 { if _, ok := m.stackProvider.(secretValueSetter); !ok { m.logger.Printf("[ERROR] [offbox] Restore REFUSED for %s: the database password must change and this provider cannot write it", stack) return nil, fmt.Errorf("restoring %s: the backup's database password cannot be written", stack) } } return ov, nil } func fileExists(p string) bool { st, err := os.Stat(p) return err == nil && !st.IsDir() } // DBDataVolumesFor forwards appbackup.DBDataVolumes. func DBDataVolumesFor(composePath string) ([]string, error) { return appbackup.DBDataVolumes(composePath) }