Compare commits

...
6 Commits
Author SHA1 Message Date
admin 0a9158d53e docs for v0.206.0: CHANGELOG, CONTEXT, REUSE, README
gates / gates (push) Successful in 19s
CHANGELOG v0.206.0 with the ruling that reversed the fix, the three changes,
the SEC 7.2 staleness decision, Q7's closed trap, and the two bugs the tests
caught rather than review.

CONTEXT carries the three rules this session established, in the form the next
session needs them:
  - a box does not create a repository key while the hub holds a sealed
    package for it;
  - the fact that answers a question must be kept where the question is asked;
  - fix the state, do not remember that it is wrong.

REUSE gains four rows, each carrying the trap rather than just the signature:
the mint guard is a CONJUNCTION and t.Enabled is load-bearing in the derived
predicate; the discriminator ships INERT unless wired in main.go's confirmer
literal; the countdown removes BOTH halves or neither and must be driven by an
injected clock; and the epoch must be synced FIRST and unconditionally or the
falling edge is lost.

README documents the three customer-visible changes and the operator levers.

No version literal was edited: the controller version is ldflags-only.
2026-08-07 12:12:35 +02:00
admin 72368654e4 R-241 part 5: escalating reminders, and operator levers for a running countdown
REMINDERS (SEC 2.3). The offer epoch now stamps when it began, and the
undecided reminder escalates in EMPHASIS at 1, 3, 7 and 14 days.

THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS, and it is written into
the code where it can be corrected. For an ABANDONING box, 5/3/1 are
unambiguously days REMAINING before a deletion. An undecided box has no
deadline - nothing counts down to anything, because SEC 7.5 deliberately does
NOT auto-abandon - so 14/7/3/1 cannot be "remaining" and are taken as days
ELAPSED, with the wording firming up rather than the bar appearing and
disappearing. If the operator meant something else, one function changes.

The stamp is re-set on every entry into the offered state, so a box that
settles and is later rebuilt starts its ladder again instead of inheriting an
old one.

OPERATOR LEVERS (SEC 7.5). --abandon-status, --abandon-extend=N and
--abandon-stop on the controller CLI, beside the existing operator
subcommands. They exist because the path that ACTUALLY happens is the customer
telephoning, and support needs something to press.

They live on the CLI and not in the customer UI deliberately: extending a
deletion the customer asked for is an operator judgement, and a customer who
wants it stopped already has the self-service route - they recover with their
code, which cancels it.

BOTH REFUSE RATHER THAN NO-OP, in two situations: when no countdown is
running, and when the store has already been deleted. A silent success is the
thing an operator most easily mistakes for "handled" - they would tell the
customer their data was safe when it is gone. Pinned by two tests.

--abandon-extend counts from NOW, not from the old due date, and a test proves
the old date passes without deleting anything.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:08:11 +02:00
admin de39e47f53 R-241 part 4: the three-state surface, and the copy tells the truth about the date
FULL PAGE ONCE PER ENTRY, NOT ONCE EVER. "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 see the page again. The
offer now carries an EPOCH, advanced on the edge into the offered state, and a
dismissal is recorded against the epoch it was made in. A fresh entry passes
the dismissal by arithmetic, with nothing to clear and nothing that can be
forgotten to clear.

That is NOT the flag the operator's ruling forbids. The forbidden thing
remembers that the customer decided so the screen can be suppressed while the
state stays wrong. This records WHICH SITUATION a dismissal was about.

A REAL BUG, caught by the test and not by review: the first draft returned
early from recoveryInterrupts 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 page never came back - the
exact defect the epoch exists to fix, reintroduced inside the fix. The sync is
now unconditional and the ordering is commented as load-bearing.

THREE LEVERS, THREE SCOPES, and none of them removes the route:
  - clicking the bar away  -> a browser SESSION cookie, cleared on login, so
    the reminder is genuinely back at the next login. Nothing persisted.
  - "ne emlekeztessen ujra" -> durable, epoch-scoped, silences the BANNER ONLY.
    It starts no countdown, abandons nothing, and a fresh entry reminds again.
  - "most nem" -> suppresses the full page only, as before.
The entry point on /backups/remote is bound to the OFFER and to nothing else,
pinned by a test that fires all three dismissals and asserts it survives.

SEC 7.3 / Q7 - THE TRAP DOES NOT SURVIVE THIS SESSION. While a recovery is
outstanding the "Helyrealitasi kod letrehozasa" button is UNAVAILABLE, not
merely captioned: creating a new code seals the current key, demotes the
package that opens the earlier history to retained custody that no shipped
path can read (R-199), and re-enables the recovery screen through the orphan
route while invalidating the code that screen accepts. A warning beside a
button is a warning people click past. The card now explains and points at
/recovery instead.

