# REPORT — Hub v0.84.0: break-glass console credential on the host page (2026-07-31) ## 1. Confirmed baselines actually used | Repo | `main` @ start | Version before | Version after | |---|---|---|---| | felhom.eu | `0a9bd3829d9a4465b032ea78813f55e935fbf88e` ("D5 SHIPPED…", 2026-07-30) | hub **v0.83.0** (`manifests/hub.yaml:128` → `felhom-hub:0.83.0`) | hub **v0.84.0** | Clean-tree gate at start: `git status --porcelain` empty, `HEAD == origin/main`. **Note:** a second Claude Code session was working in this shared clone concurrently — see §10. ## 2. Files created / modified Created: - `hub/internal/web/hosts_recovery_reveal_test.go` Modified: - `hub/internal/store/host_recovery.go` — `HostRecoveryMeta` + `GetHostRecoveryMeta` - `hub/internal/store/host_recovery_test.go` — Group H - `hub/internal/web/hosts.go` — `handleHostRevealRecoveryCredential`; `hostDetailData` +3 keys - `hub/internal/web/server.go` — route, above the bare `/hosts/` catch-all - `hub/internal/web/templates/host_detail_body.html` — Console access card + fetch-on-demand script - `hub/CHANGELOG.md`, `REUSE.md`, `CONTEXT.md` (ruling **S-4**), `documentation/runbooks/break-glass.md` (§3.1 + §5), `documentation/architecture/00-capability-map.md` (L120), `documentation/backlog/ROADMAP.md` + `documentation/backlog/OPEN-ITEMS.md` (**R-133**) ## 3. Commits pushed to `main` | Commit | What | |---|---| | `1956e5d` | hub v0.84.0 — break-glass console credential on the host page (code + tests + all docs) | | `edc7dcc` | manifests: hub 0.83.0 → 0.84.0 (Console access card) | Both fast-forwarded onto `origin/main` from `0a9bd38`. Trunk-based, no branch. ## 4. Test results + red-proofs Every test drives `RequireAuth(ServeHTTP)` — never a handler function directly. | Test | Scenario | Result | |---|---|---| | `TestReveal_A_PageNeverCarriesTheSecret` | A — render, canary absent from the WHOLE body | PASS | | `TestReveal_B_RevealDeliversAndAudits` | B — 200 + no-store + payload + exactly 1 event + no log leak + 0 notifications | PASS | | `TestReveal_C_NotVaulted` | C — explanatory card, no control, 404, zero events | PASS | | `TestReveal_D_CSRFRequired` | D — 403, no leak, zero events; + the with-token discriminator | PASS | | `TestReveal_E_MethodGateAndRouteOrder` | E — 405 and no fall-through to the host page | PASS | | `TestReveal_F_UnknownHost` | F — 404, no panic | PASS | | `TestReveal_G_Unauthenticated` | G — 401, no leak, zero events | PASS | | `TestReveal_UnboundHostRevealsWithoutAnEvent` | §8 edge — 200, no event, log is the record | PASS | | `TestGetHostRecoveryMeta_MetadataOnly` | H — store round-trip + absent cases | PASS | **Red-proofs — each mutation applied, observed failing, reverted:** | # | Mutation | Observed | Reverted | |---|---|---|---| | A | `hostDetailData` gains `RecoverySecret` (from `GetHostRecoveryCredential`) + `data-secret="{{.RecoverySecret}}"` on the card | `FAIL … SECRET LEAK: the vaulted console password appears in the rendered host page` | yes | | B | delete the `SaveEvent` block in `handleHostRevealRecoveryCredential` | `FAIL … recovery_credential_revealed rows = 0, want exactly 1` | yes | | D | short-circuit the `ServeHTTP` CSRF check (`if false && !s.validateCSRF(r)`) | `FAIL … reveal without CSRF = 200, want 403` | yes | | E | move the new `case` BELOW `case strings.HasPrefix(path, "/hosts/")` | `FAIL … GET on the reveal route = 404, want 405` | yes | **Red-proof A did not land on the first attempt, and that is worth recording.** The first mutation edited only the template; a `{{.RecoverySecret}}` against a map with no such key renders empty, so the test passed and would have certified nothing. The proof landed only once the *data* half was mutated too — i.e. the assertion is pinned to the view-model, not to template text. **Red-proof E's symptom differed from the prediction.** The spec expected the misordered route to render the host page (200); it actually 404s, because the catch-all takes `demo-felhom-8363b5/reveal-recovery-credential` as the host id and `GetHost` misses. The test goes red either way, and its second assertion (no `Console access` in the body) still pins the fall-through case. **One spec assertion caught a real gap during development.** Scenario A's requirement that the page "contains a Reveal control targeting `/hosts/{id}/reveal-recovery-credential`" failed at first: the URL was assembled in JS (`'/hosts/' + encodeURIComponent(hostID) + '/…'`) and appeared nowhere in the DOM. Fixed by putting the endpoint in `data-reveal-url` on the button **and making the fetch read it from there**, so the string the render test asserts is the string the request uses — an attribute nothing reads would have been a hollow assertion. ## 5. Test count **550 → 559** (8 web + 1 store). Full suite `go build ./... && go vet ./... && go test ./...` in `hub/`: **rc=0, all 17 packages ok**. Run as a separate command from the commit, per standing rule 1. ## 6. Deployed version + live validation **Deployed.** Image `gitea.dooplex.hu/admin/felhom-hub:0.84.0` built on DooPlex and pushed (digest `sha256:1292cf28…`, 25 MB). `manifests/hub.yaml:128` bumped in git, ArgoCD hard-refreshed and **deliberately synced** (never `kubectl set image`). - ArgoCD app `felhom`: **Synced / Healthy** - `kubectl -n felhom-system get deploy hub` image: **`gitea.dooplex.hu/admin/felhom-hub:0.84.0`** - Rollout: `deployment "hub" successfully rolled out` (rc=0) - Startup log: `2026/07/31 08:21:13 [INFO] felhom-hub 0.84.0 starting` - *(The deployment object is named `hub`, not `felhom-hub` — the build script's printed hint names the image, not the workload.)* **Method: endpoint-level** (browser automation is not available on DooPlex). Driven through the hub ClusterIP `10.43.52.34:8080` with a **real cookie-jar session** — `POST /login` with the operator password, CSRF token read out of the rendered host page, then the POST with `X-CSRF-Token`. That is the exact path the UI takes; no server logic is skipped, only rendering. | # | Check | Result | |---|---|---| | 0 | `POST /login` → session cookie | 303, `hub_session` set | | 0 | `GET /hosts/demo-felhom-8363b5` renders the card | `Console access` ×1, `data-reveal-url="/hosts/demo-felhom-8363b5/reveal-recovery-credential"` present | | 1 | `POST …/reveal-recovery-credential` (session + CSRF) | **HTTP 200**, `Cache-Control: no-store`, `Content-Type: application/json`, `username=root@pam`, `set_at=2026-07-18T16:30:41Z`, **password field present, 32 chars** | | 2 | **The one that matters** — is that password in the host page? | **ABSENT** from the captured page, from a freshly re-fetched page, AND from the customer page (the shared `host_detail_body` surface) | | 2b | Is it in the hub log? | **ABSENT.** The log carries `[INFO] operator revealed break-glass console credential for host demo-felhom-8363b5 (user=root@pam, secret 32 chars)` — username + length only | | 3 | Did the event land? | **Yes, exactly one** — the Hungarian line *„…konzolos hozzáférési jelszavát (távoli hibaelhárítás)."* on `demo-felhom`'s timeline | | 4 | Unknown host | **404** | | 4b | CSRF gate (session cookie, no token) | **403**, and the body carries no secret | | 4c | Method gate (`GET` on the reveal route) | **405**, and the body contains no `Console access` — it did **not** fall through to the catch-all | **The password was never printed** — it was written to a scratch file and used only as a `grep -F -f` pattern, so §15.4's secrets rule holds for this report and for the session transcript. **One live leg has no subject, and it is a gap worth naming.** Step 4 asked for a 404 against a host with **no** vaulted row. **All five enrolled hosts have one** (`demo-felhom-8363b5`, `demo-hp-bb76ea`, `drill-r50-0a4f9a`, `sess-f-2670b5`, `sess-g-e687c3` — each renders a Reveal control, none renders `not vaulted`), so the not-vaulted branch **could not be exercised live**; the 404 above is the unknown-host path instead. That branch is covered by Test Group C (render + POST), not by a live observation, and this report does not claim otherwise. ## 7. NOT yet live-validated — awaiting the operator **That the revealed password actually authenticates at `https://:8006` as `root@pam` on `demo-felhom-8363b5`.** This is the only test that proves the hub's copy still matches the box, and it needs a browser and a real login — CC has neither here (no `claude-in-chrome` on DooPlex). Every other leg is endpoint-level validated (§6). ## 8. Teardown **This run provisioned nothing** — no guest, no VM, no customer, no drive, no external resource. No teardown obligation. ## 9. Backlog rows opened / closed / re-ranked - **Opened: `R-133`** — the vaulted secret is plaintext at rest, so every hub DB backup is a fleet-wide console-credential dump; envelope-encrypt `host_recovery.secret` under a KEK held outside the DB. Added to `ROADMAP.md` and `OPEN-ITEMS.md` (owner CC, `READY (M)`). Named the capability-map row it would flip. - **Closed / re-ranked: none.** - **ID collision, resolved:** the spec predicted R-128. The concurrent session's *uncommitted* WIP in `OPEN-ITEMS.md` had already taken **R-128 through R-132**, so this item took **R-133**. An ID register that lives in a file two sessions edit at once cannot allocate safely by reading committed state — worth noting, not fixed here. ## 10. Observations — recorded, deliberately NOT acted on 1. **`customer_unified.html`'s `data-secret` / `toggleSecret` widget embeds the plaintext in the page HTML on every load.** It therefore lives in the back/forward cache, in "save page as", and in any DOM-capturing screenshot. Acceptable for one customer's retrieval passphrase; it is the reason this task built fetch-on-demand instead of reusing it. Not refactored — out of scope. 2. **`validateCSRF` returns `true` when no session cookie is present** (`server.go`, the Basic-Auth path). So a Basic-Auth caller reaches the reveal endpoint **without any CSRF token**. That is the pre-existing hub-wide contract, not something this endpoint introduced, and it is what makes the §6 curl validation possible at all — but it does mean "CSRF-gated" is true only for session callers. Recorded, not changed. 3. **The Console access card also renders on the customer page's Host tab**, because `host_detail_body` is shared and `configs.go` builds its view models through the same `hostDetailData`. That is per-host and operator-only (the hub has no customer login), so it does not breach the "never on the hosts LIST" rule — but it is a second surface, and it is stated here rather than left to be discovered. 4. **A concurrent session shares this clone.** Its in-flight edits to `OPEN-ITEMS.md`, `RUNBOOK-manual-build.md`, `RUNBOOK-publish-0.79-0.110-2026-07-10.md` and two new untracked files were **not** swept into this commit: every path was staged explicitly, and `OPEN-ITEMS.md` was staged as a hand-built blob (`HEAD` + the R-133 row only) so the foreign WIP stayed unstaged in the working tree. `REPORT.md` was taken here because that session had already chosen the `REPORT-tester-gate-2026-07-31.md` sibling.