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

16 KiB
Raw Blame History

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 --fastall 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 /launcher302 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 /launcherno 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.