The credential existed and was not reachable when it was wanted. Every box has
had a strong random root@pam password since TASK G1, vaulted in the hub at day 0
and used for real during the sshd incident — but the only way to read it back was
a hand-written curl carrying the global operator key, a secret kept out-of-band.
In practice the PVE web console on a demo box felt locked.
The host page grows a Console access card: presence + username + set_at by
default, Reveal fetches the plaintext on demand for 60 s with a Copy button.
Masking clears the JS variable, and also fires on a second click and on
visibilitychange. A host with nothing vaulted says so, and says why.
The secret is NEVER rendered into the page, and that constraint shapes the
change. The render path uses a new store.GetHostRecoveryMeta whose struct and
SELECT both omit the secret column, so it is structurally incapable of carrying
one. The plaintext crosses the wire only in the response to POST
/hosts/{id}/reveal-recovery-credential (Cache-Control: no-store, CSRF-gated at
the ServeHTTP level; POST precisely so that gate applies and so no secret is
retrievable by URL alone). Deliberately NOT the customer page's data-secret
widget, which embeds the plaintext on every load.
A delivered reveal writes one recovery_credential_revealed event on the host's
customer timeline (info, source hub, Hungarian) via SaveEvent alone — no
dispatcher, nobody emailed, the log_tail_requested shape. Two reveals write two
events: the register records accesses, not states. A 404 is not an access. An
unbound host reveals fine and writes no event; the [INFO] hub line, carrying the
username and a length only, is then the record.
The global-key API path is untouched by design — it is the route for when the
hub UI itself is broken, and coupling it to the session layer would delete the
independence that makes it a fallback.
Recorded as a real trade: the hub session password alone now unlocks console root
fleet-wide, where retrieval previously also needed the global key. Accepted for a
single-operator, HU-geo-fenced hub that already stores these passwords in
plaintext at rest (CONTEXT.md ruling S-4). The plaintext-at-rest half is filed as
R-133 — every hub DB backup is a fleet-wide console-credential dump.
Tests 550 -> 559; four red-proofs (page leak, audit event, CSRF gate, route
order) each run, observed failing, and reverted. The route-order proof is a seam
test driving ServeHTTP: a handler-level test cannot see that defect, because the
handler is correct and simply never runs.
7.8 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
(filled in at push — see git log --oneline)
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
(filled in after the build/manifest/sync steps)
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.