Files
felhom.eu/REPORT.md
T

171 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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://<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.