SEC 2.4 - the abandon confirmation changes with the behaviour. It used to
promise "felretesszuk - nem toroljuk". It now states the grace in days (from
the constant the countdown actually uses, never a literal in prose), that the
sealed package goes with it, that the customer can change their mind, where
the date is visible, and that the question does not come back afterwards.

The countdown is shown on /backups/remote for the WHOLE window - the bar
elsewhere is a nudge, this is the record, and a deletion date must be findable
on a quiet day too.

Tests: once-per-entry across a full settle-and-re-enter cycle; the banner
dismissal proven to be a session cookie (MaxAge 0, no Expires) and to persist
nothing; the opt-out proven to silence the banner while leaving the offer, the
route and the countdown untouched, and to remind again on a fresh entry; the
entry point surviving all three dismissals; a settled box showing nothing; and
the back-redirect refusing "//evil.example".

An existing test (TestRecovery_E) was updated: it asserted the legacy boolean,
which the epoch replaces. It now asserts the dismissal landed on the current
epoch, which is the stronger property.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:01:30 +02:00
admin a5d90ff801 R-241 part 3: abandoning starts a 14-day countdown that ends the question
Until now "set aside" renamed the remote store 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 had already said "I do not
want the old data" would be asked again at every login.

The operator's ruling is that the answer is NOT a "they decided" flag: fix the
state, do not remember that it is wrong. So the decision starts a countdown,
at the end of which the set-aside store and the sealed package that protects
it are removed TOGETHER. Afterwards shape (c) has nothing to compare and the
offer falls silent on its own - because the state is right, not because
something remembers it once was not.

THE GRACE IS REAL. The recovery offer stays reachable for the whole 14 days;
that is the change-of-mind path, and a grace in which recovery is impossible
would be decorative.

BOTH HALVES OR NEITHER. Removing only the store leaves a package that opens
nothing; removing only the package leaves ciphertext nobody can ever decrypt.
The two cannot be atomic across two machines, so it is a two-phase commit:
delete the store, record a durable marker, and keep DECLARING
offsite.abandon_purge_requested until the hub's ACK stops reporting a
superseded package. A crash between the halves re-declares on the next sweep;
it never leaves the pair half-removed and silent.

HUB HALF - SEC 8.2 ANSWERED: yes, the hub was needed, and only for this.
store.PurgeSupersededEscrowForCustomer is the one place R-198's retention is
ever undone, and it never touches host_escrow (the package covering the key
the box uses now). The handler acts on the DECLARATION, never an inference,
and is placed immediately BEFORE the ACK is built - so
GetEscrowStatusForCustomer reads the effect and the SAME response closes the
box's two-phase commit. No second round-trip and no window where the box
thinks it is still owed. felhom-agent was NOT touched.

The countdown starts in ResetOrphanedRepo, NOT in the shared helper: the
helper is also the unclaimed auto-reset path, where nobody decided anything,
and an as-delivered box tidying a stranger's leftover store must not get a
customer's deletion clock. Pinned by a test.

Cancellation is wired into the recovery unlock, BEFORE the tier-up and the
listing - those can fail, and a countdown surviving a successful unlock
because a later step errored would delete the history the customer just
proved they can open.

The sweep is a Daily job at 05:10, not on the backup leg: it must run on a box
whose tier is not configured for runs. Quiet by construction on every box with
no countdown, and that silence is asserted.

Tests (all clock-injected; SEC 7.4 forbids shortening a live timer):
Scenario E (aside + package kept + countdown + offer still reachable, and
NOTHING deleted), Scenario F (both halves, the declaration repeating, the
close-out), Scenario G (cancel, path still nameable, no later deletion),
plus: not closed out while the package remains, a transport failure leaves the
countdown due and retrying, the no-op sweep issues zero remote commands, and
the unclaimed auto-reset starts no countdown.

RED-PROOFS, each with the mutation confirmed present in the file first:
  F1) store deletion skipped -> Scenario F FAILS (no rm issued)
  F2) declaration dropped from the report -> Scenario F FAILS (the hub is
      never asked; the package would outlive the store for ever)
  G)  CancelAbandon made a no-op -> Scenario G FAILS (uncancellable countdown)

Green: controller and hub both build, vet and test clean; controller gates OK.
NOTHING WAS DELETED ANYWHERE - the terminal step has only ever run against
in-test fakes.
2026-08-07 11:47:42 +02:00
admin a491abef6c R-241 part 2: the comparison the box already makes becomes the thing that offers recovery
THE FACT WAS COMPUTED EVERY CYCLE AND KEPT NOWHERE. EscrowAutoConfirmer.Reconcile
has compared the hub's restic_pw_sha256 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 dropped it. The recovery
screen, evaluating in the same process, went on asking a question that could not
see it.

Now persisted: settings.HubEscrowKeySHA256 + HubEscrowKeyCheckedAt, recorded
UNCONDITIONALLY in Reconcile beside RecordPresence and RecordSuperseded - same
place, same reason: the box that needs it most is the rebuilt one with no target,
on which every gate below returns early.

