docs: R-204 items 1 & 3 — CONTEXT, REPORT, README (controller v0.198.0)
gates / gates (push) Successful in 9s

This commit is contained in:
2026-08-05 07:37:25 +02:00
parent 33fcc502e4
commit 68f195676b
3 changed files with 199 additions and 42 deletions
+34 -1
View File
@@ -7,7 +7,40 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-08-02 (v0.190.0 — R-157 A · R-170 · R-171: boot recovery finished)
Last updated: 2026-08-05 (v0.198.0 — R-204 items 1 & 3: the reset code and the restore scope)
> **2026-08-05 — v0.198.0 (R-204 items 1 & 3).** The 2026-08-04 drill (R-201) passed only because a
> person was there; four manual interventions stood between a recovered key and a restored file. Two
> of the three defects are in this repo.
>
> **Item 1 — the reset code needed a restart.** `--print-reset-code` is a SEPARATE process; it
> persisted a new code while the running server kept the old one cached, so the code the customer was
> told to type was refused until the controller restarted, and nothing said so. `effectiveClaimCode`
> now calls `settings.ReloadClaimCode()` first. **The settings-vs-config precedence is unchanged** —
> the defect was freshness, not precedence. **Read-through, not a TTL, and that is the point:** a TTL
> makes the new code visible AND leaves a window in which the superseded one still works, which is
> worse than the bug. That is the mutation `TestClaimCode_SupersededByASecondMint_RefusedImmediately`
> exists to kill, and its red-proof produced exactly *"the SUPERSEDED code was accepted"*. The
> function now returns an error and **every caller fails closed**; an absent settings file is NOT an
> error. `ClaimConsumedGeneration` is deliberately NOT re-read — this process is its only writer and
> re-reading could move it BACKWARDS if a save had failed, resurrecting a consumed code.
>
> **Item 3 — the restore's default returned the wrong thing silently.** `mode=unit` restores the
> recovery unit (definition + config + DB dumps) and not the customer's files. `restoreScratchOutcomeMsg`
> now names what came back, what did not, and the next step; the wizard's intent card states its scope
> before the choice. **The size gate is untouched** and pinned unchanged by
> `TestOffboxRestore_FullPathUnchanged`. **The default stays `unit`** — all three wizard forms set
> `mode` explicitly, so a change would alter nothing visible while silently changing a mode-less POST.
>
> *Live-validated endpoint-level (no browser on DooPlex):* on demo-felhom 9201 with `restarts=0`
> across both mints, a superseded code returned „Hibás vagy lejárt kód" and the current one was
> accepted first time; on demo-hp 9201 a `privatebin` unit restore produced the scoped Hungarian
> outcome and `mode=full` without confirm revealed `full_size=6.8+KB` without restoring anything.
> demo-hp's drill scratch (`calibre-web`) was not touched.
>
> **Item 2 is the hub's** (felhom.eu v0.95.0, R-196). **Item 4 — a rebuilt box cannot obtain an
> off-site credential unaided — remains OPEN (R-193)** and was deliberately not begun.
> **2026-08-02 — v0.190.0 (R-157 mechanism A · R-170 · R-171).** Three items, one live validation
> cycle, because all three are boot behaviour and all three are proven by hard-resetting the box.
+143 -41
View File
@@ -1,56 +1,158 @@
# REPORT — controller v0.197.0: R-203, both halves (2026-08-04)
# REPORT — R-204 items 1 & 3 (controller v0.198.0), 2026-08-05
Deployed to demo-hp guest 9201. No hub change. Nothing deleted, wiped or moved; the R-201 drill was
not resumed.
**Two of the four steps a customer would have hit alone.** The 2026-08-04 R-201 drill passed and only
passed because a person was there. Items 1 and 3 are this repo's; item 2 is the hub's
(felhom.eu v0.95.0); item 4 remains open (R-193).
## What shipped
## 1. Baselines, re-read on arrival
**Part 1 — one resolver, five callers.** `appbackup.NamespaceRootFor` / `IsEnrolledDrive` are now the
single expression of the drive-kind rule; `backup.Manager.namespaceRoot` and `stacks.Manager.inGuest`
delegate (they were two copies that already differed over `filepath.Clean`). Routed through it:
`stacks/deploy.go` `withPathVars` (the live defect), `appexport/fabplan.go` + `export.go` via a new
`GetStackNamespaceRoot` provider method, `web/handlers.go`'s FileBrowser mounts (the fifth site, which
the spec did not name — latent today), and `stacks/delete.go`'s `ExportDataMounts` (its own commit).
`ComputeFabBuckets` now receives the namespace root, matching `ComputeCaptureSet`.
| Repo | Expected | Found |
|---|---|---|
| `felhom-controller` | `f4796e0d00e9` / v0.197.0 | **exact match**, tree clean, `HEAD == origin/main` |
**Part 2 — the verdict.** `last_status` gains `incomplete`; the capture gaps are recorded structurally
so the verdict has something to act on; the operator hears it through the existing per-run digest.
§3.1's landmarks held: `claim.go:46` `effectiveClaimCode`, and the `--print-reset-code` block in
`cmd/controller/main.go` loading its OWN `settings.Load` from `cfg.Paths.DataDir+"/settings.json"`
the separate-process seam the whole item turns on. §3.3's held: `mode` defaulted to `"unit"`, and the
`full` path was two-step and size-gated.
## Proven live on demo-hp
**One drift, in the sibling repo:** §3.2 described `offsite.go:222-231` carrying a known-consequence
comment. That comment had already been rewritten (the R-196 comment-correction commit) and now gave a
*non-false* ground for the mark — the guest-rebuild shape. That made Scenario D the crux rather than a
formality; see the hub's REPORT.
```
bind before: /mnt/sys_drive/userdata/media/books
bind after: /mnt/sys_drive/felhom-data/userdata/media/books
capture log: 0 mandatory path(s) -> 1 mandatory path(s)
## 2. Per-item outcome
$ restic ls -l latest --tag calibre-web
-rw-r--r-- 1000 1000 181 2026-08-04 12:53:06 .../media/books/DRILL-SENTINEL.txt
```
| Item | Outcome |
|---|---|
| **1 — the reset code needs a restart** | **CLOSED.** `effectiveClaimCode` reads through to the persisted claim state (`settings.ReloadClaimCode`) before applying the precedence rule, which is **unchanged**. Read-through only, on the claim path only; fails closed. |
| **3 — the restore's default returns the wrong thing silently** | **CLOSED.** `restoreScratchOutcomeMsg` names what was restored, what was not, and the next step; the wizard card states its scope before the choice. Size gate untouched. |
| **2 — a re-issue marks a healthy escrow stale** | The hub's half — shipped in felhom.eu v0.95.0. |
| **4 — a rebuilt box cannot obtain an off-site credential unaided** | **REMAINS OPEN.** Dependency: the one-shot credential design decision. It needs an operator ruling and belongs to **R-193**. Not begun. |
The file, by name and size, inside the snapshot — not a green status.
## 3. The exact Hungarian strings added (for review as copy)
## Tests
**Unit-restore outcome** — with the scratch path resolved (the fallback drops `: <path>`):
`go build && go vet && go test ./...` rc=0; `controller_gates.py --fast` OK. Table-driven over BOTH
drive kinds, because this defect survived by being invisible on the kind that already worked.
> A(z) **&lt;app&gt;** beállításai és adatbázisa visszaállítva ellenőrző mappába: **&lt;path&gt;**. A saját
> fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak
> az alkalmazás beállításait és adatbázisát hozza vissza. Ha a fájljaidra van szükséged, indítsd el a
> „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok.
**One red-proof PASSED and the test was wrong, not the code:** the first Scenario-C test reached only
`offboxCaptureSet` while the mutation lives in `runOffboxInternal`. Replaced with a run-level test
driving `RunOffboxBackup`; it now fails under both an unreachable gap-recording and an unconditional
`ok`. Scenario D's proof also could not fail by construction (the tier filter already excludes
optional paths) — the two-part mutation that does is recorded.
**Full-restore outcome:**
## Corrections to the spec
> A(z) **&lt;app&gt;** teljes mentése visszaállítva ellenőrző mappába: **&lt;path&gt;** — a saját fájljaiddal
> együtt. A meglévő adatok változatlanok.
- **Five call sites, not four.** The fifth is the FileBrowser mount builder.
- **`ExportDataMounts` is export-only despite living in `delete.go`.** Its only caller is the `.fab`
adapter, and the delete path's own `ProtectedHDDPaths` guard is layout-agnostic by construction, so
the destructive-path risk the spec flagged does not exist.
**Wizard intent card 1** (heading + body, stated BEFORE the choice):
## Observations
> **1. Ellenőrzés külön mappába (beállítások és adatbázis)**
> Az alkalmazás beállításait és adatbázisát hozza vissza egy külön ellenőrző mappába. A saját
> fájljaidat (dokumentumok, képek, feltöltések) **nem** hozza vissza — azokhoz a 3. pont teljes
> visszaállítása kell. Az élő adataid nem változnak.
`resolveAbs` resolves `RootHDD` and `RootUserdata` against one root — fine on enrolled drives, and now
consistent between export and backup, but whether `${HDD_PATH}` should mean the namespace root on the
system drive is a separate decision affecting every deployed app. The deploy API is
first-deployment-only (409 after), so an existing app picks up the corrected path via start/redeploy.
The CSRF form field is `_csrf`, not `csrf_token` — second session running that this cost time.
**Claim fail-closed message** (new — shown on the page and on a refused submission):
> A beállító állapot most nem olvasható — próbáld újra néhány perc múlva.
## 4. Was the restore default changed? NO — and why
**The default stays `mode=unit`.** All three wizard forms set `mode` explicitly, so the `mode == ""`
fallback in `offboxRestoreHandler` is reachable **only by a hand-crafted POST**. Changing it would
alter nothing a customer sees while silently changing that POST's behaviour — and it would push a
mode-less request into the size-gated two-step path. The defect was **silence**, not the choice of
default, and silence is what was fixed: a mode-less POST now gets the same scoped wording, pinned by
`TestOffboxRestore_DefaultModeGetsTheScopedOutcome`. **The size gate stays in every case.**
## 5. Files created / modified
| File | Change |
|---|---|
| `controller/internal/settings/settings.go` | **new** `ReloadClaimCode()` — read-through of the persisted claim fields only |
| `controller/internal/web/claim.go` | `effectiveClaimCode` reads through + returns an error; `claimGateActive`, `claimLegacyOpen`, `handleClaimPage`, `handleClaimSubmit` fail closed |
| `controller/internal/web/claim_readthrough_test.go` | **new** — Scenarios A, B and the fail-closed case |
| `controller/internal/web/offbox_handlers.go` | **new** `restoreScratchOutcomeMsg`; the handler calls it with `full` |
| `controller/internal/web/offbox_restore_scope_test.go` | **new** — Scenarios E and F through the real handler |
| `controller/internal/web/restore_wizard_test.go` | intent-card copy assertion updated to the new scope wording |
| `controller/internal/web/templates/backups_restore_wizard.html` | intent card 1 states its scope |
| `CHANGELOG.md`, `CONTEXT.md`, `controller/README.md` | v0.198.0 entry; session block; claim-hatch + restore-scope architecture notes |
**Commits on `main`:** `73b6dbc` (item 1) · `2e936f4` (item 3) · `33fcc50` (CHANGELOG).
**Deployed:** `gitea.dooplex.hu/admin/felhom-controller:0.198.0` on demo-felhom 9201 and demo-hp 9201.
## 6. Tests and red-proofs
Green gate after each phase: `go build ./... && go vet ./... && go test ./...`**full suite rc=0**;
`python3 controller/scripts/controller_gates.py --fast`**all eight gates OK**.
| Test | Result | Red-proof — what was mutated | Outcome |
|---|---|---|---|
| `TestClaimCode_FreshlyMintedByHatch_AcceptedWithoutRestart` (A) | PASS | deleted the `ReloadClaimCode()` call in `effectiveClaimCode` | **FAILED***"freshly minted code refused… the running server did not see the persisted code"* |
| `TestClaimCode_SupersededByASecondMint_RefusedImmediately` (B) | PASS | substituted a **per-instance 30 s TTL cache** for the read-through | **FAILED***"the SUPERSEDED code was accepted — a stale/TTL read left an old code alive"* |
| `TestClaimCode_UnreadablePersistedState_FailsClosed` | PASS | both mutations above | **FAILED** both times (*"the gate OPENED on an unreadable claim state"*) |
| `TestOffboxRestore_UnitOutcomeNamesWhatItDidNotRestore` (E) | PASS | reverted `restoreScratchOutcomeMsg` to the single pre-R-204 sentence | **FAILED** on all four assertions |
| `TestOffboxRestore_DefaultModeGetsTheScopedOutcome` | PASS | same | **FAILED** |
| `TestOffboxRestore_FullPathUnchanged` (F) | PASS | same | **FAILED** on the full-outcome assertion; the size-gate half stayed green, correctly |
| `TestOffboxRestore_UnitRestrictsToTheUnitPath` | PASS | — (the mechanism half of E) | — |
Each mutation removed every guard its test covers, except deliberately in the last row: reverting the
message does **not** touch the size gate, and Scenario F's gate assertions correctly stayed green
while its wording assertion failed. That split is the evidence the two halves are independent.
**A red-proof that first failed for the WRONG reason, corrected and recorded** (task §10's exact
trap). Scenario B's first TTL mutation used a *package-level* variable, so test A's earlier read had
already opened the window and B failed with *"the gate is not active after the first mint"* — no code
at all, rather than a superseded code surviving. Two fixes: the mutation was made **per-`Settings`**
(what a real TTL fix would be), and the test was restructured so the server has **already seen the
first code** — through `claimGateActive()`, the production path a page load takes — before the second
is minted. Only then does the TTL demonstrably keep the old code alive.
**Seams named.** The claim tests stub **nothing**: a real `settings.Load` on a real file, the real
`PrintLocalResetCode` against a second `*Settings` loaded from the same path (the `docker exec`
shape), and submission through `fullMux()` (RequireAuth + CsrfProtect + ServeHTTP). The restore tests
stub **only the restic exec** (`SetOffboxRunner`) and drive `offboxRestoreHandler` itself, asserting
the flash on `RestoreStatus().Last.Message` — the field the wizard renders — and waiting on a real
completion marker (`!Running && Last.FinishedAt != 0`), never a fixed sleep.
## 7. Live validation (endpoint-level; `claude-in-chrome` is not available on DooPlex)
| # | What | Observable |
|---|---|---|
| 1 | **A freshly minted code is accepted with nothing restarted** | demo-felhom 9201. Two codes minted through the real hatch (`docker exec … --print-reset-code`, generations 3 then 4). Container `StartedAt 05:21:28Z`, **`RestartCount=0`**, both mints after it. The **current** code reached *"A jelszónak legalább 12 karakter hosszúnak kell lennie"* — a **positive** observable reachable only **after** the hash, generation and expiry checks all pass. The same code was then submitted with the real password: **302 → `/`**, accepted end to end. |
| 2 | **The previously valid code is refused** | The generation-3 code, superseded seconds earlier, returned **„Hibás vagy lejárt kód"**. No window. |
| 3 | **A unit restore names what it did not restore** | demo-hp 9201, app `privatebin`, via `POST /backup/offbox/restore` (mode=unit), read back from `/api/backup/restore-status`: **„A(z) privatebin beállításai és adatbázisa visszaállítva ellenőrző mappába: /mnt/sys_drive/felhom-data/backups/offsite-restore/privatebin. A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok."** |
| 4 | **The full-restore size gate still computes and reveals** | Same box/app, `mode=full` **without** `confirm`: `302 → /backups/restore/app?name=privatebin&full_prep=privatebin&full_size=6.8+KB`, and **no restore ran**. |
**Method:** authenticated `curl` against the container IP with the mandatory `Host` header, run inside
the guest; Hungarian strings transported base64 so the `ssh → pct exec → bash` chain could not mangle
an accented assertion.
**demo-hp fences respected:** no wipe, no ceremony, no re-issue. `privatebin` was chosen precisely so
the drill's `calibre-web` scratch stayed untouched — confirmed after teardown, it is the only
directory left under `backups/offsite-restore/`.
**Why 9201 on demo-felhom could not serve items 34:** its off-site repository is **orphaned with 0
snapshots** (`status: error`, the known R-193 guest-rebuild shape), so no restore can run there.
## 8. Teardown
Nothing was provisioned. The outstanding generation-4 reset code on 9201 was **consumed** through the
real claim flow using the box's existing password, so the box is left **claimed, healthy and with an
unchanged password** and no live reset code; re-submitting it now returns „Hibás vagy lejárt kód".
The `privatebin` restore scratch created on demo-hp was removed. All credential-bearing helper scripts
were deleted from `/tmp` on both Proxmox hosts and both guests. No secret appears in any committed
file — the minted codes are one-time, now consumed or superseded, and are referenced here only by
generation number.
**CI:** run **153** for `33fcc50` — success. `--no-verify` was **not** used; the pre-push gate ran and
passed on every push.
## 9. Observations — noticed, NOT acted on
- **`--print-reset-code` rewrites the WHOLE settings file** from its own `settings.Load`. If the
running server persists a setting between that load and the hatch's save, the hatch's write wins and
the server's change is lost. Not triggered here (the hatch is short-lived and operator-driven) and
out of scope — but it is a genuine lost-update window on a shared file.
- The hub's `allowedEventTypes` still lists **`escrow_stale`**, which now has **no producer** in
either repo. Harmless, but inert; removing an allowlist entry is a behaviour change and was left
alone.
- `/` on DooPlex is at **86%** used. Under the 90% abort line, but worth watching before large builds.
+22
View File
@@ -456,6 +456,17 @@ Each app can define rich metadata in `.felhom.yml`:
+ `dumps_at`; a manifest without them is a pre-v0.148 pair of unknown skew, surfaced at restore
time. The periodic refresh carries the prior stamp forward and never invents one. A dump-leg
failure is a loud WARN that does NOT abort the push (data-first: a degraded backup beats none).
- **A scratch restore NAMES ITS SCOPE (v0.198.0, R-204 item 3 — `restoreScratchOutcomeMsg`).**
`mode=unit` (the default) restores the recovery unit only — the app's definition, configuration
and DB dumps — because `RestoreOffboxScratch` passes `--include <unit path>`; the userdata that is
in the SAME snapshot is excluded by it. The outcome flash used to be one sentence for both modes
and named neither scope, so on the last step of a disaster recovery the customer was told
„visszaállítva" after the thing they were looking for had not been. The unit outcome now states
what came back, that the customer's own files did NOT, and the step that gets them; the full
outcome states that the files came with it (an absence is not a statement). The wizard's intent
card 1 carries the same scope BEFORE the choice. **The `mode=full` two-step size gate is
untouched**, and the default stays `unit` — all three wizard forms set `mode` explicitly, so
changing it would alter nothing the customer sees while silently changing a mode-less POST.
- **Offsite reconstitution (v0.148.0, R-43 — `offbox_reconstitute.go`):** the leg that was missing.
`ReconstituteFromOffsite` (`/backup/offbox/reconstitute`, „Teljes visszaállítás (fájlok +
adatbázis)") makes the live app equal to the chosen snapshot: **safety dump → stop → files
@@ -2241,6 +2252,17 @@ race where a new `felhom.<domain>` cert appears in CT logs minutes before any pa
never rewrites, a hub outage never clears). The report carries `claimed` (hub ingests set-only).
- **Escape hatch**: `felhom-controller --print-reset-code` prints a one-time local code (generation
above cached/baked/consumed); the same gate consumes it. Root-gated by `docker exec` reachability.
**It runs as a SEPARATE PROCESS, which is why `effectiveClaimCode` READS THROUGH to the persisted
state (v0.198.0, R-204 item 1).** Until then the hatch persisted a new code while the running
server kept the old one cached, so the minted code was refused until the controller was restarted —
and nothing said so. The read-through (`settings.ReloadClaimCode`) is on the claim path only, and
only while the box carries no password (`claimGateActive` returns on `authEnabled()` first).
**Deliberately not a watcher, a signal handler or a TTL:** a TTL would leave a window in which a
SUPERSEDED code still works, which is worse than the bug. Refreshes hash/generation/issuedAt only —
never `ClaimConsumedGeneration`, which this process alone writes and must stay monotonic.
`effectiveClaimCode` returns an error and **every caller fails closed** (the gate stays UP, the claim
is refused); an ABSENT settings file is not an error, since a pre-first-save box falls back to the
controller.yaml bake.
#### Session Auth (`internal/web/auth.go`)