diff --git a/CONTEXT.md b/CONTEXT.md index 55173a0..87f6217 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/REPORT.md b/REPORT.md index 5099bae..7de01b3 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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 `: `): -`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) **<app>** beállításai és adatbázisa visszaállítva ellenőrző mappába: **<path>**. 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) **<app>** teljes mentése visszaállítva ellenőrző mappába: **<path>** — 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 3–4:** 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. diff --git a/controller/README.md b/controller/README.md index 4bc36b7..fe804e0 100644 --- a/controller/README.md +++ b/controller/README.md @@ -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 `; 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.` 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`)