11 KiB
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+GetHostRecoveryMetahub/internal/store/host_recovery_test.go— Group Hhub/internal/web/hosts.go—handleHostRevealRecoveryCredential;hostDetailData+3 keyshub/internal/web/server.go— route, above the bare/hosts/catch-allhub/internal/web/templates/host_detail_body.html— Console access card + fetch-on-demand scripthub/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 hubimage: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, notfelhom-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://<box-ip>: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-encrypthost_recovery.secretunder a KEK held outside the DB. Added toROADMAP.mdandOPEN-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.mdhad 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
customer_unified.html'sdata-secret/toggleSecretwidget 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.validateCSRFreturnstruewhen 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.- The Console access card also renders on the customer page's Host tab, because
host_detail_bodyis shared andconfigs.gobuilds its view models through the samehostDetailData. 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. - 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.mdand two new untracked files were not swept into this commit: every path was staged explicitly, andOPEN-ITEMS.mdwas staged as a hand-built blob (HEAD+ the R-133 row only) so the foreign WIP stayed unstaged in the working tree.REPORT.mdwas taken here because that session had already chosen theREPORT-tester-gate-2026-07-31.mdsibling.