docs: host-addresses audit + capability-map row + REPORT (agent 0.119.0 / hub 0.85.0)

This commit is contained in:
2026-07-31 08:55:53 +02:00
parent 37f7ff69f2
commit eb5d05f496
3 changed files with 228 additions and 143 deletions
+111 -143
View File
@@ -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.