195 lines
8.8 KiB
Go
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)
|
|
}
|