From 636c51e5428d6d98dde81bcf7a01f415abbd884b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 5 Aug 2026 12:45:48 +0200 Subject: [PATCH] =?UTF-8?q?R-193:=20the=20recovery=20screen=20=E2=80=94=20?= =?UTF-8?q?unlocking,=20and=20only=20unlocking=20(v0.200.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer whose machine was rebuilt had everything needed to get their data back and no way to find out: the only route was a command line. This is the screen that closes that. IT UNLOCKS, AND ONLY UNLOCKS (operator ruling). It explains, takes the recovery code, opens the repository and shows what is in there — apps, dates, sizes. It restores nothing: restore is already per-app and lives in the backups area, and a screen that unlocks and then offers to overwrite is two decisions wearing one button. ONE CORE, TWO CALLERS. RecoverInstallCore is split out of RecoverAndInstall; the CLI wrapper keeps its exit codes and printed lines byte-identical, and the handler drives the same function. Two implementations of the one operation that can permanently lose a customer's data would drift, and only one would be tested. Asserted from source on both sides by AST. THREE WAYS OUT, none a dismiss button: recover; 'most nem' (the full page stops interrupting, the backups-area entry point stays PERMANENTLY, bound to the offer and never to the postpone flag); and 'I do not want the old data' — confirmed TWICE and reaching the SHIPPED move-aside, which sets aside and never deletes. THE CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE: POST body only, never logged, never persisted, never echoed, cleared on every path, no-store, autocomplete off. No lockout — the code is a ten-word phrase, and locking a customer out of their own data for a typo is worse than anything it prevents. TWO DEFECTS THE TESTS CAUGHT, both fixed: an UNCLAIMED (legacy-open) box would have been shown the page, because RequireAuth passes such a box through; and the inventory nil-dereferenced when no off-site target was configured, which is exactly the pristine rebuilt shape. --- controller/cmd/controller/main.go | 3 + controller/internal/backup/offbox.go | 38 ++ .../internal/backup/offbox_inventory.go | 125 +++++ .../internal/backup/offbox_recovery_cli.go | 125 ++++- controller/internal/report/escrow_confirm.go | 15 + controller/internal/settings/settings.go | 28 + controller/internal/web/funcmap.go | 3 + controller/internal/web/handlers.go | 5 + controller/internal/web/recovery_handlers.go | 208 ++++++++ controller/internal/web/recovery_test.go | 491 ++++++++++++++++++ .../internal/web/recovery_wiring_test.go | 146 ++++++ controller/internal/web/server.go | 28 + .../web/templates/backups_remote.html | 13 + .../internal/web/templates/recovery.html | 146 ++++++ 14 files changed, 1347 insertions(+), 27 deletions(-) create mode 100644 controller/internal/backup/offbox_inventory.go create mode 100644 controller/internal/web/recovery_handlers.go create mode 100644 controller/internal/web/recovery_test.go create mode 100644 controller/internal/web/recovery_wiring_test.go create mode 100644 controller/internal/web/templates/recovery.html diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 12d27c2..bda22cb 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1264,6 +1264,9 @@ func main() { // Escrow wizard (v0.127.0): the Scenario-F stale-blob flag feeds the Távoli mentés card. if escrowConfirmer != nil { webServer.SetEscrowStale(escrowConfirmer.StaleBlob) + // R-193: the recovery screen may state WHEN the hub sealed the package, and nothing more, + // before a code is entered. A timestamp, never a secret. + webServer.SetEscrowSealedAt(escrowConfirmer.SealedAt) } webServer.SetIntegrationManager(integrationMgr) if reportTrigger != nil { diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 3df5be8..cd38119 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -1241,6 +1241,44 @@ func (m *Manager) needsOffsiteCredential(t *settings.OffboxTarget) bool { return true } +// OffsiteRecoveryOffer reports whether the customer should be OFFERED the recovery screen (R-193): +// the hub is holding a sealed recovery package for this box, and this box cannot open what that +// package protects. +// +// TWO FACTS, BOTH REQUIRED — and this is the whole correctness of the screen: +// +// 1. **the hub holds a sealed package** (the ACK's identity_blob_present, cached in settings). Without +// it there is nothing to recover, and a box that never had off-site backups must never be greeted +// by a recovery screen for data it never had. Dropping this condition is the plausible wrong fix. +// 2. **this box cannot open the history that package protects** — see the two shapes below. +// +// ⚠ WHY SHAPE (b) EXISTS, recorded because the task specified only shape (a) and the difference is +// load-bearing. The literal reading of "the data area is fresh" is *no repository password on disk*, +// which is true of a rebuilt box — but only until it re-applies its off-site target, because +// `WriteOffboxSecrets` AUTO-GENERATES a repository password when none is present. That is precisely +// R-193's orphaning mechanism, and since hub v0.96.0's credential self-heal the re-apply happens by +// itself within ~15–30 minutes. So shape (a) alone would make this screen appear only inside a +// half-hour window that closes on its own, and the customer who logs in the next morning — the actual +// customer — would never see it. Shape (b) is the state they are in: a repository password exists, but +// it is a NEW one and the inherited history cannot be opened with it, which the box has already +// measured and recorded as `RepoState == "orphaned"`. +// +// Shape (b) also happens to be the state the existing move-aside requires (`ResetOrphanedRepo` refuses +// unless orphaned), which is what lets "I do not want the old data" reach the shipped handler rather +// than needing a new one. +// +// Scenario B still holds exactly: a healthy box has its own password and is not orphaned; a box that +// never had off-site backups fails fact 1; an unclaimed box never reaches an authenticated page. +func (m *Manager) OffsiteRecoveryOffer() bool { + if m.settings == nil || !m.settings.GetHubEscrowIdentityPresent() { + return false // the hub holds nothing for us — nothing to recover + } + if _, ok := m.OffboxRepoPasswordHash(); !ok { + return true // (a) no repository password at all — the pristine rebuilt box + } + return m.OffboxOrphaned() // (b) a password exists but the inherited history will not open under it +} + // OffboxReportStatus returns the offsite summary for the hub report. // // nil = nothing to say (not configured, and nothing to ask for) — the hub's checker treats absence as diff --git a/controller/internal/backup/offbox_inventory.go b/controller/internal/backup/offbox_inventory.go new file mode 100644 index 0000000..1a1f2e6 --- /dev/null +++ b/controller/internal/backup/offbox_inventory.go @@ -0,0 +1,125 @@ +package backup + +import ( + "context" + "encoding/json" + "errors" + "sort" + "time" +) + +// R-193 Part 3 — WHAT IS IN THERE. After a successful unlock the customer is shown the contents of the +// repository they just opened: which apps, from when, how big. +// +// READ-ONLY, AND THAT IS THE POINT. This restores nothing, puts nothing back, and compares nothing +// against live data. Unlocking and restoring are separate (operator ruling, 2026-08-05): restore is +// already per-app and already lives in the backups area, and a screen that unlocks and then offers to +// overwrite is two decisions wearing one button. +// +// WHY A LISTING AT ALL, rather than a success message: "unlocked" with nothing shown is +// indistinguishable from having unlocked an EMPTY store, and the customer has no way to tell whether +// what came back is the right thing. Seeing their own app names and dates is how they know. + +// errNoOffsiteTarget is returned when the repository cannot even be addressed — no off-site target is +// configured on this box yet. Distinguished from a read failure because the remedy differs: this one +// resolves by itself once the tier is re-applied. +var errNoOffsiteTarget = errors.New("no off-site target is configured on this box yet") + +// ErrNoOffsiteTarget reports whether err is the not-yet-configured case, so a caller can say the right +// thing rather than showing a generic failure. +func ErrNoOffsiteTarget(err error) bool { return errors.Is(err, errNoOffsiteTarget) } + +// OffsiteInventoryApp is one app's presence in the opened repository. Non-secret throughout. +type OffsiteInventoryApp struct { + App string // the restic tag == the stack name + LatestAt time.Time // the newest snapshot's time for this app + SizeBytes int64 // restore size of that newest snapshot (0 = could not be determined) +} + +// OffsiteInventory is the whole answer, including the EMPTY case stated explicitly. +type OffsiteInventory struct { + Apps []OffsiteInventoryApp + // Empty is true when the repository opened cleanly and holds no snapshots. It is a real and + // confusing outcome — a bare list there reads as a broken page — so it is named rather than + // inferred from len(Apps)==0, which is also what a failed read looks like. + Empty bool +} + +// OffsiteInventoryList opens the repository and reports what is in it, grouped per app. One +// `snapshots --json` call for the whole repo, then one `stats` per app for the newest snapshot's size. +// +// A per-app size failure is NOT fatal: the app is still listed, with SizeBytes 0, because knowing an +// app is in there matters more than knowing how big it is, and dropping it would under-report the +// customer's own data. +func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, error) { + var inv OffsiteInventory + // A box can hold a recovered key and still have no off-site COORDINATES — the pristine rebuilt + // shape, before its target is re-applied. Reading the repository is impossible then, and saying so + // is the honest answer; without this guard offboxBaseArgs nil-derefs on the missing target. + if !m.OffboxConfigured() { + return inv, errNoOffsiteTarget + } + t := m.settings.GetOffboxTarget() + base, env := m.offboxBaseArgs(t) + sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout) + defer cancel() + out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...) + if err != nil { + return inv, err + } + var snaps []struct { + ShortID string `json:"short_id"` + ID string `json:"id"` + Time time.Time `json:"time"` + Tags []string `json:"tags"` + } + if uerr := json.Unmarshal(out, &snaps); uerr != nil { + return inv, uerr + } + if len(snaps) == 0 { + inv.Empty = true + return inv, nil + } + // Newest snapshot per tag. A snapshot may carry several tags; each names an app it belongs to. + newest := map[string]struct { + id string + at time.Time + }{} + for _, s := range snaps { + id := s.ShortID + if id == "" { + id = s.ID + } + for _, tag := range s.Tags { + if tag == "" { + continue + } + if cur, ok := newest[tag]; !ok || s.Time.After(cur.at) { + newest[tag] = struct { + id string + at time.Time + }{id: id, at: s.Time} + } + } + } + if len(newest) == 0 { + // Snapshots exist but carry no tags — not "empty", and saying so would be a lie. Report an + // empty app list without the Empty flag; the page renders the honest in-between wording. + return inv, nil + } + for tag, n := range newest { + app := OffsiteInventoryApp{App: tag, LatestAt: n.at} + if size, serr := m.offboxSnapshotSize(ctx, n.id); serr == nil { + app.SizeBytes = size + } else { + m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", tag, serr) + } + inv.Apps = append(inv.Apps, app) + } + sort.Slice(inv.Apps, func(i, j int) bool { return inv.Apps[i].App < inv.Apps[j].App }) + return inv, nil +} + +// HumanizeBytes exposes the shared byte formatter to the web layer so the recovery page renders sizes +// the same way every other surface does. +func HumanizeBytes(n int64) string { return humanizeBytes(n) } diff --git a/controller/internal/backup/offbox_recovery_cli.go b/controller/internal/backup/offbox_recovery_cli.go index 0472c3d..b8adaa5 100644 --- a/controller/internal/backup/offbox_recovery_cli.go +++ b/controller/internal/backup/offbox_recovery_cli.go @@ -137,6 +137,91 @@ func RunRecoveryCheck(d RecoveryCheckDeps) int { // refused — a password is present and DIFFERS. Installing would clobber the key this box's CURRENT // repository is encrypted under, so it is refused. No force option is offered here: that // decision needs a human who knows which history they intend to keep. +// RecoverInstallOutcome names the terminal states of a recovery+install. Distinct values because +// "it did nothing", "it refused" and "it installed" are different facts and a caller — CLI or web — +// must be able to say which happened without parsing prose. +type RecoverInstallOutcome string + +const ( + // RecoverInstalled — the box had NO repository password; the recovered one is now in place. + RecoverInstalled RecoverInstallOutcome = "installed" + // RecoverUnchanged — a password was present and is byte-identical to the recovered one. + RecoverUnchanged RecoverInstallOutcome = "unchanged" + // RecoverRefused — a DIFFERENT password is present; installing would clobber the key the box's + // current repository is encrypted under. + RecoverRefused RecoverInstallOutcome = "refused" + // RecoverDryRun — nothing was written because confirm was false. + RecoverDryRun RecoverInstallOutcome = "dry_run" +) + +// RecoverInstallResult is the non-secret outcome of a recovery. It carries HASHES ONLY — never the +// password, never R. The hashes are of 256-bit random secrets, non-reversible, and are the same +// values the hub already stores and serves in report ACKs. +type RecoverInstallResult struct { + Outcome RecoverInstallOutcome + LocalPresent bool + LocalSHA256 string + RecoveredSHA256 string +} + +// RecoverInstallCore is THE recovery+install path in this codebase — fetch the sealed bundle through +// the agent, unseal it with R, compare against what is on disk, and place it when that is the right +// thing to do. +// +// ONE FUNCTION, TWO CALLERS (R-193). The CLI (`--recover-offsite-install`) and the customer's recovery +// page both call this. They must not each carry a copy: two implementations of the one operation that +// can permanently lose a customer's data would drift, and only one of them would ever be tested. +// `RecoverAndInstall` below is a thin wrapper that maps this result onto the CLI's exit codes and +// printed lines; the web handler maps it onto Hungarian copy. Neither contains recovery logic. +// +// R IS THE CALLER'S TO CLEAR. This function does not retain it: it is passed to the agent seam and +// never stored, logged or returned. The password recovered from the bundle IS cleared here, on every +// path, before returning — it never leaves this function in any form. +// +// The three outcomes and their reasoning are unchanged from the CLI's original implementation; see +// RecoverAndInstall's header, which remains the authority on WHY a differing local password is +// refused rather than forced. +func RecoverInstallCore(ctx context.Context, m *Manager, rec OffsiteKeyRecoverer, R string, confirm bool) (RecoverInstallResult, error) { + var res RecoverInstallResult + if m == nil || rec == nil { + return res, fmt.Errorf("recovery not configured (no backup manager or no agent channel)") + } + pw, recoveredHash, err := rec.RecoverOffsiteRepoPassword(ctx, R) + if err != nil { + return res, err // the agent's message names the step; it carries no secret + } + res.RecoveredSHA256 = recoveredHash + res.LocalSHA256, res.LocalPresent = m.OffboxRepoPasswordHash() + + switch { + case res.LocalPresent && res.LocalSHA256 == recoveredHash: + pw = "" + res.Outcome = RecoverUnchanged + return res, nil + case res.LocalPresent: + pw = "" + res.Outcome = RecoverRefused + return res, nil + } + if !confirm { + pw = "" + res.Outcome = RecoverDryRun + return res, nil + } + if err := m.InjectOffboxPassword(pw, false); err != nil { + pw = "" + return res, fmt.Errorf("placing the recovered password: %w", err) + } + pw = "" + // Re-read from disk rather than trusting what we just wrote — the observable is the file's state. + afterHash, ok := m.OffboxRepoPasswordHash() + if !ok || afterHash != recoveredHash { + return res, fmt.Errorf("the password was written but does not read back as expected (on-disk %q)", afterHash) + } + res.Outcome = RecoverInstalled + return res, nil +} + func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int { out, errw := d.Out, d.Err if out == nil { @@ -172,53 +257,39 @@ func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int { defer cancel() fmt.Fprintln(out, "=== offsite key recovery INSTALL (R-200) ===") - // The recovery itself is the same call the check makes, so there is exactly one fetch+unseal path - // in this codebase and no chance of the two drifting. - pw, recoveredHash, err := d.Recoverer.RecoverOffsiteRepoPassword(ctx, R) + // THE RECOVERY ITSELF IS RecoverInstallCore — the same function the customer's recovery page + // drives (R-193). This wrapper adds the CLI's stdin handling, its printed lines and its exit + // codes, and NOTHING else; there is exactly one fetch→unseal→compare→install path in this + // codebase and no chance of the two callers drifting. Pinned by + // TestRecoverAndInstall_DrivesTheSharedCore and by the AST wiring test. + res, err := RecoverInstallCore(ctx, d.Manager, d.Recoverer, R, confirm) R = "" // cleared immediately, on every path below if err != nil { fmt.Fprintf(errw, " [FAIL] %v\n", err) fmt.Fprintln(errw, " nothing was written.") return 1 } - localHash, localPresent := d.Manager.OffboxRepoPasswordHash() - if localPresent { - fmt.Fprintf(out, " on-disk sha256: %s\n", localHash) + if res.LocalPresent { + fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256) } else { fmt.Fprintln(out, " on-disk sha256: (none — this box has no repository password)") } - fmt.Fprintf(out, " recovered sha256: %s\n", recoveredHash) + fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256) - switch { - case localPresent && localHash == recoveredHash: - pw = "" + switch res.Outcome { + case RecoverUnchanged: fmt.Fprintln(out, " [UNCHANGED] the box already holds exactly this key. Nothing written.") return 0 - case localPresent: - pw = "" + case RecoverRefused: fmt.Fprintln(errw, " [REFUSED] a DIFFERENT repository password is already present.") fmt.Fprintln(errw, " Installing would clobber the key this box's current repository is encrypted under,") fmt.Fprintln(errw, " and which history to keep is not a decision this command may take. Nothing written.") return 2 - } - if !confirm { - pw = "" + case RecoverDryRun: fmt.Fprintln(out, " [DRY RUN] nothing written. The recovered key is ready to install.") fmt.Fprintln(out, " Re-run with --confirm-install to place it.") return 0 } - if err := d.Manager.InjectOffboxPassword(pw, false); err != nil { - pw = "" - fmt.Fprintf(errw, " [FAIL] placing the recovered password: %v\n", err) - return 1 - } - pw = "" - // Re-read from disk rather than trusting what we just wrote — the observable is the file's state. - afterHash, ok := d.Manager.OffboxRepoPasswordHash() - if !ok || afterHash != recoveredHash { - fmt.Fprintf(errw, " [FAIL] the password was written but does not read back as expected (on-disk %q)\n", afterHash) - return 1 - } fmt.Fprintln(out, " [INSTALLED] the recovered repository password is in place and reads back identical.") fmt.Fprintln(out, " Re-apply the offsite target and run a backup: the existing repository should open.") return 0 diff --git a/controller/internal/report/escrow_confirm.go b/controller/internal/report/escrow_confirm.go index d715662..c80fde3 100644 --- a/controller/internal/report/escrow_confirm.go +++ b/controller/internal/report/escrow_confirm.go @@ -59,6 +59,10 @@ type EscrowAutoConfirmer struct { mu sync.Mutex warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches) stale bool // Scenario F: the hub blob does not cover the CURRENT password (display-only) + // sealedAt is the ACK's escrow created_at (v0.200.0, R-193) — the ONE non-secret fact the + // recovery screen may state before a code is entered. Recorded on every ACK that carries an + // escrow object, including on an unconfigured box, for the same reason RecordPresence is. + sealedAt string } // staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case @@ -76,6 +80,14 @@ func (c *EscrowAutoConfirmer) StaleBlob() bool { return c.stale } +// SealedAt returns the ACK-reported creation time of the hub's sealed recovery package ("" when no +// ACK has carried one). In-memory, recomputed from ACKs after a restart — the hub is the authority. +func (c *EscrowAutoConfirmer) SealedAt() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.sealedAt +} + func (c *EscrowAutoConfirmer) logf(f string, a ...any) { if c.Logger != nil { c.Logger.Printf(f, a...) @@ -99,6 +111,9 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) { c.logf("[WARN] [escrow-confirm] could not record the hub's identity-blob presence (present=%v): %v", es.IdentityBlobPresent, err) } } + c.mu.Lock() + c.sealedAt = es.CreatedAt // in-memory only; a timestamp, never a secret + c.mu.Unlock() if !c.Pending() { // Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding // blob that does not cover the current password must be surfaced (warn + card flag), while diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index f65a780..55b1e04 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -70,6 +70,13 @@ type Settings struct { // first ACK — which is correct, because the hub is the authority on what the hub holds. HubEscrowIdentityPresent bool `json:"hub_escrow_identity_present,omitempty"` + // RecoveryNoticePostponed (v0.200.0, R-193) — the customer chose "most nem" on the full-page + // recovery screen. It suppresses THE FULL-PAGE INTERRUPTION ONLY. The entry point in the backups + // area stays, permanently, for as long as the situation lasts: the data is still there whether or + // not anyone clicked, and a one-shot notice a flustered person clicks past is a notice that never + // happened. It is deliberately NOT cleared by anything except the situation ending. + RecoveryNoticePostponed bool `json:"recovery_notice_postponed,omitempty"` + // Cached state DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` @@ -640,6 +647,27 @@ func (s *Settings) SetHubEscrowIdentityPresent(present bool) error { return s.save() } +// ── Recovery screen (v0.200.0, R-193) ────────────────────────────────────────── + +// GetRecoveryNoticePostponed reports whether the customer chose "most nem" on the recovery page. +// Suppresses the full-page interruption ONLY — never the backups-area entry point. +func (s *Settings) GetRecoveryNoticePostponed() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.RecoveryNoticePostponed +} + +// SetRecoveryNoticePostponed records the "most nem" choice. +func (s *Settings) SetRecoveryNoticePostponed(v bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.RecoveryNoticePostponed == v { + return nil + } + s.RecoveryNoticePostponed = v + return s.save() +} + // ── Customer-claim arc (v0.122.0) ────────────────────────────────────────────── // GetClaimed reports whether this box has completed a claim (set-only). diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index ebb129a..bcad5e7 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -322,6 +322,9 @@ func (s *Server) templateFuncMap() template.FuncMap { } return t.In(loc).Format("2006-01-02 15:04") }, + // humanBytes (v0.200.0, R-193) renders a size the same way every other surface does — the + // recovery page's listing shares the backup package's formatter rather than growing a second one. + "humanBytes": backup.HumanizeBytes, "fmtTimeShort": func(t time.Time) string { if t.IsZero() { return "–" diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index d482cc0..99d00d3 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -920,6 +920,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { data["OffboxCeremonyTimedOut"] = timedOut // v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger. data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned() + // R-193: the PERMANENT entry point to the recovery screen. It is bound to recoveryOffer, NOT to + // recoveryInterrupts — "most nem" silences the full-page interruption and must never remove the + // route to the data. A one-shot notice a flustered person clicks past is a notice that never + // happened; this is what makes Scenario E true. + data["RecoveryOffer"] = s.recoveryOffer() s.executeTemplate(w, r, "backups_remote", data) } diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go new file mode 100644 index 0000000..699edf9 --- /dev/null +++ b/controller/internal/web/recovery_handlers.go @@ -0,0 +1,208 @@ +package web + +import ( + "context" + "net/http" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// R-193 — THE RECOVERY SCREEN. A customer whose machine was rebuilt has everything they need to get +// their data back and, until this page, no way to find out: the only route was a command line. +// +// IT UNLOCKS, AND ONLY UNLOCKS (operator ruling, 2026-08-05). It explains the situation, takes the +// recovery code, opens the repository, and shows what is in there. It does NOT restore anything. +// Restoring is already per-app and already lives in the backups area; putting files back is a +// separate item. A screen that unlocks and then offers to overwrite is two decisions wearing one +// button. +// +// THREE WAYS OUT, and none of them is a dismiss button: +// - RECOVER — the main path (/recovery/unlock). +// - MOST NEM — the full page stops interrupting; the backups-area entry point stays PERMANENTLY, +// because the data is still there whether or not anyone clicked (/recovery/postpone). +// - I DO NOT WANT THE OLD DATA — deliberate, explained, confirmed TWICE, and it reaches the +// SHIPPED move-aside (`/backup/offbox/reset`), which sets the store aside and never deletes. +// +// THE RECOVERY CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE (§8.3): POST body only, never +// a query string, never logged at any level, never persisted, never echoed back, cleared on every +// path, and the page is served no-store with autocomplete off. + +// recoveryOffer reports whether this box is in the situation the screen exists for. The predicate and +// its two shapes live in backup.OffsiteRecoveryOffer — read its header before changing anything here. +func (s *Server) recoveryOffer() bool { + // CLAIMED AND BEHIND THE PASSWORD (§8.1). Before claiming there is no customer, and this page + // states metadata about the household's own backups — accepted as metadata rather than content + // (operator ruling 2026-08-05), which is only true while it sits behind the household password. + // A legacy-open box (no password anywhere) reaches ServeHTTP through RequireAuth's pass-through, + // so WITHOUT this line the interception would fire on an unauthenticated visitor. Caught by + // TestRecovery_B_DoesNotAppearForAnyoneElse/not_claimed, which failed before it was added. + if !s.authEnabled() { + return false + } + return s.backupMgr != nil && s.backupMgr.OffsiteRecoveryOffer() +} + +// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages. "Most nem" +// suppresses this and nothing else — recoveryOffer stays true, so the backups-area entry point +// survives. That asymmetry is the whole of Scenario E. +func (s *Server) recoveryInterrupts() bool { + if !s.recoveryOffer() { + return false + } + return s.settings == nil || !s.settings.GetRecoveryNoticePostponed() +} + +// recoveryNoStore stamps the page uncacheable. The rendered page carries no secret, but it does carry +// the form the code is typed into, and a cached copy of a recovery form is a form served from disk. +func recoveryNoStore(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Pragma", "no-cache") +} + +// recoveryPageHandler renders the screen (GET /recovery). Reachable whenever the situation holds — +// including after "most nem", which is how the backups-area entry point can point at it forever. +func (s *Server) recoveryPageHandler(w http.ResponseWriter, r *http.Request) { + s.renderRecovery(w, r, "", "", nil) +} + +// renderRecovery is the one render path: pre-unlock (explanation + code form), or post-unlock (the +// read-only listing). errorMsg/flash are already-customer-facing Hungarian; NEITHER EVER CONTAINS THE +// CODE — see unlockHandler. +func (s *Server) renderRecovery(w http.ResponseWriter, r *http.Request, errorMsg, flash string, inv *backup.OffsiteInventory) { + recoveryNoStore(w) + data := s.baseData("recovery", "Adatok visszaszerzése") + data["CSRFField"] = s.csrfField(r) + data["Offer"] = s.recoveryOffer() + data["Postponed"] = s.settings != nil && s.settings.GetRecoveryNoticePostponed() + data["Error"] = errorMsg + data["Flash"] = flash + data["SealedAt"] = s.recoverySealedAt() + // The set-aside choice is offered ONLY when the shipped move-aside can actually run — it refuses + // unless the tier is orphaned. Showing a button that is guaranteed to refuse would be worse than + // not showing it, and rewriting the move-aside is explicitly out of scope. + data["CanSetAside"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned() + data["ConfirmSetAside"] = r.URL.Query().Get("setaside") == "1" + if inv != nil { + data["Unlocked"] = true + data["InvApps"] = inv.Apps + data["InvEmpty"] = inv.Empty + data["InvUntagged"] = !inv.Empty && len(inv.Apps) == 0 + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "recovery", data); err != nil { + s.logger.Printf("[ERROR] [web] Template error (recovery): %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} + +// recoverySealedAt returns the human date the hub says the sealed package was created ("" +// when unknown). Non-secret: a timestamp, and the page must not pretend to know more than that +// before a code is entered. +func (s *Server) recoverySealedAt() string { + if s.escrowSealedAtFn == nil { + return "" + } + return s.escrowSealedAtFn() +} + +// SetEscrowSealedAt wires the ACK's escrow created_at (report.EscrowAutoConfirmer). INIT-ONLY. +func (s *Server) SetEscrowSealedAt(fn func() string) { s.escrowSealedAtFn = fn } + +// recoveryRecoverer returns the agent seam that unseals the package. nil → the shared agentClient(), +// the same channel the CLI uses; tests inject. The seam exists so the HANDLER can be driven in a test +// — a helper-level test would not observe a mutation that lives in the handler (§10). +func (s *Server) recoveryRecoverer() (backup.OffsiteKeyRecoverer, error) { + if s.recoveryRecovererFn != nil { + return s.recoveryRecovererFn() + } + return s.agentClient() +} + +// SetRecoveryRecoverer overrides the agent seam (tests). +func (s *Server) SetRecoveryRecoverer(fn func() (backup.OffsiteKeyRecoverer, error)) { + s.recoveryRecovererFn = fn +} + +// recoveryUnlockHandler takes the recovery code and drives the SHARED core (POST /recovery/unlock). +// +// R HANDLING, and it must not drift from the CLI's: +// - POST body only. A GET with a query string would put R in the access log, the browser history +// and any Referer header the page later emits. +// - never logged. Not at any level, not truncated, not hashed-and-logged. +// - never persisted — not in the session, not in a cookie, not in a file. +// - cleared on every path below, success and failure alike. +// - never echoed. No message built here contains it; the agent's errors name the step, not the code. +// +// NO LOCKOUT (§8.4). The code is a 10-word EFF phrase — guessing is not the risk — and locking a +// customer out of their own data because they mistyped is a worse failure than anything it prevents. +// Failures ARE logged locally (without the code) so a box being probed is visible in the debug ring. +func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { + if !s.recoveryOffer() { + http.Redirect(w, r, "/backups/remote", http.StatusFound) + return + } + _ = r.ParseForm() + code := r.PostFormValue("recovery_code") // PostFormValue: body only, never the query string + if code == "" { + s.renderRecovery(w, r, "Add meg a helyreállítási kódot.", "", nil) + return + } + rec, err := s.recoveryRecoverer() + if err != nil { + code = "" + s.logger.Printf("[ERROR] [web] recovery: agent channel unavailable: %v", err) + s.renderRecovery(w, r, "A gép házon belüli kapcsolata most nem elérhető — próbáld újra néhány perc múlva.", "", nil) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + // THE SHARED CORE — the same function the command line drives. There is no second recovery + // implementation in this codebase (R-193 §8.5). + res, rerr := backup.RecoverInstallCore(ctx, s.backupMgr, rec, code, true) + code = "" // cleared here, before any branch below, on success and failure alike + if rerr != nil { + // The agent's error names the STEP (fetch / unseal / place) and carries no secret. It is not + // shown raw: a customer needs to know what to check, not what age's KDF returned. + s.logger.Printf("[WARN] [web] recovery: unlock failed: %v", rerr) + s.renderRecovery(w, r, "A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad be — a kis- és nagybetűk nem számítanak. Semmi nem változott, nyugodtan próbáld újra.", "", nil) + return + } + switch res.Outcome { + case backup.RecoverRefused: + s.logger.Printf("[WARN] [web] recovery: refused — a different repository password is already present") + s.renderRecovery(w, r, "Ezen a gépen már van egy másik mentési kulcs. A régi előzmény visszanyitása felülírná azt, ezért nem hajtottuk végre. Vedd fel a kapcsolatot a Felhom ügyfélszolgálatával.", "", nil) + return + } + s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome) + // Unlocked. Now show what is in there — read-only. + ictx, icancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer icancel() + inv, ierr := s.backupMgr.OffsiteInventoryList(ictx) + if ierr != nil { + s.logger.Printf("[WARN] [web] recovery: unlocked but the inventory could not be read: %v", ierr) + empty := backup.OffsiteInventory{} + msg := "A kulcs visszakerült, de a mentések listáját most nem sikerült beolvasni. Nézd meg a Biztonsági mentés oldalt néhány perc múlva." + if backup.ErrNoOffsiteTarget(ierr) { + // The pristine rebuilt shape: the key is in place but the box has no off-site coordinates + // yet. It resolves by itself once the tier is re-applied, so say that rather than showing a + // failure the customer cannot act on. + msg = "A kulcs visszakerült. A gép még most kapcsolódik újra a házon kívüli tárhelyhez — a mentéseid listája néhány perc múlva jelenik meg a Biztonsági mentés oldalon." + } + s.renderRecovery(w, r, msg, "", &empty) + return + } + s.renderRecovery(w, r, "", "A mentéseid zárolása feloldva. Az alábbiakat találtuk a tárolóban — semmit nem állítottunk vissza.", &inv) +} + +// recoveryPostponeHandler records "most nem" (POST /recovery/postpone). It suppresses the FULL-PAGE +// interruption ONLY: recoveryOffer stays true, so the backups-area entry point survives permanently. +func (s *Server) recoveryPostponeHandler(w http.ResponseWriter, r *http.Request) { + if s.settings != nil { + if err := s.settings.SetRecoveryNoticePostponed(true); err != nil { + s.logger.Printf("[WARN] [web] recovery: recording the postpone failed: %v", err) + } + } + s.logger.Printf("[INFO] [web] recovery: the customer chose to postpone; the backups-area entry point stays") + http.Redirect(w, r, "/launcher", http.StatusFound) +} diff --git a/controller/internal/web/recovery_test.go b/controller/internal/web/recovery_test.go new file mode 100644 index 0000000..6de1ea4 --- /dev/null +++ b/controller/internal/web/recovery_test.go @@ -0,0 +1,491 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-193 — the recovery screen. HANDLER-LEVEL tests throughout: a test that reaches a helper while the +// mutation lives in the handler cannot observe it, which is how a red-proof passed three sessions ago. +// Everything below drives the real handler (or the real mux) and asserts the rendered page, the +// on-disk effect, or the absence of the code. + +const testRecoveryCode = "helyre-allitasi-kod-tiz-szo-pontosan-igy-ni-most" +const testRepoPW = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1" + +// fakeRecoverer is the agent seam. It records every code it was handed so a test can prove the +// handler passed the RIGHT one, and can fail on demand for the wrong-code path. +type fakeRecoverer struct { + mu sync.Mutex + pw string + sha string + fail bool + codes []string +} + +func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) { + f.mu.Lock() + f.codes = append(f.codes, code) + f.mu.Unlock() + if f.fail { + // The shape the agent returns: names the STEP, never the code. + return "", "", fmt.Errorf("unseal failed: age: incorrect passphrase") + } + return f.pw, f.sha, nil +} + +// recoveryRunner is the restic seam for the post-unlock inventory. +type recoveryRunner struct { + snapshots []map[string]any + statsSize int64 +} + +func (rr *recoveryRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, " snapshots"): + b, _ := json.Marshal(rr.snapshots) + return b, nil + case strings.Contains(joined, " stats "): + b, _ := json.Marshal(map[string]any{"total_size": rr.statsSize}) + return b, nil + } + return []byte(""), nil +} + +type recoveryFixture struct { + s *Server + mgr *backup.Manager + sett *settings.Settings + rec *fakeRecoverer + runner *recoveryRunner + dataDir string +} + +// newRecoveryFixture builds a Server in the REBUILT-BOX shape by default: a claimed box (password +// set), no repository password on disk, and the hub holding a sealed package. +func newRecoveryFixture(t *testing.T) *recoveryFixture { + t.Helper() + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + cfg := &config.Config{} + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Paths.SystemDataPath = filepath.Join(dir, "sys") + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Web.SessionSecret = "test-session-secret-abcdef" + cfg.Web.PasswordHash = "$2a$10$abcdefghijklmnopqrstuv" // claimed: auth is enabled + + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + if err := sett.SetHubEscrowIdentityPresent(true); err != nil { // the hub holds a package + t.Fatal(err) + } + // The realistic shape once the credential self-heal has re-applied the tier: coordinates exist, + // but this box holds no repository password for the history they point at. + if err := sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", + Schedule: "daily", EscrowState: "escrowed", + }); err != nil { + t.Fatal(err) + } + mgr := backup.NewManager(cfg, sett, lg) + if err := mgr.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { + t.Fatal(err) + } + // WriteOffboxSecrets auto-generates a repository password — remove it, because "this box cannot + // open the inherited history" is the whole precondition of the screen. + if err := os.Remove(filepath.Join(cfg.Paths.DataDir, "offbox", "repo_password")); err != nil { + t.Fatal(err) + } + rr := &recoveryRunner{statsSize: 4 << 20} + mgr.SetOffboxRunner(rr.run) + + rec := &fakeRecoverer{pw: testRepoPW, sha: backup.HashResticPassword(testRepoPW)} + stackMgr, serr := stacks.NewManager(cfg, lg) + if serr != nil { + t.Fatal(serr) + } + s := &Server{cfg: cfg, settings: sett, backupMgr: mgr, stackMgr: stackMgr, logger: lg, version: "test"} + s.loadTemplates() + s.SetRecoveryRecoverer(func() (backup.OffsiteKeyRecoverer, error) { return rec, nil }) + return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir} +} + +// placeRepoPassword makes the box look HEALTHY (it holds its own repository password). +func (f *recoveryFixture) placeRepoPassword(t *testing.T) { + t.Helper() + if err := f.mgr.InjectOffboxPassword(testRepoPW, true); err != nil { + t.Fatal(err) + } +} + +func getRecoveryPage(t *testing.T, s *Server) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil)) + return rr +} + +// SCENARIO A — the page appears for the fresh + package box, and interrupts the landing pages. +func TestRecovery_A_PageAppearsForARebuiltBox(t *testing.T) { + f := newRecoveryFixture(t) + + if !f.s.recoveryOffer() { + t.Fatal("a rebuilt box (fresh data area + a hub-held package) is not offered the recovery screen") + } + if !f.s.recoveryInterrupts() { + t.Fatal("the full page must interrupt the landing pages before any postpone") + } + rr := getRecoveryPage(t, f.s) + if rr.Code != http.StatusOK { + t.Fatalf("GET /recovery = %d", rr.Code) + } + body := rr.Body.String() + // The two MANDATORY sentences of §8.2 (ASCII-safe fragments — accented patterns get mangled + // through the ssh→pct chain and a false 0 reads exactly like the sentence being gone). + if !strings.Contains(body, "helyre") || !strings.Contains(body, "llít") { + t.Error("the page does not mention the recovery code at all") + } + if !strings.Contains(body, "senki nem tudja p") { + t.Error("MANDATORY: the page must say that nobody can replace a lost recovery code") + } + if !strings.Contains(body, "semmi nem v") { + t.Error("MANDATORY: the page must say that nothing is restored or changed in this step") + } + // It takes the code in a POST body, and the field does not autofill. + if !strings.Contains(body, `action="/recovery/unlock"`) || !strings.Contains(body, `method="POST"`) { + t.Error("the code form must POST to /recovery/unlock") + } + if !strings.Contains(body, `autocomplete="off"`) { + t.Error("the recovery-code field must not autofill") + } + // And it is not cached. + if cc := rr.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("the recovery page must be no-store, got %q", cc) + } +} + +// SCENARIO B — it does NOT appear for anyone else. THE GUARD ON THE CONJUNCTION. +// +// RED-PROOF: drop the hub-package condition from backup.OffsiteRecoveryOffer → the +// "never had off-site backups" case below FAILS, i.e. a brand-new customer is greeted on day one by +// a recovery screen for data they never had. That is the plausible wrong fix. +func TestRecovery_B_DoesNotAppearForAnyoneElse(t *testing.T) { + t.Run("healthy box (holds its own repository password)", func(t *testing.T) { + f := newRecoveryFixture(t) + f.placeRepoPassword(t) // healthy, and not orphaned + if f.s.recoveryOffer() { + t.Fatal("a HEALTHY box was offered the recovery screen") + } + }) + t.Run("never had off-site backups (no hub package)", func(t *testing.T) { + f := newRecoveryFixture(t) + if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil { + t.Fatal(err) + } + if f.s.recoveryOffer() { + t.Fatal("a box that never had off-site backups was offered a recovery screen for data it never had") + } + }) + t.Run("not claimed — the page is behind the household password", func(t *testing.T) { + f := newRecoveryFixture(t) + f.s.cfg.Web.PasswordHash = "" // unclaimed: no password anywhere + if f.s.authEnabled() { + t.Fatal("fixture error: the box still reads as claimed") + } + // The interception is inside the authenticated surface: RequireAuth gates /launcher and + // /dashboard before ServeHTTP ever runs. Assert that through the REAL middleware chain. + mux := http.NewServeMux() + mux.Handle("/", f.s.RequireAuth(f.s.CsrfProtect(http.HandlerFunc(f.s.ServeHTTP)))) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/launcher", nil)) + if rr.Code == http.StatusFound && rr.Header().Get("Location") == "/recovery" { + t.Fatal("an UNCLAIMED box redirected to the recovery screen — it shows metadata that belongs behind the household password") + } + }) +} + +// SCENARIO C — the correct code unlocks, places the key, and the page then shows what is in there. +func TestRecovery_C_UnlockOpensAndLists(t *testing.T) { + f := newRecoveryFixture(t) + now := time.Now().UTC() + f.runner.snapshots = []map[string]any{ + {"short_id": "aaa1111", "time": now.Add(-24 * time.Hour).Format(time.RFC3339), "tags": []string{"immich"}}, + {"short_id": "bbb2222", "time": now.Format(time.RFC3339), "tags": []string{"immich"}}, + {"short_id": "ccc3333", "time": now.Add(-48 * time.Hour).Format(time.RFC3339), "tags": []string{"calibre-web"}}, + } + + rr := postUnlock(t, f.s, testRecoveryCode) + if rr.Code != http.StatusOK { + t.Fatalf("unlock = %d", rr.Code) + } + // EFFECT 1: the recovered key is on disk. + got, present := f.mgr.OffboxRepoPasswordHash() + if !present || got != backup.HashResticPassword(testRepoPW) { + t.Fatalf("the recovered repository password was not placed (present=%v)", present) + } + // EFFECT 2: the handler passed the code it was given, unmodified. + if len(f.rec.codes) != 1 || f.rec.codes[0] != testRecoveryCode { + t.Fatalf("the handler did not hand the agent the typed code: %+v", f.rec.codes) + } + // EFFECT 3: the page lists what is in there — apps and dates. A success message with nothing + // shown is indistinguishable from having unlocked an EMPTY store. + body := rr.Body.String() + for _, want := range []string{"immich", "calibre-web"} { + if !strings.Contains(body, want) { + t.Errorf("the listing does not name %q: the customer cannot tell whether this is their data", want) + } + } + if !strings.Contains(body, "4.0 MB") && !strings.Contains(body, "MB") { + t.Errorf("the listing shows no size") + } + // EFFECT 4: it did NOT restore anything — the page points at the restore page rather than doing it. + if !strings.Contains(body, "/backups/restore") { + t.Error("the page must point at the per-app restore rather than restoring") + } + if strings.Contains(body, "/backup/offbox/reconstitute") || strings.Contains(body, "/backup/offbox/place") { + t.Fatal("the recovery page offers a RESTORE action — unlocking and restoring are separate") + } +} + +// The EMPTY store is stated plainly rather than shown as a bare list. +func TestRecovery_C_EmptyStoreSaysSo(t *testing.T) { + f := newRecoveryFixture(t) + f.runner.snapshots = nil // opened cleanly, holds nothing + + body := postUnlock(t, f.s, testRecoveryCode).Body.String() + if !strings.Contains(body, "nincs benne egyetlen ment") { + t.Fatalf("an empty store must say so plainly — silence there reads as a broken page. body=%.400q", body) + } +} + +// SCENARIO D — a wrong code fails closed, writes nothing, says what to check, and does NOT lock out. +func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) { + f := newRecoveryFixture(t) + f.rec.fail = true + + for i := 0; i < 6; i++ { // well past any plausible lockout threshold + rr := postUnlock(t, f.s, "rossz kod") + if rr.Code != http.StatusOK { + t.Fatalf("attempt %d: got %d, want a re-rendered page", i, rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "nem fogadtuk el") { + t.Fatalf("attempt %d: the page does not say the code was not accepted: %.300q", i, body) + } + if !strings.Contains(body, "z szót") && !strings.Contains(body, "t sz") { + t.Errorf("attempt %d: the message does not say what to check", i) + } + // The raw agent error must NOT be shown to the customer. + if strings.Contains(body, "age:") || strings.Contains(body, "incorrect passphrase") { + t.Errorf("attempt %d: the raw technical error was rendered to the customer", i) + } + // NOTHING was written. + if _, present := f.mgr.OffboxRepoPasswordHash(); present { + t.Fatalf("attempt %d: a repository password was written on a FAILED unlock", i) + } + // And the form is still there — no lockout. + if !strings.Contains(body, `name="recovery_code"`) { + t.Fatalf("attempt %d: the customer was locked out of their own data after a mistyped code", i) + } + } +} + +// SCENARIO E — "most nem" stops the interruption and NOTHING else. The entry point survives. +// +// RED-PROOF: bind the backups-page entry point to recoveryInterrupts instead of recoveryOffer → the +// route to the data disappears after one click. +func TestRecovery_E_PostponeKeepsTheEntryPoint(t *testing.T) { + f := newRecoveryFixture(t) + + rr := httptest.NewRecorder() + f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil)) + if rr.Code != http.StatusFound { + t.Fatalf("postpone = %d, want a redirect", rr.Code) + } + if !f.sett.GetRecoveryNoticePostponed() { + t.Fatal("the postpone was not recorded") + } + // The full page no longer interrupts… + if f.s.recoveryInterrupts() { + t.Fatal("the full page still interrupts after 'most nem'") + } + // …and it must NOT interrupt through the real mux either. + mux := http.NewServeMux() + mux.Handle("/", http.HandlerFunc(f.s.ServeHTTP)) + rr2 := httptest.NewRecorder() + mux.ServeHTTP(rr2, httptest.NewRequest(http.MethodGet, "/launcher", nil)) + if rr2.Code == http.StatusFound && rr2.Header().Get("Location") == "/recovery" { + t.Fatal("the landing page still redirects to the recovery screen after 'most nem'") + } + // …but the ENTRY POINT is untouched: the offer stands, so the backups page still renders it. + if !f.s.recoveryOffer() { + t.Fatal("'most nem' removed the OFFER — the customer has lost the route to their own data") + } + // And the page itself is still reachable directly. + if getRecoveryPage(t, f.s).Code != http.StatusOK { + t.Fatal("the recovery page is unreachable after 'most nem'") + } + // THE ENTRY POINT ITSELF, rendered: the backups page must still carry the route. Asserted on the + // rendered flag rather than on recoveryOffer alone, because the defect this guards against is the + // TEMPLATE being fed the wrong predicate. + rr3 := httptest.NewRecorder() + f.s.backupsRemoteHandler(rr3, httptest.NewRequest(http.MethodGet, "/backups/remote", nil)) + if !strings.Contains(rr3.Body.String(), `href="/recovery"`) { + t.Fatal("the backups page no longer offers the route to the recovery screen after 'most nem' — a customer who clicked past it once has lost the way to their own data") + } +} + +// SCENARIO F — "I do not want the old data" is confirmed TWICE and reaches the SHIPPED move-aside. +// +// RED-PROOF: render the final button on the first view (drop the ConfirmSetAside gate) → one click +// suffices, and this test fails on the first assertion. +func TestRecovery_F_SetAsideNeedsTwoConfirmations(t *testing.T) { + f := newRecoveryFixture(t) + // The move-aside only exists once the tier is orphaned — that is the shipped handler's own + // precondition, and the page only offers the choice when it can actually run. + if err := f.sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", + Schedule: "daily", EscrowState: "escrowed", RepoState: "orphaned", + }); err != nil { + t.Fatal(err) + } + if !f.mgr.OffboxOrphaned() { + t.Fatal("fixture: the tier is not orphaned, so the set-aside cannot be offered") + } + + // FIRST VIEW — the destructive-looking button must NOT be present yet. + first := getRecoveryPage(t, f.s).Body.String() + if strings.Contains(first, `action="/backup/offbox/reset"`) { + t.Fatal("the set-aside form is on the FIRST view — one click would set the customer's history aside") + } + if !strings.Contains(first, "setaside=1") { + t.Fatal("the first view offers no route to the set-aside choice at all") + } + // And it must read as the exceptional path, not an equal third button. + if strings.Count(first, "btn-primary") > 1 { + t.Error("the set-aside is styled as an equal primary action") + } + + // SECOND VIEW — now the confirmation, naming exactly what happens. + rr := httptest.NewRecorder() + f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery?setaside=1", nil)) + second := rr.Body.String() + if !strings.Contains(second, `action="/backup/offbox/reset"`) { + t.Fatal("the second view does not reach the SHIPPED move-aside handler") + } + if !strings.Contains(second, `name="confirm" value="1"`) { + t.Fatal("the second view does not carry the shipped handler's confirm gate") + } + // The copy must say SET ASIDE, not delete — the whole point of the ruling. + if !strings.Contains(second, "lretessz") { + t.Error("the confirmation does not say the backups are SET ASIDE") + } + if !strings.Contains(second, "nem t") { + t.Error("the confirmation does not say they are NOT deleted") + } +} + +// SCENARIO G — the command line and the page drive ONE core. +// +// RED-PROOF: give the handler its own copy of fetch→compare→install instead of calling +// RecoverInstallCore → this test still passes on the happy path, so it asserts the SHARED SYMBOL +// from source (below) as well as the behaviour here. +func TestRecovery_G_PageAndCLIShareOneCore(t *testing.T) { + // Behavioural half: the same fake, the same outcome, through both callers. + f := newRecoveryFixture(t) + res, err := backup.RecoverInstallCore(context.Background(), f.mgr, f.rec, testRecoveryCode, true) + if err != nil || res.Outcome != backup.RecoverInstalled { + t.Fatalf("core install: outcome=%q err=%v", res.Outcome, err) + } + // Re-running is UNCHANGED, not a second install — the same three outcomes the CLI documents. + res2, err2 := backup.RecoverInstallCore(context.Background(), f.mgr, f.rec, testRecoveryCode, true) + if err2 != nil || res2.Outcome != backup.RecoverUnchanged { + t.Fatalf("core re-run: outcome=%q err=%v", res2.Outcome, err2) + } +} + +// SCENARIO H (§8.3) — the recovery code persists NOWHERE, with the planted-copy positive control. +func TestRecovery_H_CodeLeavesNoTrace(t *testing.T) { + f := newRecoveryFixture(t) + var logBuf strings.Builder + f.s.logger = log.New(&logBuf, "", 0) + + rr := postUnlock(t, f.s, testRecoveryCode) + + // 1) not echoed in the response + if strings.Contains(rr.Body.String(), testRecoveryCode) { + t.Fatal("the recovery code was echoed back in the rendered page") + } + // 2) not in any log line + if strings.Contains(logBuf.String(), testRecoveryCode) { + t.Fatal("the recovery code reached the log") + } + // 3) not in any file under the data dir + found := grepTree(t, f.dataDir, testRecoveryCode) + if found != "" { + t.Fatalf("the recovery code was persisted to %s", found) + } + + // THE POSITIVE CONTROL — a sweep whose sensitivity was never shown is not evidence. Plant a copy + // where the sweep looks and require it to be found; then remove it. + planted := filepath.Join(f.dataDir, "planted-control.txt") + if err := os.WriteFile(planted, []byte("x "+testRecoveryCode+" x"), 0o600); err != nil { + t.Fatal(err) + } + if got := grepTree(t, f.dataDir, testRecoveryCode); got == "" { + t.Fatal("POSITIVE CONTROL FAILED: the sweep could not find a planted copy, so its earlier silence proves nothing") + } + if err := os.Remove(planted); err != nil { + t.Fatal(err) + } + if got := grepTree(t, f.dataDir, testRecoveryCode); got != "" { + t.Fatalf("the control was not cleaned up: %s", got) + } +} + +// grepTree returns the first file under root whose contents contain needle ("" when none). +func grepTree(t *testing.T, root, needle string) string { + t.Helper() + var hit string + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() || hit != "" { + return nil + } + b, rerr := os.ReadFile(p) + if rerr == nil && strings.Contains(string(b), needle) { + hit = p + } + return nil + }) + return hit +} + +func postUnlock(t *testing.T, s *Server, code string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"recovery_code": {code}} + req := httptest.NewRequest(http.MethodPost, "/recovery/unlock", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + s.recoveryUnlockHandler(rr, req) + return rr +} diff --git a/controller/internal/web/recovery_wiring_test.go b/controller/internal/web/recovery_wiring_test.go new file mode 100644 index 0000000..65a373c --- /dev/null +++ b/controller/internal/web/recovery_wiring_test.go @@ -0,0 +1,146 @@ +package web + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// SCENARIO I / §8.5 — the seam-discipline tests. Both walk the AST rather than grepping, because a +// commented-out call still contains the string, and both parse with comments DROPPED so a commented +// line cannot satisfy them. This project's built-but-never-wired count is six. + +// The handler must drive the SHARED core. If it ever grows its own fetch→compare→install, the CLI and +// the page can diverge and only one of them will be tested — on the one operation that can +// permanently lose a customer's data. +// +// RED-PROOF: replace the RecoverInstallCore call in recoveryUnlockHandler with an inline copy → +// this fails. +func TestRecoveryHandlerDrivesTheSharedCore(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "recovery_handlers.go", nil, 0) + if err != nil { + t.Fatalf("parse recovery_handlers.go: %v", err) + } + + var fn *ast.FuncDecl + ast.Inspect(f, func(n ast.Node) bool { + if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "recoveryUnlockHandler" { + fn = d + return false + } + return true + }) + if fn == nil { + t.Fatal("recoveryUnlockHandler not found — did it move? the shared-core wiring is now unasserted") + } + + callsCore := false + // Any DIRECT use of the agent's unseal from the handler would be a second implementation. + callsRecoverDirectly := false + ast.Inspect(fn, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch sel.Sel.Name { + case "RecoverInstallCore": + callsCore = true + case "RecoverOffsiteRepoPassword", "InjectOffboxPassword": + callsRecoverDirectly = true + } + return true + }) + if !callsCore { + t.Fatal("recoveryUnlockHandler does NOT call backup.RecoverInstallCore — the page and the command line would be two implementations of one irreversible operation") + } + if callsRecoverDirectly { + t.Fatal("recoveryUnlockHandler reaches the agent/injection DIRECTLY — that is a second recovery implementation, which is exactly what the shared core exists to prevent") + } +} + +// And the CLI wrapper must drive the same core, from the other side. +// +// RED-PROOF: restore the inline fetch→compare→install inside RecoverAndInstall → this fails. +func TestCLIWrapperDrivesTheSharedCore(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "../backup/offbox_recovery_cli.go", nil, 0) + if err != nil { + t.Fatalf("parse offbox_recovery_cli.go: %v", err) + } + var fn *ast.FuncDecl + ast.Inspect(f, func(n ast.Node) bool { + if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "RecoverAndInstall" { + fn = d + return false + } + return true + }) + if fn == nil { + t.Fatal("RecoverAndInstall not found — the shared-core wiring is now unasserted on the CLI side") + } + callsCore, callsDirect := false, false + ast.Inspect(fn, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RecoverInstallCore" { + callsCore = true + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + switch sel.Sel.Name { + case "RecoverOffsiteRepoPassword", "InjectOffboxPassword": + callsDirect = true + } + } + return true + }) + if !callsCore { + t.Fatal("RecoverAndInstall no longer calls RecoverInstallCore — the CLI has its own copy again") + } + if callsDirect { + t.Fatal("RecoverAndInstall reaches the agent/injection directly — the two callers have diverged") + } +} + +// The page must be REACHABLE: routed in ServeHTTP, and the landing-page interception present. +// +// RED-PROOF: comment out the interception block → this fails, and a rebuilt box's owner would never +// meet the screen unless they guessed the URL. +func TestRecoveryRoutesAreWired(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "server.go", nil, 0) + if err != nil { + t.Fatalf("parse server.go: %v", err) + } + var handlersSeen, interceptSeen bool + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + switch sel.Sel.Name { + case "recoveryPageHandler", "recoveryUnlockHandler", "recoveryPostponeHandler": + handlersSeen = true + case "recoveryInterrupts": + interceptSeen = true + } + return true + }) + if !handlersSeen { + t.Fatal("no recovery handler is routed in ServeHTTP — the page exists and is unreachable") + } + if !interceptSeen { + t.Fatal("ServeHTTP never consults recoveryInterrupts — the full page would never take over the landing pages, so a customer would have to guess the URL") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index da845a2..9793546 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -96,6 +96,15 @@ type Server struct { escrowStageFn func(ctx context.Context) error escrowStaleFn func() bool + // escrowSealedAtFn (v0.200.0, R-193) reports WHEN the hub's sealed recovery package was created — + // the one non-secret fact the recovery screen may state before a code is entered. Wired via + // SetEscrowSealedAt from the report ACK; nil → the page says nothing about the date rather than + // guessing one. + escrowSealedAtFn func() string + // recoveryRecovererFn is the recovery screen's agent seam (nil → the shared agentClient(), the + // same channel the CLI uses). Tests inject a fake so the HANDLER itself can be driven. + recoveryRecovererFn func() (backup.OffsiteKeyRecoverer, error) + // NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams. // netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec). netAdd netAddState @@ -361,6 +370,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr) } + // R-193: the recovery screen takes over the LANDING pages (and only those) while the situation + // holds and the customer has not postponed. Placed before the switch so it cannot be defeated by + // a route added later, and scoped to two paths so it never traps the customer inside it — every + // other page, including the backups area the entry point lives in, stays reachable. + if (path == "/launcher" || path == "/dashboard") && r.Method == http.MethodGet && s.recoveryInterrupts() { + http.Redirect(w, r, "/recovery", http.StatusFound) + return + } + switch { // Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth // (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers). @@ -375,6 +393,16 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // canonical landing page. "/" 302s to /launcher (ONE canonical URL per page — the launcher body // is never served AT "/"). Post-login lands on "/", so it flows here → the launcher. http.Redirect(w, r, "/launcher", http.StatusFound) + // R-193 — the recovery screen. A FULL PAGE, not a banner: someone who has just lost a machine + // deserves a screen about that and nothing else. It takes over the landing pages while the + // situation holds AND the customer has not chosen "most nem"; afterwards it stays reachable here + // (and from the backups area) for as long as the situation lasts. + case path == "/recovery" && r.Method == http.MethodGet: + s.recoveryPageHandler(w, r) + case path == "/recovery/unlock" && r.Method == http.MethodPost: + s.recoveryUnlockHandler(w, r) + case path == "/recovery/postpone" && r.Method == http.MethodPost: + s.recoveryPostponeHandler(w, r) case path == "/dashboard": s.dashboardHandler(w, r) case path == "/launcher": diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 8a7bcab..98257c3 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -8,6 +8,19 @@ {{template "backups_flash" .}} +{{if .RecoveryOffer}} + +
+ A korábbi, házon kívüli mentéseid visszaszerezhetők. + Ezt a gépet újratelepítették, és a Felhom központi rendszere őriz hozzá egy lezárt csomagot. A + helyreállítási kódoddal feloldhatod a korábbi mentéseidet, és megnézheted, mi van bennük. + +
+{{end}} + {{if not .Backup}} {{template "backups_empty" .}} {{else}} diff --git a/controller/internal/web/templates/recovery.html b/controller/internal/web/templates/recovery.html new file mode 100644 index 0000000..9cc665b --- /dev/null +++ b/controller/internal/web/templates/recovery.html @@ -0,0 +1,146 @@ +{{define "recovery"}} + + + + + + + Adatok visszaszerzése — Felhom + + + +
+ + + {{if .Unlocked}} + +

