diff --git a/CHANGELOG.md b/CHANGELOG.md index b595da0..a581c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ ## Changelog +### v0.105.0 — fork-4: offsite password custody hand-off + atomicity gate + DR inject + DR coord (2026-07-09) + +Pairs with agent v0.77.0 to make the restic-offsite repo password recoverable at DR (rides the customer-R +escrow) and forbids an un-escrowed offsite copy from existing. Validated design: custody spike `febdc56`. + +- **Hand-off** (`internal/agentapi/client.go`): `StageEscrowSecret` pushes the repo password to the agent's + `POST /escrow/stage-secret` over the authenticated pinned local-API channel (value never logged). The + enable flow (`internal/web/offbox_handlers.go`) reads the 0600 password via a new + `Manager.PushOffboxPasswordForEscrow` (the handler never sees the value) and marks `EscrowState="pending"`. +- **Atomicity gate** (`internal/backup/offbox.go`): `OffboxRunnable()`/`offboxEscrowed()` — `RunOffboxBackup` + (and thus the daily scheduler + the run handler) **refuses to run until `EscrowState=="escrowed"`**, so no + un-recoverable offsite ciphertext can exist. `OffboxConfigured()` is unchanged (config/UI still work). + New `settings.OffboxTarget.EscrowState` (`""|"pending"|"escrowed"`, additive, preserved across edits). +- **Confirm + DR inject** (`internal/web`): `POST /backup/offbox/confirm-escrow` (operator, after the escrow + ceremony) → escrowed; `POST /backup/offbox/inject-password` (DR) → `Manager.InjectOffboxPassword` + pre-places a recovered 64-hex password 0600 (tmp+rename), refusing to clobber without `force` — a + subsequent `WriteOffboxSecrets` then uses it (the pre-place seam). `/backups` shows a pending-escrow + notice + confirm button. +- **DR recipe** (`internal/report/dr_recipe.go`): `DRRecipeAppHalf.OffsiteRestic *DRResticCoord` + {host,user,port,repo_path} — coordinates ONLY (the password is escrowed, the SFTP key is regenerable); + populated from `Manager.OffboxCoord()`; clears the `_NoSecrets` regex. +- Tests: atomicity (pending blocks run; confirm enables) **+ companion red-proof** (gate disabled → runs + while pending → FAIL); DR inject pre-place honored + refuse-clobber + companion (no-inject generates a + DIFFERENT password); `OffboxCoord`; agent stage endpoint (0600 + non-secret ack + cross-guest 403 + + value-not-in-log); `DRResticCoord` no-secrets. Web: run-gate + confirm + inject endpoints. +- **NOT yet live-validated** — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run) + is the operator-run follow-up. + ### v0.104.0 — off-box unit discovery (durable, deployment-independent) + no-silent-success (2026-07-09) Fixes the off-box mis-resolution + silent-success landmine surfaced by the Storage-Box spike and pinned by diff --git a/CONTEXT.md b/CONTEXT.md index f5e00d7..82dd8d5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,18 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-09 (v0.104.0 — off-box unit discovery + no-silent-success) +Last updated: 2026-07-09 (v0.105.0 — fork-4 offsite password custody + atomicity gate + DR inject) + +> **2026-07-09 — v0.105.0: fork-4 offsite password custody (pairs with agent v0.77.0).** The restic-offsite +> repo password now rides the **customer-R escrow** (age-under-R in the agent `IdentityBundle`; custody spike +> `febdc56`). Enable → controller pushes the password (`StageEscrowSecret` → agent `POST /escrow/stage-secret`) +> → `EscrowState="pending"`. **Atomicity gate:** no offsite RUN until `EscrowState="escrowed"` (operator +> `POST /backup/offbox/confirm-escrow` after the escrow ceremony) — so no un-recoverable offsite ciphertext +> exists. **DR:** `POST /backup/offbox/inject-password` pre-places the recovered password (honored by +> `WriteOffboxSecrets`). DR recipe gains non-secret `offsite_restic` coords (`DRResticCoord`); the SFTP key is +> regenerated at DR (not escrowed). Atomicity + inject companion red-proofs green. **NOT yet live-validated** +> — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run) is operator-run; NEXT = +> hub-verified auto-confirm + customer-self-serve enable (provisioning task). Deployed to 9201; see REPORT. > **2026-07-09 — v0.104.0: off-box discovery over inference + no-silent-success.** The Storage-Box spike > found offbox reporting `ok`/0 snapshots while backing up nothing; DIAG pinned it: offbox resolved each diff --git a/controller/README.md b/controller/README.md index d368b8d..3777c35 100644 --- a/controller/README.md +++ b/controller/README.md @@ -716,8 +716,22 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > anywhere) is a **hard error** → `LastStatus="error"` + operator alert (was a misleading `ok`/0 snapshots). > A *partial* run (some units missing) stays `ok` but sets a Hungarian **`LastWarning`** naming the skipped > apps, shown on `/backups`. -> - **Secrets** (SSH key + auto-gen repo password) are **0600 files in the data dir** — never logged/committed; -> they ride DR via the PBS whole-CT snapshot of the rootfs (the recovery-unit/dr-recipe stay secret-free). +> - **Secrets** (SSH key + auto-gen repo password) are **0600 files in the data dir** — never logged/committed. +> - **Password custody + atomicity (v0.105.0, fork-4; pairs with agent v0.77.0).** The repo password is the +> irreplaceable DATA key for the offsite tier, so it rides the **customer-recovery-code (R) escrow** +> (age-under-R, in the agent's `IdentityBundle` — custody spike validated a recovered password opens the +> real repo). **Atomicity gate:** enabling offsite pushes the password to the agent (`StageEscrowSecret` → +> `POST /escrow/stage-secret`) and marks `EscrowState="pending"`; **no offsite backup runs until the escrow +> is confirmed** (`OffboxRunnable()`), so an un-recoverable offsite copy can never exist. +> **Operator ceremony (pilot, supervised):** enable offsite (→ pending) → run +> `felhom-agent --selftest=escrow-create --identity-bundle --upload` (the bundle now carries +> the staged restic password, auto-injected, then wiped) → hand the customer the fresh **R** (once; +> supersedes any prior code) → `POST /backup/offbox/confirm-escrow` (or the "Letét megerősítése" button). +> **DR:** recover R → the escrow yields the password → `POST /backup/offbox/inject-password {password}` +> pre-places it 0600 → configure offbox → restore. The SFTP access key is **regenerated** at DR (a fresh +> sub-account key), NOT escrowed; the DR recipe carries only the non-secret `offsite_restic` +> coordinates (`DRResticCoord`). *(A hub-verified auto-confirm + customer-self-serve enable + fresh-R UX +> are the provisioning task, deferred.)* > - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh > option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid > target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`). diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index af6c011..0f4b9f5 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -463,6 +463,17 @@ type EjectResult struct { // EjectDisk safe-unmounts a host mount (data preserved) and returns the dependent guests. // Status-aware POST (campaign F2 evidence gap): the agent's refusal body carries the reason // (e.g. "…eject refused (role: system)") — surface it instead of a bare "HTTP 403". +// StageEscrowSecret pushes the offsite restic repo password to the agent (fork-4), which stages it +// transiently for the escrow-create ceremony to wrap under the customer recovery code R. The value is +// sent over the authenticated pinned local-API channel; the CALLER must never log it. +func (c *Client) StageEscrowSecret(ctx context.Context, resticRepoPassword string) error { + env, status, err := c.postWithStatus(ctx, "/escrow/stage-secret", map[string]string{"restic_repo_password": resticRepoPassword}) + if err != nil { + return err + } + return refusalError("/escrow/stage-secret", status, env) +} + func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, error) { var out EjectResult env, status, err := c.postWithStatus(ctx, "/disks/eject", map[string]string{"where": where}) diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 877793d..ceab565 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -161,6 +161,73 @@ func (m *Manager) OffboxConfigured() bool { return true } +// offboxRepoPwPattern matches a valid restic repo password (generateOffboxPassword = 32 rand bytes → 64 hex). +var offboxRepoPwPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + +// PushOffboxPasswordForEscrow reads the 0600 repo password and hands it to `stage` (the agent push), so +// the web/handler caller never sees the value — used by the enable flow to escrow-stage the offsite key. +func (m *Manager) PushOffboxPasswordForEscrow(ctx context.Context, stage func(ctx context.Context, pw string) error) error { + pw, err := os.ReadFile(m.offboxPwPath()) + if err != nil { + return fmt.Errorf("read offbox password: %w", err) + } + return stage(ctx, strings.TrimSpace(string(pw))) +} + +// InjectOffboxPassword pre-places a RECOVERED repo password at offboxPwPath (fork-4 DR seam) so a +// subsequent WriteOffboxSecrets uses it instead of generating a new one. Refuses to clobber an existing +// password unless force. Written 0600 via tmp+rename. The value is NEVER logged. +func (m *Manager) InjectOffboxPassword(pw string, force bool) error { + pw = strings.TrimSpace(pw) + if !offboxRepoPwPattern.MatchString(pw) { + return fmt.Errorf("invalid repo password (expected 64 hex characters)") + } + if _, err := os.Stat(m.offboxPwPath()); err == nil && !force { + return fmt.Errorf("a repo password already exists (pass force to overwrite)") + } + if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil { + return fmt.Errorf("offbox dir: %w", err) + } + tmp := m.offboxPwPath() + ".tmp" + if err := os.WriteFile(tmp, []byte(pw), 0o600); err != nil { + return fmt.Errorf("write injected password: %w", err) + } + if err := os.Rename(tmp, m.offboxPwPath()); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("place injected password: %w", err) + } + return nil +} + +// offboxEscrowed reports whether the offsite repo password is confirmed escrowed under R (fork-4). +func (m *Manager) offboxEscrowed() bool { + t := m.settings.GetOffboxTarget() + return t != nil && t.EscrowState == "escrowed" +} + +// OffboxRunnable reports whether an off-box RUN may proceed: configured AND escrowed. Config/UI still work +// when not runnable — only actual backup writes are gated (the atomicity guarantee). For the run handler. +func (m *Manager) OffboxRunnable() bool { return m.OffboxConfigured() && m.offboxEscrowed() } + +// OffboxCoord returns the non-secret offsite repo coordinates for the DR recipe (fork-4). ok=false when no +// offbox target is configured. NEVER returns the repo password or the SSH key (those are escrowed/regenerable). +func (m *Manager) OffboxCoord() (host, user string, port int, repoPath string, ok bool) { + t := m.settings.GetOffboxTarget() + if t == nil || t.Host == "" || t.User == "" || t.RepoPath == "" { + return "", "", 0, "", false + } + return t.Host, t.User, t.Port, t.RepoPath, true +} + +// OffboxEscrowState returns the current escrow state ("" | "pending" | "escrowed") for the UI/handlers. +func (m *Manager) OffboxEscrowState() string { + t := m.settings.GetOffboxTarget() + if t == nil { + return "" + } + return t.EscrowState +} + // offboxBaseArgs builds the restic global args (repo + sftp.args carrying the ConnectTimeout, key, pinned // known_hosts, port) and the env (RESTIC_PASSWORD_FILE). The ConnectTimeout is MANDATORY (fail-fast). func (m *Manager) offboxBaseArgs(t *settings.OffboxTarget) ([]string, []string) { @@ -212,6 +279,12 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error { if !m.OffboxConfigured() { return fmt.Errorf("off-box backup not configured") } + // fork-4 atomicity gate: no offsite RUN until the repo password is confirmed escrowed under R, so no + // un-recoverable offsite ciphertext can exist. Not an error (config/UI still work) — a skip. + if !m.offboxEscrowed() { + m.logger.Printf("[INFO] [offbox] skipped — pending key escrow (no offsite run until the repo password is escrowed under R)") + return nil + } if m.migrationActive() { m.logger.Printf("[INFO] [offbox] skipped — migration in progress") return nil diff --git a/controller/internal/backup/offbox_test.go b/controller/internal/backup/offbox_test.go index a4a5a73..eb4879b 100644 --- a/controller/internal/backup/offbox_test.go +++ b/controller/internal/backup/offbox_test.go @@ -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 { diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index 1c099e6..c6ff478 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -165,6 +165,12 @@ func BuildReport( // Allowlist-only (the boundary): NO env/secret fields. The hub assembles it with the agent half. r.DRRecipe = BuildDRRecipeAppHalf(cfg.Customer.ID, cfg.Customer.Name, cfg.Customer.Domain, stackMgr.GetStacks(), readComposeFile) + // fork-4: attach the non-secret offsite restic repo coordinates so DR knows where to recover from. + if backupMgr != nil { + if host, user, port, repoPath, ok := backupMgr.OffboxCoord(); ok { + r.DRRecipe.OffsiteRestic = &DRResticCoord{Host: host, User: user, Port: port, RepoPath: repoPath} + } + } if debug && logger != nil { logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d", diff --git a/controller/internal/report/dr_recipe.go b/controller/internal/report/dr_recipe.go index fe58a05..8485b43 100644 --- a/controller/internal/report/dr_recipe.go +++ b/controller/internal/report/dr_recipe.go @@ -36,6 +36,19 @@ type DRRecipeAppHalf struct { RecipeVersion int `json:"recipe_version"` Customer DRCustomer `json:"customer"` Apps []AppRecipe `json:"apps"` + // OffsiteRestic (fork-4) is the non-secret location of the offsite restic repo, so DR knows WHERE to + // recover from. nil when offsite is not configured. Coordinates ONLY — see DRResticCoord. + OffsiteRestic *DRResticCoord `json:"offsite_restic,omitempty"` +} + +// DRResticCoord is the offsite restic repo's non-secret coordinates. The repo PASSWORD rides the R-escrow +// (IdentityBundle.ResticRepoPassword); the SFTP access key is regenerated at DR (a fresh sub-account key) — +// so NEITHER appears here. All field names deliberately clear the _NoSecrets regex (no password/key/token). +type DRResticCoord struct { + Host string `json:"host"` + User string `json:"user"` + Port int `json:"port"` + RepoPath string `json:"repo_path"` } // DRCustomer is the customer identity — public identifiers only. diff --git a/controller/internal/report/dr_recipe_test.go b/controller/internal/report/dr_recipe_test.go index 27ab0b0..c7599c9 100644 --- a/controller/internal/report/dr_recipe_test.go +++ b/controller/internal/report/dr_recipe_test.go @@ -99,6 +99,36 @@ func TestBuildAppRecipe_AllowlistIsLoadBearing(t *testing.T) { } } +// Scenario E (fork-4) — the OffsiteRestic DR coord carries coordinates ONLY (no password/key), clears the +// secret-name regex, and its fields are emitted. Extends the _NoSecrets boundary to the new field. +func TestDRResticCoord_NoSecrets(t *testing.T) { + half := &DRRecipeAppHalf{ + RecipeVersion: DRRecipeVersion, + Customer: DRCustomer{ID: "cust", Display: "Cust", Domain: "demo-felhom.eu"}, + Apps: []AppRecipe{}, + OffsiteRestic: &DRResticCoord{ + Host: "u629193-sub1.your-storagebox.de", User: "u629193-sub1", Port: 23, RepoPath: "/home/felhom-demo-repo", + }, + } + b, err := json.Marshal(half) + if err != nil { + t.Fatal(err) + } + // (1) no credential-shaped KEY name survived (the DRResticCoord field names must clear the regex). + assertNoSecretKeys(t, b) + out := string(b) + // (2) positive: the coordinate fields ARE emitted (not a vacuous pass). + for _, want := range []string{`"offsite_restic"`, `"host":"u629193-sub1.your-storagebox.de"`, `"user":"u629193-sub1"`, `"port":23`, `"repo_path":"/home/felhom-demo-repo"`} { + if !strings.Contains(out, want) { + t.Errorf("DR coord missing %s in %s", want, out) + } + } + // (3) the password/key field names must never appear. + if strings.Contains(out, "repo_password") || strings.Contains(out, "ssh_key") || strings.Contains(out, "password") { + t.Fatalf("DR coord must carry NO password/key: %s", out) + } +} + func TestAppStorageBindings(t *testing.T) { got := appStorageBindings(rommCompose, "/mnt/felhom-drives/felhom-flash") want := map[string]StorageBinding{ diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 6c5e2aa..e3a970e 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -129,6 +129,11 @@ type OffboxTarget struct { // LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had // no discoverable recovery unit (partial run). Empty on a fully-successful or failed run. LastWarning string `json:"last_warning,omitempty"` + // EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending" + // |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run + // proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable + // offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here. + EscrowState string `json:"escrow_state,omitempty"` } // CrossDriveBackup configures per-app backup to a secondary drive. diff --git a/controller/internal/web/offbox_escrow_test.go b/controller/internal/web/offbox_escrow_test.go new file mode 100644 index 0000000..35eb141 --- /dev/null +++ b/controller/internal/web/offbox_escrow_test.go @@ -0,0 +1,96 @@ +package web + +import ( + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +func newOffboxWebServer(t *testing.T) (*Server, *settings.Settings, *backup.Manager) { + t.Helper() + tmp := t.TempDir() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = tmp + m := backup.NewManager(cfg, sett, lg) + return &Server{cfg: cfg, backupMgr: m, settings: sett, logger: lg}, sett, m +} + +// The run handler refuses while escrow is pending, and confirm-escrow flips to escrowed + runnable. +func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) { + s, sett, m := newOffboxWebServer(t) + if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { + t.Fatal(err) + } + if err := sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily", + EscrowState: "pending", + }); err != nil { + t.Fatal(err) + } + if !m.OffboxConfigured() { + t.Fatal("target should be configured") + } + // run while pending → refused with the escrow-wait flash, no run launched + w := httptest.NewRecorder() + s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil)) + if loc := w.Header().Get("Location"); w.Code != 302 || !strings.Contains(loc, "let%C3%A9t") { + t.Fatalf("pending run must redirect with the escrow-wait flash, got %d %q", w.Code, loc) + } + if m.OffboxRunnable() { + t.Fatal("must not be runnable while pending") + } + // confirm-escrow → escrowed + runnable + w2 := httptest.NewRecorder() + s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil)) + if w2.Code != 302 { + t.Fatalf("confirm: got %d", w2.Code) + } + if got := sett.GetOffboxTarget().EscrowState; got != "escrowed" { + t.Fatalf("confirm must set EscrowState=escrowed, got %q", got) + } + if !m.OffboxRunnable() { + t.Fatal("must be runnable after confirm") + } +} + +// The inject endpoint pre-places a recovered password (DR seam). +func TestOffboxWeb_InjectPassword(t *testing.T) { + s, _, _ := newOffboxWebServer(t) + pwPath := filepath.Join(s.cfg.Paths.DataDir, "offbox", "repo_password") + const pw = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + form := url.Values{"password": {pw}} + r := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.offboxInjectPasswordHandler(w, r) + if w.Code != 302 { + t.Fatalf("inject: got %d", w.Code) + } + got, err := os.ReadFile(pwPath) + if err != nil || string(got) != pw { + t.Fatalf("injected password not placed 0600 at offboxPwPath: err=%v", err) + } + // an invalid password is refused (error flash) + bad := url.Values{"password": {"nope"}} + rb := httptest.NewRequest("POST", "/backup/offbox/inject-password", strings.NewReader(bad.Encode())) + rb.Header.Set("Content-Type", "application/x-www-form-urlencoded") + wb := httptest.NewRecorder() + s.offboxInjectPasswordHandler(wb, rb) + if loc := wb.Header().Get("Location"); !strings.Contains(loc, "flash_error") { + t.Fatalf("invalid password must produce an error flash, got %q", loc) + } +} diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index 708dc28..c7df056 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -81,13 +81,71 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) { tgt.LastRun, tgt.LastStatus, tgt.LastError = prev.LastRun, prev.LastStatus, prev.LastError tgt.LastDuration, tgt.RepoSizeHuman, tgt.SnapshotCount = prev.LastDuration, prev.RepoSizeHuman, prev.SnapshotCount tgt.LastWarning = prev.LastWarning + tgt.EscrowState = prev.EscrowState + } + // fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks + // it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already + // escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push + // failure does NOT mark escrowed; it is surfaced (the run gate still protects data). + stageErr := "" + if tgt.Enabled { + if tgt.EscrowState != "escrowed" { + tgt.EscrowState = "pending" + } + if client, cerr := s.agentClient(); cerr != nil { + stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra." + s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr) + } else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil { + stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra." + s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret + } } if err := s.settings.SetOffboxTarget(tgt); err != nil { offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) return } - s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v)", user, host, repoPath, port, tgt.Enabled) - offboxRedirect(w, r, "A NAS mentési cél elmentve.", false) + s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState) + offboxRedirect(w, r, "A NAS mentési cél elmentve."+stageErr, stageErr != "") +} + +// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4). The operator +// calls this after a successful escrow-create ceremony; offsite runs stay gated until then. (The +// provisioning task should replace this with a hub-verified auto-confirm to remove the operator-forgets/ +// operator-lies footgun.) +func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() { + offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true) + return + } + if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }); err != nil { + offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) + return + } + s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled") + offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a NAS-mentés mostantól futhat.", false) +} + +// offboxInjectPasswordHandler pre-places a RECOVERED repo password at the offbox password path (fork-4 DR +// seam) so a subsequent configure uses it and the existing offsite repo opens. Operator/DR only; the value +// is never logged. Body: {password, force?}. +func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil { + offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true) + return + } + _ = r.ParseForm() + pw := r.FormValue("password") + force := r.FormValue("force") == "on" || r.FormValue("force") == "true" + if strings.TrimSpace(pw) == "" { + offboxRedirect(w, r, "A repo jelszó kötelező.", true) + return + } + if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil { + offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true) + return + } + s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force) + offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false) } // offboxToggleHandler flips an app's off-box inclusion. @@ -112,6 +170,11 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) { offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true) return } + // fork-4 atomicity: refuse the run until the repo password is escrowed under R. + if !s.backupMgr.OffboxRunnable() { + offboxRedirect(w, r, "A NAS-mentés a kulcs letétbe helyezésére vár.", true) + return + } go func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour) defer cancel() diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 571e93a..fde8eae 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -311,6 +311,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.offboxRunHandler(w, r) case path == "/backup/offbox/restore" && r.Method == http.MethodPost: s.offboxRestoreHandler(w, r) + // fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password. + case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost: + s.offboxConfirmEscrowHandler(w, r) + case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost: + s.offboxInjectPasswordHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"): name := strings.TrimPrefix(path, "/stacks/") name = strings.TrimSuffix(name, "/export") diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 4103dd8..f14af5c 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -142,6 +142,14 @@ {{if .Offbox.LastError}}

Utolsó hiba: {{.Offbox.LastError}}

{{end}} {{if .Offbox.LastWarning}}

{{.Offbox.LastWarning}}

{{end}} + {{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}} +
+

A NAS-mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.

+
{{.CSRFField}} + +
+
+ {{end}} {{if .OffboxConfigured}}
{{.CSRFField}}