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. +
+ Adatok visszaszerzése +
+
+{{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}} +

+ Ezek a te mentéseid, a lent jelzett időpontokból. Nézd át, hogy tényleg azt + találod-e itt, amire számítasz — semmit nem állítottunk vissza és semmi nem változott. +

+ + + + {{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. +

+
+ Tovább a visszaállításhoz + Vissza a kezdőlapra +
+ + {{else}} + +

Adatok visszaszerzése

+

{{.CustomerName}}

+ + {{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:

+ +

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}}