D5: an app restore works from the drive alone (v0.188.0)

The recovery unit on the customer's drive now carries the PORTABLE secret
class, so Tier-1/Tier-2 restore no longer depends on the whole-guest tier.
A customer needs the drive and nothing else.

Part 0's rulings overturned the brief's recommendation, on evidence:
- the data_key flag is untrustworthy (4+ encryption keys the catalog itself
  labels as such are unflagged) -> R-127
- a DB password is not resettable in practice: POSTGRES_PASSWORD is ignored
  once PGDATA is non-empty, so a regenerated value leaves the app unable to
  authenticate against its own restored rows while the dump replay still
  reports success (proven on a throwaway postgres:16-alpine)

Ruling (operator): type:secret travels, type:password never does, minus the
nonPortableSecrets code register. Plaintext -- withholding the internet-
reachable class is what licenses that, and the two are coupled.

Precedence: the UNIT WINS over the guest -- the unit's secrets were captured
in the same run as the dumps beside them, so they match the data being
restored. The fail-closed data-key gate is unchanged.

Secret values are never logged; the manifest records NAMES only.
This commit is contained in:
2026-07-30 16:33:06 +02:00
parent 2f27a363d5
commit 4ed938cce4
12 changed files with 818 additions and 151 deletions
+20 -10
View File
@@ -28,10 +28,10 @@ type StackDataProvider interface {
StopStack(name string) error
StartStack(name string) error
RefreshAndIsRunning(name string) bool
// GetStackRecoveryInfo returns the data needed to capture a SECRET-FREE recovery unit
// (Phase 2): the stack dir, pinned image tags, the non-secret env, and the NAMES of the
// secret/data-key env vars (values are NEVER returned — they are recovered at restore time
// from the guest's own app.yaml, live or via the PBS whole-guest snapshot). ok=false if the
// GetStackRecoveryInfo returns the data needed to capture a recovery unit: the stack dir,
// pinned image tags, the non-secret env, the NAMES of the secret/data-key env vars, and (D5)
// the decrypted VALUES of the portable class. A WITHHELD secret's value is never returned —
// it is recovered at restore time from the guest's app.yaml, or regenerated. ok=false if the
// stack is unknown.
GetStackRecoveryInfo(name string) (RecoveryInfo, bool)
@@ -62,17 +62,27 @@ type StackDataProvider interface {
GetStackClassifiedBinds(name string) ([]ClassifiedBind, bool)
}
// RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack.
// It deliberately holds NO secret values — only the names of secret/data-key env vars, so the
// manifest can record what must be recovered from elsewhere (guest app.yaml / PBS) without the
// unit ever storing a secret or a data-encrypting key.
// RecoveryInfo carries everything needed to write a recovery unit for a stack.
//
// D5: it now carries the VALUES of the PORTABLE secret class (stacks.PortableSecretEnvVars — every
// `type: secret` field bar the nonPortableSecrets register), because a Tier-1/2 restore that depends
// on the guest for a data-encrypting key or a DB password is not independent of the guest at all: the
// data sits safely on the drive and cannot be read back. The EXCLUDED class (`type: password` admin
// logins) is still name-only and never leaves the guest.
type RecoveryInfo struct {
StackDir string // dir holding docker-compose.yml + .felhom.yml + app.yaml
DisplayName string // app display name
ImagePins []string // pinned image tags from compose `image:` lines (re-pulled on restore)
NonSecretEnv map[string]string // env with all secret/password/data-key values removed (plaintext only)
SecretEnvVars []string // NAMES of stripped secret/password fields (recovered from guest/PBS)
NonSecretEnv map[string]string // env with ALL secret/password values removed (plaintext only)
SecretEnvVars []string // NAMES of every secret/password field
DataKeyEnvVars []string // NAMES of data-encrypting-key fields (fail-closed gate on restore)
// PortableSecretEnvVars are the NAMES of the secrets that travel in the unit (D5), and
// PortableSecrets their DECRYPTED values. A name present here but absent from PortableSecrets was
// unset/empty in the guest's app.yaml — the restore's fail-closed gate decides what that means.
// Never logged, never in the manifest's value space: the values reach disk only inside the unit's
// 0600 app.yaml.
PortableSecretEnvVars []string
PortableSecrets map[string]string
}
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
+4 -3
View File
@@ -40,9 +40,10 @@ func PrimaryBackupPath(nsRoot string) string {
// RecoveryUnitPath returns the per-app self-contained recovery-unit ROOT under a namespace root.
// It is the existing per-app backup dir (`backups/primary/<stack>/`) — the legacy name is kept so the
// db-dumps/ and volume-dumps/ already written there need no migration; the unit gains compose/ and
// manifest.json as siblings, making the whole dir a complete, recreatable unit (Phase 2). The unit is
// secret-free: secrets/data-keys are recovered from the guest's own app.yaml (live or via PBS), never
// stored here. See backup.recoveryUnit / restore for the capture + restore flow.
// manifest.json as siblings, making the whole dir a complete, recreatable unit (Phase 2). Since D5 the
// unit's compose/app.yaml CARRIES the portable secret class (data keys, DB passwords, internal signing
// secrets) at mode 0600, so a Tier-1/2 restore needs the drive and nothing else; internet-reachable
// admin logins are still withheld. See backup.recoveryUnit / restore for the capture + restore flow.
func RecoveryUnitPath(nsRoot, stackName string) string {
return filepath.Join(nsRoot, "backups", "primary", stackName)
}
+94 -42
View File
@@ -15,20 +15,28 @@ import (
"gopkg.in/yaml.v3"
)
// RecoveryManifest describes an app's self-contained, SECRET-FREE recovery unit (Phase 2).
// RecoveryManifest describes an app's self-contained recovery unit.
//
// The unit on a drive is `<nsRoot>/backups/primary/<app>/` and contains:
//
// compose/ docker-compose.yml + .felhom.yml + a SECRET-STRIPPED app.yaml
// compose/ docker-compose.yml + .felhom.yml + app.yaml (0600; carries the PORTABLE secrets)
// db-dumps/ app-consistent DB dump(s) (written by the dump flow)
// volume-dumps/ named-volume tars (written by the dump flow)
// manifest.json this file
//
// The unit holds NO secret values, NO data-encrypting keys, and NOT the Docker image — only the
// pinned image tag(s) (re-pulled on restore) and the NAMES of the secret/data-key env vars. The
// secret values are recovered at restore time from the guest's own app.yaml (live on the rootfs,
// or via the PBS whole-guest snapshot) — see Restore. "Restore from the unit alone" is therefore
// honestly "unit + the guest's app.yaml"; SecretSource records that dependency explicitly.
// D5 (schema 2) changed what the unit holds. Before it held NO secret at all, which made
// "restore from the drive alone" false: the fast, local, customer-doable Tier-1/2 restore secretly
// depended on the slow, operator-driven whole-guest restore, because a data-encrypting key or a DB
// password absent from the guest cannot be regenerated without rendering the restored data
// unreachable. The unit now carries the PORTABLE secret class (stacks.PortableSecretEnvVars) in its
// 0600 app.yaml, and Tier-1/2 needs the DRIVE AND NOTHING ELSE.
//
// It still holds NO `type: password` admin login (those are internet-reachable, so their blast radius
// is not bounded by the drive — they stay in the guest and are regenerated on restore) and NOT the
// Docker image, only the pinned tag(s), re-pulled on restore. SecretSource records the split.
//
// A schema-1 unit carries no secrets: the restore degrades to the pre-D5 guest-only behaviour rather
// than failing, and the next capture rewrites it (the app.yaml checksum changes).
type RecoveryManifest struct {
SchemaVersion int `json:"schema_version"`
AppName string `json:"app_name"`
@@ -38,13 +46,17 @@ type RecoveryManifest struct {
Drive string `json:"drive"` // HDD_PATH (in-guest mount)
NamespaceRoot string `json:"namespace_root"` // resolved felhom-data namespace root
ImagePins []string `json:"image_pins"` // image NOT stored — re-pulled on restore
SecretEnvVars []string `json:"secret_env_vars"` // NAMES only — recovered from guest/PBS
SecretEnvVars []string `json:"secret_env_vars"` // NAMES of every secret/password field
DataKeyEnvVars []string `json:"data_key_env_vars"` // fail-closed gate on restore
SecretSource string `json:"secret_source"` // human note: where secrets come from
ConfigFiles []string `json:"config_files"` // captured into compose/
DBDumps []string `json:"db_dumps"`
VolumeDumps []string `json:"volume_dumps"`
Checksums map[string]string `json:"checksums"` // sha256 of captured compose/ files
// PortableSecretEnvVars (D5) are the NAMES of the secrets this unit's app.yaml CARRIES. Names only
// — the manifest is 0644 and never holds a value. The restore reads it to know which app.yaml env
// entries are secrets rather than plain config; absent (schema 1) ⇒ the unit carries none.
PortableSecretEnvVars []string `json:"portable_secret_env_vars,omitempty"`
// R-43/R-44 (v0.148.0): the coherence stamp. An offsite run refreshes the dumps FIRST and then
// captures the unit, so a manifest carrying an OffsiteRunID asserts "the db-dumps/ in this unit
// were taken by that run" — i.e. the snapshot is an internally coherent {DB@T, files@T} pair.
@@ -68,9 +80,11 @@ func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time
m.tier2Notify = fn
}
// CaptureRecoveryUnit writes/refreshes an app's secret-free recovery unit: it captures the
// compose + metadata + a secret-stripped app.yaml into compose/, enumerates the DB/volume dumps
// already present, and writes manifest.json. It NEVER writes a secret value or the Docker image.
// CaptureRecoveryUnit writes/refreshes an app's recovery unit: it captures the compose + metadata +
// an app.yaml carrying the PORTABLE secret class (D5) into compose/, enumerates the DB/volume dumps
// already present, and writes manifest.json. It never writes the Docker image (only the pinned tag),
// and never writes a WITHHELD secret — the split is decided in buildUnitAppYaml, pinned by
// TestCaptureRecoveryUnitCarriesPortableSecretsOnly.
//
// Idempotent: it builds the captured content in memory first and SKIPS all writes when the unit is
// already current (same config checksums, same dump set, same controller version) — so it can run on
@@ -107,7 +121,7 @@ func (m *Manager) CaptureRecoveryUnit(stackName string) error {
checksums[fname] = sha256Hex(data)
configFiles = append(configFiles, fname)
}
appYaml := buildStrippedAppYaml(info)
appYaml := buildUnitAppYaml(info)
files = append(files, capFile{"app.yaml", appYaml, 0600})
checksums["app.yaml"] = sha256Hex(appYaml)
configFiles = append(configFiles, "app.yaml")
@@ -151,30 +165,34 @@ func (m *Manager) CaptureRecoveryUnit(stackName string) error {
}
manifest := &RecoveryManifest{
SchemaVersion: 1,
AppName: stackName,
DisplayName: info.DisplayName,
ControllerVer: version,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Drive: drivePath,
NamespaceRoot: nsRoot,
ImagePins: info.ImagePins,
SecretEnvVars: info.SecretEnvVars,
DataKeyEnvVars: info.DataKeyEnvVars,
SecretSource: "guest app.yaml (live rootfs) or PBS whole-guest snapshot — never stored in this unit",
ConfigFiles: configFiles,
DBDumps: dbDumps,
VolumeDumps: volDumps,
Checksums: checksums,
OffsiteRunID: runID,
DumpsAt: dumpsAt,
SchemaVersion: 2, // D5: compose/app.yaml carries the portable secret class
AppName: stackName,
DisplayName: info.DisplayName,
ControllerVer: version,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Drive: drivePath,
NamespaceRoot: nsRoot,
ImagePins: info.ImagePins,
SecretEnvVars: info.SecretEnvVars,
DataKeyEnvVars: info.DataKeyEnvVars,
PortableSecretEnvVars: info.PortableSecretEnvVars,
SecretSource: "portable secrets (data keys, DB passwords, internal signing secrets) are IN this unit's compose/app.yaml (0600); internet-reachable admin logins are NOT, and come from the guest's app.yaml or are regenerated on restore",
ConfigFiles: configFiles,
DBDumps: dbDumps,
VolumeDumps: volDumps,
Checksums: checksums,
OffsiteRunID: runID,
DumpsAt: dumpsAt,
}
if err := writeManifest(manifestPath, manifest); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
m.logger.Printf("[INFO] [backup] Recovery unit captured for %s → %s (images=%d, secrets-referenced=%d, data_keys=%d)",
stackName, RecoveryUnitPath(nsRoot, stackName), len(info.ImagePins), len(info.SecretEnvVars), len(info.DataKeyEnvVars))
// Counts and NAMES only — never a value (D5 puts more secrets through this path than before).
m.logger.Printf("[INFO] [backup] Recovery unit captured for %s → %s (images=%d, secrets-referenced=%d, data_keys=%d, portable-carried=%d/%d, withheld=%d)",
stackName, RecoveryUnitPath(nsRoot, stackName), len(info.ImagePins), len(info.SecretEnvVars),
len(info.DataKeyEnvVars), len(info.PortableSecrets), len(info.PortableSecretEnvVars),
len(withheldSecretNames(info)))
return nil
}
@@ -201,29 +219,63 @@ func (m *Manager) versionLocked() string {
return m.version
}
// strippedAppYaml is the on-disk shape of the secret-free app.yaml captured into the unit.
// strippedAppYaml is the on-disk shape of the app.yaml captured into the unit. The name is historical:
// since D5 the `env` map carries the PORTABLE secrets alongside the plain config (see buildUnitAppYaml).
type strippedAppYaml struct {
Deployed bool `yaml:"deployed"`
Env map[string]string `yaml:"env"`
}
// buildStrippedAppYaml renders a secret-free app.yaml (non-secret env only) as bytes. Deterministic:
// yaml.v3 sorts map keys and the secret-name list comes in stable metadata order, so identical input
// yields identical bytes (needed for the checksum-skip guard).
func buildStrippedAppYaml(info RecoveryInfo) []byte {
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: info.NonSecretEnv})
// buildUnitAppYaml renders the unit's app.yaml as bytes: the non-secret env PLUS the portable secret
// values (D5). Deterministic: yaml.v3 sorts map keys and the name lists come in stable metadata order,
// so identical input yields identical bytes (needed for the checksum-skip guard).
//
// This is the ONE place the capture side decides what does and does not reach the drive — there is no
// second path that writes a unit app.yaml. The caller writes the result 0600.
func buildUnitAppYaml(info RecoveryInfo) []byte {
env := make(map[string]string, len(info.NonSecretEnv)+len(info.PortableSecrets))
for k, v := range info.NonSecretEnv {
env[k] = v
}
// Portable secrets last: NonSecretEnv is disjoint from the secret set by construction
// (GetStackRecoveryInfo), so this cannot shadow a plain config value.
for k, v := range info.PortableSecrets {
env[k] = v
}
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env})
if err != nil {
body = []byte("deployed: true\nenv: {}\n")
}
header := "# Captured by felhom-controller recovery unit — SECRET-FREE.\n" +
"# Secret/data-key values are intentionally omitted; recover them at restore from the\n" +
"# guest's own app.yaml (live rootfs, or the PBS whole-guest snapshot). Stripped names:\n"
if len(info.SecretEnvVars) > 0 {
header += "# " + strings.Join(info.SecretEnvVars, ", ") + "\n"
header := "# Captured by felhom-controller recovery unit.\n" +
"# This file CARRIES SECRETS (D5) so a Tier-1/2 restore needs the drive and nothing else:\n" +
"# data-encrypting keys, database passwords and internal signing secrets. Mode 0600.\n"
if len(info.PortableSecretEnvVars) > 0 {
header += "# Carried: " + strings.Join(info.PortableSecretEnvVars, ", ") + "\n"
}
// The withheld class is named, not valued — an operator reading the unit must be able to see WHY a
// credential is missing rather than suspecting a capture bug.
if withheld := withheldSecretNames(info); len(withheld) > 0 {
header += "# WITHHELD (internet-reachable logins — stay in the guest, regenerated on restore): " +
strings.Join(withheld, ", ") + "\n"
}
return []byte(header + string(body))
}
// withheldSecretNames returns the secret names deliberately NOT carried by the unit, in stable order.
func withheldSecretNames(info RecoveryInfo) []string {
portable := make(map[string]bool, len(info.PortableSecretEnvVars))
for _, n := range info.PortableSecretEnvVars {
portable[n] = true
}
var out []string
for _, n := range info.SecretEnvVars {
if !portable[n] {
out = append(out, n)
}
}
return out
}
// writeManifest writes the manifest JSON atomically.
func writeManifest(dst string, manifest *RecoveryManifest) error {
data, err := json.MarshalIndent(manifest, "", " ")
@@ -70,12 +70,21 @@ func (f *fakeRecoveryProvider) StartStackServices(_ string, services []string) e
return f.startSvcErr
}
// TestCaptureRecoveryUnitIsSecretFree proves the captured unit (a) contains compose+config+manifest,
// (b) enumerates the existing dumps, and (c) is SECRET-FREE: a secret value present in the SOURCE
// app.yaml does NOT appear anywhere in the unit, because the capture writes the stripped NonSecretEnv
// (not the raw app.yaml). The manifest records the secret NAMES + data_key flag for recovery-from-guest.
func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
const secretVal = "SUPERSECRETVALUE-do-not-leak"
// TestCaptureRecoveryUnitCarriesPortableSecretsOnly proves the captured unit (a) contains
// compose+config+manifest, (b) enumerates the existing dumps, and (c) implements the D5 secret split:
// the PORTABLE class is written into the unit's app.yaml, and the WITHHELD class appears NOWHERE in
// the unit.
//
// This test replaces TestCaptureRecoveryUnitIsSecretFree, whose global "no secret value appears in the
// unit" invariant D5 deliberately overturns for the portable class. The wrong-outcome half — the
// withheld value must still leak nowhere — is kept verbatim, because that is the half that is still a
// security boundary.
func TestCaptureRecoveryUnitCarriesPortableSecretsOnly(t *testing.T) {
const (
dataKeyVal = "DATAKEY-must-travel-or-the-data-is-unreadable"
dbPwVal = "DBPASSWORD-must-travel-or-the-app-cannot-authenticate"
withheldVal = "ADMINLOGIN-must-never-reach-the-drive"
)
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive") // in-guest namespace root (basename need not be felhom-data)
@@ -83,25 +92,30 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
t.Fatal(err)
}
// Source stack files — the raw app.yaml DELIBERATELY holds a secret to prove it's not copied.
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
"services:\n app:\n image: example/app:1.2.3\n")
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\n")
// The SOURCE app.yaml holds the withheld admin login too, so the leak check below is not vacuous:
// it fails if anything ever copies the raw app.yaml into the unit instead of the generated one.
mustWrite(t, filepath.Join(stackDir, "app.yaml"),
"deployed: true\nenv:\n DB_PASSWORD: "+secretVal+"\n SUBDOMAIN: example\n")
"deployed: true\nenv:\n DB_PASSWORD: "+dbPwVal+"\n ADMIN_PASSWORD: "+withheldVal+
"\n SUBDOMAIN: example\n")
// Pre-existing dumps (written by the dump flow before capture).
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "example"), "example-postgres.sql"), "dump")
mustWrite(t, filepath.Join(AppVolumeDumpPath(drive, "example"), "example_data.tar"), "tar")
// RecoveryInfo as the adapter would build it: secret values already stripped from NonSecretEnv.
// RecoveryInfo as the adapter builds it: NonSecretEnv holds no secret, PortableSecrets holds the
// decrypted portable class, and ADMIN_PASSWORD is named in SecretEnvVars but NOT portable.
info := RecoveryInfo{
StackDir: stackDir,
DisplayName: "Example",
ImagePins: []string{"example/app:1.2.3"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
DataKeyEnvVars: []string{"SECRET_KEY"},
StackDir: stackDir,
DisplayName: "Example",
ImagePins: []string{"example/app:1.2.3"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY", "ADMIN_PASSWORD"},
DataKeyEnvVars: []string{"SECRET_KEY"},
PortableSecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
PortableSecrets: map[string]string{"DB_PASSWORD": dbPwVal, "SECRET_KEY": dataKeyVal},
}
m := &Manager{
logger: log.New(io.Discard, "", 0),
@@ -136,8 +150,8 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
if len(man.ImagePins) != 1 || man.ImagePins[0] != "example/app:1.2.3" {
t.Errorf("image pins: %v", man.ImagePins)
}
if len(man.SecretEnvVars) != 2 {
t.Errorf("secret env-var names: %v (want 2)", man.SecretEnvVars)
if len(man.SecretEnvVars) != 3 {
t.Errorf("secret env-var names: %v (want 3)", man.SecretEnvVars)
}
if len(man.DataKeyEnvVars) != 1 || man.DataKeyEnvVars[0] != "SECRET_KEY" {
t.Errorf("data-key env-vars: %v", man.DataKeyEnvVars)
@@ -145,21 +159,48 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
if len(man.DBDumps) != 1 || len(man.VolumeDumps) != 1 {
t.Errorf("dumps enumerated: db=%v vol=%v", man.DBDumps, man.VolumeDumps)
}
// app.yaml in the unit must carry the non-secret env but NOT the secret value.
appy := mustRead(t, filepath.Join(composeDir, "app.yaml"))
if !strings.Contains(appy, "SUBDOMAIN") {
t.Errorf("stripped app.yaml missing non-secret env: %s", appy)
// D5: schema 2 + the carried names, so the restore can tell secrets from plain config.
if man.SchemaVersion != 2 {
t.Errorf("schema version = %d, want 2 (D5 units carry secrets)", man.SchemaVersion)
}
if len(man.PortableSecretEnvVars) != 2 {
t.Errorf("portable secret names: %v (want DB_PASSWORD + SECRET_KEY)", man.PortableSecretEnvVars)
}
// The manifest is 0644 — it must record NAMES, never a value.
if s := string(mfData); strings.Contains(s, dbPwVal) || strings.Contains(s, dataKeyVal) {
t.Error("SECRET LEAK: a secret VALUE reached manifest.json (names only)")
}
// SECRET-FREE invariant: the secret value must not appear ANYWHERE in the unit.
// app.yaml in the unit must carry the non-secret env AND the portable secrets.
appyPath := filepath.Join(composeDir, "app.yaml")
appy := mustRead(t, appyPath)
if !strings.Contains(appy, "SUBDOMAIN") {
t.Errorf("unit app.yaml missing non-secret env: %s", appy)
}
// THE CONSEQUENCE of D5 at capture time: without these two values on the drive, a guest-less
// restore cannot read the data sitting beside them.
if !strings.Contains(appy, dataKeyVal) {
t.Error("data-encrypting key did NOT travel — a guest-less restore would be impossible")
}
if !strings.Contains(appy, dbPwVal) {
t.Error("DB password did NOT travel — the restored app could not authenticate to its own data")
}
// Secret-bearing ⇒ owner-only.
if fi, err := os.Stat(appyPath); err != nil {
t.Fatal(err)
} else if perm := fi.Mode().Perm(); perm != 0600 {
t.Errorf("unit app.yaml mode = %04o, want 0600 (it carries secrets)", perm)
}
// THE WRONG-OUTCOME CHECK: the withheld class must appear NOWHERE in the unit. This is the half of
// the old secret-free invariant that D5 does not relax.
unitRoot := RecoveryUnitPath(drive, "example")
_ = filepath.WalkDir(unitRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.Contains(mustRead(t, path), secretVal) {
t.Errorf("SECRET LEAK: %q found in %s", secretVal, path)
if strings.Contains(mustRead(t, path), withheldVal) {
t.Errorf("SECRET LEAK: withheld admin login %q found in %s", withheldVal, path)
}
return nil
})
+90 -28
View File
@@ -11,27 +11,53 @@ import (
)
// reconcileRestoreSecrets merges the recovery unit's non-secret env with the secrets recovered from
// the guest's own app.yaml, and applies the FAIL-CLOSED data-key gate. It is the safety-critical heart
// of Phase 2b and is deliberately a pure function (no I/O) so it can be exhaustively unit-tested.
// the unit itself (D5) and from the guest's own app.yaml, and applies the FAIL-CLOSED data-key gate.
// It is the safety-critical heart of Phase 2b and is deliberately a pure function (no I/O) so it can
// be exhaustively unit-tested — the D5 source arrives as an ARGUMENT, not as a read.
//
// Policy (per the Phase 2 design — see REPORT/CHANGELOG):
// - Regenerate NOTHING. Every secret comes from the guest (live rootfs, or PBS whole-guest restore).
// Policy:
// - Regenerate NOTHING here. Secrets come from the unit (portable class) or the guest (the rest).
// - A missing DATA-ENCRYPTING key (`dataKeyNames`) is FATAL: regenerating it would render the
// restored data unreadable, so we refuse and tell the operator to do a PBS whole-guest restore.
// - A missing resettable secret (DB password, admin password) is NON-fatal: it's returned in
// `missing` so the caller can warn; the app may simply need a credential reset, no data is lost.
func reconcileRestoreSecrets(nonSecretEnv, recoveredSecrets map[string]string, secretNames, dataKeyNames []string) (fullEnv map[string]string, missing []string, err error) {
// D5 means the key is normally IN the unit — but "normally" is not a reason to soften the gate.
// - A missing resettable secret is NON-fatal: returned in `missing` so the caller can warn or
// regenerate it (O4). No data is lost.
//
// PRECEDENCE — the UNIT WINS over the guest when both hold a value for the same name.
//
// This is not arbitrary and it is not "newest wins". The unit's secrets are captured in the SAME run
// as the dumps beside them (runVolumeDumps → captureAllRecoveryUnits, backup.go), so the unit's value
// is the one that MATCHES THE DATA ABOUT TO BE RESTORED, whereas the guest's value is merely the most
// recent. Where they disagree the guest's has been rotated since the capture, and preferring it is
// precisely the data-loss bug:
// - a rotated data-encrypting key does not decrypt data encrypted with the old one;
// - a rotated DB password does not match the scram/mysql hash inside the restored data directory
// (POSTGRES_PASSWORD is ignored once PGDATA is non-empty), so the app cannot reach its own rows.
//
// The restore persists fullEnv back to the guest's app.yaml (RecreateStackDefinitionFromUnit), so
// unit-wins also leaves the guest consistent with the data now on disk.
func reconcileRestoreSecrets(nonSecretEnv, unitSecrets, guestSecrets map[string]string, secretNames, dataKeyNames []string) (fullEnv map[string]string, missing []string, err error) {
fullEnv = make(map[string]string, len(nonSecretEnv)+len(secretNames))
for k, v := range nonSecretEnv {
fullEnv[k] = v
}
// resolve applies the precedence: unit first, guest only as a fallback.
resolve := func(n string) (string, bool) {
if v, ok := unitSecrets[n]; ok && v != "" {
return v, true
}
if v, ok := guestSecrets[n]; ok && v != "" {
return v, true
}
return "", false
}
have := func(n string) bool {
v, ok := recoveredSecrets[n]
return ok && v != ""
_, ok := resolve(n)
return ok
}
for _, n := range secretNames {
if have(n) {
fullEnv[n] = recoveredSecrets[n]
if v, ok := resolve(n); ok {
fullEnv[n] = v
} else {
missing = append(missing, n)
}
@@ -45,24 +71,42 @@ func reconcileRestoreSecrets(nonSecretEnv, recoveredSecrets map[string]string, s
}
if len(missingDataKeys) > 0 {
return nil, missing, fmt.Errorf(
"refusing to restore: data-encrypting key(s) %v could not be recovered from the guest's app.yaml — "+
"refusing to restore: data-encrypting key(s) %v are in NEITHER the recovery unit nor the guest's app.yaml — "+
"a PBS whole-guest restore is required first (regenerating the key would render stored data unreadable)",
missingDataKeys)
}
return fullEnv, missing, nil
}
// readStrippedEnv parses the non-secret env from a recovery unit's secret-stripped app.yaml.
func readStrippedEnv(path string) map[string]string {
// readUnitEnv parses a recovery unit's app.yaml and SPLITS it into the plain config env and the
// secrets the unit carries (D5), using the manifest's portable-secret names as the discriminator.
//
// The split is driven by the MANIFEST, not by guessing from key names: the manifest and the app.yaml
// are captured together and checksummed together, so they cannot disagree about which entries are
// secrets. A schema-1 unit has no portable names, so everything lands in nonSecret — exactly the
// pre-D5 behaviour, which is what makes an old unit still restorable.
func readUnitEnv(path string, portableNames []string) (nonSecret, unitSecrets map[string]string) {
nonSecret, unitSecrets = map[string]string{}, map[string]string{}
data, err := os.ReadFile(path)
if err != nil {
return map[string]string{}
return nonSecret, unitSecrets
}
var s strippedAppYaml
if yaml.Unmarshal(data, &s) != nil || s.Env == nil {
return map[string]string{}
return nonSecret, unitSecrets
}
return s.Env
isPortable := make(map[string]bool, len(portableNames))
for _, n := range portableNames {
isPortable[n] = true
}
for k, v := range s.Env {
if isPortable[k] {
unitSecrets[k] = v
continue
}
nonSecret[k] = v
}
return nonSecret, unitSecrets
}
// hasReplayableDump reports whether dumpDir holds a .sql dump that the replay could actually use.
@@ -85,13 +129,17 @@ func hasReplayableDump(dumpDir string) bool {
return false
}
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit + the guest's own secrets.
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit.
//
// It reads the unit manifest, recovers the secret values from the guest's live app.yaml, applies the
// fail-closed data-key gate, restores the named-volume data from the unit's tars, then restores the
// app's definition from the unit and redeploys it with the reconstructed env (re-pulling the pinned
// image). No secret is ever regenerated, and no secret is read from the unit. If no unit exists it
// falls back to the legacy volume-only RestoreApp.
// It reads the unit manifest, takes the portable secrets from the UNIT and the rest from the guest's
// live app.yaml (unit wins — see reconcileRestoreSecrets), applies the fail-closed data-key gate,
// restores the named-volume data from the unit's tars, then restores the app's definition from the unit
// and redeploys it with the reconstructed env (re-pulling the pinned image). If no unit exists it falls
// back to the legacy volume-only RestoreApp.
//
// D5: this no longer needs the guest. A restore with the guest's app.yaml absent succeeds, which is
// pinned by TestRestoreFromRecoveryUnitWithGuestAbsent — the withheld class is regenerated (O4) and
// only a data key missing from BOTH sources still refuses.
func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
if m.stackProvider == nil {
return fmt.Errorf("stack provider not configured")
@@ -126,11 +174,15 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
}
composeDir := RecoveryUnitComposePath(nsRoot, stackName)
nonSecretEnv := readStrippedEnv(filepath.Join(composeDir, "app.yaml"))
nonSecretEnv, unitSecrets := readUnitEnv(filepath.Join(composeDir, "app.yaml"), manifest.PortableSecretEnvVars)
// Recover secrets from the GUEST (never the unit), then apply the fail-closed gate.
recovered := m.stackProvider.RecoverStackSecrets(stackName, manifest.SecretEnvVars)
fullEnv, missing, err := reconcileRestoreSecrets(nonSecretEnv, recovered, manifest.SecretEnvVars, manifest.DataKeyEnvVars)
// D5: the unit carries the portable class, so this is the leg that no longer needs the guest. The
// guest is still consulted for the WITHHELD class (internet-reachable admin logins) and as the
// fallback for a schema-1 unit — it returns an empty map when the guest is gone, which is the whole
// point: a Tier-1/2 restore must survive that. Precedence is unit-over-guest (see
// reconcileRestoreSecrets), then the fail-closed gate.
guestSecrets := m.stackProvider.RecoverStackSecrets(stackName, manifest.SecretEnvVars)
fullEnv, missing, err := reconcileRestoreSecrets(nonSecretEnv, unitSecrets, guestSecrets, manifest.SecretEnvVars, manifest.DataKeyEnvVars)
if err != nil {
m.logger.Printf("[ERROR] [backup] Restore REFUSED for %s: %v", stackName, err)
return err
@@ -141,6 +193,16 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
// encrypted in the guest app.yaml and round-trips on the next backup/restore. Data-keys are
// never generated: the fail-closed gate above already refused if one was missing, and the
// generator itself refuses data-key fields (defense-in-depth). Values are never logged.
//
// D5 shrinks this path to the rare case: the portable class now comes from the unit, so a
// generator run means the secret was empty at capture AND absent from the guest.
//
// It does NOT claim the reset is harmless. R-127: for a DB password it is not — a restored data
// directory keeps the OLD role hash (POSTGRES_PASSWORD is ignored once PGDATA is non-empty), so a
// regenerated value leaves the app unable to authenticate against its own restored rows while the
// dump replay, which uses the container's local trust socket, still reports success. The old wording
// here asserted "stored data is unaffected" for every non-data-key secret; that is false for the 18
// DB/root-password fields and is now scoped to what is actually true.
if len(missing) > 0 {
dataKeySet := make(map[string]bool, len(manifest.DataKeyEnvVars))
for _, dk := range manifest.DataKeyEnvVars {
@@ -158,7 +220,7 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) error {
unresolved = append(unresolved, name)
}
if len(generated) > 0 {
m.logger.Printf("[WARN] [backup] Restore %s: generated replacement for %v — the credential was reset (old value unrecoverable); stored data is unaffected (no data-key involved)",
m.logger.Printf("[WARN] [backup] Restore %s: generated replacement for %v — the credential was reset (old value unrecoverable); no data-encrypting key was involved, but a regenerated DATABASE password will not match the restored data directory's stored hash (R-127) — check the app can reach its data",
stackName, generated)
}
if len(unresolved) > 0 {
+260 -11
View File
@@ -3,10 +3,123 @@ package backup
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
// captureFixtureUnit writes a real stack tree, runs the REAL CaptureRecoveryUnit over it, and returns
// the drive (namespace root) holding the resulting unit.
//
// Fixtures come from a CAPTURED unit rather than hand-written YAML deliberately: the capture side and
// the restore side must meet at real bytes on a real filesystem, so a change to the on-disk shape
// (header text, key ordering, the non-secret/portable split) cannot pass by having a test agree with
// itself. Everything from buildUnitAppYaml through readUnitEnv is production code here.
func captureFixtureUnit(t *testing.T, portable map[string]string) (drive string) {
t.Helper()
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive = filepath.Join(tmp, "drive")
if err := os.MkdirAll(stackDir, 0755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
"services:\n app:\n image: example/app:1\n db:\n image: postgres:16\n")
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: App\n")
mustWrite(t, filepath.Join(stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: trips\n")
var names []string
for _, n := range []string{"DB_PASSWORD", "SECRET_KEY"} {
if _, ok := portable[n]; ok {
names = append(names, n)
}
}
info := RecoveryInfo{
StackDir: stackDir,
DisplayName: "App",
ImagePins: []string{"example/app:1"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "trips"},
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
DataKeyEnvVars: []string{"SECRET_KEY"},
PortableSecretEnvVars: names,
PortableSecrets: portable,
}
m := &Manager{
logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(tmp, "system"),
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive},
version: "vtest",
}
if err := m.CaptureRecoveryUnit("app"); err != nil {
t.Fatalf("capture fixture: %v", err)
}
return drive
}
// TestRestoreFromRecoveryUnitWithGuestAbsent is D5's entire claim, as a test rather than a
// description: a Tier-1/2 restore SUCCEEDS when the guest's app.yaml is unavailable.
//
// SEAM (R-125): injection is at Manager.stackProvider only — i.e. the docker/compose operations and the
// guest's app.yaml decrypt. RecoverStackSecrets returning nil IS the guest being gone: it is exactly
// what the real adapter returns when the stack or its app.yaml cannot be read (main.go
// GetStack/LoadAppConfigDecrypted nil paths). Everything under test is production code: the unit was
// written by the real CaptureRecoveryUnit, read back by the real readUnitEnv, and reconciled by the
// real reconcileRestoreSecrets.
func TestRestoreFromRecoveryUnitWithGuestAbsent(t *testing.T) {
const (
dataKey = "deadbeefdeadbeef"
dbPw = "pw-from-the-drive"
)
drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": dbPw, "SECRET_KEY": dataKey})
// The guest is GONE: no secrets recoverable from it at all.
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
m := &Manager{logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
t.Fatalf("restore must succeed from the drive alone, got: %v", err)
}
if fake.gotEnv == nil {
t.Fatal("recreate was not called — the restore did not reach the redeploy")
}
// The consequence: the app is redeployed with the key that decrypts the data beside it.
if fake.gotEnv["SECRET_KEY"] != dataKey {
t.Errorf("data-encrypting key not recovered from the unit: %q", fake.gotEnv["SECRET_KEY"])
}
if fake.gotEnv["DB_PASSWORD"] != dbPw {
t.Errorf("DB password not recovered from the unit: %q", fake.gotEnv["DB_PASSWORD"])
}
if fake.gotEnv["SUBDOMAIN"] != "trips" {
t.Errorf("plain config lost: %v", fake.gotEnv)
}
}
// TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed proves D5 did not soften the gate: with the
// data key in NEITHER the unit nor the guest, the restore still REFUSES and mutates nothing.
func TestRestoreFromRecoveryUnitGuestAbsentStillFailsClosed(t *testing.T) {
// Unit carries only the DB password — the data key is absent from both sources.
drive := captureFixtureUnit(t, map[string]string{"DB_PASSWORD": "pw"})
fake := &fakeRecoveryProvider{hdd: drive, running: true, secrets: nil}
m := &Manager{logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
err := m.RestoreFromRecoveryUnit("app")
if err == nil {
t.Fatal("expected fail-closed refusal when the data key is in neither source")
}
if !strings.Contains(err.Error(), "SECRET_KEY") {
t.Errorf("refusal should name the missing data key, got: %v", err)
}
if fake.gotEnv != nil {
t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
}
if fake.stopped {
t.Error("the live app must not be stopped when the restore refuses")
}
}
// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest →
// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the
// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the
@@ -15,7 +128,7 @@ func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
newUnit := func(t *testing.T) (drive string) {
tmp := t.TempDir()
drive = filepath.Join(tmp, "drive")
// stripped (secret-free) app.yaml in the unit
// A schema-1 unit: no portable secrets, so the guest is the only source (pre-D5 behaviour).
mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
"deployed: true\nenv:\n SUBDOMAIN: trips\n")
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
@@ -49,6 +162,21 @@ func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
}
})
t.Run("schema-1 unit still restores from the guest — no regression", func(t *testing.T) {
// An old unit carries nothing; the guest must still be able to supply everything.
drive := newUnit(t)
fake := &fakeRecoveryProvider{hdd: drive, running: true,
secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}}
m := &Manager{logger: log.New(io.Discard, "", 0),
systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake}
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
t.Fatalf("a pre-D5 unit must still restore: %v", err)
}
if fake.gotEnv["SECRET_KEY"] != "deadbeef" {
t.Errorf("guest fallback lost the data key: %v", fake.gotEnv)
}
})
t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) {
drive := newUnit(t)
fake := &fakeRecoveryProvider{
@@ -67,13 +195,82 @@ func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
})
}
// TestReconcileRestoreSecretsPrecedence pins the D5 precedence rule in BOTH directions. An undefined
// precedence between two sources of a decryption key is a data-loss bug waiting for its first
// disagreement, so this is not a style question.
//
// The UNIT wins: its secrets were captured in the same run as the dumps beside them, so the unit's
// value is the one that matches the data about to be restored. The guest's value is merely the most
// recent — and a rotated key does not decrypt data encrypted with the old one.
func TestReconcileRestoreSecretsPrecedence(t *testing.T) {
nonSecret := map[string]string{"SUBDOMAIN": "trips"}
names := []string{"DB_PASSWORD", "SECRET_KEY"}
dataKeys := []string{"SECRET_KEY"}
t.Run("both sources disagree — the UNIT wins", func(t *testing.T) {
unit := map[string]string{"DB_PASSWORD": "unit-pw", "SECRET_KEY": "unit-key"}
guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
full, missing, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(missing) != 0 {
t.Errorf("missing: %v", missing)
}
if full["SECRET_KEY"] != "unit-key" {
t.Errorf("data key: got %q, want the UNIT's value (it matches the restored data)", full["SECRET_KEY"])
}
if full["DB_PASSWORD"] != "unit-pw" {
t.Errorf("DB password: got %q, want the UNIT's value (it matches the restored data dir hash)", full["DB_PASSWORD"])
}
})
t.Run("unit silent — the GUEST fills in", func(t *testing.T) {
// The withheld class (admin logins) is never in the unit, so this direction must work too.
guest := map[string]string{"DB_PASSWORD": "guest-pw", "SECRET_KEY": "guest-key"}
full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest, names, dataKeys)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if full["SECRET_KEY"] != "guest-key" || full["DB_PASSWORD"] != "guest-pw" {
t.Errorf("guest fallback not applied: %v", full)
}
})
t.Run("unit present but EMPTY for a name — the guest fills in", func(t *testing.T) {
// An empty value is not a value; it must not shadow a good one from the guest.
unit := map[string]string{"SECRET_KEY": ""}
guest := map[string]string{"SECRET_KEY": "guest-key", "DB_PASSWORD": "guest-pw"}
full, _, err := reconcileRestoreSecrets(nonSecret, unit, guest, names, dataKeys)
if err != nil {
t.Fatalf("an empty unit value must fall through to the guest, got: %v", err)
}
if full["SECRET_KEY"] != "guest-key" {
t.Errorf("empty unit value shadowed the guest: %q", full["SECRET_KEY"])
}
})
t.Run("a portable secret never shadows plain config", func(t *testing.T) {
// GetStackRecoveryInfo keeps the two sets disjoint; if that ever breaks, the merge order in
// buildUnitAppYaml decides silently. Pin the intended outcome.
full, _, err := reconcileRestoreSecrets(map[string]string{"DB_PASSWORD": "should-not-win"},
map[string]string{"DB_PASSWORD": "unit-pw"}, nil, []string{"DB_PASSWORD"}, nil)
if err != nil {
t.Fatal(err)
}
if full["DB_PASSWORD"] != "unit-pw" {
t.Errorf("the secret source must win over a stray non-secret entry: %q", full["DB_PASSWORD"])
}
})
}
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
func TestReconcileRestoreSecrets(t *testing.T) {
nonSecret := map[string]string{"SUBDOMAIN": "trips", "DOMAIN": "demo-felhom.eu"}
t.Run("all recovered, no data_key — full env, no error", func(t *testing.T) {
recovered := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
guest := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
[]string{"DB_PASSWORD", "SECRET_KEY"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -87,9 +284,9 @@ func TestReconcileRestoreSecrets(t *testing.T) {
}
})
t.Run("data_key missing — FAIL CLOSED (refuse)", func(t *testing.T) {
recovered := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
full, _, err := reconcileRestoreSecrets(nonSecret, recovered,
t.Run("data_key missing from BOTH sources — FAIL CLOSED (refuse)", func(t *testing.T) {
guest := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
full, _, err := reconcileRestoreSecrets(nonSecret, nil, guest,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err == nil {
t.Fatal("expected fail-closed error for missing data-encrypting key, got nil")
@@ -99,17 +296,34 @@ func TestReconcileRestoreSecrets(t *testing.T) {
}
})
t.Run("data_key empty value — FAIL CLOSED", func(t *testing.T) {
recovered := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
_, _, err := reconcileRestoreSecrets(nonSecret, recovered, []string{"SECRET_KEY"}, []string{"SECRET_KEY"})
t.Run("data_key empty in both — FAIL CLOSED", func(t *testing.T) {
guest := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
_, _, err := reconcileRestoreSecrets(nonSecret, map[string]string{"SECRET_KEY": ""}, guest,
[]string{"SECRET_KEY"}, []string{"SECRET_KEY"})
if err == nil {
t.Fatal("empty data-key value must fail closed")
}
})
t.Run("data_key recovered from the UNIT — no refusal", func(t *testing.T) {
// The D5 case: the guest is gone but the unit carries the key, so the gate must NOT fire.
unit := map[string]string{"SECRET_KEY": "deadbeef", "DB_PASSWORD": "pw"}
full, missing, err := reconcileRestoreSecrets(nonSecret, unit, nil,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err != nil {
t.Fatalf("the unit's data key must satisfy the gate: %v", err)
}
if len(missing) != 0 {
t.Errorf("nothing should be missing: %v", missing)
}
if full["SECRET_KEY"] != "deadbeef" {
t.Errorf("data key wrong: %v", full)
}
})
t.Run("resettable secret missing — proceed with warning", func(t *testing.T) {
recovered := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
guest := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
full, missing, err := reconcileRestoreSecrets(nonSecret, nil, guest,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err != nil {
t.Fatalf("a missing resettable secret must NOT fail closed: %v", err)
@@ -125,3 +339,38 @@ func TestReconcileRestoreSecrets(t *testing.T) {
}
})
}
// TestReadUnitEnvSplitsByManifest proves the split is driven by the manifest's portable names, and that
// a schema-1 unit (no names) degrades to "everything is plain config" rather than losing entries.
func TestReadUnitEnvSplitsByManifest(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "app.yaml")
mustWrite(t, path, "deployed: true\nenv:\n SUBDOMAIN: trips\n DB_PASSWORD: pw\n SECRET_KEY: key\n")
t.Run("named secrets land in unitSecrets, the rest in nonSecret", func(t *testing.T) {
nonSecret, unitSecrets := readUnitEnv(path, []string{"DB_PASSWORD", "SECRET_KEY"})
if nonSecret["SUBDOMAIN"] != "trips" || len(nonSecret) != 1 {
t.Errorf("nonSecret = %v, want only SUBDOMAIN", nonSecret)
}
if unitSecrets["DB_PASSWORD"] != "pw" || unitSecrets["SECRET_KEY"] != "key" || len(unitSecrets) != 2 {
t.Errorf("unitSecrets = %v, want the two named secrets", unitSecrets)
}
})
t.Run("schema-1 (no portable names) — everything is plain config, nothing lost", func(t *testing.T) {
nonSecret, unitSecrets := readUnitEnv(path, nil)
if len(unitSecrets) != 0 {
t.Errorf("a schema-1 unit carries no secrets, got %v", unitSecrets)
}
if len(nonSecret) != 3 {
t.Errorf("nonSecret should keep every entry, got %v", nonSecret)
}
})
t.Run("absent file — empty maps, no panic", func(t *testing.T) {
nonSecret, unitSecrets := readUnitEnv(filepath.Join(dir, "nope.yaml"), []string{"DB_PASSWORD"})
if len(nonSecret) != 0 || len(unitSecrets) != 0 {
t.Errorf("want empty maps, got %v / %v", nonSecret, unitSecrets)
}
})
}
+42
View File
@@ -822,6 +822,48 @@ func SensitiveEnvVars(meta *Metadata) []string {
return vars
}
// nonPortableSecrets is the register of secrets that must NEVER travel in an on-drive recovery unit
// even though the catalog types them `secret` — i.e. credentials whose reach is NOT bounded by
// physical possession of the drive, because they authenticate against a service published to the
// internet. Keyed by catalog SLUG (never empty — LoadMetadata falls back to the directory name).
//
// It is a CODE register, not a catalog flag, deliberately: the D5 ruling is a security boundary, and
// a boundary a catalog push can silently move is not a boundary (the R-97a lesson — an invariant that
// only configuration enforced). Adding an app whose `type: secret` field gates an internet-reachable
// login means adding a row here.
//
// - vaultwarden/ADMIN_TOKEN gates the /admin panel, served on the app's own public web port.
var nonPortableSecrets = map[string]map[string]bool{
"vaultwarden": {"ADMIN_TOKEN": true},
}
// PortableSecretEnvVars returns the env-var names of secrets that TRAVEL inside the on-drive recovery
// unit (D5), in deterministic metadata order.
//
// The ruling (operator, 2026-07-30): `type: secret` travels, `type: password` does not, minus
// nonPortableSecrets. The line is drawn on REACH, not on whether a secret is nominally resettable:
//
// - Every `type: secret` field either decrypts data sitting on the SAME drive (the 5 declared
// data_keys, plus encryption keys the catalog labels as such but never flagged — see R-127) or
// authenticates to a container on an internal compose network with no external listener (the 18
// DB/root passwords, and the signing secrets). Possessing it adds nothing to possessing the
// drive, which is exactly D2's argument for keeping the DATA plaintext.
// - Every `type: password` field is an admin/UI login for a published service, so its blast radius
// is NOT bounded by the drive. Those stay in the guest and are regenerated on restore (O4).
//
// Excluding the `type: password` class is what licenses the plaintext ruling; the two are coupled and
// must not be relaxed independently.
func PortableSecretEnvVars(meta *Metadata) []string {
blocked := nonPortableSecrets[meta.Slug]
var vars []string
for _, f := range meta.DeployFields {
if f.Type == "secret" && !blocked[f.EnvVar] {
vars = append(vars, f.EnvVar)
}
}
return vars
}
// --- Secret generation ---
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -0,0 +1,76 @@
package stacks
import "testing"
// TestPortableSecretEnvVars pins the D5 ruling (operator, 2026-07-30) about WHICH secrets may travel
// on the customer's backup drive: `type: secret` travels, `type: password` does not, minus the
// nonPortableSecrets register.
//
// This is a security boundary, so the test asserts the CONSEQUENCE in both directions — what travels
// and, more importantly, what must not. A change that widens the class fails here.
func TestPortableSecretEnvVars(t *testing.T) {
t.Run("type=secret travels, type=password does not", func(t *testing.T) {
meta := &Metadata{Slug: "paperless-ngx", DeployFields: []DeployField{
{EnvVar: "DB_PASSWORD", Type: "secret"},
{EnvVar: "PAPERLESS_SECRET_KEY", Type: "secret"},
{EnvVar: "PAPERLESS_ADMIN_PASSWORD", Type: "password"},
{EnvVar: "SUBDOMAIN", Type: "subdomain"},
}}
got := PortableSecretEnvVars(meta)
want := []string{"DB_PASSWORD", "PAPERLESS_SECRET_KEY"}
if len(got) != len(want) {
t.Fatalf("portable = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] { // order must be stable metadata order (checksum-skip depends on it)
t.Errorf("portable[%d] = %q, want %q (got %v)", i, got[i], want[i], got)
}
}
})
t.Run("an internet-reachable admin login is WITHHELD even when typed secret", func(t *testing.T) {
meta := &Metadata{Slug: "vaultwarden", DeployFields: []DeployField{
{EnvVar: "ADMIN_TOKEN", Type: "secret"},
}}
if got := PortableSecretEnvVars(meta); len(got) != 0 {
t.Errorf("vaultwarden/ADMIN_TOKEN must NOT travel (it gates the public /admin panel), got %v", got)
}
})
t.Run("the register is scoped to its app, not the bare env-var name", func(t *testing.T) {
// Another app's ADMIN_TOKEN is not vaultwarden's and must not be caught by the register.
meta := &Metadata{Slug: "some-other-app", DeployFields: []DeployField{
{EnvVar: "ADMIN_TOKEN", Type: "secret"},
}}
if got := PortableSecretEnvVars(meta); len(got) != 1 || got[0] != "ADMIN_TOKEN" {
t.Errorf("register must be slug-scoped, got %v", got)
}
})
t.Run("no deploy fields — nothing travels", func(t *testing.T) {
if got := PortableSecretEnvVars(&Metadata{Slug: "x"}); len(got) != 0 {
t.Errorf("want empty, got %v", got)
}
})
t.Run("portable is a SUBSET of SensitiveEnvVars", func(t *testing.T) {
// The unit's app.yaml splits env into non-secret + portable using two different helpers; if
// PortableSecretEnvVars ever returned a name SensitiveEnvVars does not, that name would land in
// NonSecretEnv as well and the disjointness GetStackRecoveryInfo relies on would break.
meta := &Metadata{Slug: "nextcloud", DeployFields: []DeployField{
{EnvVar: "DB_PASSWORD", Type: "secret"},
{EnvVar: "MYSQL_ROOT_PASSWORD", Type: "secret"},
{EnvVar: "NEXTCLOUD_ADMIN_PASSWORD", Type: "password"},
{EnvVar: "DOMAIN", Type: "domain"},
}}
sensitive := make(map[string]bool)
for _, n := range SensitiveEnvVars(meta) {
sensitive[n] = true
}
for _, n := range PortableSecretEnvVars(meta) {
if !sensitive[n] {
t.Errorf("%q is portable but not sensitive — it would also land in NonSecretEnv", n)
}
}
})
}