v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user