Compare commits
6
Commits
c6b69d888e
...
0a9158d53e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9158d53e | ||
|
|
72368654e4 | ||
|
|
de39e47f53 | ||
|
|
a5d90ff801 | ||
|
|
a491abef6c | ||
|
|
763de3a025 |
No files matched your search
@@ -1,3 +1,84 @@
|
||||
## v0.206.0 — the box does not mint a key over a sealed package, and abandoning ends the question (2026-08-07, R-241) — MinAgent 0.127.0
|
||||
|
||||
**R-241 was ruled a MINTING defect, not a screen-predicate defect** (`SPIKE-r241-recovery-offer-2026-08-07.md`),
|
||||
and that reversed the fix. The recovery screen was telling the truth: there genuinely was nothing
|
||||
recoverable under the key the box held, **because the box minted that key itself, over the top of a
|
||||
sealed package it already knew the hub was holding.** Mending the screen would have papered over a
|
||||
machine quietly making its own backups unopenable.
|
||||
|
||||
### 1. It stops minting
|
||||
|
||||
`WriteOffboxSecrets` auto-generated on **one** input — does the file exist. Its two neighbours in the
|
||||
same file, `OffsiteRecoveryOffer` and `needsOffsiteCredential`, both consult
|
||||
`GetHubEscrowIdentityPresent()`. **The same fact was available on three paths and used on two.**
|
||||
|
||||
Measured on the final walk: the credential self-heal reached it at 03:18:06Z and minted `9b4a9a9d…`
|
||||
over a package sealing `30ef574f…`. The flag was not merely available at that moment — it was the
|
||||
**precondition of the chain that reached the function**, logged at 02:48:03Z, six ticks earlier.
|
||||
|
||||
The guard is a **conjunction** (a package held AND no key present), so a first-time box mints exactly
|
||||
as before. The refusal is a **holding state, not a failure**: the transport is still written, so the
|
||||
recovery screen can bring the tier up the instant the key arrives (R-219). Returning an error instead
|
||||
would have left the hub re-staging a consumed credential for ever. New declared state
|
||||
`offsite.state=awaiting_recovery_key`, shown inert to every existing hub reader from their code.
|
||||
|
||||
### 2. The comparison it already made now drives the offer
|
||||
|
||||
`EscrowAutoConfirmer.Reconcile` has compared the hub's `restic_pw_sha256` against the local key on
|
||||
every ACK since SLICE 3. On the venue it logged the mismatch at **03:28:03Z — thirty-five minutes
|
||||
before the customer looked** — and threw it away. It is now persisted, and `OffsiteRecoveryOffer`
|
||||
gains **shape (c)**: the hub holds a package for a key other than the one we are using.
|
||||
|
||||
**§7.2, decided deliberately:** a **known difference offers however old the reading** (age is not
|
||||
gated on — gating would make a box offline from the hub silently stop offering); a **hash never
|
||||
learned falls back to (a)/(b)**, because an empty hash is the hub positively saying its package seals
|
||||
no key, not an unknown.
|
||||
|
||||
### 3. Abandoning is now a finishable thing
|
||||
|
||||
Setting the old history aside used to touch neither the escrow nor the key, so the hub went on holding
|
||||
a package for a key nobody used and the question returned at every login. It now starts a **14-day
|
||||
countdown**, visible and reversible, at the end of which the set-aside store **and the sealed package
|
||||
that protects it are removed together** — after which shape (c) has nothing to compare and the offer
|
||||
falls silent **because the state is right, not because something remembers it once was not**.
|
||||
|
||||
The grace is real: the recovery offer stays reachable throughout. The two halves cannot be atomic
|
||||
across two machines, so it is a two-phase commit whose confirmation rides the **same ACK** that
|
||||
carries the request. Needs hub **v0.98.0**.
|
||||
|
||||
### 4. The surface, and the trap that does not survive this session
|
||||
|
||||
The full page appears **once per entry into the offered state, not once ever** (an epoch, so a box
|
||||
rebuilt months later is a new situation). Three dismissal levers with three scopes — a per-visit
|
||||
session cookie, a durable epoch-scoped reminder opt-out, and the existing "most nem" — and **none of
|
||||
them removes the entry point on the backups page.**
|
||||
|
||||
**§7.3 / Q7:** while a recovery is outstanding, „Helyreállítási kód létrehozása" is now **unavailable**
|
||||
rather than merely captioned. Creating a new code seals the current key, demotes the package that
|
||||
opens the earlier history to retained custody no shipped path can read (R-199), and re-enables the
|
||||
screen while invalidating the code it accepts. A warning beside a button is a warning people click
|
||||
past.
|
||||
|
||||
**The abandon confirmation changed with the behaviour (§2.4):** it used to promise *„félretesszük —
|
||||
nem töröljük"*, and after this the history **is** deleted, on a date it now states.
|
||||
|
||||
### 5. Reminders and operator levers
|
||||
|
||||
Escalating emphasis at 1/3/7/14 days for an undecided box, 5/3/1 days remaining for an abandoning one.
|
||||
`--abandon-status` / `--abandon-extend=N` / `--abandon-stop` on the controller CLI, because the path
|
||||
that actually happens is the customer telephoning. Both levers **refuse rather than no-op** when
|
||||
nothing is running or the store is already gone.
|
||||
|
||||
**The automatic 30-day abandonment is recorded and NOT built** → R-245.
|
||||
|
||||
### Caught by tests rather than review
|
||||
|
||||
Two real bugs in this change: `OffboxAwaitingRecoveryKey` omitted `t.Enabled`, so a customer who had
|
||||
switched off-site off would have declared a holding state (caught by the existing
|
||||
`TestOffsiteDeclare_DisabledTargetIsNotStranded`); and `recoveryInterrupts` returned early when the
|
||||
offer was false, so the **falling** edge was never recorded and the page never came back — the exact
|
||||
defect the epoch exists to fix, reintroduced inside the fix.
|
||||
|
||||
## v0.205.0 — a backup that skipped an app the customer chose is not „Rendben" (2026-08-06, R-234) — MinAgent 0.127.0
|
||||
|
||||
**Two defects, and the one that actually produced the measured sequence was NOT the one filed.**
|
||||
|
||||
+36
-1
@@ -7,7 +7,42 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-08-06 (v0.205.0 — R-234: a run that skipped a selected app is not successful)
|
||||
Last updated: 2026-08-07 (v0.206.0 — R-241: the box does not mint a key over a sealed package)
|
||||
|
||||
> **2026-08-07 — v0.206.0 (R-241). THE RULING, and it reversed the fix: this was a MINTING defect,
|
||||
> not a screen-predicate defect.** The recovery screen was telling the truth — there genuinely was
|
||||
> nothing recoverable under the key the box held, because **the box minted that key itself over the
|
||||
> top of a sealed package it already knew the hub was holding**. Fixing the predicate would have
|
||||
> papered over a machine quietly making its own backups unopenable.
|
||||
>
|
||||
> **THE RULE: a box does not create a repository key while the hub holds a sealed package for it.**
|
||||
> The guard is a conjunction (package held AND no key), so a first-time box is untouched, and the
|
||||
> refusal is a HOLDING state rather than a failure — the transport is still configured so the
|
||||
> recovery screen can bring the tier up the moment the key arrives.
|
||||
>
|
||||
> **THE SECOND RULE: the fact that answers a question must be kept where the question is asked.** The
|
||||
> hub-vs-local key comparison had been computed on every ACK since SLICE 3 and persisted nowhere; on
|
||||
> the venue it logged the right answer thirty-five minutes before the customer looked at a screen
|
||||
> that could not see it. It is now persisted and drives shape (c) of the offer.
|
||||
>
|
||||
> **THE THIRD RULE (the operator's, and it generalises): fix the state, do not remember that it is
|
||||
> wrong.** Abandoning the old history now starts a 14-day countdown that removes the set-aside store
|
||||
> and its sealed package TOGETHER, after which the offer falls silent on its own because there is
|
||||
> nothing left to compare — rather than a "they decided" flag suppressing a screen over a state that
|
||||
> is still wrong. The recovery offer stays reachable for the whole grace; a grace in which recovery
|
||||
> is impossible is decorative.
|
||||
>
|
||||
> **Surface:** the full page appears once per ENTRY into the offered state, not once ever — a box
|
||||
> rebuilt months later is a new situation. Three dismissal levers with three scopes, and none of them
|
||||
> removes the entry point on the backups page.
|
||||
>
|
||||
> **Needs hub v0.98.0** for the superseded-package purge. `felhom-agent` untouched.
|
||||
>
|
||||
> **Two real bugs were caught by tests rather than by review** — a missing `t.Enabled` (an existing
|
||||
> test) and a missing falling-edge sync that reintroduced the very defect the epoch exists to fix.
|
||||
>
|
||||
> **NOT built, deliberately:** the automatic 30-day abandonment (R-245, with the operator's reasoning
|
||||
> recorded), and R-242's release-to-golden gate.
|
||||
|
||||
> **2026-08-06 — v0.205.0 (R-234).** THE RULE: **a run that skipped an app the customer selected is
|
||||
> not a successful run.** The R-203 verdict block already said *"a warning beside a success is read
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
|
||||
| Symbol | File | Short signature | Use for | Gotchas |
|
||||
|---|---|---|---|---|
|
||||
| `backup.ErrOffboxSealedPackageHeld` + `IsOffboxSealedPackageHeld` + `sealedPackageHeld` + `OffboxAwaitingRecoveryKey` (R-241, v0.206.0) | controller/internal/backup/offbox.go | sentinel; `(error) bool`; `() bool`; `() bool` | **THE MINT GUARD** — a box never creates a repository key while the hub holds a sealed package for it | **The guard is a CONJUNCTION** (package held AND no key present). Widening it to "never mint" leaves a first-time box unable to start, waiting for a package that will never exist — pinned by `TestR241_ScenarioB_FirstTimeBoxStillMints`. **The refusal is a HOLDING state, not a failure:** `ApplyOffsiteTarget` catches the sentinel and still writes the transport, so `/recovery`'s synchronous tier-up (R-219) can bring the tier up the instant the key arrives; returning the error instead leaves `needsOffsiteCredential` true and the hub re-staging a consumed credential for ever. `OffboxAwaitingRecoveryKey` is **DERIVED, never stored** — and **`t.Enabled` is load-bearing in it**: a customer who switched off-site OFF is not awaiting anything (the Scenario-E carve-out `needsOffsiteCredential` makes two functions above; the first draft omitted it and an existing test caught it). A nil settings store reads as "no package held" — a transient read failure must never become a permanently-held tier |
|
||||
| `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate |
|
||||
| `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone |
|
||||
| `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only |
|
||||
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
|
||||
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
|
||||
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
|
||||
|
||||
@@ -167,6 +167,30 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
|
||||
reads); a disconnected/decommissioned drive has its own signal. The manual „Távoli mentés most”
|
||||
also refuses SYNCHRONOUSLY when a run is already in flight, instead of answering „elindult” and
|
||||
leaving the previous run's verdict on the card.
|
||||
**The box does not mint a repository key over a sealed package (v0.206.0, R-241):**
|
||||
`WriteOffboxSecrets` auto-generated on ONE input — does the password file exist — while its two
|
||||
neighbours in the same file both consulted `GetHubEscrowIdentityPresent()`. A rebuilt box's
|
||||
credential self-heal therefore wrote a fresh key over the package the hub was holding for it, and
|
||||
the recovery screen then correctly reported that nothing was recoverable under the key the box
|
||||
held. The guard is a CONJUNCTION (a package held AND no key present), so a first-time box mints
|
||||
exactly as before; the refusal is a HOLDING state that still writes the transport and declares
|
||||
`offsite.state=awaiting_recovery_key`, so `/recovery` can bring the tier up the moment the key
|
||||
arrives. `--abandon-status` / `--abandon-extend=N` / `--abandon-stop` are the operator levers.
|
||||
**The recovery offer has a third shape (v0.206.0, R-241):** shape (c) — *the hub holds a package
|
||||
for a key OTHER than the one we are using*. Shapes (a) "no key at all" and (b) "a run proved the
|
||||
repo will not open" are proxies for that question and have each been wrong in opposite directions.
|
||||
The comparison was already computed on every report ACK and persisted nowhere; it now lives in
|
||||
`settings.HubEscrowKeySHA256`. A KNOWN difference offers however old the reading; a hash never
|
||||
learned falls back to (a)/(b).
|
||||
**Abandoning the old history is a finishable thing (v0.206.0, R-241):** the customer's confirmed
|
||||
set-aside starts a **14-day countdown**, visible on Távoli mentés and reversible by recovering with
|
||||
the code, at the end of which the set-aside store AND the hub's retained sealed package are removed
|
||||
TOGETHER (two-phase commit; the hub half is `PurgeSupersededEscrowForCustomer`, hub v0.98.0).
|
||||
Afterwards shape (c) has nothing to compare and the offer ends because the state is right. The
|
||||
recovery screen appears **once per ENTRY into the offered state** (an epoch), the reminder bar is
|
||||
per-visit, the durable opt-out silences the bar only, and **none of the three removes the entry
|
||||
point on Távoli mentés**. While a recovery is outstanding, „Helyreállítási kód létrehozása" is
|
||||
UNAVAILABLE — creating a new code would put the earlier history beyond every shipped path.
|
||||
(traefik/cloudflared/filebrowser) get curated Hungarian display identity from the
|
||||
`inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon);
|
||||
filebrowser is the only infra stack with a customer link (`files.<domain>`).
|
||||
|
||||
@@ -78,6 +78,12 @@ func main() {
|
||||
recoverOffsiteCheck := flag.Bool("recover-offsite-check", false, "R-200 diagnostic: read the customer recovery code from STDIN, recover the offsite repository password from the hub-held sealed escrow via the agent, and report whether it matches the one on disk — BY HASH. Compares, never installs; writes nothing. Exit 0 = match, 2 = clean mismatch, 1 = a step failed.")
|
||||
recoverOffsiteInstall := flag.Bool("recover-offsite-install", false, "R-200: read the customer recovery code from STDIN, recover the offsite repository password, print both hashes, and — with --confirm-install — PLACE it so the existing repository reopens. Without --confirm-install it is a dry run that writes nothing.")
|
||||
confirmInstall := flag.Bool("confirm-install", false, "Required alongside --recover-offsite-install to actually write the recovered repository password. Deliberately a second invocation so the hashes are seen before any write is possible.")
|
||||
// §7.5 (R-241): the operator levers for a running abandonment countdown. The automatic 30-day
|
||||
// ending is deliberately NOT built (R-245) — what is built is the path that actually happens,
|
||||
// which is the customer getting in touch and support needing something to press.
|
||||
abandonStatus := flag.Bool("abandon-status", false, "R-241: print the state of this box's off-site abandonment countdown (set-aside path, due date, days left) and exit. Read-only.")
|
||||
abandonExtend := flag.Int("abandon-extend", 0, "R-241 (operator): extend a running abandonment countdown by N days from now, then exit. Refuses when no countdown is running.")
|
||||
abandonStop := flag.Bool("abandon-stop", false, "R-241 (operator): stop a running abandonment countdown, then exit. The set-aside history is kept and nothing is deleted. Refuses when no countdown is running.")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
@@ -152,6 +158,52 @@ func main() {
|
||||
}))
|
||||
}
|
||||
|
||||
// §7.5 (R-241) — the operator's abandonment levers. Grouped in one block, before the server
|
||||
// starts, exactly like the other CLI subcommands: each loads config + settings, acts, and exits.
|
||||
if *abandonStatus || *abandonExtend > 0 || *abandonStop {
|
||||
cfg, err := config.LoadPermissive(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "abandon: loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
lg := log.New(os.Stderr, "", 0)
|
||||
sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", lg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "abandon: loading settings: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
mgr := backup.NewManager(cfg, sett, lg)
|
||||
st := mgr.AbandonStatus()
|
||||
switch {
|
||||
case *abandonStop:
|
||||
if serr := mgr.StopAbandon(); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "abandon-stop: %v\n", serr)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Printf("abandonment STOPPED — the set-aside history at %s is kept; nothing was deleted\n", st.RepoPath)
|
||||
case *abandonExtend > 0:
|
||||
due, eerr := mgr.ExtendAbandon(*abandonExtend)
|
||||
if eerr != nil {
|
||||
fmt.Fprintf(os.Stderr, "abandon-extend: %v\n", eerr)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Printf("abandonment EXTENDED by %d day(s) — the set-aside history at %s is now deleted on %s\n",
|
||||
*abandonExtend, st.RepoPath, due.Format("2006-01-02"))
|
||||
default:
|
||||
if !st.Active && !st.PurgeRequested {
|
||||
fmt.Println("no abandonment countdown is running on this box")
|
||||
os.Exit(0)
|
||||
}
|
||||
if st.PurgeRequested {
|
||||
fmt.Printf("set-aside history DELETED at %s; awaiting the hub to drop the sealed package\n", st.RepoPath)
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Printf("abandonment countdown RUNNING\n set-aside history : %s\n chosen on : %s\n deleted on : %s\n days left : %d\n",
|
||||
st.RepoPath, st.StartedAt.Format("2006-01-02"), st.DueAt.Format("2006-01-02"), st.DaysLeft)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *printResetCode {
|
||||
cfg, err := config.LoadPermissive(*configPath)
|
||||
if err != nil {
|
||||
@@ -677,8 +729,23 @@ func main() {
|
||||
RecordPresence: sett.SetHubEscrowIdentityPresent,
|
||||
// v0.201.0 (R-222): whether the hub is ALSO keeping an EARLIER sealed package, so the
|
||||
// recovery screen can name that situation instead of blaming the customer's typing.
|
||||
RecordSuperseded: sett.SetHubEscrowSuperseded,
|
||||
Logger: logger,
|
||||
// v0.206.0 (R-241): the superseded fact ALSO closes out an abandonment. When the hub stops
|
||||
// reporting a superseded package, the sealed package that protected the deleted set-aside
|
||||
// history is gone too — both halves are away and the question is finished (Scenario F).
|
||||
// Chained onto the existing recorder rather than added as a second ACK consumer, for the
|
||||
// reason RecordPresence's own comment gives.
|
||||
RecordSuperseded: func(present bool, at string) error {
|
||||
if backupMgr != nil {
|
||||
backupMgr.ClearAbandonPurgeIfConfirmed(present)
|
||||
}
|
||||
return sett.SetHubEscrowSuperseded(present, at)
|
||||
},
|
||||
// v0.206.0 (R-241): the hash the hub's package COVERS. This is the fact the recovery
|
||||
// screen's shape (c) reads, and the comparison against it was already being computed here
|
||||
// on every ACK and discarded. Wired at the same point as the two above, deliberately —
|
||||
// a second wiring site in this file is how six features got built and never reached.
|
||||
RecordEscrowKeyHash: sett.SetHubEscrowKeySHA256,
|
||||
Logger: logger,
|
||||
}
|
||||
// Wire hub verification: update settings when hub reports customer status
|
||||
hubPusher.OnPushResponse = func(resp *report.PushResponse) {
|
||||
@@ -932,6 +999,12 @@ func main() {
|
||||
case "offbox_repo_reset":
|
||||
notifier.PushEvent("offbox_repo_reset", "info",
|
||||
"A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", map[string]string{"renamed_to": renamedTo})
|
||||
case "offbox_abandon_completed":
|
||||
// R-241: the ONLY event in the product that reports a customer's off-site history
|
||||
// being deleted. It is fired after the deletion, not before — the operator wants to
|
||||
// know it happened, and a pre-announcement that then fails would be worse than silence.
|
||||
notifier.PushEvent("offbox_abandon_completed", "info",
|
||||
"A korábbi távoli mentések a türelmi idő lejártával törlésre kerültek, az ügyfél döntése alapján. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítását is kértük.", map[string]string{"deleted_path": renamedTo})
|
||||
}
|
||||
})
|
||||
sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error {
|
||||
@@ -941,6 +1014,26 @@ func main() {
|
||||
}
|
||||
return backupMgr.RunOffboxBackup(ctx)
|
||||
})
|
||||
// R-241 — the abandonment terminal step. DAILY and not on the backup leg, deliberately: it must
|
||||
// run on a box whose off-site tier is NOT configured for runs (an abandoning box may be sitting
|
||||
// with escrow pending), and tying it to the backup leg would make the deletion depend on a
|
||||
// condition that has nothing to do with it.
|
||||
//
|
||||
// It is quiet by construction: on every box with no countdown it returns immediately and logs
|
||||
// nothing, which is asserted (TestR241_Sweep_QuietWhenNothingDue).
|
||||
sched.Daily("offsite-abandon-sweep", "05:10", func(ctx context.Context) error {
|
||||
deleted, err := backupMgr.AbandonSweep(ctx)
|
||||
if err != nil {
|
||||
// NOT a job failure: the countdown stays due and tomorrow's sweep retries. A transport
|
||||
// blip must never silently abandon the abandonment.
|
||||
logger.Printf("[WARN] [offbox] abandonment sweep: %v (the countdown stays due and retries)", err)
|
||||
return nil
|
||||
}
|
||||
if deleted {
|
||||
logger.Printf("[INFO] [offbox] abandonment sweep: the set-aside history was deleted this cycle")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Metrics prune — daily at 04:00
|
||||
|
||||
@@ -103,6 +103,13 @@ type Manager struct {
|
||||
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
|
||||
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
|
||||
offboxSizer func(path string) int64
|
||||
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
|
||||
//
|
||||
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
|
||||
// the only thing in the product that deletes a customer's off-site history; driving it with a
|
||||
// clock keeps that step exercised on every run of the suite instead of once, on real data, by an
|
||||
// operator who then has to hope.
|
||||
offboxNow func() time.Time
|
||||
// offboxEnlargeBlockedNotify (3a), if set, is called ONCE per app that NEWLY enters the
|
||||
// quota-blocked (enlargement-refused) state — edge-triggered against the persisted EnlargedBlocked
|
||||
// set so a nightly schedule can't re-notify a persistently-blocked app (the hub owns cooldown; the
|
||||
|
||||
@@ -344,7 +344,21 @@ func (m *Manager) ResetOrphanedRepo(ctx context.Context) error {
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
return m.resetOrphanedRepo(ctx, base, env, "operator-confirmed (claimed)")
|
||||
if err := m.resetOrphanedRepo(ctx, base, env, "operator-confirmed (claimed)"); err != nil {
|
||||
return err
|
||||
}
|
||||
// R-241: THE COUNTDOWN STARTS HERE AND NOT IN THE SHARED HELPER, deliberately. The helper is also
|
||||
// the UNCLAIMED auto-reset path (Scenario B in ensureOffboxRepo), where nobody decided anything —
|
||||
// an as-delivered box tidying a stranger's leftover store must not put a customer's 14-day
|
||||
// deletion clock on it. Only the confirmed, claimed choice is a decision.
|
||||
//
|
||||
// The path is read back from OrphanedRenamedTo, which the helper has just written.
|
||||
if cur := m.settings.GetOffboxTarget(); cur != nil && cur.OrphanedRenamedTo != "" {
|
||||
m.startAbandonCountdown(cur.OrphanedRenamedTo)
|
||||
} else {
|
||||
m.logger.Printf("[WARN] [offbox] the reset succeeded but no set-aside path was recorded — no countdown started; the old history stays indefinitely")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote single-quotes a path for the remote shell (our repo paths have no single quotes).
|
||||
@@ -381,10 +395,46 @@ func (m *Manager) offboxKeyPath() string { return filepath.Join(m.offboxDir()
|
||||
func (m *Manager) offboxPwPath() string { return filepath.Join(m.offboxDir(), "repo_password") }
|
||||
func (m *Manager) offboxKnownHosts() string { return filepath.Join(m.offboxDir(), "known_hosts") }
|
||||
|
||||
// ErrOffboxSealedPackageHeld is the R-241 mint refusal: this box has no repository password and the
|
||||
// hub is holding a sealed recovery package for it, so minting one would write a key the package does
|
||||
// not cover — orphaning the very history the customer's recovery code protects.
|
||||
//
|
||||
// It is a SENTINEL, not a failure. `ApplyOffsiteTarget` catches it and still configures the transport
|
||||
// (SSH key, known_hosts, host/user/path), because the transport is not the problem and having it is
|
||||
// what lets the recovery screen bring the tier up the moment the key arrives (R-219). What it does
|
||||
// NOT do is let the tier come up under a key nobody escrowed.
|
||||
var ErrOffboxSealedPackageHeld = fmt.Errorf("offbox: the hub holds a sealed recovery package for this box — not minting a repository password over it")
|
||||
|
||||
// ErrOffboxSealedPackageHeld reports whether err is the mint refusal (errors.Is-friendly for callers
|
||||
// that wrap it).
|
||||
func IsOffboxSealedPackageHeld(err error) bool { return errors.Is(err, ErrOffboxSealedPackageHeld) }
|
||||
|
||||
// WriteOffboxSecrets persists the SSH private key + (auto-generated if empty) repo password + the pinned
|
||||
// known-host line as 0600/0644 files in the data dir. The key is provided out-of-band by the operator
|
||||
// (UI), never logged. Returns the repo password so the caller need not read the file. Idempotent: an empty
|
||||
// sshKey/knownHosts leaves the existing file untouched (a re-save of just the target shouldn't wipe keys).
|
||||
// (UI), never logged. Idempotent: an empty sshKey/knownHosts leaves the existing file untouched (a
|
||||
// re-save of just the target shouldn't wipe keys).
|
||||
//
|
||||
// ⚠ R-241 (v0.206.0) — IT NO LONGER MINTS OVER A SEALED PACKAGE, AND THAT IS THE WHOLE FIX.
|
||||
//
|
||||
// Until now the auto-generate branch consulted **one** input: does the file exist. Not the settings,
|
||||
// not the hub's ACK — nothing about whether anything already depended on a different key. Its two
|
||||
// neighbours in this very file, `OffsiteRecoveryOffer` (:1412) and `needsOffsiteCredential` (:1377),
|
||||
// BOTH consult `GetHubEscrowIdentityPresent()`. The same fact was available on three paths and used
|
||||
// on two.
|
||||
//
|
||||
// WHAT THAT COST, measured on the final walk (2026-08-06/07, SPIKE-r241-recovery-offer-2026-08-07):
|
||||
// a rebuilt box's credential self-heal reached here at 03:18:06Z and minted `9b4a9a9d…` over a hub
|
||||
// package sealing `30ef574f…`. The recovery screen then looked, found a key present and no orphan
|
||||
// recorded, and correctly said there was nothing to recover. **The screen was telling the truth; the
|
||||
// lie happened thirty minutes earlier, here.** And the flag was not merely available at that moment —
|
||||
// it was the PRECONDITION of the chain that reached this function: the credential retry only runs
|
||||
// while `needsOffsiteCredential` is true, which requires this exact flag, and the venue logged it at
|
||||
// 02:48:03Z, six ticks before the mint.
|
||||
//
|
||||
// THE GUARD IS DELIBERATELY NARROW — see the Scenario B test. It fires ONLY when a package is held AND
|
||||
// no password exists. A box the hub holds nothing for mints exactly as before, which is every
|
||||
// first-time box in the fleet; widening this to "never mint" would leave a new customer unable to
|
||||
// start, waiting for a package that will never exist.
|
||||
func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error {
|
||||
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
|
||||
return fmt.Errorf("offbox dir: %w", err)
|
||||
@@ -407,8 +457,15 @@ func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error {
|
||||
return fmt.Errorf("offbox known_hosts: %w", err)
|
||||
}
|
||||
}
|
||||
// Auto-generate the repo password once (0600), never log it.
|
||||
// Auto-generate the repo password once (0600), never log it — UNLESS the hub is holding a sealed
|
||||
// package for us (R-241). The transport files above are already written and that is deliberate.
|
||||
if _, err := os.Stat(m.offboxPwPath()); os.IsNotExist(err) {
|
||||
if m.sealedPackageHeld() {
|
||||
m.logger.Printf("[WARN] [offbox] NOT minting a repository password: the hub holds a sealed recovery package for this box, " +
|
||||
"and a fresh key would orphan the history that package protects (R-241). The transport is configured; " +
|
||||
"the tier stays down until the customer's recovery code places the escrowed key.")
|
||||
return ErrOffboxSealedPackageHeld
|
||||
}
|
||||
pw, gerr := generateOffboxPassword()
|
||||
if gerr != nil {
|
||||
return gerr
|
||||
@@ -420,6 +477,37 @@ func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sealedPackageHeld reports the ACK-cached fact that the hub is holding a sealed recovery package for
|
||||
// this box. It is the SAME call `OffsiteRecoveryOffer` and `needsOffsiteCredential` already make —
|
||||
// deliberately, so the three paths can never disagree about it. A nil settings store reads as "no
|
||||
// package": the mint guard must never block a box whose settings could not be read, because that
|
||||
// would turn a transient read failure into a tier that never comes up.
|
||||
func (m *Manager) sealedPackageHeld() bool {
|
||||
return m.settings != nil && m.settings.GetHubEscrowIdentityPresent()
|
||||
}
|
||||
|
||||
// OffboxAwaitingRecoveryKey reports the R-241 holding state: a transport target exists, but no
|
||||
// repository password does, because the hub holds a sealed package and the mint was refused.
|
||||
//
|
||||
// DERIVED, NOT STORED, and that is the §2.1 ruling applied to this field too: a stored flag would be a
|
||||
// second copy of a fact the three inputs already carry, and a second copy is a thing that can drift.
|
||||
// The moment a recovery places the escrowed key, this goes false on its own with nothing to clear.
|
||||
//
|
||||
// ⚠ `t.Enabled` IS LOAD-BEARING, and it was missing in the first draft — caught by the existing
|
||||
// TestOffsiteDeclare_DisabledTargetIsNotStranded rather than by review. A customer who switched
|
||||
// off-site OFF is not awaiting anything, and a box that declares a holding state for a tier nobody
|
||||
// asked for is the R-215 shape (a screen about data the customer did not ask to protect). This is the
|
||||
// SAME Scenario-E carve-out `needsOffsiteCredential` makes two functions above; the two must agree,
|
||||
// and now do.
|
||||
func (m *Manager) OffboxAwaitingRecoveryKey() bool {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || !t.Enabled || !m.sealedPackageHeld() {
|
||||
return false
|
||||
}
|
||||
_, hasPw := m.OffboxRepoPasswordHash()
|
||||
return !hasPw
|
||||
}
|
||||
|
||||
// generateOffboxPassword returns a 256-bit hex repo password.
|
||||
func generateOffboxPassword() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
@@ -489,8 +577,19 @@ var offboxRepoPwPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||
// the repo password to the agent for escrow — the SAME fork-4 enable path a manual config takes. `stage` is
|
||||
// the agent escrow-stage push (nil skips it, e.g. when the agent is unreachable — the run gate still holds).
|
||||
func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTarget, sshKeyPEM, knownHosts string, stage func(ctx context.Context, pw string) error) error {
|
||||
// R-241: the mint refusal is a HOLDING state, not a failure. The transport files were written
|
||||
// before the refusal, so we still record the target — `OffboxConfigured()` stays false because the
|
||||
// password file is absent, which is what keeps runs gated, and the recovery screen can bring the
|
||||
// tier up the instant the escrowed key is placed (R-219's synchronous tier-up).
|
||||
//
|
||||
// Returning the error here instead would leave `needsOffsiteCredential` true forever, so the hub
|
||||
// would re-stage a credential the box had already consumed, on every cycle, for ever.
|
||||
awaitingKey := false
|
||||
if err := m.WriteOffboxSecrets(sshKeyPEM, knownHosts); err != nil {
|
||||
return fmt.Errorf("apply offsite secrets: %w", err)
|
||||
if !IsOffboxSealedPackageHeld(err) {
|
||||
return fmt.Errorf("apply offsite secrets: %w", err)
|
||||
}
|
||||
awaitingKey = true
|
||||
}
|
||||
// Re-apply (v0.109.1 live finding): the bridge rebuilds the target from the descriptor, but the
|
||||
// EXISTING target's custody + runtime status must carry over — EscrowState tracks the REPO PASSWORD
|
||||
@@ -514,6 +613,14 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
|
||||
if err := m.settings.SetOffboxTarget(tgt); err != nil {
|
||||
return fmt.Errorf("apply offsite target: %w", err)
|
||||
}
|
||||
// R-241: nothing to stage — there is no repository password, by design. Say so once, plainly, and
|
||||
// return without touching the escrow. `PushOffboxPasswordForEscrow` would fail on the absent file
|
||||
// anyway; naming the situation beats a misleading "agent unreachable?" warning.
|
||||
if awaitingKey {
|
||||
m.logger.Printf("[INFO] [offbox] apply-offsite: transport configured for %s@%s:%s, tier HELD awaiting the escrowed key "+
|
||||
"(the hub holds a sealed package; no key was minted — R-241)", tgt.User, tgt.Host, tgt.RepoPath)
|
||||
return nil
|
||||
}
|
||||
if stage != nil {
|
||||
// Best-effort: the offbox is configured + pending regardless. A stage-push failure (agent momentarily
|
||||
// unreachable) is logged, not fatal — the escrow can be (re-)staged later (operator ceremony / re-enable).
|
||||
@@ -1313,6 +1420,15 @@ type OffboxReportStatus struct {
|
||||
// rebuilt-and-stranded — and the hub cannot tell them apart. The BOX can, from two local facts it
|
||||
// holds with certainty. So it says so.
|
||||
State string `json:"state,omitempty"`
|
||||
// AbandonPurgeRequested (v0.206.0, R-241) — the customer's abandonment countdown has run out, the
|
||||
// set-aside off-site history HAS been deleted, and the hub is asked to drop the sealed package
|
||||
// that protected it so the two halves go together (Scenario F).
|
||||
//
|
||||
// It is a DECLARATION, on the same principle as State: the box knows it has deleted the store; the
|
||||
// hub cannot see that and must not infer it. It keeps being sent until the ACK stops reporting a
|
||||
// superseded package, so a lost request retries by itself rather than leaving the pair half-gone.
|
||||
// Absent/false on every other box, so a healthy report is byte-identical to v0.205.0's.
|
||||
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok" | "incomplete" (R-203) | "error" | "running"
|
||||
// LastSuccess (R-100) is the last run that SUCCEEDED — the hub's staleness anchor. Absent on a
|
||||
@@ -1331,6 +1447,27 @@ type OffboxReportStatus struct {
|
||||
// That was the last of the four manual interventions the 2026-08-04 drill needed.
|
||||
const OffsiteStateNeedsCredential = "needs_credential"
|
||||
|
||||
// OffsiteStateAwaitingRecoveryKey (v0.206.0, R-241) is the declared HOLDING state: the transport is
|
||||
// configured, but no repository password exists because the hub holds a sealed package and minting
|
||||
// one would orphan the history it protects. The box is not stranded (it has its credential) and not
|
||||
// healthy (it cannot run) — it is waiting for a person with a recovery code.
|
||||
//
|
||||
// WHY IT IS INERT TO EVERY EXISTING HUB READER, established from their code rather than assumed —
|
||||
// the same discipline `OffsiteStateNeedsCredential`'s own note applies:
|
||||
//
|
||||
// - `offsiteheal` acts on EXACTLY ONE string, `needs_credential` ("Everything else … is a no-op"),
|
||||
// so it will not re-stage a credential this box already has;
|
||||
// - `monitor.OffsiteChecker.isStale` returns false unless `Enabled && EscrowState == "escrowed"`,
|
||||
// and this object carries Enabled=false;
|
||||
// - `monitor/offsite_delivery.go` keys on the delivery shape, which is `applied` here (the secret
|
||||
// WAS consumed), and that branch is skipped;
|
||||
// - an unknown `state` string is ignored by encoding/json on an older hub.
|
||||
//
|
||||
// So this needs NO hub change to be safe. It does mean a held box raises no alarm — which is R-243,
|
||||
// filed and deliberately not widened here; the difference from R-241 is that this state is now
|
||||
// VISIBLE to the customer instead of silent.
|
||||
const OffsiteStateAwaitingRecoveryKey = "awaiting_recovery_key"
|
||||
|
||||
// needsOffsiteCredential is the stranded-rebuild predicate. BOTH facts are required and neither is
|
||||
// sufficient on its own — this is the whole correctness of the feature:
|
||||
//
|
||||
@@ -1408,13 +1545,49 @@ func (m *Manager) needsOffsiteCredential(t *settings.OffboxTarget) bool {
|
||||
//
|
||||
// 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.
|
||||
// ── SHAPE (c), v0.206.0, R-241 — THE DISCRIMINATOR THAT ANSWERS THE REAL QUESTION ───────────────
|
||||
//
|
||||
// Shapes (a) and (b) are both PROXIES for one question — *does the hub hold a package for a key other
|
||||
// than the one I am using?* — and both have now been wrong, in opposite directions:
|
||||
//
|
||||
// - (a) "no repository password" went false the moment anything minted one. Before v0.206.0's mint
|
||||
// guard that happened by itself, ~30 minutes after a rebuild, and the customer who logged in the
|
||||
// next morning never saw the screen. That is R-241.
|
||||
// - (b) "a run proved the repo will not open" is unreachable on exactly that box: the only producer
|
||||
// of RepoState=="orphaned" is ensureOffboxRepo, which is downstream of the escrow gate in
|
||||
// runOffboxBackup, and the escrow can never confirm while the hub's package covers a different
|
||||
// key. Self-locking.
|
||||
//
|
||||
// (c) asks the question directly, from two facts the box already holds: the hash the hub's package
|
||||
// covers (ACK-cached) and the hash of the key on disk. **This comparison was already computed on every
|
||||
// ACK and thrown away** — see settings.HubEscrowKeySHA256.
|
||||
//
|
||||
// ⚠ §7.2 — WHAT A STALE OR ABSENT READING RESOLVES TO, decided deliberately rather than by default:
|
||||
//
|
||||
// - **A KNOWN DIFFERENCE OFFERS, however old the reading.** Age is not gated on. Both sides of the
|
||||
// comparison are local; only the hub's half can be stale, and what the hub holds does not change
|
||||
// without a ceremony THIS BOX runs — which refreshes the hash on the next ACK. Gating on age would
|
||||
// add a second failure mode (a box offline from the hub silently stops offering) to fix a window
|
||||
// that closes itself. `HubEscrowKeyCheckedAt` is persisted for diagnosis, not as a gate.
|
||||
// - **AN ABSENT HASH FALLS BACK TO (a)/(b), it does not offer.** "" is what the hub sends for a
|
||||
// legacy hash-less package — one that provably seals no repository password. There is nothing for
|
||||
// (c) to compare, and offering on it would put a permanent screen in front of every legacy box.
|
||||
// This is the one place where "not knowing" resolves to silence, and it does so because an empty
|
||||
// hash is not an unknown: it is the hub positively saying the package covers no key.
|
||||
//
|
||||
// So: fail-closed (offer) on a known difference; fall back on a hash never learned. Pinned by
|
||||
// TestR241_ScenarioD_* and TestR241_StaleComparison_*.
|
||||
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 {
|
||||
localHash, hasLocal := m.OffboxRepoPasswordHash()
|
||||
if !hasLocal {
|
||||
return true // (a) no repository password at all — the pristine rebuilt box
|
||||
}
|
||||
if hubHash, _ := m.settings.GetHubEscrowKeySHA256(); hubHash != "" && hubHash != localHash {
|
||||
return true // (c) the hub's package covers a DIFFERENT key than the one we are using
|
||||
}
|
||||
return m.OffboxOrphaned() // (b) a password exists but the inherited history will not open under it
|
||||
}
|
||||
|
||||
@@ -1438,6 +1611,13 @@ func (m *Manager) OffsiteRecoveryOffer() bool {
|
||||
// never emitted a disabled object before.
|
||||
func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
// R-241: the HOLDING state is declared before the enabled/disabled split, because a held target IS
|
||||
// enabled — the customer wants off-site backups; what is missing is the key. Reported with
|
||||
// Enabled=false so every existing hub reader treats it exactly as the stranded declaration (see
|
||||
// OffsiteStateAwaitingRecoveryKey), while the string names the difference for anything that looks.
|
||||
if m.OffboxAwaitingRecoveryKey() {
|
||||
return &OffboxReportStatus{Enabled: false, State: OffsiteStateAwaitingRecoveryKey, EscrowState: t.EscrowState}
|
||||
}
|
||||
if t == nil || !t.Enabled {
|
||||
if m.needsOffsiteCredential(t) {
|
||||
return &OffboxReportStatus{Enabled: false, State: OffsiteStateNeedsCredential}
|
||||
@@ -1448,6 +1628,7 @@ func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
|
||||
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
|
||||
LastSuccess: t.LastSuccess,
|
||||
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
|
||||
AbandonPurgeRequested: t.AbandonPurgeRequested, // R-241: declared until the hub drops the package
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// ABANDONMENT — deciding to give up the old off-site history is a finishable thing (R-241, v0.206.0).
|
||||
//
|
||||
// THE PROBLEM THIS SOLVES. `resetOrphanedRepo` renamed the remote store aside and touched neither the
|
||||
// escrow nor the key, so the hub went on holding a sealed package for a key the box no longer used.
|
||||
// Shape (c) compares those two, finds them different, and offers recovery — correctly, and for ever.
|
||||
// A customer who has already said "I do not want the old data" would be asked again at every login.
|
||||
//
|
||||
// THE OPERATOR'S RULING (2026-08-07) is that the answer is NOT a "they decided" flag. A flag would
|
||||
// leave the box in a state that is genuinely wrong (the hub holding a package for a key nobody uses)
|
||||
// and paper over it. Instead the decision starts a **14-day countdown**, at the end of which the
|
||||
// set-aside store and the sealed package that protects it are removed TOGETHER — after which there is
|
||||
// nothing left to compare and nothing left to ask about. **Fix the state, do not remember that it is
|
||||
// wrong.**
|
||||
//
|
||||
// THE GRACE IS REAL, NOT DECORATIVE. The recovery offer stays reachable for the whole window; that is
|
||||
// the change-of-mind path (Scenario G). A grace period during which recovery is impossible would be
|
||||
// theatre.
|
||||
|
||||
// abandonGraceDays is the countdown the operator set. Reminders fire at 5, 3 and 1 days (see
|
||||
// AbandonRemindAtDays) — visible, reversible, and running out in public.
|
||||
const abandonGraceDays = 14
|
||||
|
||||
// AbandonGraceDays is the exported grace, for the customer-facing copy. The confirmation screen must
|
||||
// state the SAME number the countdown uses — a literal typed into prose is how a promise drifts away
|
||||
// from the code that keeps it.
|
||||
const AbandonGraceDays = abandonGraceDays
|
||||
|
||||
// AbandonRemindAtDays are the remaining-day marks at which the abandoning box reminds the customer.
|
||||
// Descending, so the surface can pick the first one that has been reached.
|
||||
var AbandonRemindAtDays = []int{5, 3, 1}
|
||||
|
||||
// abandonNow is the countdown's clock seam. Tests inject; nil → time.Now. It exists so the terminal
|
||||
// step can be driven deterministically — §7.4 forbids shortening a live timer to watch it fire,
|
||||
// because that is how an irreversible step gets tested once and regretted once.
|
||||
func (m *Manager) abandonNow() time.Time {
|
||||
if m.offboxNow != nil {
|
||||
return m.offboxNow()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// SetOffboxClock injects the abandonment clock (tests only).
|
||||
func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
|
||||
|
||||
// startAbandonCountdown records the decision and the date the terminal step will run. Called by
|
||||
// resetOrphanedRepo AFTER the move-aside has succeeded — a countdown started before the store has
|
||||
// actually moved would count down to deleting a path that does not exist.
|
||||
func (m *Manager) startAbandonCountdown(setAsidePath string) {
|
||||
now := m.abandonNow().UTC()
|
||||
due := now.AddDate(0, 0, abandonGraceDays)
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonStartedAt = now.Format(time.RFC3339)
|
||||
o.AbandonAt = due.Format(time.RFC3339)
|
||||
o.AbandonRepoPath = setAsidePath
|
||||
o.AbandonPurgeRequested = false
|
||||
}); err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [offbox] abandonment countdown started: the set-aside history at %s and the hub's sealed package "+
|
||||
"are removed together on %s (%d days). The recovery screen stays reachable until then.",
|
||||
setAsidePath, due.Format("2006-01-02"), abandonGraceDays)
|
||||
}
|
||||
|
||||
// AbandonState is the surface's read model. Zero value = nothing in progress.
|
||||
type AbandonState struct {
|
||||
Active bool // a countdown is running
|
||||
StartedAt time.Time //
|
||||
DueAt time.Time // when the terminal step runs
|
||||
DaysLeft int // ceiling, so "0 days left" only ever means "today"
|
||||
RepoPath string // the set-aside store awaiting deletion
|
||||
PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package
|
||||
}
|
||||
|
||||
// AbandonStatus reports the countdown for the UI and the report. It never mutates.
|
||||
func (m *Manager) AbandonStatus() AbandonState {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil {
|
||||
return AbandonState{}
|
||||
}
|
||||
st := AbandonState{RepoPath: t.AbandonRepoPath, PurgeRequested: t.AbandonPurgeRequested}
|
||||
if t.AbandonAt == "" {
|
||||
return st
|
||||
}
|
||||
due, err := time.Parse(time.RFC3339, t.AbandonAt)
|
||||
if err != nil {
|
||||
// A malformed stamp must not silently mean "never due" — that would strand the store for ever
|
||||
// with a countdown the customer can see and nothing behind it.
|
||||
m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", t.AbandonAt, err)
|
||||
return st
|
||||
}
|
||||
st.Active, st.DueAt = true, due
|
||||
if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil {
|
||||
st.StartedAt = s
|
||||
}
|
||||
// Ceiling: a countdown with 30 minutes left says "1 day", never "0". Zero is reserved for due.
|
||||
remaining := due.Sub(m.abandonNow())
|
||||
if remaining <= 0 {
|
||||
st.DaysLeft = 0
|
||||
} else {
|
||||
st.DaysLeft = int((remaining + 24*time.Hour - time.Nanosecond) / (24 * time.Hour))
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// CancelAbandon stops a running countdown — the change-of-mind path (Scenario G). Called when a
|
||||
// recovery succeeds: the customer has their code after all, and the history they were about to give
|
||||
// up is exactly what the code opens.
|
||||
//
|
||||
// It clears the schedule but KEEPS AbandonRepoPath, so the set-aside store remains nameable on the
|
||||
// backups page. Nothing has been deleted at this point by construction — the terminal step is the
|
||||
// only thing that deletes, and it has not run.
|
||||
func (m *Manager) CancelAbandon(reason string) {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested) {
|
||||
return // nothing running — silent, so a healthy recovery does not log about a countdown
|
||||
}
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonStartedAt, o.AbandonAt = "", ""
|
||||
o.AbandonPurgeRequested = false
|
||||
}); err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] could not cancel the abandonment countdown: %v", err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [offbox] abandonment countdown CANCELLED (%s) — the set-aside history at %s is kept and nothing was deleted", reason, t.AbandonRepoPath)
|
||||
}
|
||||
|
||||
// AbandonSweep is the daily terminal step. It is the ONLY thing in the product that deletes a
|
||||
// customer's off-site history, and it does so on a date the customer was shown.
|
||||
//
|
||||
// ⚠ IT REMOVES BOTH HALVES OR NEITHER — Scenario F. The set-aside store and the sealed package that
|
||||
// protects it are the two halves of one thing; removing only the store leaves the hub holding a
|
||||
// package for a key that opens nothing, and removing only the package leaves ciphertext nobody can
|
||||
// ever decrypt. Either is a state that asks a question nobody can answer.
|
||||
//
|
||||
// The two halves cannot be made atomic across two machines, so this is a two-phase commit with the
|
||||
// STORE FIRST and a durable marker: delete the remote store, record AbandonPurgeRequested, and keep
|
||||
// declaring it in the report until the hub's ACK stops reporting a superseded package. A crash
|
||||
// between the two leaves the marker set and the next sweep re-declares — it never leaves the pair
|
||||
// half-removed and silent.
|
||||
//
|
||||
// Returns (deleted, err). deleted=false with err=nil is the normal "nothing due" case.
|
||||
func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
|
||||
st := m.AbandonStatus()
|
||||
// Phase 2 outstanding: the store is gone, the hub has not confirmed. Re-declare and wait.
|
||||
if st.PurgeRequested {
|
||||
m.logger.Printf("[DEBUG] [offbox] abandonment: the set-aside store is deleted; awaiting the hub to drop the sealed package")
|
||||
return false, nil
|
||||
}
|
||||
if !st.Active || st.DueAt.After(m.abandonNow()) {
|
||||
return false, nil // not due — quiet by construction on every healthy box
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || t.AbandonRepoPath == "" {
|
||||
m.logger.Printf("[WARN] [offbox] abandonment is due but no set-aside path is recorded — nothing deleted; clearing the countdown so it does not retry for ever")
|
||||
m.CancelAbandon("no set-aside path recorded")
|
||||
return false, fmt.Errorf("abandonment due with no recorded path")
|
||||
}
|
||||
port := t.Port
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
m.logger.Printf("[WARN] [offbox] abandonment DUE — deleting the set-aside off-site history at %s (chosen by the customer on %s; this is irreversible)",
|
||||
t.AbandonRepoPath, st.StartedAt.Format("2006-01-02"))
|
||||
out, err := m.sshRunner()(ctx, t.Host, t.User, port, m.offboxKeyPath(), m.offboxKnownHosts(),
|
||||
"rm -rf "+shellQuote(t.AbandonRepoPath))
|
||||
if err != nil {
|
||||
// NOT cleared: a transport failure must retry tomorrow, not silently abandon the abandonment.
|
||||
m.logger.Printf("[ERROR] [offbox] abandonment: deleting the set-aside history failed — the countdown stays due and retries: %v: %s", err, truncate(out))
|
||||
return false, fmt.Errorf("delete set-aside history: %w", err)
|
||||
}
|
||||
// Phase 1 done. Record it durably BEFORE anything else, so a crash here re-declares rather than
|
||||
// forgetting that the store is already gone.
|
||||
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonPurgeRequested = true
|
||||
o.AbandonAt = "" // the schedule has fired; the marker now drives the rest
|
||||
}); uerr != nil {
|
||||
m.logger.Printf("[ERROR] [offbox] abandonment: the store was deleted but the marker could not be saved — the hub's package may outlive it: %v", uerr)
|
||||
return true, uerr
|
||||
}
|
||||
m.logger.Printf("[INFO] [offbox] abandonment: set-aside history deleted; requesting the hub to drop the sealed package that protected it")
|
||||
if m.offboxOrphanEvent != nil {
|
||||
m.offboxOrphanEvent("offbox_abandon_completed", t.AbandonRepoPath)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ClearAbandonPurgeIfConfirmed closes the two-phase commit: once the hub's ACK stops reporting a
|
||||
// superseded package, both halves are gone and the abandonment is finished. Called from the ACK path.
|
||||
//
|
||||
// This is what makes §2.1 work without a "they decided" flag: afterwards the hub holds a package for
|
||||
// the key the box is actually using (or none at all), shape (c) has nothing to compare, and the
|
||||
// recovery offer falls silent on its own — because the state is right, not because something is
|
||||
// remembering that it once was not.
|
||||
func (m *Manager) ClearAbandonPurgeIfConfirmed(supersededPresent bool) {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || !t.AbandonPurgeRequested || supersededPresent {
|
||||
return
|
||||
}
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonPurgeRequested = false
|
||||
o.AbandonRepoPath = ""
|
||||
o.AbandonStartedAt = ""
|
||||
o.OrphanedRenamedTo = ""
|
||||
}); err != nil {
|
||||
m.logger.Printf("[WARN] [offbox] could not close out the abandonment: %v", err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [offbox] abandonment COMPLETE — the set-aside history and the sealed package that protected it are both gone; nothing further to ask about")
|
||||
}
|
||||
|
||||
// ── OPERATOR CONTROL (§7.5) ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The automatic 30-day abandonment is deliberately NOT built (see R-245). What IS built is the path
|
||||
// that actually happens: **the customer gets in touch.** Someone who cannot find their recovery code
|
||||
// rings support, and support needs something to press — either "give them longer" or "stop it".
|
||||
//
|
||||
// Both live on the controller CLI rather than in the customer UI, deliberately: extending a deletion
|
||||
// the customer asked for is an operator judgement, not a self-service button, and a customer who
|
||||
// wants it stopped already has the self-service route — they recover with their code, which cancels
|
||||
// it (Scenario G).
|
||||
|
||||
// ExtendAbandon pushes the terminal step out by `days` from NOW. Returns the new due date.
|
||||
//
|
||||
// It refuses when no countdown is running: extending nothing would print a reassuring date for a
|
||||
// deletion that was never scheduled, which is the kind of comfort this project keeps removing.
|
||||
func (m *Manager) ExtendAbandon(days int) (time.Time, error) {
|
||||
if days <= 0 {
|
||||
return time.Time{}, fmt.Errorf("the extension must be a positive number of days")
|
||||
}
|
||||
st := m.AbandonStatus()
|
||||
if !st.Active {
|
||||
if st.PurgeRequested {
|
||||
return time.Time{}, fmt.Errorf("too late: the set-aside history has already been deleted and only the sealed package is still being removed")
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("no abandonment countdown is running on this box — nothing to extend")
|
||||
}
|
||||
due := m.abandonNow().UTC().AddDate(0, 0, days)
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonAt = due.Format(time.RFC3339)
|
||||
}); err != nil {
|
||||
return time.Time{}, fmt.Errorf("record the extension: %w", err)
|
||||
}
|
||||
m.logger.Printf("[WARN] [offbox] abandonment EXTENDED by an operator: the set-aside history at %s is now deleted on %s (was %s)",
|
||||
st.RepoPath, due.Format("2006-01-02"), st.DueAt.Format("2006-01-02"))
|
||||
return due, nil
|
||||
}
|
||||
|
||||
// StopAbandon cancels the countdown outright — the operator's version of Scenario G, for the
|
||||
// customer who telephoned instead of finding their code. The set-aside history is kept and nothing
|
||||
// is deleted; it is `CancelAbandon` with an operator's reason and a refusal when nothing is running,
|
||||
// so an operator never gets a silent no-op they might read as success.
|
||||
func (m *Manager) StopAbandon() error {
|
||||
st := m.AbandonStatus()
|
||||
if !st.Active {
|
||||
if st.PurgeRequested {
|
||||
return fmt.Errorf("too late: the set-aside history has already been deleted")
|
||||
}
|
||||
return fmt.Errorf("no abandonment countdown is running on this box — nothing to stop")
|
||||
}
|
||||
m.CancelAbandon("stopped by an operator")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-241 — abandoning starts a countdown that ENDS THE QUESTION (Scenarios E, F, G).
|
||||
//
|
||||
// The countdown is driven by an injected clock throughout. §7.4 forbids shortening a live timer to
|
||||
// watch the terminal step fire: it is the only thing in the product that deletes a customer's
|
||||
// off-site history, and a step tested once on real data is a step regretted once.
|
||||
|
||||
// abandonFixture: an orphaned, configured box holding a key, with the hub holding a package for a
|
||||
// DIFFERENT key — i.e. shape (c) is live and the customer is being offered recovery.
|
||||
// Returns the manager and a recorder of every remote shell command issued.
|
||||
type sshRecorder struct{ cmds []string }
|
||||
|
||||
func (r *sshRecorder) run(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
|
||||
r.cmds = append(r.cmds, remoteCmd)
|
||||
return []byte(""), nil
|
||||
}
|
||||
|
||||
func abandonFixture(t *testing.T, now time.Time) (*Manager, *settings.Settings, *sshRecorder) {
|
||||
t.Helper()
|
||||
m, sett, _ := offerFixture(t, true)
|
||||
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, now.Format(time.RFC3339)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.EscrowState = "escrowed"
|
||||
o.RepoState = "orphaned"
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := &sshRecorder{}
|
||||
m.SetOffboxSSH(rec.run)
|
||||
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { return []byte(""), nil })
|
||||
m.SetOffboxClock(func() time.Time { return now })
|
||||
return m, sett, rec
|
||||
}
|
||||
|
||||
// ── SCENARIO E — abandoning sets aside, keeps the package, starts a countdown, stays reversible ──
|
||||
func TestR241_ScenarioE_AbandonStartsAReversibleCountdown(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, _, rec := abandonFixture(t, start)
|
||||
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatalf("abandon: %v", err)
|
||||
}
|
||||
// The store was MOVED, not deleted — no rm anywhere in this phase.
|
||||
joined := strings.Join(rec.cmds, " | ")
|
||||
if !strings.Contains(joined, "mv ") {
|
||||
t.Errorf("the old store must be moved aside; commands were: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "rm -rf") {
|
||||
t.Fatalf("NOTHING may be deleted when the customer abandons — only at the end of the grace. Commands: %s", joined)
|
||||
}
|
||||
st := m.AbandonStatus()
|
||||
if !st.Active {
|
||||
t.Fatal("a countdown must be running after an abandonment")
|
||||
}
|
||||
if got := st.DueAt.Sub(start); got != abandonGraceDays*24*time.Hour {
|
||||
t.Errorf("countdown length = %v, want %d days", got, abandonGraceDays)
|
||||
}
|
||||
if st.DaysLeft != abandonGraceDays {
|
||||
t.Errorf("DaysLeft = %d, want %d", st.DaysLeft, abandonGraceDays)
|
||||
}
|
||||
if st.RepoPath == "" {
|
||||
t.Error("the set-aside path must be recorded, or the terminal step has nothing to delete")
|
||||
}
|
||||
// THE GRACE IS REAL: the recovery offer stays reachable for the whole window. A grace in which
|
||||
// recovery is impossible would be decorative.
|
||||
if !m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("the recovery offer MUST stay reachable during the grace — that is the change-of-mind path")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO G — changing your mind inside the window ───────────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: make the countdown uncancellable (delete the body of CancelAbandon). The countdown then
|
||||
// survives a successful recovery and this test fails — a customer who proved they hold their code
|
||||
// would still have the history deleted under them.
|
||||
func TestR241_ScenarioG_RecoveryInsideTheWindowCancelsTheCountdown(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, _, _ := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
day6 := start.AddDate(0, 0, 6)
|
||||
m.SetOffboxClock(func() time.Time { return day6 })
|
||||
if st := m.AbandonStatus(); !st.Active || st.DaysLeft != 8 {
|
||||
t.Fatalf("precondition: day 6 of 14 should leave 8 days, got %+v", st)
|
||||
}
|
||||
pathBefore := m.AbandonStatus().RepoPath
|
||||
|
||||
m.CancelAbandon("the customer recovered with their code")
|
||||
|
||||
st := m.AbandonStatus()
|
||||
if st.Active {
|
||||
t.Fatal("a countdown must be cancellable — the customer found their code")
|
||||
}
|
||||
if st.RepoPath != pathBefore {
|
||||
t.Errorf("the set-aside store must stay NAMEABLE after a cancel: got %q want %q", st.RepoPath, pathBefore)
|
||||
}
|
||||
// And a sweep now deletes nothing, on any later date.
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) })
|
||||
deleted, err := m.AbandonSweep(context.Background())
|
||||
if err != nil || deleted {
|
||||
t.Fatalf("a cancelled countdown must never delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO F — the countdown ends the question, and removes BOTH halves ────────────────────────
|
||||
//
|
||||
// RED-PROOF (store half): make AbandonSweep skip the rm. The first assertion fails.
|
||||
// RED-PROOF (package half): drop AbandonPurgeRequested from OffboxReportStatus. The declaration
|
||||
// assertion fails — the hub is never asked and the package outlives the store for ever.
|
||||
func TestR241_ScenarioF_TerminalStepRemovesBothHalvesTogether(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, sett, rec := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setAside := m.AbandonStatus().RepoPath
|
||||
|
||||
// Not due yet — nothing happens, quietly.
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 13) })
|
||||
if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil {
|
||||
t.Fatalf("day 13 must not delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
|
||||
// Due.
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 14).Add(time.Minute) })
|
||||
rec.cmds = nil
|
||||
deleted, err := m.AbandonSweep(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("terminal step: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatal("the terminal step must delete when due")
|
||||
}
|
||||
// HALF 1: the store is gone.
|
||||
joined := strings.Join(rec.cmds, " | ")
|
||||
if !strings.Contains(joined, "rm -rf") || !strings.Contains(joined, setAside) {
|
||||
t.Fatalf("the set-aside store at %s must be deleted; commands: %s", setAside, joined)
|
||||
}
|
||||
// HALF 2: the hub is ASKED for the package, and keeps being asked until it confirms.
|
||||
st := m.OffboxReportStatus()
|
||||
if st == nil || !st.AbandonPurgeRequested {
|
||||
t.Fatalf("the report must declare abandon_purge_requested until the hub drops the package, got %+v", st)
|
||||
}
|
||||
// It repeats — a lost request must retry rather than leave the pair half-removed.
|
||||
if d2, err2 := m.AbandonSweep(context.Background()); d2 || err2 != nil {
|
||||
t.Fatalf("a second sweep must be a quiet no-op while awaiting the hub: deleted=%v err=%v", d2, err2)
|
||||
}
|
||||
if st2 := m.OffboxReportStatus(); st2 == nil || !st2.AbandonPurgeRequested {
|
||||
t.Fatal("the declaration must persist across sweeps until confirmed")
|
||||
}
|
||||
|
||||
// The hub confirms by no longer reporting a superseded package → the question is over.
|
||||
m.ClearAbandonPurgeIfConfirmed(false)
|
||||
if got := sett.GetOffboxTarget(); got.AbandonPurgeRequested || got.AbandonRepoPath != "" || got.AbandonAt != "" {
|
||||
t.Errorf("the abandonment must be fully closed out, got %+v", got)
|
||||
}
|
||||
if st3 := m.OffboxReportStatus(); st3 != nil && st3.AbandonPurgeRequested {
|
||||
t.Error("the declaration must stop once the hub has confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
// While the hub STILL reports a superseded package, the close-out must not fire — otherwise the box
|
||||
// stops asking and the package outlives the store silently, which is exactly half of Scenario F.
|
||||
func TestR241_PurgeIsNotClosedOutWhileThePackageRemains(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, sett, _ := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
|
||||
if _, err := m.AbandonSweep(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.ClearAbandonPurgeIfConfirmed(true) // the hub STILL holds a retained package
|
||||
if !sett.GetOffboxTarget().AbandonPurgeRequested {
|
||||
t.Fatal("the request must stand while the hub still reports a superseded package")
|
||||
}
|
||||
}
|
||||
|
||||
// A transport failure during the terminal step must NOT clear the countdown — it retries tomorrow.
|
||||
// Silently abandoning the abandonment would leave the store for ever with nothing counting down.
|
||||
func TestR241_TerminalStepFailureKeepsTheCountdownDue(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, sett, _ := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
|
||||
return []byte("ssh: connect to host nas.local port 22: No route to host"), context.DeadlineExceeded
|
||||
})
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
|
||||
deleted, err := m.AbandonSweep(context.Background())
|
||||
if deleted || err == nil {
|
||||
t.Fatalf("a failed deletion must be reported, not swallowed: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
got := sett.GetOffboxTarget()
|
||||
if got.AbandonAt == "" || got.AbandonPurgeRequested {
|
||||
t.Fatalf("a failed terminal step must leave the countdown DUE and unrequested, got %+v", got)
|
||||
}
|
||||
if !m.AbandonStatus().Active {
|
||||
t.Error("the countdown must still be active so tomorrow's sweep retries")
|
||||
}
|
||||
}
|
||||
|
||||
// Quiet by construction: a box with no countdown does no work and says nothing (§ the daily job's
|
||||
// own contract). Asserted, because "it probably does nothing" is how a sweep with a bug hides.
|
||||
func TestR241_Sweep_QuietWhenNothingDue(t *testing.T) {
|
||||
m, _, rec := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC))
|
||||
deleted, err := m.AbandonSweep(context.Background())
|
||||
if deleted || err != nil {
|
||||
t.Fatalf("a box with no countdown must be a pure no-op: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
if len(rec.cmds) != 0 {
|
||||
t.Fatalf("a no-op sweep must issue no remote commands, got %v", rec.cmds)
|
||||
}
|
||||
if m.AbandonStatus().Active {
|
||||
t.Error("no countdown should be reported")
|
||||
}
|
||||
}
|
||||
|
||||
// The UNCLAIMED auto-reset must NOT start a customer countdown — nobody decided anything there.
|
||||
// An as-delivered box tidying a stranger's leftover store must not put a 14-day deletion clock on it.
|
||||
func TestR241_UnclaimedAutoResetStartsNoCountdown(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, _, _ := abandonFixture(t, start)
|
||||
t2 := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t2)
|
||||
if err := m.resetOrphanedRepo(context.Background(), base, env, "auto (unclaimed)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.AbandonStatus().Active {
|
||||
t.Fatal("the unclaimed auto-reset must not start a customer abandonment countdown")
|
||||
}
|
||||
}
|
||||
|
||||
// ── §7.5 — THE OPERATOR LEVERS ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The automatic 30-day ending is deliberately NOT built (R-245). These are what IS built: the path
|
||||
// that actually happens is the customer telephoning, and support needs something to press.
|
||||
func TestR241_OperatorCanExtendARunningCountdown(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, _, rec := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
day10 := start.AddDate(0, 0, 10)
|
||||
m.SetOffboxClock(func() time.Time { return day10 })
|
||||
|
||||
due, err := m.ExtendAbandon(30)
|
||||
if err != nil {
|
||||
t.Fatalf("extend: %v", err)
|
||||
}
|
||||
if want := day10.AddDate(0, 0, 30); !due.Equal(want) {
|
||||
t.Errorf("new due = %v, want %v (from NOW, not from the old date)", due, want)
|
||||
}
|
||||
// The original date has passed and nothing is deleted, because the extension moved it.
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
|
||||
rec.cmds = nil
|
||||
if deleted, serr := m.AbandonSweep(context.Background()); deleted || serr != nil {
|
||||
t.Fatalf("an extended countdown must not fire on the old date: deleted=%v err=%v", deleted, serr)
|
||||
}
|
||||
if len(rec.cmds) != 0 {
|
||||
t.Fatalf("nothing may be deleted after an extension, got %v", rec.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR241_OperatorCanStopARunningCountdown(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, _, rec := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.StopAbandon(); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if m.AbandonStatus().Active {
|
||||
t.Fatal("the countdown must be stopped")
|
||||
}
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) })
|
||||
rec.cmds = nil
|
||||
if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil {
|
||||
t.Fatalf("a stopped countdown must never delete: deleted=%v err=%v", deleted, err)
|
||||
}
|
||||
if len(rec.cmds) != 0 {
|
||||
t.Fatalf("a stopped countdown must issue no remote commands, got %v", rec.cmds)
|
||||
}
|
||||
}
|
||||
|
||||
// Both levers REFUSE when nothing is running. A silent no-op is the thing an operator most easily
|
||||
// mistakes for success — they would tell the customer it was handled.
|
||||
func TestR241_OperatorLeversRefuseWhenNothingIsRunning(t *testing.T) {
|
||||
m, _, _ := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC))
|
||||
if _, err := m.ExtendAbandon(30); err == nil {
|
||||
t.Error("extending a countdown that is not running must be an error, never a quiet success")
|
||||
}
|
||||
if err := m.StopAbandon(); err == nil {
|
||||
t.Error("stopping a countdown that is not running must be an error, never a quiet success")
|
||||
}
|
||||
if _, err := m.ExtendAbandon(0); err == nil {
|
||||
t.Error("a non-positive extension must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// Once the store is deleted there is nothing left to extend or stop, and saying otherwise would be
|
||||
// the worst kind of reassurance: an operator telling a customer their data is safe when it is gone.
|
||||
func TestR241_OperatorLeversRefuseAfterTheDeletion(t *testing.T) {
|
||||
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
|
||||
m, _, _ := abandonFixture(t, start)
|
||||
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
|
||||
if _, err := m.AbandonSweep(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.ExtendAbandon(30); err == nil {
|
||||
t.Error("extending after the deletion must be refused — there is nothing left to save")
|
||||
}
|
||||
if err := m.StopAbandon(); err == nil {
|
||||
t.Error("stopping after the deletion must be refused — there is nothing left to save")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-241 — THE MINT GUARD. This file is the session's headline test.
|
||||
//
|
||||
// The defect, measured on the final walk (SPIKE-r241-recovery-offer-2026-08-07): a rebuilt box's
|
||||
// credential self-heal reached WriteOffboxSecrets at 03:18:06Z and minted a fresh repository password
|
||||
// over a hub package sealing a DIFFERENT key. The recovery screen then correctly reported that there
|
||||
// was nothing recoverable under the key the box held. The screen was honest; the minting was not.
|
||||
//
|
||||
// Scenario A asserts the key is NOT written. Scenario B asserts the guard is narrow enough that a
|
||||
// first-time box still starts — the guard's own failure mode, and the one an over-broad fix produces.
|
||||
|
||||
// mintGuardManager builds a Manager with NO offbox secrets written, so the mint branch is live.
|
||||
// hubHoldsPackage sets the ACK-cached fact the guard consults.
|
||||
func mintGuardManager(t *testing.T, hubHoldsPackage bool) (*Manager, *settings.Settings, string) {
|
||||
t.Helper()
|
||||
logger := log.New(os.Stderr, "", 0)
|
||||
dataDir := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dataDir
|
||||
cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys")
|
||||
m := NewManager(cfg, sett, logger)
|
||||
if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m, sett, filepath.Join(dataDir, "offbox", "repo_password")
|
||||
}
|
||||
|
||||
// ── SCENARIO A — the box does not mint over a sealed package ────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: delete the `if m.sealedPackageHeld()` block in WriteOffboxSecrets. The password file
|
||||
// then exists and this test fails on the first assertion — which is exactly the 03:18:06Z event.
|
||||
func TestR241_ScenarioA_NoMintWhenHubHoldsSealedPackage(t *testing.T) {
|
||||
m, _, pwPath := mintGuardManager(t, true)
|
||||
|
||||
err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey")
|
||||
|
||||
if !IsOffboxSealedPackageHeld(err) {
|
||||
t.Fatalf("want the sealed-package refusal sentinel, got %v", err)
|
||||
}
|
||||
// THE ASSERTION THAT IS THE WHOLE SESSION: no key on disk.
|
||||
if _, serr := os.Stat(pwPath); !os.IsNotExist(serr) {
|
||||
t.Fatalf("R-241 REGRESSION: a repository password was minted over the hub's sealed package (stat err=%v)", serr)
|
||||
}
|
||||
// The transport IS still written — the refusal is a holding state, not a failure. Without this the
|
||||
// recovery screen could not bring the tier up when the key arrives (R-219).
|
||||
for _, f := range []string{"ssh_key", "known_hosts"} {
|
||||
if _, serr := os.Stat(filepath.Join(filepath.Dir(pwPath), f)); serr != nil {
|
||||
t.Errorf("transport file %s should still be written on the refusal path: %v", f, serr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A at the APPLY level — the path the self-heal actually takes. ApplyOffsiteTarget must
|
||||
// swallow the sentinel, record the target, and NOT stage an escrow.
|
||||
func TestR241_ScenarioA_ApplyOffsiteTargetHoldsInsteadOfMinting(t *testing.T) {
|
||||
m, sett, pwPath := mintGuardManager(t, true)
|
||||
|
||||
staged := 0
|
||||
stage := func(ctx context.Context, pw string) error { staged++; return nil }
|
||||
|
||||
tgt := &settings.OffboxTarget{Enabled: true, Host: "box.example", Port: 23, User: "u1", RepoPath: "/home/felhom-repo"}
|
||||
if err := m.ApplyOffsiteTarget(context.Background(), tgt, "KEYMATERIAL", "box.example ssh-ed25519 HOSTKEY", stage); err != nil {
|
||||
t.Fatalf("apply should SUCCEED into the holding state, not fail: %v", err)
|
||||
}
|
||||
if _, serr := os.Stat(pwPath); !os.IsNotExist(serr) {
|
||||
t.Fatalf("R-241 REGRESSION: apply minted a repository password over the sealed package")
|
||||
}
|
||||
if staged != 0 {
|
||||
t.Errorf("nothing may be staged for escrow — there is no key to escrow; staged=%d", staged)
|
||||
}
|
||||
// The target is recorded, so the box stops declaring needs_credential and the hub stops re-staging.
|
||||
if got := sett.GetOffboxTarget(); got == nil {
|
||||
t.Fatal("the transport target must be recorded, or the hub re-stages a consumed credential forever")
|
||||
}
|
||||
// Runs stay gated: no password file ⇒ not configured.
|
||||
if m.OffboxConfigured() {
|
||||
t.Error("OffboxConfigured must be false while the key is awaited — runs must not proceed")
|
||||
}
|
||||
// And the box says so, in the state the hub reads.
|
||||
if !m.OffboxAwaitingRecoveryKey() {
|
||||
t.Error("OffboxAwaitingRecoveryKey should be true in the holding state")
|
||||
}
|
||||
st := m.OffboxReportStatus()
|
||||
if st == nil || st.State != OffsiteStateAwaitingRecoveryKey {
|
||||
t.Fatalf("want declared state %q, got %+v", OffsiteStateAwaitingRecoveryKey, st)
|
||||
}
|
||||
if st.Enabled {
|
||||
t.Error("the declared holding object must carry Enabled=false so existing hub readers stay inert")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO B — a box the hub holds nothing for still mints, exactly as today ───────────────────
|
||||
//
|
||||
// RED-PROOF: widen the guard to `if true` (or drop the GetHubEscrowIdentityPresent() conjunct in
|
||||
// sealedPackageHeld). A first-time box then cannot start, and this test fails — the failure mode an
|
||||
// over-broad fix produces, which is why the guard is written as a conjunction.
|
||||
func TestR241_ScenarioB_FirstTimeBoxStillMints(t *testing.T) {
|
||||
m, _, pwPath := mintGuardManager(t, false) // the hub holds nothing for us
|
||||
|
||||
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
|
||||
t.Fatalf("a first-time box must mint exactly as before, got %v", err)
|
||||
}
|
||||
pw, rerr := os.ReadFile(pwPath)
|
||||
if rerr != nil {
|
||||
t.Fatalf("a first-time box must get a repository password: %v", rerr)
|
||||
}
|
||||
if !offboxRepoPwPattern.Match(pw) {
|
||||
t.Errorf("minted password is not the expected 64-hex shape")
|
||||
}
|
||||
if m.OffboxAwaitingRecoveryKey() {
|
||||
t.Error("a box with no sealed package is not awaiting anything")
|
||||
}
|
||||
}
|
||||
|
||||
// The guard must not fire once a key EXISTS — a healthy box re-applying its target (a quota bump,
|
||||
// a hub re-push) must be untouched, package or no package. This is the idempotency half.
|
||||
func TestR241_ExistingKeyIsNeverDisturbed(t *testing.T) {
|
||||
m, _, pwPath := mintGuardManager(t, false)
|
||||
if err := m.WriteOffboxSecrets("K", "kh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(pwPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Now the hub starts holding a package (the ceremony ran) and the target is re-applied.
|
||||
if err := m.settings.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.WriteOffboxSecrets("K2", "kh2"); err != nil {
|
||||
t.Fatalf("a re-apply on a box that already has a key must not be refused: %v", err)
|
||||
}
|
||||
after, err := os.ReadFile(pwPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(before) != string(after) {
|
||||
t.Error("the existing repository password must never be rotated by an apply")
|
||||
}
|
||||
if m.OffboxAwaitingRecoveryKey() {
|
||||
t.Error("a box holding its key is not awaiting one")
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-safe: an unreadable settings store must not block a tier. A transient read failure turning
|
||||
// into a permanently-held tier is a worse defect than the one being fixed.
|
||||
func TestR241_NilSettingsDoesNotBlockTheMint(t *testing.T) {
|
||||
logger := log.New(os.Stderr, "", 0)
|
||||
dataDir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dataDir
|
||||
m := NewManager(cfg, nil, logger)
|
||||
if m.sealedPackageHeld() {
|
||||
t.Fatal("a nil settings store must read as 'no package held' — fail toward letting the box work")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E's carve-out, pinned for the HOLDING state too. A customer who switched off-site off is
|
||||
// not awaiting a recovery key, and must not declare one. The first draft of
|
||||
// OffboxAwaitingRecoveryKey omitted `t.Enabled` and TestOffsiteDeclare_DisabledTargetIsNotStranded
|
||||
// caught it; this test pins the same invariant from the new predicate's own side, so a future edit
|
||||
// to THIS function fails here rather than in a neighbouring file.
|
||||
func TestR241_DisabledTargetIsNotAwaitingAnything(t *testing.T) {
|
||||
m, sett, _ := mintGuardManager(t, true) // the hub holds a package, and there is no key
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.OffboxAwaitingRecoveryKey() {
|
||||
t.Fatal("a deliberately DISABLED target must never declare the holding state (Scenario E)")
|
||||
}
|
||||
if st := m.OffboxReportStatus(); st != nil {
|
||||
t.Fatalf("a disabled target must stay silent in the report, got %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-241 shape (c) — the recovery offer is driven by the comparison the box already makes.
|
||||
//
|
||||
// Scenarios C and D from the task, plus §7.2's two staleness cases. The point of shape (c) is that
|
||||
// it asks the real question — *does the hub hold a package for a key other than the one I am
|
||||
// using?* — rather than the two proxies that have each now been wrong in opposite directions.
|
||||
|
||||
// offerFixture builds a manager holding a repository password, with the hub's cached facts settable.
|
||||
// Returns the local key's hash so a test can make the hub's hash match or differ deliberately.
|
||||
func offerFixture(t *testing.T, hubHoldsPackage bool) (*Manager, *settings.Settings, string) {
|
||||
t.Helper()
|
||||
m, sett, pwPath := mintGuardManager(t, false) // mint freely first
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(pwPath); err != nil {
|
||||
t.Fatalf("fixture should hold a repository password: %v", err)
|
||||
}
|
||||
local, ok := m.OffboxRepoPasswordHash()
|
||||
if !ok {
|
||||
t.Fatal("fixture should be able to hash its own key")
|
||||
}
|
||||
if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m, sett, local
|
||||
}
|
||||
|
||||
const otherKeyHash = "9b4a9a9dcec7898e7544f35b18470aac77c3d9064e5d3a302897617fa62edd65"
|
||||
|
||||
// ── SCENARIO C — a differing key offers recovery, whatever the reason for the difference ────────
|
||||
//
|
||||
// This is the venue's exact state on 2026-08-07: a key present, no orphan recorded, escrow stuck
|
||||
// pending — and before shape (c), silence.
|
||||
func TestR241_ScenarioC_DifferingKeyOffersRecovery(t *testing.T) {
|
||||
m, sett, local := offerFixture(t, true)
|
||||
if local == otherKeyHash {
|
||||
t.Fatal("fixture precondition: the local key must differ from the hub's")
|
||||
}
|
||||
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T03:28:03Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Neither proxy fires: a key EXISTS (so not shape (a)) and nothing is orphaned (so not shape (b)).
|
||||
if _, ok := m.OffboxRepoPasswordHash(); !ok {
|
||||
t.Fatal("precondition: shape (a) must be false")
|
||||
}
|
||||
if m.OffboxOrphaned() {
|
||||
t.Fatal("precondition: shape (b) must be false")
|
||||
}
|
||||
if !m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("R-241: the hub holds a package for a DIFFERENT key and the screen was not offered — this is the defect")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCENARIO D — a healthy box is never offered recovery ────────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: drop the `hubHash != localHash` conjunct in shape (c) (make it `hubHash != ""`). A
|
||||
// healthy box is then offered recovery forever, and this test fails — which is how a screen stops
|
||||
// being read.
|
||||
func TestR241_ScenarioD_MatchingKeyOffersNothing(t *testing.T) {
|
||||
m, sett, local := offerFixture(t, true)
|
||||
if err := sett.SetHubEscrowKeySHA256(local, "2026-08-07T09:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("a box whose key the hub's package covers must never be offered recovery")
|
||||
}
|
||||
}
|
||||
|
||||
// A box the hub holds nothing for is never offered, even if a stale hash lingers in settings. Fact 1
|
||||
// stays required — the spike's comment block calls dropping it "the plausible wrong fix".
|
||||
func TestR241_ShapeC_NeverHadOffsiteIsStillSilent(t *testing.T) {
|
||||
m, sett, _ := offerFixture(t, false) // the hub holds NOTHING
|
||||
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T09:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("a box that never had off-site backups must never be greeted by a recovery screen")
|
||||
}
|
||||
}
|
||||
|
||||
// ── §7.2 — the staleness decision, both halves ──────────────────────────────────────────────────
|
||||
|
||||
// A KNOWN DIFFERENCE OFFERS, however old the reading. Age is deliberately not gated on: gating would
|
||||
// make a box offline from the hub silently stop offering, which is the failure this session exists
|
||||
// to remove.
|
||||
func TestR241_StaleComparison_KnownDifferenceStillOffers(t *testing.T) {
|
||||
m, sett, _ := offerFixture(t, true)
|
||||
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2020-01-01T00:00:00Z"); err != nil { // ancient
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("a known difference must offer regardless of how old the reading is (§7.2)")
|
||||
}
|
||||
}
|
||||
|
||||
// AN ABSENT HASH FALLS BACK TO (a)/(b) — it does not offer. "" is the hub positively saying its
|
||||
// package seals no repository password (legacy hash-less escrow); there is nothing to compare, and
|
||||
// offering would put a permanent screen in front of every legacy box.
|
||||
func TestR241_StaleComparison_AbsentHashFallsBackAndDoesNotOffer(t *testing.T) {
|
||||
m, sett, _ := offerFixture(t, true)
|
||||
if err := sett.SetHubEscrowKeySHA256("", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("a hash never learned must fall back to (a)/(b), not offer (§7.2)")
|
||||
}
|
||||
// ...and the fallback still works: mark the repo orphaned and shape (b) fires as before.
|
||||
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoState = "orphaned" }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("shape (b) must still work when the hub's hash was never learned")
|
||||
}
|
||||
}
|
||||
|
||||
// Shape (a) is untouched: a box with no key at all is still offered, which is the pristine rebuild.
|
||||
func TestR241_ShapeAStillWorks(t *testing.T) {
|
||||
m, sett, _ := offerFixture(t, true)
|
||||
if err := os.Remove(filepath.Join(m.cfg.Paths.DataDir, "offbox", "repo_password")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T09:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.OffsiteRecoveryOffer() {
|
||||
t.Fatal("shape (a) — no repository password at all — must still offer")
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,23 @@ type EscrowAutoConfirmer struct {
|
||||
// second wiring point in main.go is how this project accumulated six features that were built and
|
||||
// never wired. nil → not recorded (older wiring, tests).
|
||||
RecordSuperseded func(present bool, at string) error
|
||||
Logger *log.Logger
|
||||
// RecordEscrowKeyHash persists the ACK's `restic_pw_sha256` — the hash of the repository password
|
||||
// the hub's sealed package COVERS — with the time it was recorded (v0.206.0, R-241).
|
||||
//
|
||||
// WHY IT LIVES HERE, and it is the point of the whole change: the comparison between this hash and
|
||||
// the local key is ALREADY MADE in Reconcile, on every ACK, and has been since SLICE 3 — and the
|
||||
// result was used for one warning line and then discarded. On the final-walk venue that line
|
||||
// (03:28:03Z) was the correct answer to the recovery screen's real question, thirty-five minutes
|
||||
// before the customer looked at a screen that could not see it.
|
||||
//
|
||||
// Recorded UNCONDITIONALLY, before every gate below, for exactly the reason RecordPresence is: the
|
||||
// box that needs this most is the rebuilt one with no configured target, on which `Pending()` and
|
||||
// `Escrowed()` are both false and Reconcile used to return immediately. nil → not recorded.
|
||||
RecordEscrowKeyHash func(sha, checkedAt string) error
|
||||
// Now returns the current time; nil → time.Now. Injected so the persisted "when we last heard"
|
||||
// stamp is testable without a sleep.
|
||||
Now func() time.Time
|
||||
Logger *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
|
||||
@@ -130,6 +146,14 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
|
||||
c.logf("[WARN] [escrow-confirm] could not record the hub's superseded-package state (present=%v): %v", es.SupersededPresent, err)
|
||||
}
|
||||
}
|
||||
// R-241: persist the hash the hub's package covers, with the moment we heard it. Same discipline,
|
||||
// same place, same reason as the two above — and this one is the fact the recovery screen has been
|
||||
// unable to see. A record failure is logged, never swallowed, and never blocks the auto-confirm.
|
||||
if c.RecordEscrowKeyHash != nil {
|
||||
if err := c.RecordEscrowKeyHash(es.ResticPwSHA256, c.now().UTC().Format(time.RFC3339)); err != nil {
|
||||
c.logf("[WARN] [escrow-confirm] could not record the hub's escrowed-key hash (%.12s…): %v", es.ResticPwSHA256, err)
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.sealedAt = es.CreatedAt // in-memory only; a timestamp, never a secret
|
||||
c.mu.Unlock()
|
||||
@@ -216,3 +240,11 @@ func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) {
|
||||
}
|
||||
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash)
|
||||
}
|
||||
|
||||
// now returns the injected clock or time.Now.
|
||||
func (c *EscrowAutoConfirmer) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
@@ -66,3 +66,61 @@ func TestMainWiresRecordPresence(t *testing.T) {
|
||||
t.Fatal("EscrowAutoConfirmer is constructed WITHOUT RecordPresence — the box will never learn the hub holds its recovery package, and R-204 item 4 ships inert")
|
||||
}
|
||||
}
|
||||
|
||||
// confirmerFieldIsWired is the generalised form of the walk above: it reports whether
|
||||
// `EscrowAutoConfirmer{...}` in main.go assigns `field`, and whether the literal was found at all.
|
||||
// Comments are dropped on purpose, so a commented-out assignment cannot satisfy it.
|
||||
func confirmerFieldIsWired(t *testing.T, field string) (found, sawLiteral bool) {
|
||||
t.Helper()
|
||||
const mainPath = "../../cmd/controller/main.go"
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, mainPath, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v — the wiring of %s is now unasserted", mainPath, err, field)
|
||||
}
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := ""
|
||||
switch tt := lit.Type.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
name = tt.Sel.Name
|
||||
case *ast.Ident:
|
||||
name = tt.Name
|
||||
}
|
||||
if name != "EscrowAutoConfirmer" {
|
||||
return true
|
||||
}
|
||||
sawLiteral = true
|
||||
for _, el := range lit.Elts {
|
||||
kv, ok := el.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == field {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found, sawLiteral
|
||||
}
|
||||
|
||||
// TestMainWiresRecordEscrowKeyHash — R-241's seam-discipline test, and it matters more than most.
|
||||
//
|
||||
// `RecordEscrowKeyHash` is nil-able exactly like `RecordPresence`. Unwired, the confirmer still
|
||||
// compiles, every test in this package still passes, the auto-confirm still works — and
|
||||
// `OffsiteRecoveryOffer`'s shape (c) reads an empty hash forever, silently falling back to the two
|
||||
// proxies that R-241 proved insufficient. **The fix would ship inert, in precisely the shape the
|
||||
// spike found: a correct answer computed and kept nowhere.**
|
||||
func TestMainWiresRecordEscrowKeyHash(t *testing.T) {
|
||||
found, sawLiteral := confirmerFieldIsWired(t, "RecordEscrowKeyHash")
|
||||
if !sawLiteral {
|
||||
t.Fatal("no EscrowAutoConfirmer composite literal found in main.go — did the wiring move? This test can no longer see it")
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("EscrowAutoConfirmer is constructed WITHOUT RecordEscrowKeyHash — the hub's escrowed-key hash is never persisted, so the recovery screen's shape (c) can never fire and R-241 ships inert")
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,32 @@ type Settings struct {
|
||||
HubEscrowSupersededPresent bool `json:"hub_escrow_superseded_present,omitempty"`
|
||||
HubEscrowSupersededAt string `json:"hub_escrow_superseded_at,omitempty"`
|
||||
|
||||
// HubEscrowKeySHA256 / HubEscrowKeyCheckedAt (v0.206.0, R-241) cache the report ACK's
|
||||
// `escrow.restic_pw_sha256` — the sha256 of the repository password the hub's sealed package
|
||||
// COVERS — and when it was last recorded.
|
||||
//
|
||||
// ⚠ THIS FACT WAS ALREADY COMPUTED EVERY CYCLE AND KEPT NOWHERE, and that is the whole of R-241's
|
||||
// second half. `report.EscrowAutoConfirmer.Reconcile` has compared this hash against the local key
|
||||
// on every ACK since SLICE 3; on the final-walk venue it logged, at 03:28:03Z and thirty-five
|
||||
// minutes before the customer looked, *"the hub's escrow blob does not cover the CURRENT repo
|
||||
// password (hub hash 30ef574f… != local 9b4a9a9d…)"* — and then dropped it on the floor. The
|
||||
// recovery screen, evaluating in the same process, went on asking a question that could not see it.
|
||||
//
|
||||
// It is the ONE fact that answers the screen's real question directly: *does the hub hold a package
|
||||
// for a key other than the one I am using?* Shape (a) ("no key at all") and shape (b) ("a run
|
||||
// proved the repo will not open") are both proxies for it, and both have now been wrong in
|
||||
// opposite directions — (a) goes false the moment anything mints, (b) is unreachable while the
|
||||
// escrow is pending.
|
||||
//
|
||||
// NON-SECRET: the sha256 of a 256-bit random secret is non-reversible and is already logged and
|
||||
// served over the ACK. It must still never reach a customer-facing message.
|
||||
//
|
||||
// EMPTY IS MEANINGFUL AND IS NOT "THEY DIFFER": the hub sends "" for a legacy hash-less escrow
|
||||
// (a package that provably seals no repository password). Shape (c) requires a NON-EMPTY hash —
|
||||
// see backup.OffsiteRecoveryOffer for the staleness reasoning.
|
||||
HubEscrowKeySHA256 string `json:"hub_escrow_key_sha256,omitempty"`
|
||||
HubEscrowKeyCheckedAt string `json:"hub_escrow_key_checked_at,omitempty"` // RFC3339
|
||||
|
||||
// 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
|
||||
@@ -93,6 +119,37 @@ type Settings struct {
|
||||
// happened. It is deliberately NOT cleared by anything except the situation ending.
|
||||
RecoveryNoticePostponed bool `json:"recovery_notice_postponed,omitempty"`
|
||||
|
||||
// ── THE OFFER EPOCH (v0.206.0, R-241) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// RecoveryOfferEpoch counts ENTRIES into the offered state; RecoveryOfferActive is the edge
|
||||
// detector that makes counting possible. Together they turn "once ever" into "once per entry".
|
||||
//
|
||||
// ⚠ WHY THIS IS NOT THE FLAG §2.1 FORBIDS. That ruling forbids remembering *that the customer
|
||||
// decided* so the screen can be suppressed while the underlying state stays wrong. These record
|
||||
// something else entirely: WHICH SITUATION a dismissal was about. A box that abandons, is rebuilt
|
||||
// months later and enters the offered state afresh is in a NEW situation, and a dismissal of the
|
||||
// old one must not swallow it. `RecoveryNoticePostponed` alone did exactly that — it was
|
||||
// deliberately never cleared by anything.
|
||||
//
|
||||
// RecoveryNoticePostponedEpoch / RecoveryRemindOptOutEpoch record the epoch a choice was made in.
|
||||
// The full page interrupts while `Epoch > PostponedEpoch`, and the banner reminds while
|
||||
// `Epoch > OptOutEpoch` — so a fresh entry resets BOTH by arithmetic, with nothing to clear and
|
||||
// nothing that can be forgotten to clear (§7.1 condition 2).
|
||||
RecoveryOfferEpoch int `json:"recovery_offer_epoch,omitempty"`
|
||||
RecoveryOfferActive bool `json:"recovery_offer_active,omitempty"`
|
||||
// RecoveryOfferSince (RFC3339) stamps when the CURRENT epoch began — the anchor the undecided
|
||||
// reminders escalate against (§2.3). Re-stamped on every entry, so a box that settles and is later
|
||||
// rebuilt starts its reminder ladder again rather than inheriting an old one.
|
||||
RecoveryOfferSince string `json:"recovery_offer_since,omitempty"`
|
||||
RecoveryNoticePostponedEpoch int `json:"recovery_notice_postponed_epoch,omitempty"`
|
||||
// RecoveryRemindOptOutEpoch — the customer ticked „ne emlékeztessen újra" in this epoch.
|
||||
//
|
||||
// It silences THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts
|
||||
// no countdown, and the entry point on the backups page never goes away because of it — silencing
|
||||
// a reminder is not the same as removing the route, and this session exists partly because a route
|
||||
// disappeared.
|
||||
RecoveryRemindOptOutEpoch int `json:"recovery_remind_optout_epoch,omitempty"`
|
||||
|
||||
// Cached state
|
||||
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
||||
|
||||
@@ -292,6 +349,34 @@ type OffboxTarget struct {
|
||||
// NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
|
||||
// that turns out false is worse on this surface than saying less.
|
||||
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
|
||||
|
||||
// ── ABANDONMENT (v0.206.0, R-241) — deciding to give up the old history is a FINISHABLE thing ──
|
||||
//
|
||||
// Until now "set aside" renamed the remote store and touched nothing else, so the hub went on
|
||||
// holding a package for a key the box no longer used — and the recovery question came back at
|
||||
// every login, for ever. The operator's ruling (2026-08-07) is that the answer is NOT to remember
|
||||
// that the customer decided, but to reach a state where the question stops arising: **fix the
|
||||
// state, do not remember that it is wrong.** These fields are that countdown.
|
||||
//
|
||||
// ⚠ THEY ARE NOT A "THEY DECIDED" FLAG, and the distinction is the ruling. Nothing here suppresses
|
||||
// the recovery offer — the offer stays reachable for the whole 14 days, because a grace period in
|
||||
// which nothing can be done is decorative (Scenario E). What ends the offer is the TERMINAL STEP
|
||||
// removing the store and the sealed package together, after which shape (c) has nothing to
|
||||
// compare and falls silent on its own.
|
||||
//
|
||||
// AbandonStartedAt / AbandonAt are RFC3339; AbandonRepoPath is the move-aside path the terminal
|
||||
// step deletes. AbandonRepoPath is separate from OrphanedRenamedTo deliberately: that field is
|
||||
// overwritten by the NEXT reset, and a countdown that loses the path it is counting down to would
|
||||
// delete nothing and report success.
|
||||
AbandonStartedAt string `json:"abandon_started_at,omitempty"`
|
||||
AbandonAt string `json:"abandon_at,omitempty"`
|
||||
AbandonRepoPath string `json:"abandon_repo_path,omitempty"`
|
||||
// AbandonPurgeRequested — the remote store IS deleted and the hub has not yet confirmed the sealed
|
||||
// package is gone. It is a two-phase-commit marker for an operation in flight, NOT a memory of a
|
||||
// decision: it is set by the terminal step, declared in the report, and cleared the moment the
|
||||
// hub's ACK stops reporting a superseded package. If the two halves could not be removed together
|
||||
// this marker is what makes the box keep asking until they are (Scenario F).
|
||||
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
|
||||
}
|
||||
|
||||
// CrossDriveBackup configures per-app backup to a secondary drive.
|
||||
@@ -2052,3 +2137,132 @@ func (s *Settings) GetIntegrationsForTarget(target string) map[string]Integratio
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetHubEscrowKeySHA256 returns the sha256 the hub's sealed package covers, and when it was last
|
||||
// recorded from an ACK ("" / "" when never learned). See the field comment: empty is "the hub never
|
||||
// told us", not "they match".
|
||||
func (s *Settings) GetHubEscrowKeySHA256() (sha, checkedAt string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.HubEscrowKeySHA256, s.HubEscrowKeyCheckedAt
|
||||
}
|
||||
|
||||
// SetHubEscrowKeySHA256 records the ACK's `escrow.restic_pw_sha256` and stamps when. Same
|
||||
// last-write-wins mirror discipline as SetHubEscrowIdentityPresent — the hub is the authority on
|
||||
// what the hub holds, and a re-ceremony legitimately moves this.
|
||||
//
|
||||
// The timestamp is refreshed on EVERY ack that carries a hash, including an unchanged one, because
|
||||
// it records *when we last heard*, not *when it last changed* — a distinction this project has got
|
||||
// wrong before (R-100: LastRun recorded an attempt and was read as a result). A no-op save is
|
||||
// avoided only when BOTH the hash and the day are unchanged, so an idle box does not rewrite
|
||||
// settings.json every fifteen minutes.
|
||||
func (s *Settings) SetHubEscrowKeySHA256(sha, checkedAt string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.HubEscrowKeySHA256 == sha && sameDayStamp(s.HubEscrowKeyCheckedAt, checkedAt) {
|
||||
return nil
|
||||
}
|
||||
s.HubEscrowKeySHA256, s.HubEscrowKeyCheckedAt = sha, checkedAt
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// sameDayStamp reports whether two RFC3339 stamps fall on the same UTC day — the write-damper for
|
||||
// SetHubEscrowKeySHA256. Unparseable stamps are treated as different, so a malformed value always
|
||||
// gets replaced rather than sticking.
|
||||
func sameDayStamp(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
ta, erra := time.Parse(time.RFC3339, a)
|
||||
tb, errb := time.Parse(time.RFC3339, b)
|
||||
if erra != nil || errb != nil {
|
||||
return false
|
||||
}
|
||||
return ta.UTC().Format("2006-01-02") == tb.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ── The recovery-offer epoch (v0.206.0, R-241) ─────────────────────────────────
|
||||
|
||||
// RecoveryOfferView is the epoch read model.
|
||||
type RecoveryOfferView struct {
|
||||
Epoch int
|
||||
Active bool
|
||||
PostponedEpoch int
|
||||
OptOutEpoch int
|
||||
Since string // RFC3339 — when the current epoch began
|
||||
}
|
||||
|
||||
// GetRecoveryOfferView returns the epoch state in one lock.
|
||||
func (s *Settings) GetRecoveryOfferView() RecoveryOfferView {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return RecoveryOfferView{
|
||||
Epoch: s.RecoveryOfferEpoch,
|
||||
Active: s.RecoveryOfferActive,
|
||||
PostponedEpoch: s.RecoveryNoticePostponedEpoch,
|
||||
OptOutEpoch: s.RecoveryRemindOptOutEpoch,
|
||||
Since: s.RecoveryOfferSince,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncRecoveryOfferEpoch advances the epoch on the EDGE into the offered state and returns the view.
|
||||
// Idempotent: called on every landing page load, it writes only on a transition.
|
||||
//
|
||||
// THE LEGACY MIGRATION IS HERE AND HAPPENS ONCE. A box upgrading from ≤v0.205.0 may carry
|
||||
// `RecoveryNoticePostponed=true` — a customer who already said "most nem" about the situation they
|
||||
// are still in. Re-interrupting them on upgrade would be a regression dressed as a feature, so the
|
||||
// first epoch inherits that choice and the legacy flag is retired. A box that never dismissed keeps
|
||||
// PostponedEpoch 0 and is interrupted, which is correct.
|
||||
func (s *Settings) SyncRecoveryOfferEpoch(offered bool, now time.Time) (RecoveryOfferView, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
changed := false
|
||||
if offered && !s.RecoveryOfferActive {
|
||||
s.RecoveryOfferActive = true
|
||||
s.RecoveryOfferEpoch++
|
||||
s.RecoveryOfferSince = now.UTC().Format(time.RFC3339)
|
||||
if s.RecoveryOfferEpoch == 1 && s.RecoveryNoticePostponed {
|
||||
s.RecoveryNoticePostponedEpoch = 1 // inherit the pre-epoch dismissal, once
|
||||
s.RecoveryNoticePostponed = false
|
||||
}
|
||||
changed = true
|
||||
} else if !offered && s.RecoveryOfferActive {
|
||||
s.RecoveryOfferActive = false
|
||||
changed = true
|
||||
}
|
||||
view := RecoveryOfferView{
|
||||
Epoch: s.RecoveryOfferEpoch, Active: s.RecoveryOfferActive,
|
||||
PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch,
|
||||
Since: s.RecoveryOfferSince,
|
||||
}
|
||||
if !changed {
|
||||
return view, nil
|
||||
}
|
||||
return view, s.save()
|
||||
}
|
||||
|
||||
// PostponeRecoveryNoticeForEpoch records "most nem" for the CURRENT epoch. It suppresses the
|
||||
// full-page interruption only — the banner and the backups-area entry point both survive.
|
||||
func (s *Settings) PostponeRecoveryNoticeForEpoch() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.RecoveryNoticePostponedEpoch == s.RecoveryOfferEpoch {
|
||||
return nil
|
||||
}
|
||||
s.RecoveryNoticePostponedEpoch = s.RecoveryOfferEpoch
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// OptOutRecoveryRemindersForEpoch records „ne emlékeztessen újra" for the CURRENT epoch. It silences
|
||||
// the BANNER and nothing else: no countdown starts, nothing is abandoned, and the entry point on the
|
||||
// backups page remains. A later fresh entry into the offered state advances the epoch and reminds
|
||||
// again — the §7.1 conditions, satisfied by arithmetic rather than by remembering to clear a flag.
|
||||
func (s *Settings) OptOutRecoveryRemindersForEpoch() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.RecoveryRemindOptOutEpoch == s.RecoveryOfferEpoch {
|
||||
return nil
|
||||
}
|
||||
s.RecoveryRemindOptOutEpoch = s.RecoveryOfferEpoch
|
||||
return s.save()
|
||||
}
|
||||
@@ -206,6 +206,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: isSecure,
|
||||
})
|
||||
|
||||
// R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder
|
||||
// is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the
|
||||
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
|
||||
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
|
||||
|
||||
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
|
||||
|
||||
// Redirect to ?next= target if provided, otherwise to dashboard
|
||||
|
||||
@@ -176,6 +176,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||
|
||||
data := s.baseData("dashboard", "Vezérlőpult")
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||
data["Stacks"] = deployedStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||
@@ -312,6 +313,7 @@ func (s *Server) launcherApps() []LauncherApp {
|
||||
// "Indítópult megosztása" share state (v0.165.0) for the modal.
|
||||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("launcher", "Indítópult")
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["Apps"] = s.launcherApps()
|
||||
|
||||
// Share modal state. The share URL is built from the request Host at render time (the canonical
|
||||
@@ -925,6 +927,21 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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()
|
||||
// R-241 (v0.206.0): the abandonment countdown, stated on the page the customer chose it from.
|
||||
// It is shown for the WHOLE window, not only at the reminder marks — the bar on other pages is a
|
||||
// nudge, this is the record, and a deletion date must be findable on a quiet day too.
|
||||
if s.backupMgr != nil {
|
||||
if st := s.backupMgr.AbandonStatus(); st.Active {
|
||||
data["AbandonActive"] = true
|
||||
data["AbandonDaysLeft"] = st.DaysLeft
|
||||
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||
} else if st.PurgeRequested {
|
||||
// The store is deleted and the sealed package is on its way out. Say so rather than
|
||||
// showing nothing, or the page silently loses a thing the customer was watching.
|
||||
data["AbandonPurging"] = true
|
||||
}
|
||||
}
|
||||
s.addRecoveryBanner(data, r)
|
||||
s.executeTemplate(w, r, "backups_remote", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-193 — THE RECOVERY SCREEN. A customer whose machine was rebuilt has everything they need to get
|
||||
@@ -44,14 +45,67 @@ func (s *Server) recoveryOffer() bool {
|
||||
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.
|
||||
// recoveryBannerCookie is the PER-VISIT banner dismissal (v0.206.0, R-241, §7.1). It is a browser
|
||||
// SESSION cookie — no MaxAge, no Expires — and it is cleared on login, so "I have seen this" lasts
|
||||
// for the visit and the reminder is back next time.
|
||||
//
|
||||
// It is deliberately NOT persisted in settings. A dismissal that outlived the visit would be a
|
||||
// permanently-dismissed banner over data still sitting there, which is the failure Scenario H exists
|
||||
// to catch. The durable, deliberate version of "stop reminding me" is the tick-box (§7.1), and that
|
||||
// one is an explicit decision the customer takes, not a click to get a bar off the screen.
|
||||
const recoveryBannerCookie = "felhom_recovery_banner"
|
||||
|
||||
// recoveryOfferEpoch advances and returns the offer-epoch view. Called from the landing-page
|
||||
// interception, which runs on every dashboard/launcher GET — so the edge is detected promptly without
|
||||
// a second scheduler job. Writes only on a transition.
|
||||
func (s *Server) recoveryOfferEpoch() settings.RecoveryOfferView {
|
||||
if s.settings == nil {
|
||||
return settings.RecoveryOfferView{}
|
||||
}
|
||||
v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer(), s.recoveryNow())
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] [web] recovery: could not persist the offer epoch: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages.
|
||||
//
|
||||
// ⚠ ONCE PER ENTRY INTO THE OFFERED STATE, NOT ONCE EVER (v0.206.0, §7.1). "Most nem" used to set a
|
||||
// flag that nothing ever cleared, so a box that abandoned its history and was rebuilt months later —
|
||||
// a genuinely NEW situation — would never show the page again. The epoch fixes that by arithmetic:
|
||||
// a fresh entry advances it past the dismissal, with nothing to clear.
|
||||
//
|
||||
// It still suppresses the full page ONLY. `recoveryOffer` stays true, so the banner and the
|
||||
// backups-area entry point both survive, and that asymmetry is the whole of Scenario E.
|
||||
func (s *Server) recoveryInterrupts() bool {
|
||||
if !s.recoveryOffer() {
|
||||
// ⚠ THE EPOCH IS SYNCED FIRST AND UNCONDITIONALLY, and that ordering is the whole mechanism.
|
||||
// The first draft returned early when the offer was false, so the FALLING edge was never
|
||||
// recorded — `RecoveryOfferActive` stayed true through a settled period and the next entry
|
||||
// therefore counted as a continuation rather than a new situation. The page never came back.
|
||||
// Caught by TestR241_FullPageAppearsOncePerEntryNotOnceEver, not by review.
|
||||
if s.settings == nil {
|
||||
return s.recoveryOffer()
|
||||
}
|
||||
v := s.recoveryOfferEpoch()
|
||||
if !v.Active {
|
||||
return false
|
||||
}
|
||||
return s.settings == nil || !s.settings.GetRecoveryNoticePostponed()
|
||||
return v.Epoch > v.PostponedEpoch
|
||||
}
|
||||
|
||||
// recoveryBannerVisible reports whether the per-visit reminder bar should render on ordinary pages.
|
||||
// Three conditions, and each is a separate lever: the situation holds, the customer has not opted out
|
||||
// of reminders for THIS epoch, and they have not clicked the bar away during this visit.
|
||||
func (s *Server) recoveryBannerVisible(r *http.Request) bool {
|
||||
if !s.recoveryOffer() || s.settings == nil {
|
||||
return false
|
||||
}
|
||||
if c, err := r.Cookie(recoveryBannerCookie); err == nil && c.Value == "1" {
|
||||
return false // dismissed for this visit only
|
||||
}
|
||||
v := s.settings.GetRecoveryOfferView()
|
||||
return v.Epoch > v.OptOutEpoch
|
||||
}
|
||||
|
||||
// recoveryNoStore stamps the page uncacheable. The rendered page carries no secret, but it does carry
|
||||
@@ -112,6 +166,9 @@ func (s *Server) renderRecoveryState(w http.ResponseWriter, r *http.Request, err
|
||||
// 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()
|
||||
// §7.3: the confirmation states the grace in days, from the constant the countdown actually uses —
|
||||
// never a literal in the copy, which is how a number in prose drifts away from the number in code.
|
||||
data["AbandonGraceDays"] = backup.AbandonGraceDays
|
||||
data["ConfirmSetAside"] = r.URL.Query().Get("setaside") == "1"
|
||||
if inv != nil {
|
||||
data["Unlocked"] = true
|
||||
@@ -405,6 +462,20 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome)
|
||||
|
||||
// ── SCENARIO G — CHANGING YOUR MIND INSIDE THE WINDOW (R-241, v0.206.0) ─────────────────────
|
||||
//
|
||||
// The customer may have chosen to abandon the old history and then found their code after all.
|
||||
// The countdown is cancelled HERE, at the moment the code proves they still have it — the same
|
||||
// act that makes the abandonment wrong is the act that stops it.
|
||||
//
|
||||
// It is placed before the tier-up and the listing deliberately: those can fail, and a countdown
|
||||
// that survives a successful unlock because a later step errored would delete the very history
|
||||
// the customer just proved they can open. Nothing has been deleted at this point by construction —
|
||||
// AbandonSweep is the only deleter, and a running countdown means it has not fired.
|
||||
if s.backupMgr != nil {
|
||||
s.backupMgr.CancelAbandon("the customer recovered with their code")
|
||||
}
|
||||
|
||||
// ── FINISH THE JOB (R-219, v0.201.0) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// The screen promises: *"feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük"*. On the
|
||||
@@ -469,10 +540,133 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
// Recorded against the CURRENT epoch (v0.206.0): a dismissal is about the situation the
|
||||
// customer is in, not about the screen for ever. A later fresh entry shows the page again.
|
||||
if err := s.settings.PostponeRecoveryNoticeForEpoch(); 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")
|
||||
s.logger.Printf("[INFO] [web] recovery: the customer chose to postpone; the banner and the backups-area entry point both stay")
|
||||
http.Redirect(w, r, "/launcher", http.StatusFound)
|
||||
}
|
||||
|
||||
// recoveryBannerDismissHandler records "seen it, for now" (POST /recovery/banner/dismiss) — a browser
|
||||
// SESSION cookie and nothing durable. The bar is back at the next login, because the data is still
|
||||
// sitting there whether or not anyone clicked.
|
||||
func (s *Server) recoveryBannerDismissHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: recoveryBannerCookie, Value: "1", Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil,
|
||||
// NO MaxAge and NO Expires — a session cookie, deliberately. See recoveryBannerCookie.
|
||||
})
|
||||
http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound)
|
||||
}
|
||||
|
||||
// recoveryRemindOptOutHandler records „ne emlékeztessen újra" (POST /recovery/remind-optout).
|
||||
//
|
||||
// ⚠ IT SILENCES THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts
|
||||
// no countdown, and it must never be presented as a way of deciding. The entry point on the backups
|
||||
// page stays exactly where it was (condition 1) — silencing a reminder is not removing the route, and
|
||||
// this whole session exists partly because a route disappeared. A fresh entry into the offered state
|
||||
// reminds again (condition 2), by epoch arithmetic.
|
||||
func (s *Server) recoveryRemindOptOutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.settings != nil {
|
||||
if err := s.settings.OptOutRecoveryRemindersForEpoch(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] recovery: recording the reminder opt-out failed: %v", err)
|
||||
}
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] recovery: reminders silenced for this situation at the customer's request — the backups-area entry point is UNCHANGED and no countdown was started")
|
||||
http.Redirect(w, r, redirectBackTo(r, "/backups/remote"), http.StatusFound)
|
||||
}
|
||||
|
||||
// redirectBackTo returns a SAFE same-site redirect target from the form, or the fallback. Only a
|
||||
// leading single "/" is accepted: "//evil.example" is a protocol-relative URL and must not pass.
|
||||
func redirectBackTo(r *http.Request, fallback string) string {
|
||||
v := r.FormValue("back")
|
||||
if len(v) > 1 && v[0] == '/' && v[1] != '/' {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// addRecoveryBanner decorates a page's data with the reminder bar's state (R-241, v0.206.0).
|
||||
//
|
||||
// It is an EXPLICIT call rather than a `baseData` change, deliberately: `baseData` has no request and
|
||||
// the per-visit dismissal is a cookie, and threading a request through every caller to reach four
|
||||
// pages would be a large diff for a small feature. The callers are the pages a customer actually
|
||||
// lands on — the dashboard, the launcher and the backups area.
|
||||
//
|
||||
// ⚠ IT IS A REMINDER, NOT THE ROUTE. Nothing here gates the entry point on /backups/remote; that is
|
||||
// driven by `.RecoveryOffer` in the template and stays put whatever the customer does about the bar.
|
||||
func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) {
|
||||
if !s.recoveryBannerVisible(r) {
|
||||
return
|
||||
}
|
||||
data["RecoveryBanner"] = true
|
||||
data["RecoveryBannerBack"] = r.URL.Path
|
||||
if data["CSRFField"] == nil {
|
||||
data["CSRFField"] = s.csrfField(r)
|
||||
}
|
||||
// §2.3 — THE UNDECIDED LADDER. A customer who never decides is reminded with escalating
|
||||
// EMPHASIS as the situation ages: marks at 1, 3, 7 and 14 days since this epoch began.
|
||||
//
|
||||
// ⚠ THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS. For an ABANDONING box, 5/3/1 are
|
||||
// unambiguously days REMAINING before a deletion. An undecided box has no deadline — nothing is
|
||||
// counting down to anything, because §7.5 deliberately does NOT auto-abandon — so 14/7/3/1 cannot
|
||||
// be "remaining" and are taken as days ELAPSED, with the wording escalating rather than the bar
|
||||
// appearing and disappearing. If the operator meant something else, this is the line to change.
|
||||
dw := s.recoveryDaysWaiting()
|
||||
data["RecoveryDaysWaiting"] = dw
|
||||
data["RecoveryReminderTier"] = RecoveryReminderTier(dw)
|
||||
// While a countdown runs the bar counts it down instead of asking the same question — and the
|
||||
// reminder opt-out is deliberately NOT offered there: a deletion date is not something to silence.
|
||||
if s.backupMgr != nil {
|
||||
if st := s.backupMgr.AbandonStatus(); st.Active {
|
||||
for _, mark := range backup.AbandonRemindAtDays {
|
||||
if st.DaysLeft <= mark {
|
||||
data["RecoveryAbandonDays"] = st.DaysLeft
|
||||
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||
break
|
||||
}
|
||||
}
|
||||
if data["RecoveryAbandonDays"] == nil {
|
||||
// Outside the reminder marks the countdown is visible on the backups page only —
|
||||
// a bar on every screen for fourteen days is a bar nobody reads by day three.
|
||||
delete(data, "RecoveryBanner")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recoveryDaysWaiting returns whole days since the current offered epoch began (0 when unknown).
|
||||
// It drives the escalating emphasis of the undecided reminder — see addRecoveryBanner.
|
||||
func (s *Server) recoveryDaysWaiting() int {
|
||||
if s.settings == nil {
|
||||
return 0
|
||||
}
|
||||
v := s.settings.GetRecoveryOfferView()
|
||||
if v.Since == "" {
|
||||
return 0
|
||||
}
|
||||
since, err := time.Parse(time.RFC3339, v.Since)
|
||||
if err != nil {
|
||||
return 0 // an unparseable stamp means "we do not know", never "it has been ages"
|
||||
}
|
||||
d := int(s.recoveryNow().Sub(since) / (24 * time.Hour))
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// RecoveryReminderTier maps days-waiting to the escalation marks in §2.3. It returns the HIGHEST
|
||||
// mark reached, so the copy can firm up without the bar flickering in and out.
|
||||
func RecoveryReminderTier(daysWaiting int) int {
|
||||
tier := 0
|
||||
for _, mark := range []int{1, 3, 7, 14} {
|
||||
if daysWaiting >= mark {
|
||||
tier = mark
|
||||
}
|
||||
}
|
||||
return tier
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-241 §7.1 / Scenario H — the three-state surface.
|
||||
//
|
||||
// The full page once PER ENTRY into the offered state (not once ever), a per-visit banner, and an
|
||||
// entry point on the restore page that NOTHING removes.
|
||||
|
||||
// ── THE FULL PAGE APPEARS ONCE PER ENTRY, NOT ONCE EVER ─────────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: make recoveryInterrupts read the legacy boolean again (`!GetRecoveryNoticePostponed()`).
|
||||
// The second entry is then swallowed and this test fails — a box that abandoned its history and was
|
||||
// rebuilt months later would never see the page again.
|
||||
func TestR241_FullPageAppearsOncePerEntryNotOnceEver(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
|
||||
// Entry #1 → interrupts.
|
||||
if !f.s.recoveryInterrupts() {
|
||||
t.Fatal("the first entry into the offered state must interrupt")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
|
||||
if f.s.recoveryInterrupts() {
|
||||
t.Fatal("after 'most nem' the full page must stop interrupting for THIS situation")
|
||||
}
|
||||
|
||||
// The situation ends (the customer recovered, or the state was fixed): the offer goes false and
|
||||
// the epoch's active edge falls.
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.s.recoveryInterrupts() {
|
||||
t.Fatal("a settled box must not interrupt")
|
||||
}
|
||||
|
||||
// ENTRY #2 — a genuinely new situation months later.
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.s.recoveryInterrupts() {
|
||||
t.Fatal("a FRESH entry into the offered state must show the full page again — a dismissal is about a situation, not for ever")
|
||||
}
|
||||
}
|
||||
|
||||
// ── THE BANNER IS PER-VISIT ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: persist the dismissal in settings (or give the cookie a MaxAge). It then survives the
|
||||
// visit and this test fails — a permanently-dismissed banner over data still sitting there.
|
||||
func TestR241_ScenarioH_BannerIsDismissedForTheVisitOnly(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.s.recoveryInterrupts() // establish the epoch
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/launcher", nil)
|
||||
if !f.s.recoveryBannerVisible(req) {
|
||||
t.Fatal("the banner should be visible while the situation holds")
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryBannerDismissHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil))
|
||||
var dismissed *http.Cookie
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
if c.Name == recoveryBannerCookie {
|
||||
dismissed = c
|
||||
}
|
||||
}
|
||||
if dismissed == nil {
|
||||
t.Fatal("the dismissal must set its cookie")
|
||||
}
|
||||
// IT MUST BE A SESSION COOKIE — no MaxAge, no Expires. That is what makes it per-visit.
|
||||
if dismissed.MaxAge != 0 || !dismissed.Expires.IsZero() {
|
||||
t.Fatalf("the banner dismissal must be a SESSION cookie (MaxAge=0, no Expires), got MaxAge=%d Expires=%v", dismissed.MaxAge, dismissed.Expires)
|
||||
}
|
||||
// With the cookie presented, the banner is gone…
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/launcher", nil)
|
||||
req2.AddCookie(dismissed)
|
||||
if f.s.recoveryBannerVisible(req2) {
|
||||
t.Fatal("the banner must be hidden for the rest of this visit")
|
||||
}
|
||||
// …and NOTHING durable was written: a fresh visit (no cookie) sees it again.
|
||||
if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("the banner must be back on the next visit — the dismissal must not be persisted")
|
||||
}
|
||||
if v := f.sett.GetRecoveryOfferView(); v.OptOutEpoch != 0 {
|
||||
t.Fatalf("clicking the bar away must NOT record an opt-out, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ── THE EXPLICIT OPT-OUT SILENCES THE BANNER AND NOTHING ELSE ───────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: make the opt-out also clear the offer (or gate the backups entry point on it). The
|
||||
// route to the data then disappears and this test fails — the failure this whole session exists to
|
||||
// remove.
|
||||
func TestR241_ScenarioH_OptOutSilencesTheBannerOnly(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.s.recoveryInterrupts()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryRemindOptOutHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil))
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("opt-out = %d, want a redirect", rr.Code)
|
||||
}
|
||||
|
||||
// 3. It silences the BANNER…
|
||||
if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("the banner must be silenced after an explicit opt-out")
|
||||
}
|
||||
// 1. …and the ROUTE never goes away.
|
||||
if !f.s.recoveryOffer() {
|
||||
t.Fatal("CONDITION 1: the entry point must survive — silencing a reminder is not removing the route")
|
||||
}
|
||||
// …nor is it an abandonment: no countdown started.
|
||||
if st := f.s.backupMgr.AbandonStatus(); st.Active {
|
||||
t.Fatal("an opt-out must never start a countdown — it is not a decision about the data")
|
||||
}
|
||||
|
||||
// 2. A FRESH entry into the offered state reminds again.
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.s.recoveryInterrupts() // the edge falls
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.s.recoveryInterrupts() // a new epoch
|
||||
if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("CONDITION 2: a fresh entry into the offered state must remind again")
|
||||
}
|
||||
}
|
||||
|
||||
// The backups-page entry point is bound to the OFFER and to nothing else — not to the interruption,
|
||||
// not to the banner, not to the opt-out. Pinned here because every one of those is a lever someone
|
||||
// could plausibly bind it to, and the last time a route disappeared it cost a walk.
|
||||
func TestR241_EntryPointSurvivesEveryDismissal(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.s.recoveryInterrupts()
|
||||
|
||||
f.s.recoveryPostponeHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
|
||||
f.s.recoveryRemindOptOutHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil))
|
||||
f.s.recoveryBannerDismissHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil))
|
||||
|
||||
if !f.s.recoveryOffer() {
|
||||
t.Fatal("no combination of dismissals may remove the route to the customer's data")
|
||||
}
|
||||
// And the page itself still renders rather than redirecting away.
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /recovery = %d after every dismissal, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A settled box shows no banner at all (Scenario D's surface half).
|
||||
func TestR241_SettledBoxHasNoBanner(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("a settled box must show nothing — no page, no banner, no entry point")
|
||||
}
|
||||
if f.s.recoveryInterrupts() {
|
||||
t.Fatal("a settled box must not interrupt")
|
||||
}
|
||||
}
|
||||
|
||||
// redirectBackTo must refuse an off-site target. A dismissal button that can be pointed at another
|
||||
// host is an open redirect on an authenticated page.
|
||||
func TestR241_BannerRedirectIsSameSiteOnly(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"/backups/remote", "/backups/remote"},
|
||||
{"//evil.example/x", "/launcher"},
|
||||
{"https://evil.example", "/launcher"},
|
||||
{"", "/launcher"},
|
||||
{"/", "/launcher"}, // len<=1 falls back; harmless and keeps the rule simple
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x?back="+tc.in, nil)
|
||||
if got := redirectBackTo(r, "/launcher"); got != tc.want {
|
||||
t.Errorf("redirectBackTo(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,14 +118,16 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
|
||||
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 {
|
||||
// R-241 (v0.206.0): with a sealed package held, WriteOffboxSecrets now REFUSES to mint — which is
|
||||
// precisely the state this fixture used to hand-construct by deleting the key afterwards. Accept
|
||||
// the sentinel; it is the product doing what this test's precondition describes.
|
||||
if err := mgr.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil &&
|
||||
!backup.IsOffboxSealedPackageHeld(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Belt and braces for any path that DID mint (a fixture variant with no package held): "this box
|
||||
// cannot open the inherited history" is the whole precondition of the screen.
|
||||
_ = os.Remove(filepath.Join(cfg.Paths.DataDir, "offbox", "repo_password"))
|
||||
rr := &recoveryRunner{statsSize: 4 << 20}
|
||||
mgr.SetOffboxRunner(rr.run)
|
||||
|
||||
@@ -335,13 +337,20 @@ func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) {
|
||||
func TestRecovery_E_PostponeKeepsTheEntryPoint(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
|
||||
// v0.206.0 (R-241): the interruption is now epoch-scoped, so the epoch has to exist before a
|
||||
// dismissal can be recorded against it. recoveryInterrupts advances it on the edge, exactly as the
|
||||
// landing-page interception does in production.
|
||||
if !f.s.recoveryInterrupts() {
|
||||
t.Fatal("precondition: the full page should interrupt on the first entry into the offered state")
|
||||
}
|
||||
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")
|
||||
// Recorded against the CURRENT epoch — the situation, not the screen for ever.
|
||||
if v := f.sett.GetRecoveryOfferView(); v.PostponedEpoch != v.Epoch || v.Epoch == 0 {
|
||||
t.Fatalf("the postpone was not recorded against the current epoch: %+v", v)
|
||||
}
|
||||
// The full page no longer interrupts…
|
||||
if f.s.recoveryInterrupts() {
|
||||
|
||||
@@ -416,6 +416,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.recoveryUnlockHandler(w, r)
|
||||
case path == "/recovery/postpone" && r.Method == http.MethodPost:
|
||||
s.recoveryPostponeHandler(w, r)
|
||||
// R-241 (v0.206.0): the per-visit banner dismissal and the durable reminder opt-out. They are
|
||||
// SEPARATE ROUTES because they are separate decisions — one is "not now", the other is "stop
|
||||
// asking about this situation", and neither removes the entry point on the backups page.
|
||||
case path == "/recovery/banner/dismiss" && r.Method == http.MethodPost:
|
||||
s.recoveryBannerDismissHandler(w, r)
|
||||
case path == "/recovery/remind-optout" && r.Method == http.MethodPost:
|
||||
s.recoveryRemindOptOutHandler(w, r)
|
||||
case path == "/dashboard":
|
||||
s.dashboardHandler(w, r)
|
||||
case path == "/launcher":
|
||||
|
||||
@@ -133,7 +133,19 @@
|
||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p style="margin:0 0 .35rem"><strong>Helyreállítási kód szükséges</strong></p>
|
||||
<p class="form-hint" style="margin:0 0 .5rem">A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.</p>
|
||||
{{if .EscrowAgentOK}}
|
||||
{{/* §7.3 / Q7 — THE TRAP THAT MUST NOT SURVIVE THIS SESSION. While a recovery is outstanding,
|
||||
creating a NEW code seals the CURRENT key and demotes the package that opens the earlier
|
||||
history to retained custody, which no shipped path can read (R-199). It also re-enables
|
||||
the recovery screen through the orphan route while invalidating the code that screen
|
||||
accepts. The button is therefore made UNAVAILABLE here rather than merely captioned:
|
||||
a warning beside a button is a warning people click past. */}}
|
||||
{{if .RecoveryOffer}}
|
||||
<p class="form-hint" style="margin:0">
|
||||
Ehhez a géphez <strong>egy korábbi helyreállítási kód tartozik</strong>, és a korábbi mentéseid még megvannak.
|
||||
Új kód létrehozása <strong>a régi mentéseidet elérhetetlenné tenné</strong>, ezért most nem indítható.
|
||||
Előbb <a href="/recovery">add meg a meglévő kódodat</a> — vagy ott jelezheted, ha nem kéred vissza a korábbi adatokat.
|
||||
</p>
|
||||
{{else if .EscrowAgentOK}}
|
||||
<a href="/backup/escrow" class="btn btn-sm btn-primary">Helyreállítási kód létrehozása</a>
|
||||
{{else}}
|
||||
<p class="form-hint" style="margin:0">A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.</p>
|
||||
@@ -156,6 +168,22 @@
|
||||
{{if .EscrowAgentOK}}<a href="/backup/escrow" class="btn btn-sm btn-outline">Új helyreállítási kód készítése</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .AbandonActive}}
|
||||
{{/* R-241: the countdown, stated for the whole window on the page the choice was made from. */}}
|
||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p style="margin:0 0 .35rem"><strong>A korábbi mentések törlése folyamatban</strong></p>
|
||||
<p class="form-hint" style="margin:0 0 .5rem">
|
||||
A kérésed szerint a korábbi távoli mentéseidet <strong>{{.AbandonDate}}</strong> napján véglegesen töröljük
|
||||
(még <strong>{{.AbandonDaysLeft}} nap</strong>). Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
|
||||
a mentéseid visszaszerezhetők, és a törlés elmarad.
|
||||
</p>
|
||||
<a href="/recovery" class="btn btn-sm btn-primary">Mégis visszaszerzem a kóddal</a>
|
||||
</div>
|
||||
{{else if .AbandonPurging}}
|
||||
<div class="card" style="border-left:3px solid var(--border,#2a3142);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p class="form-hint" style="margin:0">A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .OffboxConfigured}}
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<form method="POST" action="/backup/offbox/run" style="display:inline">{{.CSRFField}}
|
||||
|
||||
@@ -130,6 +130,43 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* R-241 (v0.206.0) — the recovery reminder bar. Rendered while the situation holds, the customer
|
||||
has not opted out of reminders for THIS situation, and they have not clicked it away this visit.
|
||||
It NEVER replaces the entry point on the Távoli mentés page — silencing a reminder is not the
|
||||
same as removing the route. */}}
|
||||
{{if .RecoveryBanner}}
|
||||
<div class="alerts-container">
|
||||
<div class="alert-banner alert-banner-warning">
|
||||
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
||||
<span class="alert-message">
|
||||
{{if .RecoveryAbandonDays}}
|
||||
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal.
|
||||
{{else if ge .RecoveryReminderTier 14}}
|
||||
<strong>Két hete</strong> várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá.
|
||||
{{else if ge .RecoveryReminderTier 7}}
|
||||
Már <strong>egy hete</strong> megvannak a korábbi távoli mentéseid, de a helyreállítási kódod nélkül nem tudjuk megnyitni őket.
|
||||
{{else if ge .RecoveryReminderTier 3}}
|
||||
A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges.
|
||||
{{else}}
|
||||
A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges.
|
||||
{{end}}
|
||||
<a href="/recovery">Megnézem</a>
|
||||
</span>
|
||||
<span class="alert-actions">
|
||||
<form method="POST" action="/recovery/banner/dismiss" style="display:inline">
|
||||
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Most nem</button>
|
||||
</form>
|
||||
{{if not .RecoveryAbandonDays}}
|
||||
<form method="POST" action="/recovery/remind-optout" style="display:inline">
|
||||
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Ne emlékeztessen újra</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Alerts}}
|
||||
<div class="alerts-container">
|
||||
{{range .Alerts}}
|
||||
|
||||
@@ -166,16 +166,22 @@
|
||||
<!-- ── 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}}
|
||||
{{/* §7.3 / §2.4 — THE COPY CHANGES WITH THE BEHAVIOUR (v0.206.0, R-241).
|
||||
It used to promise "félretesszük — nem töröljük". After this change the set-aside history IS
|
||||
deleted, on a date, together with the sealed package that protects it — which is what lets
|
||||
the question end instead of returning at every login. A confirmation that still said "we do
|
||||
not delete" would be the most consequential false sentence on the whole surface. */}}
|
||||
<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 félretett mentések <strong>innen többé nem nyithatók meg</strong> — sem kóddal, sem anélkül;</li>
|
||||
<li>a korábbi mentéseket <strong>most félretesszük</strong>, és <strong>{{.AbandonGraceDays}} nap múlva véglegesen töröljük</strong> — a lezárt helyreállítási csomaggal együtt;</li>
|
||||
<li>a {{.AbandonGraceDays}} nap alatt <strong>meggondolhatod magad</strong>: ha előkerül a helyreállítási kódod, a mentéseid visszaszerezhetők, és a törlés elmarad;</li>
|
||||
<li>a pontos dátumot a <strong>Távoli mentés</strong> oldalon végig látni fogod, és emlékeztetni is fogunk;</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>
|
||||
<li>a törlés után <strong>ez a kérdés nem jön vissza többé</strong> — mert nem marad mit visszaszerezni.</li>
|
||||
</ul>
|
||||
<p>Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget.</p>
|
||||
<p>Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget — az semmit nem indít el.</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<form method="POST" action="/backup/offbox/reset">
|
||||
|
||||
Reference in new issue
Block a user