Files
felhom.eu/REPORT.md
T

11 KiB
Raw Blame History

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:128felhom-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.goHostRecoveryMeta + GetHostRecoveryMeta
  • hub/internal/store/host_recovery_test.go — Group H
  • hub/internal/web/hosts.gohandleHostRevealRecoveryCredential; 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 sessionPOST /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-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.