Compare commits
2 Commits
be3c5fa7f6
...
62b85ecf13
| Author | SHA1 | Date | |
|---|---|---|---|
| 62b85ecf13 | |||
| 636c51e542 |
@@ -1,3 +1,75 @@
|
||||
## v0.200.0 — the recovery screen: unlocking, and only unlocking (2026-08-05, R-193)
|
||||
|
||||
R-193's remaining half. The drill proved a customer's file comes back after a machine is destroyed;
|
||||
the sessions since removed every step that needed the operator. What was left is that the customer had
|
||||
no way to *begin* without a command line.
|
||||
|
||||
### It unlocks, and only unlocks (operator ruling, 2026-08-05)
|
||||
|
||||
The page explains the situation, takes the recovery code, opens the repository, and shows what is in
|
||||
there — which apps, from when, how big. **It restores nothing.** Restore is already per-app and already
|
||||
lives in the backups area; putting files back is a separate item and is not started here. 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** — every pre-existing CLI test passes unchanged — and the web handler
|
||||
drives the same function. 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. **Both directions are asserted
|
||||
from source by AST** (comments dropped, so a commented-out call cannot satisfy them), plus a third test
|
||||
that the routes and the landing-page interception exist at all.
|
||||
|
||||
### When it appears — two facts, and a third shape the task did not name
|
||||
|
||||
`backup.OffsiteRecoveryOffer` requires the hub to hold a sealed package **and** this box to be unable
|
||||
to open what it protects. The second half has **two** shapes:
|
||||
|
||||
- **(a) no repository password at all** — the pristine rebuilt box. This is the literal reading of
|
||||
"the data area is fresh".
|
||||
- **(b) a password exists but the inherited history will not open under it** (`RepoState == orphaned`).
|
||||
|
||||
**Shape (b) had to be added, and the reason is load-bearing.** `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 now happens by itself within ~15–30 minutes. Shape (a)
|
||||
alone would have made this screen appear only inside a half-hour window that closes on its own, so the
|
||||
customer who logs in the next morning — the actual customer — would never have seen it.
|
||||
|
||||
Shape (b) is also the state the shipped move-aside requires, which is what lets "I do not want the old
|
||||
data" reach the existing handler rather than needing a new one.
|
||||
|
||||
**Claimed and behind the household password.** Added after a test caught the omission: a legacy-open
|
||||
box (no password anywhere) reaches `ServeHTTP` through `RequireAuth`'s pass-through, so without an
|
||||
explicit `authEnabled()` check the interception fired for an unauthenticated visitor.
|
||||
|
||||
### Three ways out, and none of them is a dismiss button
|
||||
|
||||
- **Recover** — the main path.
|
||||
- **„Most nem"** — the full page stops interrupting. **The backups-area entry point stays,
|
||||
permanently**: it is bound to `recoveryOffer`, never to the postpone flag, because 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.
|
||||
- **„Nem kérem vissza a korábbi adatokat"** — the exceptional path, not an equal third button. Two
|
||||
confirmations, the second naming exactly what happens, then the **shipped** `/backup/offbox/reset`,
|
||||
which sets the store aside and never deletes. Offered only when that handler can actually run.
|
||||
|
||||
### The recovery code is handled no more loosely than on the command line
|
||||
|
||||
POST body only (`PostFormValue` — never a query string), never logged at any level, never persisted,
|
||||
never echoed into any message, cleared on every path, the page served `no-store`, and the field
|
||||
`autocomplete="off"`. **No lockout** (§8.4): the code is a ten-word EFF phrase, guessing is not the
|
||||
risk, and locking a customer out of their own data for a typo is a worse failure than anything a
|
||||
lockout prevents. Failed attempts ARE logged locally — without the code — so a box being probed is
|
||||
visible in the debug ring.
|
||||
|
||||
### Two defects the tests caught before they shipped
|
||||
|
||||
1. **An unclaimed box would have been shown the page** (above).
|
||||
2. **The inventory nil-dereferenced when no off-site target was configured** — which is exactly the
|
||||
pristine rebuilt shape. It now returns a named error and the page says the key is back and the
|
||||
listing will appear once the box has re-connected, rather than showing a failure the customer
|
||||
cannot act on.
|
||||
|
||||
## v0.199.0 — a rebuilt box asks for its credential back (2026-08-05, R-204 item 4 / R-193)
|
||||
|
||||
The last of the four manual interventions the 2026-08-04 drill needed. A rebuilt box has no off-site
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 "–"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
|
||||
{{template "backups_flash" .}}
|
||||
|
||||
{{if .RecoveryOffer}}
|
||||
<!-- R-193: the PERMANENT entry point. Bound to RecoveryOffer, never to the postpone flag — a
|
||||
customer who clicked "Most nem" once must not lose the route to their own data. -->
|
||||
<div class="alert alert-warning">
|
||||
<strong>A korábbi, házon kívüli mentéseid visszaszerezhetők.</strong>
|
||||
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.
|
||||
<div class="form-actions" style="margin-top:.5rem">
|
||||
<a href="/recovery" class="btn btn-primary">Adatok visszaszerzése</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .Backup}}
|
||||
{{template "backups_empty" .}}
|
||||
{{else}}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
{{define "recovery"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Adatok visszaszerzése — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="login-card" style="max-width:46rem">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||
|
||||
{{if .Unlocked}}
|
||||
<!-- ── AFTER THE UNLOCK: what is in there. Read-only — nothing was restored. ────────────── -->
|
||||
<h1 class="login-title">A mentéseid <span class="title-accent">elérhetők</span></h1>
|
||||
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .InvEmpty}}
|
||||
<div class="alert alert-warning">
|
||||
A tároló megnyílt, de <strong>nincs benne egyetlen mentés sem</strong>. 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.
|
||||
</div>
|
||||
{{else if .InvUntagged}}
|
||||
<div class="alert alert-warning">
|
||||
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.
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="login-subtitle" style="margin-bottom:1rem">
|
||||
Ezek a <strong>te</strong> mentéseid, a lent jelzett időpontokból. Nézd át, hogy tényleg azt
|
||||
találod-e itt, amire számítasz — <strong>semmit nem állítottunk vissza és semmi nem változott.</strong>
|
||||
</p>
|
||||
<table class="data-table" style="width:100%;margin-bottom:1rem">
|
||||
<thead><tr><th>Alkalmazás</th><th>Legutóbbi mentés</th><th>Méret</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .InvApps}}
|
||||
<tr>
|
||||
<td>{{.App}}</td>
|
||||
<td>{{fmtTime .LatestAt}}</td>
|
||||
<td>{{if gt .SizeBytes 0}}{{humanBytes .SizeBytes}}{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<p class="form-hint">
|
||||
A visszaállítás alkalmazásonként történik, a <strong>Biztonsági mentés → Visszaállítás</strong>
|
||||
oldalon. Ott választhatod ki, melyik alkalmazás mit hozzon vissza.
|
||||
</p>
|
||||
<div class="form-actions">
|
||||
<a href="/backups/restore" class="btn btn-primary">Tovább a visszaállításhoz</a>
|
||||
<a href="/launcher" class="btn btn-outline">Vissza a kezdőlapra</a>
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
<!-- ── BEFORE ANY CODE: explain, then take the code. ───────────────────────────────────── -->
|
||||
<h1 class="login-title">Adatok <span class="title-accent">visszaszerzése</span></h1>
|
||||
<p class="login-subtitle">{{.CustomerName}}</p>
|
||||
|
||||
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<p>
|
||||
Ezt a gépet újratelepítették. A korábbi, <strong>házon kívüli mentéseid megvannak</strong> — a
|
||||
Felhom központi rendszere őriz hozzájuk egy lezárt csomagot{{with .SealedAt}}, amelyet
|
||||
<strong>{{.}}</strong> zártunk le{{end}}. A csomagot csak a <strong>te helyreállítási
|
||||
kódoddal</strong> lehet kinyitni.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong>A helyreállítási kódot senki nem tudja pótolni</strong> — 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é.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Ha megadod a kódot, <strong>feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük</strong>
|
||||
— melyik alkalmazás, mikorról, mekkora. <strong>Ebben a lépésben semmit nem állítunk vissza és
|
||||
semmi nem változik.</strong> A visszaállítást utána, alkalmazásonként külön választhatod.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="/recovery/unlock" autocomplete="off">
|
||||
{{.CSRFField}}
|
||||
<label for="recovery_code">Helyreállítási kód (tíz szó)</label>
|
||||
<input type="password" id="recovery_code" name="recovery_code"
|
||||
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
||||
placeholder="tíz szó, szóközökkel elválasztva" required>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentések feloldása</button>
|
||||
<form method="POST" action="/recovery/postpone" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline">Most nem</button>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="form-hint">
|
||||
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 <strong>Biztonsági mentés → Távoli mentés</strong> oldalról
|
||||
bármikor újra elérhető.
|
||||
</p>
|
||||
|
||||
<!-- ── THE EXCEPTIONAL PATH. Deliberately not an equal third button. ───────────────────── -->
|
||||
<hr style="margin:1.5rem 0;border:none;border-top:1px solid var(--border,#2a3142)">
|
||||
{{if .ConfirmSetAside}}
|
||||
<div class="alert alert-error">
|
||||
<p><strong>Biztosan nem kéred vissza a korábbi mentéseket?</strong></p>
|
||||
<p>Ha megerősíted:</p>
|
||||
<ul>
|
||||
<li>a korábbi mentéseket <strong>félretesszük — nem töröljük</strong>;</li>
|
||||
<li>a helyreállítási kód nélkül <strong>többé nem lesznek megnyithatók</strong>;</li>
|
||||
<li>a gép <strong>új, üres mentési tárolót kezd</strong>, és mostantól oda ment;</li>
|
||||
<li>ez az oldal <strong>többé nem jelenik meg</strong>.</li>
|
||||
</ul>
|
||||
<p>Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget.</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<form method="POST" action="/backup/offbox/reset">
|
||||
{{.CSRFField}}
|
||||
<input type="hidden" name="confirm" value="1">
|
||||
<button type="submit" class="btn btn-danger">Igen, félretehetitek a korábbi mentéseket</button>
|
||||
</form>
|
||||
<a href="/recovery" class="btn btn-outline">Mégsem</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="form-hint">
|
||||
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}}
|
||||
<a href="/recovery?setaside=1">nem kérem vissza a korábbi adatokat</a>.
|
||||
{{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}}
|
||||
</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user