Files
felhom-controller/REPORT.md
T
2026-08-05 12:56:43 +02:00

223 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# REPORT — R-193: the recovery screen (controller v0.200.0), 2026-08-05
**A customer whose machine was rebuilt can now find out, and unlock their backups, without a command
line.** R-193's remaining half. No hub change; `felhom.eu` carries registers and docs only.
## 1. Baselines, and §3's five pieces
`felhom-controller` at `be3c5fa7f6a1` / v0.199.0 — **exact match**, tree clean, `HEAD == origin/main`.
`felhom.eu` at hub v0.96.0, unchanged. **All five pieces of §3's table were where it said**, at the
cited landmarks: `RunRecoveryCheck` (`:55`), `RecoverAndInstall` (`:140`), `offboxResetHandler`
(`:245`) with its `confirm=1` gate and orphaned precondition, `OffboxOrphaned` (`:244`), and the
per-app snapshot lookup (`offbox_restore.go:84`). **No mechanism was invented.**
**Register:** the highest ID was **R-212** as stated, and still is — re-checked at the end for the
second writer. This session mints **R-213**.
## 2. The one thing the task under-specified, and what I did about it
§8.1 says the page appears on the same two facts the box uses to declare `needs_credential`: **a fresh
data area** (no repository password) and **a hub-held package**. Taken literally that is a window that
**closes by itself**: `WriteOffboxSecrets` AUTO-GENERATES a repository password on re-apply — which is
R-193's own orphaning mechanism — and since hub v0.96.0's credential self-heal the re-apply now happens
unaided within ~1530 minutes. The customer who logs in the next morning would never have seen the page.
So the predicate is **the hub holds a package AND (no repository password OR the tier is orphaned)**.
Shape (b) is the state a real rebuilt box is in, and it is also the state the shipped move-aside
requires — which is what lets "I do not want the old data" reach the existing handler instead of
needing a new one. **Scenario B is unaffected:** a healthy box has its own key and is not orphaned; a
box that never had off-site backups fails the package condition. **demo-felhom is in shape (b) right
now**, which is why validation 1 needed no arrangement at all.
## 3. Every Hungarian string, quoted for review as copy
**The two MANDATORY sentences of §8.2** — both verified in the live rendered page:
> **A helyreállítási kódot senki nem tudja pótolni** — sem a Felhom, sem az ügyfélszolgálat, sem az
> üzemeltető. Ez szándékos: így a mentéseidet rajtad kívül senki nem tudja megnyitni. Ha a kód
> elveszett, a korábbi mentések nem nyithatók meg többé.
> Ha megadod a kódot, **feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük** — melyik
> alkalmazás, mikorról, mekkora. **Ebben a lépésben semmit nem állítunk vissza és semmi nem változik.**
> A visszaállítást utána, alkalmazásonként külön választhatod.
**The situation, before any code:**
> Ezt a gépet újratelepítették. A korábbi, **házon kívüli mentéseid megvannak** — a Felhom központi
> rendszere őriz hozzájuk egy lezárt csomagot, amelyet **<dátum>** zártunk le. A csomagot csak a
> **te helyreállítási kódoddal** lehet kinyitni.
**The form:** `Helyreállítási kód (tíz szó)` · placeholder `tíz szó, szóközökkel elválasztva` ·
buttons `Mentések feloldása` / `Most nem`.
**What "most nem" means:**
> A „Most nem" csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől
> megmaradnak, és ez az oldal a **Biztonsági mentés → Távoli mentés** oldalról bármikor újra elérhető.
**A wrong code** (quoted from the LIVE response):
> A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan,
> szóközökkel elválasztva írtad be — a kis- és nagybetűk nem számítanak. Semmi nem változott, nyugodtan
> próbáld újra.
**Refused (a different key is already present):**
> Ezen a gépen már van egy másik mentési kulcs. A régi előzmény visszanyitása felülírná azt, ezért nem
> hajtottuk végre. Vedd fel a kapcsolatot a Felhom ügyfélszolgálatával.
**After the unlock:**
> Ezek a **te** mentéseid, a lent jelzett időpontokból. Nézd át, hogy tényleg azt találod-e itt, amire
> számítasz — **semmit nem állítottunk vissza és semmi nem változott.**
> A mentéseid zárolása feloldva. Az alábbiakat találtuk a tárolóban — semmit nem állítottunk vissza.
Table headings: `Alkalmazás` · `Legutóbbi mentés` · `Méret`. Next step:
> A visszaállítás alkalmazásonként történik, a **Biztonsági mentés → Visszaállítás** oldalon. Ott
> választhatod ki, melyik alkalmazás mit hozzon vissza.
**An EMPTY store, stated plainly (§8.6):**
> A tároló megnyílt, de **nincs benne egyetlen mentés sem**. Ez azt jelenti, hogy a kulcs jó volt, de
> ehhez a géphez nem tartozik korábbi mentés. Ha korábban biztosan készültek mentések, keresd a Felhom
> ügyfélszolgálatát, mielőtt bármit tennél.
**Unlocked but no coordinates yet** (the pristine rebuilt shape, added after a test found the crash):
> A kulcs visszakerült. A gép még most kapcsolódik újra a házon kívüli tárhelyhez — a mentéseid listája
> néhány perc múlva jelenik meg a Biztonsági mentés oldalon.
**The exceptional path — first mention, then the SECOND confirmation:**
> Ha a helyreállítási kódod véglegesen elveszett, és tudomásul veszed, hogy a korábbi mentések így nem
> nyithatók meg többé: *nem kérem vissza a korábbi adatokat*.
> **Biztosan nem kéred vissza a korábbi mentéseket?** Ha megerősíted:
> • a korábbi mentéseket **félretesszük — nem töröljük**;
> • a helyreállítási kód nélkül **többé nem lesznek megnyithatók**;
> • a gép **új, üres mentési tárolót kezd**, és mostantól oda ment;
> • ez az oldal **többé nem jelenik meg**.
> Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem" lehetőséget.
Button: `Igen, félretehetitek a korábbi mentéseket` / `Mégsem`.
**The permanent entry point in the backups area:**
> **A korábbi, házon kívüli mentéseid visszaszerezhetők.** Ezt a gépet újratelepítették, és a Felhom
> központi rendszere őriz hozzá egy lezárt csomagot. A helyreállítási kódoddal feloldhatod a korábbi
> mentéseidet, és megnézheted, mi van bennük. → `Adatok visszaszerzése`
## 4. §8.4 — repeated failures: LOG LOCALLY, DO NOT ALERT. The reasoning.
**No lockout**, as instructed. On repeated failures I chose to **log locally and raise no operator
signal**, and the reasoning is:
- The realistic generator of repeated failures is **the owner mistyping a ten-word phrase**, which is
the single most likely event on this page. An alert there is noise on every real recovery.
- The threat it would guard against — someone guessing R — is not credible: a 10-word EFF phrase is
~129 bits, and an attacker who could reach this page already holds the household password.
- **Silence is not total:** every failure writes a `[WARN] [web] recovery: unlock failed: …` line
carrying the agent's step and no code, so it reaches the debug ring and any operator log pull. A box
being probed is visible; it just does not page anyone.
The deliberate residual: a slow, patient prober produces only ring entries nobody reads unprompted.
That is accepted, and stated here rather than left implied.
## 5. Files created / modified
| File | Change |
|---|---|
| `internal/backup/offbox_recovery_cli.go` | **new** `RecoverInstallCore` + its three outcomes; `RecoverAndInstall` reduced to a wrapper (exit codes and printed lines unchanged) |
| `internal/backup/offbox_inventory.go` | **new** — the read-only listing, the explicit empty case, `ErrNoOffsiteTarget`, `HumanizeBytes` |
| `internal/backup/offbox.go` | **new** `OffsiteRecoveryOffer` (the two-shape predicate) |
| `internal/settings/settings.go` | **new** `RecoveryNoticePostponed` + accessors |
| `internal/report/escrow_confirm.go` | **new** `SealedAt()` — the ACK's escrow `created_at` |
| `internal/web/recovery_handlers.go` | **new** — the page, unlock, postpone, the seams |
| `internal/web/templates/recovery.html` | **new** — the full page |
| `internal/web/{server,handlers,funcmap}.go`, `templates/backups_remote.html` | routes, the landing-page interception, the permanent entry point, `humanBytes` |
| `cmd/controller/main.go` | wires `SetEscrowSealedAt` |
| `internal/web/recovery_test.go`, `recovery_wiring_test.go` | **new** — Scenarios AH + three AST seam tests |
**Commits on `main`:** `636c51e` (implementation) · `62b85ec` (CHANGELOG). **Deployed:**
`gitea.dooplex.hu/admin/felhom-controller:0.200.0` on demo-felhom 9201 and demo-hp 9201.
## 6. Tests and red-proofs
Green gate: `go build ./... && go vet ./... && go test ./...`**full suite rc=0**;
`controller_gates.py --fast`**all eight OK**.
| Scenario | Test | Result | Red-proof — what was mutated | Outcome |
|---|---|---|---|---|
| A | `TestRecovery_A_PageAppearsForARebuiltBox` | PASS | — | — |
| B | `TestRecovery_B_DoesNotAppearForAnyoneElse` (3 sub-cases) | PASS | dropped the hub-package condition from `OffsiteRecoveryOffer` | **FAILED***"a box that never had off-site backups was offered a recovery screen for data it never had"* |
| C | `TestRecovery_C_UnlockOpensAndLists` + `…EmptyStoreSaysSo` | PASS | — | — |
| D | `TestRecovery_D_WrongCodeFailsClosedAndIsKind` | PASS | — | — |
| E | `TestRecovery_E_PostponeKeepsTheEntryPoint` | PASS | bound the backups-page entry point to `recoveryInterrupts` instead of `recoveryOffer` | **FAILED***"the backups page no longer offers the route… a customer who clicked past it once has lost the way to their own data"* |
| F | `TestRecovery_F_SetAsideNeedsTwoConfirmations` | PASS | removed the `ConfirmSetAside` gate in the template | **FAILED***"the set-aside form is on the FIRST view — one click would set the customer\'s history aside"* |
| G | `TestRecovery_G_PageAndCLIShareOneCore` + `TestRecoveryHandlerDrivesTheSharedCore` | PASS | gave the handler its own inline fetch→compare→install | **FAILED***"recoveryUnlockHandler does NOT call backup.RecoverInstallCore"* |
| H | `TestRecovery_H_CodeLeavesNoTrace` | PASS | (carries its own positive control) | — |
| I | `TestCLIWrapperDrivesTheSharedCore`, `TestRecoveryRoutesAreWired` | PASS | — | — |
**Scenario E's assertion was STRENGTHENED mid-session** because the first version asserted only
`recoveryOffer()`, which the mutation did not touch — it would have passed against the very defect it
names. It now asserts the **rendered** backups page contains `href="/recovery"`, and only then did the
mutation fail. That is the red-proof-that-passes trap, caught and closed rather than reported as green.
**Two real defects the tests caught before shipping:**
1. **An UNCLAIMED (legacy-open) box would have been shown the page.** `RequireAuth` passes a
password-less box through, so `ServeHTTP`'s interception fired for an unauthenticated visitor.
Fixed by an explicit `authEnabled()` check in `recoveryOffer`.
2. **The inventory nil-dereferenced when no off-site target was configured** — exactly the pristine
rebuilt shape. Now a named error with its own honest customer message.
## 7. Live validation (endpoint-level; `claude-in-chrome` is not available on DooPlex)
| # | What | Observable |
|---|---|---|
| 1 | **The page rendered on a genuinely stranded box — no arrangement needed** | demo-felhom 9201 is really in shape (b) (`orphaned=True, status=error, snapshots=0`). `GET /launcher`**`302 Location: /recovery`**; `GET /recovery` → 200 with the code form; both MANDATORY sentences present in the rendered bytes (transported base64 so the ssh→pct chain could not mangle an accented assertion). |
| 2 | **A healthy box shows no page** | demo-hp 9201 (`orphaned=False, status=ok, 6 snapshots`, image `0.200.0`): `GET /launcher`**no redirect**, and the backups page carries **0** occurrences of `href="/recovery"`. |
| 3 | **A wrong code is refused, nothing written, no lockout** | Three consecutive wrong codes → `200` each time, the form still present each time, the Hungarian above rendered, **0** occurrences of `age:` / `passphrase` / `unseal` in the page, and the `offbox/` directory listing **byte-identical before and after**. The controller log shows the failure came from the AGENT (`POST /escrow/recover-offsite-password: HTTP 400: the recovery code did not open the sealed bundle`), i.e. the whole chain — handler → agent → hub fetch → age KDF — ran live. |
| 4 | **The correct code unlocking + the listing** | **NOT RUN LIVE, and it cannot be here.** No recovery code was kept for demo-felhom's orphaned history (`RECON-offsite-dr-chain-2026-08-04.md` §12.3: *"no recovery code was kept"*), and demo-hp's drill code is operator-held out of band. It rests on `TestRecovery_C_UnlockOpensAndLists` (install + listing + "restores nothing"), `…EmptyStoreSaysSo`, and `TestRecovery_G_PageAndCLIShareOneCore` (installed → unchanged on re-run). |
| 5 | **The code persists nowhere — with the positive control** | 0 hits in every file under the data volume, 0 in the container log, 0 in the debug ring. **The positive control earned its keep:** the first sweep ran against the CONTAINER path, which does not exist in the guest, so its "0 hits" proved nothing — the planted copy was NOT found, which exposed the mis-aimed sweep. Re-run against the real path: plant found (**1**), removed (**0**), and the real sweeps stayed at 0. |
**Teardown:** nothing was arranged, so nothing needed reversing — demo-felhom is exactly as found
(`recovery_notice_postponed` unset, offbox enabled, `repo_state=orphaned`, repository password
present). Helper scripts removed from both guests.
## 8. Registers
- **R-193 → CLOSED** (credential half v0.199.0/hub v0.96.0; the screen v0.200.0), with the two
operator rulings recorded and the shape-(b) reasoning stated.
- **R-213 → NEW**: putting files back in place, with the **live-versus-backup comparison** named as its
requirement. Deliberately not started.
- **R-202 stays open**, untouched.
- The register's highest ID was R-212 on arrival **and still is** — re-checked for the second writer.
## 9. The capability-map row as it now reads
All four drill interventions closed **and** the customer is now offered the recovery. The old
*"needs someone who knows to look"* qualifier is **gone**. What remains, stated narrowly: (1) the final
unlock has never been driven with a **correct** code through the page; (2) **putting files back is
deliberately not part of this** (→ R-213); (3) **the journey has not been re-walked end to end** since
these fixes — one more drill is what is owed.
## 10. CI and hooks
CI green by `head_sha` for both pushes. **`--no-verify` was NOT used**; every push ran the pre-push
gate, including the workspace-root assertion added yesterday.
## 11. Observations — noticed, NOT acted on
- **The postpone flag is never cleared.** If a box leaves and re-enters the stranded state, a customer
who once chose „most nem" will not be interrupted again. The entry point still shows, so the route
is intact; clearing it on a transition is a judgement call I did not make unasked.
- **`/backup/offbox/reset` redirects to `/backups/remote`, not back to the recovery page.** Correct —
the situation has ended — but the customer lands on a page whose recovery banner has just
disappeared, with no explicit "done" message. Worth a copy pass with the put-back work.
- **`RunRecoveryCheck` was left alone**, as instructed, so it still contains its own fetch+compare. It
is compare-only and writes nothing, so it cannot diverge dangerously — but it is now the ONE recovery
path not routed through `RecoverInstallCore`.