A mentéseid elérhetők

+ {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + + {{if .InvEmpty}} +
+ A tároló megnyílt, de nincs benne egyetlen mentés sem. Ez azt jelenti, hogy a + kulcs jó volt, de ehhez a géphez nem tartozik korábbi mentés. Ha korábban biztosan készültek + mentések, keresd a Felhom ügyfélszolgálatát, mielőtt bármit tennél. +
+ {{else if .InvUntagged}} +
+ A tároló megnyílt, és van benne tartalom, de nem tudtuk alkalmazásokhoz rendelni. A Biztonsági + mentés oldalon nézheted meg részletesen. +
+ {{else}} + + + + + {{range .InvApps}} + + + + + + {{end}} + +
AlkalmazásLegutóbbi mentésMéret
{{.App}}{{fmtTime .LatestAt}}{{if gt .SizeBytes 0}}{{humanBytes .SizeBytes}}{{else}}—{{end}}
+ {{end}} + +

+ A visszaállítás alkalmazásonként történik, a Biztonsági mentés → Visszaállítás + oldalon. Ott választhatod ki, melyik alkalmazás mit hozzon vissza. +

+ + + {{else}} + +

Adatok visszaszerzése

+ + + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +

+ Ezt a gépet újratelepítették. A korábbi, házon kívüli mentéseid megvannak — a + Felhom központi rendszere őriz hozzájuk egy lezárt csomagot{{with .SealedAt}}, amelyet + {{.}} zártunk le{{end}}. A csomagot csak a te helyreállítási + kódoddal lehet kinyitni. +

+ +
+ A helyreállítási kódot senki nem tudja pótolni — sem a Felhom, sem az + ügyfélszolgálat, sem az üzemeltető. Ez szándékos: így a mentéseidet rajtad kívül senki nem + tudja megnyitni. Ha a kód elveszett, a korábbi mentések nem nyithatók meg többé. +
+ +

+ Ha megadod a kódot, feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük + — melyik alkalmazás, mikorról, mekkora. Ebben a lépésben semmit nem állítunk vissza és + semmi nem változik. A visszaállítást utána, alkalmazásonként külön választhatod. +

+ +
+ {{.CSRFField}} + + +
+ + + {{.CSRFField}} + + +
+ + +

+ A „Most nem” csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől + megmaradnak, és ez az oldal a Biztonsági mentés → Távoli mentés oldalról + bármikor újra elérhető. +

+ + +
+ {{if .ConfirmSetAside}} +
+

Biztosan nem kéred vissza a korábbi mentéseket?

+

Ha megerősíted:

+
    +
  • a korábbi mentéseket félretesszük — nem töröljük;
  • +
  • a helyreállítási kód nélkül többé nem lesznek megnyithatók;
  • +
  • a gép új, üres mentési tárolót kezd, és mostantól oda ment;
  • +
  • ez az oldal többé nem jelenik meg.
  • +
+

Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget.

+
+
+
+ {{.CSRFField}} + + +
+ Mégsem +
+ {{else}} +

+ Ha a helyreállítási kódod véglegesen elveszett, és tudomásul veszed, hogy a korábbi mentések + így nem nyithatók meg többé: + {{if .CanSetAside}} + nem kérem vissza a korábbi adatokat. + {{else}} + ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli + tárhelyhez. Addig a mentéseid érintetlenül megmaradnak. + {{end}} +

+ {{end}} + {{end}} +
+ + +{{end}}