v0.105.0: fork-4 offsite password custody — hand-off + atomicity gate + DR inject + coord

Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the
agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending".
Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until
EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the
escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR:
POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600
(honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without
force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP
key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState.
Tests + atomicity & inject companion red-proofs green; UI gates pass. NOT yet
live-validated (supervised ceremony).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 15:13:59 +02:00
parent bde43f3a74
commit 0b09a799cb
14 changed files with 498 additions and 5 deletions
+130
View File
@@ -32,6 +32,7 @@ func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) {
m := NewManager(cfg, sett, logger)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "escrowed", // fork-4: default the harness to escrowed so behavioral run tests exercise the run path
}); err != nil {
t.Fatal(err)
}
@@ -498,6 +499,135 @@ func TestOffbox_NoAppsToggledIsCleanOK(t *testing.T) {
}
}
// --- fork-4: atomicity gate + DR inject + coord ---
// setPending overrides the harness's escrowed default to pending.
func setEscrowState(t *testing.T, sett *settings.Settings, state string) {
t.Helper()
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = state }); err != nil {
t.Fatal(err)
}
}
// Scenario A — a toggled app with a present unit is NOT backed up while escrow is pending (atomicity).
func TestOffbox_PendingEscrowBlocksRun(t *testing.T) {
m, sett := newOffboxManager(t)
setEscrowState(t, sett, "pending")
usb := t.TempDir()
addSchedulablePath(t, sett, usb)
writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z")
_ = sett.SetAppOffbox("app1", true)
rr := &recordingOffboxRunner{}
m.SetOffboxRunner(rr.run)
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("pending escrow must be a clean skip, got %v", err)
}
if len(rr.backupSrc) != 0 {
t.Fatalf("NO offsite backup may run while escrow is pending, got %v", rr.backupSrc)
}
if !m.OffboxConfigured() {
t.Fatal("config must still be valid while pending (only RUNS are gated)")
}
if m.OffboxRunnable() {
t.Fatal("OffboxRunnable must be false while pending")
}
}
// Scenario B — confirming escrow flips to escrowed and the run then proceeds.
func TestOffbox_ConfirmEscrowEnablesRun(t *testing.T) {
m, sett := newOffboxManager(t)
setEscrowState(t, sett, "pending")
usb := t.TempDir()
addSchedulablePath(t, sett, usb)
writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z")
_ = sett.SetAppOffbox("app1", true)
rr := &recordingOffboxRunner{}
m.SetOffboxRunner(rr.run)
if err := m.RunOffboxBackup(context.Background()); err != nil || len(rr.backupSrc) != 0 {
t.Fatalf("must be blocked while pending (err=%v src=%v)", err, rr.backupSrc)
}
setEscrowState(t, sett, "escrowed")
if !m.OffboxRunnable() {
t.Fatal("must be runnable after confirm")
}
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run after confirm: %v", err)
}
if len(rr.backupSrc) != 1 {
t.Fatalf("must back up after confirm, got %v", rr.backupSrc)
}
}
// Scenario C — a pre-placed (DR-injected) recovered password is honored, not regenerated.
func TestOffbox_InjectPasswordPrePlaced(t *testing.T) {
m, _ := newOffboxManager(t)
_ = os.Remove(m.offboxPwPath()) // simulate a fresh controller (no password yet)
const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if err := m.InjectOffboxPassword(recovered, false); err != nil {
t.Fatalf("inject: %v", err)
}
if err := m.WriteOffboxSecrets("newkey", "nas.local ssh-ed25519 NEWKEY"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(m.offboxPwPath())
if string(got) != recovered {
t.Fatalf("injected password was overwritten (len now %d) — the existing repo would be unopenable", len(got))
}
// refuse to clobber an existing password without force
if err := m.InjectOffboxPassword("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", false); err == nil {
t.Fatal("inject must refuse to clobber an existing password without force")
}
// force overwrites
const forced = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
if err := m.InjectOffboxPassword(forced, true); err != nil {
t.Fatalf("force inject: %v", err)
}
if got2, _ := os.ReadFile(m.offboxPwPath()); string(got2) != forced {
t.Fatal("force inject must overwrite")
}
// invalid (non-hex / wrong length) rejected
if err := m.InjectOffboxPassword("not-a-valid-hex-password", false); err == nil {
t.Fatal("an invalid repo password must be rejected")
}
}
// Companion to C — WITHOUT inject, WriteOffboxSecrets generates a DIFFERENT password (so the recovered
// one is load-bearing: a fresh gen could never open the existing offsite repo).
func TestOffbox_NoInjectGeneratesDifferentPassword(t *testing.T) {
m, _ := newOffboxManager(t)
_ = os.Remove(m.offboxPwPath())
const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
if err := m.WriteOffboxSecrets("k", "kh"); err != nil { // no inject → generates fresh
t.Fatal(err)
}
raw, rerr := os.ReadFile(m.offboxPwPath())
if rerr != nil {
t.Fatal(rerr)
}
got := strings.TrimSpace(string(raw))
if got == recovered {
t.Fatal("the freshly generated password coincided with the recovered one (impossible with 256-bit entropy)")
}
if len(got) != 64 {
t.Fatalf("generated repo password must be 64 hex, got %d", len(got))
}
}
// Scenario E (backup half) — OffboxCoord returns the non-secret coordinates, ok=false when unconfigured.
func TestOffbox_CoordForDR(t *testing.T) {
m, sett := newOffboxManager(t)
host, user, port, repo, ok := m.OffboxCoord()
if !ok || host != "nas.local" || user != "felhom" || port != 22 || repo != "/srv/repo" {
t.Fatalf("coord = %s/%s/%d/%s ok=%v", host, user, port, repo, ok)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{}); err != nil { // empty target
t.Fatal(err)
}
if _, _, _, _, ok := m.OffboxCoord(); ok {
t.Fatal("an unconfigured target must yield ok=false")
}
}
func runtimeIsUnix() bool { return os.PathSeparator == '/' }
func contains(ss []string, want string) bool {