Files
felhom-controller/controller/internal/backup/restore_dbcreds.go
T

195 lines
8.8 KiB
Go

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)
}