OffsiteRecoveryOffer gains SHAPE (c): the hub holds a package for a key OTHER than
the one we are using. (a) and (b) are both proxies for that question and both have
now been wrong in opposite directions - (a) goes false the moment anything mints,
(b) is unreachable while the escrow is pending.

SEC 7.2, decided deliberately and stated in the code:
  - a KNOWN DIFFERENCE offers, however old the reading. Age is not gated on. Both
    sides 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 make a box offline from the hub silently stop
    offering - the exact failure this session removes. CheckedAt is persisted for
    diagnosis, not as a gate.
  - an ABSENT hash falls back to (a)/(b) and does NOT offer. "" is the hub
    positively saying its package seals no repository password (legacy hash-less
    escrow). Nothing to compare, and offering would put a permanent screen in
    front of every legacy box.

The write damper: CheckedAt refreshes on every ack carrying a hash, but a save is
skipped when both the hash and the UTC day are unchanged, so an idle box does not
rewrite settings.json every fifteen minutes. It records WHEN WE LAST HEARD, not
when it last changed - the R-100 distinction.

Tests: Scenario C (a differing key offers, with both proxies asserted false first),
Scenario D (a matching key offers nothing), fact 1 still required, shape (a) still
works, and both SEC 7.2 halves.

RED-PROOFS, each with the mutation confirmed present in the file first:
  D) hubHash != localHash conjunct dropped -> Scenario D FAILS (a healthy box
     offered recovery forever); Scenario C still passes
  WIRING) RecordEscrowKeyHash removed from the EscrowAutoConfirmer literal in
     main.go -> TestMainWiresRecordEscrowKeyHash FAILS. This is the ships-inert
     shape: unwired, everything compiles, every test in the package passes, the
     auto-confirm still works, and shape (c) reads an empty hash forever.

Green: go build, go vet, go test ./... all pass.
2026-08-07 11:33:42 +02:00
admin 763de3a025 R-241 part 1: the box does not mint a repository key over a sealed package
THE DEFECT. 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: 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 correctly reported nothing recoverable under the key the
box held. The screen was honest; the minting was not. And the flag was not
merely available at that moment - it was the PRECONDITION of the chain that
reached this function, logged at 02:48:03Z, six ticks earlier.

THE GUARD IS A CONJUNCTION, deliberately: a package held AND no key present.
A box the hub holds nothing for mints exactly as before.

The refusal is a HOLDING state, not a failure. ApplyOffsiteTarget catches the
sentinel and still writes the transport (ssh key, known_hosts, coordinates),
so the recovery screen can bring the tier up the instant the escrowed key is
placed (R-219). Returning the error instead would leave needsOffsiteCredential
true forever and the hub re-staging a consumed credential on every cycle.

New declared state offsite.state=awaiting_recovery_key, shown INERT to every
existing hub reader from their code rather than assumed: offsiteheal acts on
exactly one string; isStale needs Enabled && escrowed and this carries
Enabled=false; the delivery checker skips the applied shape; an unknown state
string is ignored by encoding/json. So NO hub change is needed for this part.

OffboxAwaitingRecoveryKey is DERIVED, not stored - the operator's ruling that
the state should be fixed rather than remembered, applied to this field too.

t.Enabled is load-bearing in that predicate and was MISSING in the first
draft. The existing TestOffsiteDeclare_DisabledTargetIsNotStranded caught it,
not review: a customer who switched off-site off is not awaiting anything.
Now pinned from the new predicate's own side as well.

Tests: Scenario A (no key written; transport still written; apply holds and
stages nothing), Scenario B (first-time box still mints), idempotency, the
nil-settings fail-safe, and the Scenario E carve-out.

RED-PROOFS, each with the mutation confirmed present in the file first:
  A) guard block deleted   -> both Scenario A tests FAIL with
     "R-241 REGRESSION: apply minted a repository password over the sealed
     package"; Scenario B still passes (the mutation is specific)
  B) guard over-widened (hub-package conjunct dropped) -> Scenario B FAILS
     with a first-time box unable to start; Scenario A still passes

Green: go build, go vet, go test ./... all pass; controller_gates all OK.
2026-08-07 11:25:58 +02:00
23 changed files with 2191 additions and 30 deletions

No files matched your search

+81
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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!) |
+24
View File
@@ -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>`).
+95 -2
View File
@@ -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
+7
View File
@@ -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
+187 -6
View File
@@ -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")
}
}
+33 -1
View File
@@ -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")
}
}
+214
View File
@@ -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()
}
+5
View File
@@ -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
+17
View File
@@ -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)
}
+201 -7
View File
@@ -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)
}
}
}
+17 -8
View File
@@ -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() {
+7
View File
@@ -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">