docs: host-addresses audit + capability-map row + REPORT (agent 0.119.0 / hub 0.85.0)
This commit is contained in:
@@ -1,170 +1,138 @@
|
||||
# REPORT — Hub v0.84.0: break-glass console credential on the host page (2026-07-31)
|
||||
# REPORT — Hub v0.85.0: the Network card (2026-07-31)
|
||||
|
||||
## 1. Confirmed baselines actually used
|
||||
**Overwritten** per the standing rule. Pairs with **agent v0.119.0** (see `felhom-agent/REPORT.md`);
|
||||
neither half is useful alone. Live evidence:
|
||||
`documentation/audits/host-addresses-visible-2026-07-31.md`.
|
||||
|
||||
| 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** |
|
||||
> The earlier v0.84.0 work this file previously described (break-glass Console access card) shipped,
|
||||
> deployed and validated in the same session; its durable record is `hub/CHANGELOG.md` v0.84.0 plus
|
||||
> `CONTEXT.md` ruling **S-4** and `documentation/backlog/` **R-133**.
|
||||
|
||||
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.
|
||||
## 1. Baselines
|
||||
|
||||
## 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 |
|
||||
| Repo | `main` @ start of this half | Version before → after |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| felhom.eu (hub) | `b4edc087fa9a` | hub **v0.84.0 → v0.85.0** |
|
||||
| felhom-agent | `6b5dade4dcc5` | agent **0.118.1 → 0.119.0** |
|
||||
|
||||
**Red-proofs — each mutation applied, observed failing, reverted:**
|
||||
## 2. The ask, and what it turned out to be
|
||||
|
||||
*"Show the LAN and WireGuard IPs on the hosts page — it looks like it is not visible anywhere."*
|
||||
Nearly exact, and the two halves were very different jobs:
|
||||
|
||||
- **WireGuard** — the hub already had it (`wg_peers.assigned_ip`, `GetWGPeerForHost`, **no UI
|
||||
consumer**). It was visible in exactly one place, `/offsite`'s peer table keyed by pubkey, so you
|
||||
could go peer→host and never host→peer.
|
||||
- **LAN** — **not renderable at all.** `HostReport`/`HostMetrics` carried no address of any kind, so
|
||||
this needed a cross-repo agent release, not an edit. Confirmed against the struct and against a
|
||||
live box before proposing anything.
|
||||
|
||||
Operator chose: both together as one arc, with the `addresses[]` (iface + cidr) shape.
|
||||
|
||||
## 3. Files (hub half)
|
||||
|
||||
- `hub/internal/web/hosts.go` — `parseHostAddresses`, `hostAddressView`, `hostNetworkView`,
|
||||
`hostNetwork`, `minAgentForAddresses`, `hostDetailData` +1 key
|
||||
- `hub/internal/web/templates/host_detail_body.html` — the Network card
|
||||
- `hub/internal/web/hosts_network_test.go` (**new**)
|
||||
- `hub/internal/api/testdata/host-report.golden.json` — the cross-repo contract, moved in lockstep
|
||||
- `hub/CHANGELOG.md`, `REUSE.md`, `manifests/hub.yaml`
|
||||
- `documentation/architecture/00-capability-map.md` (new row),
|
||||
`documentation/audits/host-addresses-visible-2026-07-31.md` (new)
|
||||
|
||||
## 4. Commits pushed to `main`
|
||||
|
||||
| Repo | Commit | What |
|
||||
|---|---|---|
|
||||
| felhom-agent | `14642e3` | v0.119.0 — the host report carries the box's addresses |
|
||||
| felhom-agent | `4663df7` | REPORT |
|
||||
| felhom.eu | `e07d90f` | hub v0.85.0 — Network card |
|
||||
| felhom.eu | `37f7ff6` | manifests: hub 0.84.0 → 0.85.0 |
|
||||
|
||||
## 5. Tests + red-proofs (hub half)
|
||||
|
||||
Suite **559 → 566** (+7). `go build ./... && go vet ./... && go test ./...` **rc=0**, run separately
|
||||
from the commit per standing rule 1.
|
||||
|
||||
| Test | Asserts | Result |
|
||||
|---|---|---|
|
||||
| `TestNetwork_A_LANAddressRendered` | the LAN address + its interface reach the page | PASS |
|
||||
| `TestNetwork_B_WireGuardAllocatedAndConfirmed` | WG shown once, marked confirmed, not duplicated as a plain row | PASS |
|
||||
| `TestNetwork_C_AllocatedButBoxDoesNotHoldIt` | the drift case renders `not confirmed by the box` | PASS |
|
||||
| `TestNetwork_D_OldAgentSaysUnknownNotNone` | an old agent renders UNKNOWN, never "no addresses" | PASS |
|
||||
| `TestNetwork_E_NoPeerAllocated` | no peer → says so; the box's wg address is not silently dropped | PASS |
|
||||
| `TestNetwork_F_NoReportYet` | never-reported host says "waiting", not "no addresses" | PASS |
|
||||
| `TestParseHostAddresses_RealWire` | parses the real wire; empty/malformed degrade without panic | PASS |
|
||||
|
||||
**The report fixture is the REAL wire** — the `addresses` block copied out of
|
||||
`felhom-agent --selftest=hub` on demo-felhom running 0.119.0. A hand-written shape would have proved
|
||||
only that the parser matches my own idea of the format.
|
||||
|
||||
| # | 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 |
|
||||
| A | `hostDetailData` ships an empty `hostNetworkView{}` (the inert-seam shape) | `FAIL … the LAN address is not on the host page — the feature shows nothing` | yes |
|
||||
| B | `WGConfirmed` set unconditionally from the allocation | `FAIL … an allocated-but-unheld peer renders as healthy — the drift is invisible` | yes |
|
||||
| D | delete the `AgentTooOld` branch | `FAIL … an old agent's silence is not explained` | 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 A took two attempts, and the first one proved nothing.** The initial mutation
|
||||
(`_ = network` inside a map literal) did not compile, so the "failure" was a build error, not a test
|
||||
result. Recorded because a red-proof that fails to build is indistinguishable from one that lands if
|
||||
you only read the exit code.
|
||||
|
||||
**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.
|
||||
## 6. Deployed
|
||||
|
||||
**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`).
|
||||
Image `gitea.dooplex.hu/admin/felhom-hub:0.85.0` built on DooPlex and pushed; `manifests/hub.yaml`
|
||||
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`**
|
||||
- `deploy/hub` image: **`gitea.dooplex.hu/admin/felhom-hub:0.85.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.)*
|
||||
- Startup log: `2026/07/31 08:50:57 [INFO] felhom-hub 0.85.0 starting`
|
||||
|
||||
**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.
|
||||
**One sequencing note worth keeping:** the first sync fired before ArgoCD had re-read git, so it
|
||||
reported `Synced/Healthy` while still running 0.84.0 — a green pair that meant nothing. The fix is to
|
||||
wait until `.status.sync.revision` equals the pushed commit **before** trusting the sync, which the
|
||||
final run did.
|
||||
|
||||
| # | 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 |
|
||||
## 7. Live validation
|
||||
|
||||
**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.
|
||||
**Method: endpoint-level** (no browser on DooPlex), against the deployed hub, cross-checked
|
||||
against `ip addr` read directly over SSH on both boxes.
|
||||
|
||||
**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.
|
||||
| Host | Agent | Network card, live | Matches ground truth? |
|
||||
|---|---|---|---|
|
||||
| `demo-felhom-8363b5` | 0.119.0 | WG **10.77.0.2 confirmed**; `vmbr0 192.168.0.162/24`, `tailscale0 100.70.170.35/32` + ULA | **yes, exactly** |
|
||||
| `demo-hp-bb76ea` | 0.119.0 | WG **10.77.0.3 confirmed**; `vmbr0 192.168.0.87/24`, `tailscale0 100.76.96.79/32` + ULA | **yes, exactly** |
|
||||
| `drill-r50-0a4f9a` | 0.113.0 | WG 10.77.0.4 **allocated**; *"agent (0.113.0) does not report its addresses — they are unknown, not absent. Needs 0.119.0 or newer."* | correct UNKNOWN rendering |
|
||||
| `sess-f-2670b5` | old | same UNKNOWN rendering | correct |
|
||||
|
||||
## 7. NOT yet live-validated — awaiting the operator
|
||||
Correctly absent from every card: `lo`, `vmbr9`'s `169.254.253.1` island literal, every `fe80::`.
|
||||
|
||||
**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).
|
||||
**A fifth host 404'd and it was not a bug.** `sess-g-e687c3` appeared in a host list I read 30
|
||||
minutes earlier and returned 404 when I fetched its page; the concurrent session had deleted it as
|
||||
part of its scratch-customer cleanup. Chased to ground before reporting rather than filed as a defect.
|
||||
|
||||
## 8. Teardown
|
||||
## 8. Not yet validated / not covered
|
||||
|
||||
**This run provisioned nothing** — no guest, no VM, no customer, no drive, no external resource. No
|
||||
teardown obligation.
|
||||
- **A box with two LAN bridges** — handled by design (one row per interface) and unit-tested; no such
|
||||
box exists to observe.
|
||||
- **A real WireGuard drift** — the `not confirmed` branch is unit-tested and red-proofed but never
|
||||
seen in production, because no live peer is currently unapplied.
|
||||
- **IPv4-only / IPv6-only hosts** — every box here is dual-stacked via tailscale's ULA.
|
||||
|
||||
## 9. Backlog rows opened / closed / re-ranked
|
||||
## 9. Teardown
|
||||
|
||||
- **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.
|
||||
This run provisioned nothing — no guest, no VM, no scratch customer, no external resource. The only
|
||||
mutations are two in-place agent upgrades (previous binaries retained as `.bak-0.118.1` on both
|
||||
boxes), the Day-0 manifest agent vouch, and the hub image bump.
|
||||
|
||||
## 10. Observations — recorded, deliberately NOT acted on
|
||||
## 10. Backlog
|
||||
|
||||
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.
|
||||
- **Opened: none.** **Closed: none.**
|
||||
- **R-115 / R-121 touched, not closed.** This release walked all three legs deliberately —
|
||||
deployed, **published** (round-trip verified) and **vouched** (read back) — so no new drift
|
||||
instance was created. The finding those rows record is that nothing *notices* when the legs
|
||||
diverge, which is unchanged. One small contribution: the Network card now surfaces
|
||||
"this host's agent is older than 0.119.0" per host, which is a per-field hint, not the
|
||||
installed-vs-vouched checker R-121 asks for.
|
||||
- **Concurrent session:** every path was staged explicitly; no foreign WIP was swept into any commit.
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
| Forgot dashboard password → instant reset code | controller v0.123, hub | **PROVEN-LIVE** | `DRILL-day0-take2-2026-07-12` F-15 (live re-run of the exact failure path: hash applied 1s after request, code accepted first try) | |
|
||||
| Multiple household users / per-person accounts | — | **MISSING** | — | Single dashboard password; acceptable for alpha → R-15 |
|
||||
| WireGuard base infra always-on; OOB operator access (felhom-sshd, /32 peer) | agent v0.72, hub v0.35 | **IMPLEMENTED** | `SPIKE-oob-wg-operator-peer-2026-07-05`, `SPIKE-felhom-sshd-2026-07-05` | Mutual-repair desired-state arc not built → R-13 |
|
||||
| The operator can see WHERE a managed host is — its LAN address and its WireGuard address, on the host page | agent **v0.119.0**, hub **v0.85.0** | **PROVEN-LIVE** (2026-07-31) | `audits/host-addresses-visible-2026-07-31.md` | Before this the LAN IP was **not reportable at all** — `HostMetrics` carried no address of any kind — and the WG IP existed only in `/offsite`'s peer table keyed by pubkey (peer→host, never host→peer). New wire field `addresses[]`, one row per (interface, address); `IsGlobalUnicast()` is the whole filter, chosen by MEASURING both demo boxes, and it needs no veth/fwbr denylist because that plumbing carries no IP. Rendered live on both 0.119.0 hosts matching their `ip addr` ground truth exactly. **Two honesty properties carry the risk and are both red-proofed:** WireGuard shows the hub ALLOCATION and whether the box CONFIRMS holding it (allocation alone cannot tell a live tunnel from a peer never applied), and an agent below 0.119.0 renders **UNKNOWN, never "no addresses"** — proven live on `drill-r50-0a4f9a` (0.113.0). **Not covered:** a two-LAN-bridge box and a real WG drift, neither of which exists to observe |
|
||||
| Break-glass management-plane recovery | agent v0.71, hub v0.84 | **IMPLEMENTED** | `runbooks/break-glass.md` | hub v0.84.0 adds an **operator-SESSION** retrieval path (host page → Console access → Reveal; `POST /hosts/{id}/reveal-recovery-credential`, CSRF-gated, writes a customer-visible `recovery_credential_revealed` event) beside the pre-existing **global-key** one (`GET /api/v1/admin/hosts/{id}/recovery-credential`), which is untouched and stays the route for when the hub UI itself is down. Still IMPLEMENTED, not PROVEN-LIVE: the UI path has not been exercised on a real lockout, and that the revealed password authenticates at `:8006` is operator-verified only. The vaulted secret is plaintext at rest → **R-133** |
|
||||
|
||||
## F. Notifications & monitoring
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# AUDIT — a managed host's addresses are visible in the hub (2026-07-31)
|
||||
|
||||
**Artifacts:** felhom-agent **v0.119.0** + hub **v0.85.0**. Method: **endpoint-level** on live
|
||||
hardware (no browser on DooPlex), plus direct `ip addr` reads on both boxes as the ground truth the
|
||||
rendered page is checked against.
|
||||
|
||||
## 1. The finding that started it
|
||||
|
||||
The operator asked to see the LAN and WireGuard IPs on the hosts page, "because it looks like it is
|
||||
not visible anywhere". That was very nearly exact:
|
||||
|
||||
- **LAN IP — not visible, and not reportable.** `HostReport`/`HostMetrics` carried node, cpu, memory,
|
||||
disk, loadavg, uptime, cpu temp and the wrapper sha — **no address of any kind**. The hub could not
|
||||
render what it was never sent.
|
||||
- **WireGuard IP — visible in one place, in the wrong direction.** `/offsite` renders a peer table
|
||||
with an Assigned IP column and a `Host` link, so an operator could go peer→host but never
|
||||
host→peer, which is the direction the question is actually asked in. `GetWGPeerForHost` already
|
||||
existed with **no UI consumer**.
|
||||
|
||||
## 2. Two plausible LAN-IP sources were rejected BEFORE any code, on measurement
|
||||
|
||||
| Candidate | Why it fails |
|
||||
|---|---|
|
||||
| `lan_resolver.host_ip` (agent config) | OPTIONAL — set only when the split-horizon DNS feature is configured. Absent on a box that never enabled it. |
|
||||
| `DeriveHostIP(local_api.listen_addr)` | Returns **`169.254.253.1`** — since the R-50 island migration the local API binds a link-local address that is **byte-identical on every box**. |
|
||||
|
||||
Both would have produced a confident wrong answer, which is worse than the blank they replace. The
|
||||
agent now reads the kernel's own interface table instead.
|
||||
|
||||
## 3. Ground truth — what the boxes actually hold
|
||||
|
||||
`ip -o addr show`, 2026-07-31:
|
||||
|
||||
```
|
||||
demo-felhom demo-hp
|
||||
lo 127.0.0.1/8 + ::1/128 (both)
|
||||
tailscale0 100.70.170.35/32 + ULA + fe80 100.76.96.79/32 + ULA + fe80
|
||||
vmbr0 192.168.0.162/24 + fe80 192.168.0.87/24 + fe80
|
||||
vmbr9 169.254.253.1/30 + fe80 169.254.253.1/30 + fe80 <- IDENTICAL on both
|
||||
wg-felhom 10.77.0.2/32 10.77.0.3/32
|
||||
(no address at all: enp*/wlp*/wlo1, veth9201i0, veth9201i1)
|
||||
```
|
||||
|
||||
**The measurement decided the design.** `IsGlobalUnicast()` alone drops loopback, `fe80::/10` and
|
||||
`169.254/16` — that last one being the island literal above. And **no veth/fwbr/tap denylist is
|
||||
needed**, because on a Proxmox host that per-guest plumbing carries no IP at all and self-excludes.
|
||||
The unit-test fixtures are these tables transcribed verbatim, **including the address-less rows**,
|
||||
because the no-denylist claim rests on those rows really being empty.
|
||||
|
||||
## 4. The wire, from the agent that will ship
|
||||
|
||||
`felhom-agent --selftest=hub` on demo-felhom running 0.119.0 — "the report it would send":
|
||||
|
||||
```json
|
||||
"addresses": [
|
||||
{ "iface": "tailscale0", "cidr": "100.70.170.35/32" },
|
||||
{ "iface": "tailscale0", "cidr": "fd7a:115c:a1e0::5236:aa24/128" },
|
||||
{ "iface": "vmbr0", "cidr": "192.168.0.162/24" },
|
||||
{ "iface": "wg-felhom", "cidr": "10.77.0.2/32" }
|
||||
]
|
||||
```
|
||||
|
||||
Correctly present: the LAN address. Correctly absent: `lo`, `vmbr9`'s island address, every `fe80::`.
|
||||
|
||||
## 5. Rendered, live, on every enrolled host
|
||||
|
||||
Read off `GET /hosts/<id>` on the deployed hub 0.85.0:
|
||||
|
||||
| Host | Agent | Network card |
|
||||
|---|---|---|
|
||||
| `demo-felhom-8363b5` | 0.119.0 | WireGuard **10.77.0.2 confirmed**; `vmbr0 192.168.0.162/24`, `tailscale0 100.70.170.35/32` + ULA |
|
||||
| `demo-hp-bb76ea` | 0.119.0 | WireGuard **10.77.0.3 confirmed**; `vmbr0 192.168.0.87/24`, `tailscale0 100.76.96.79/32` + ULA |
|
||||
| `drill-r50-0a4f9a` | 0.113.0 | WireGuard 10.77.0.4 **allocated**; *"This host's agent (0.113.0) does not report its addresses — they are **unknown**, not absent. Needs agent 0.119.0 or newer."* |
|
||||
| `sess-f-2670b5` | old | same UNKNOWN rendering |
|
||||
|
||||
Both 0.119.0 rows match the `ip addr` ground truth in §3 exactly.
|
||||
|
||||
## 6. The two design points that carry the risk
|
||||
|
||||
**WireGuard is rendered as TWO facts.** `WGAssignedIP` is the hub's allocation (`wg_peers`, desired
|
||||
state); `WGConfirmed` is whether the box reports holding it. Rendering the allocation alone would
|
||||
make a peer that was **never applied** look healthy — the presence-is-not-result trap, in the shape
|
||||
this project has already been bitten by twice (F-CRIT-2, R-100). A mismatch renders `not confirmed by
|
||||
the box`, and a red-proof pins it: hard-wiring `WGConfirmed = true` turns that test red.
|
||||
|
||||
The split is keyed on the **allocation**, never on the interface name `wg-felhom` — that is a systemd
|
||||
unit name, and a UI keyed on it would silently mis-render the day it changes.
|
||||
|
||||
**An old agent renders UNKNOWN, never "no addresses."** Below `0.119.0` the field is absent from the
|
||||
wire. An absent signal and a negative result are different facts, and conflating them would have the
|
||||
page state something false about a live host. Proven live on `drill-r50-0a4f9a` (§5) and red-proofed:
|
||||
deleting the branch makes the page claim the host has no routable address.
|
||||
|
||||
## 7. Release hygiene (R-115's leg, walked deliberately)
|
||||
|
||||
Deployed **and** installable, in the same session — the two states R-115 says drift silently:
|
||||
|
||||
- built v0.119.0, deployed to **both** demo boxes (`systemctl is-active` → `active`, `--version` → 0.119.0)
|
||||
- **published** to Gitea (`scripts/publish-agent.sh`), sha `e37aca824d0f57bb…`, **round-trip GET verified**
|
||||
- **vouched** in the Day-0 manifest and **read back**: `agent=0.119.0`, with golden `0.188.0`,
|
||||
`min_agent 0.113.0` and `wrapper_sha256` preserved **verbatim**
|
||||
- `min_agent` deliberately **NOT** raised: the field has no controller coupling, so holding the
|
||||
controller floor for it would be an unnecessary fleet-wide write
|
||||
|
||||
A trap hit on the way, worth recording: the Gitea package list sorted **lexicographically** put
|
||||
`0.96.0` above `0.118.1` — the exact shape R-120 recorded for SQL `MAX()`. Re-sorted by semver before
|
||||
choosing the version.
|
||||
|
||||
## 8. Not covered
|
||||
|
||||
- **A box with two LAN bridges.** The design handles it (one row per interface) and a unit test covers
|
||||
it, but no such box exists to observe.
|
||||
- **IPv4-only or IPv6-only hosts** — every box here is dual-stacked via tailscale's ULA.
|
||||
- **A real WireGuard drift** — the `not confirmed` branch is unit-tested and red-proofed, never seen
|
||||
in production, because no live peer is currently unapplied.
|
||||
- The card is **operator-facing only**; no customer surface changed.
|
||||
Reference in New Issue
Block a user