diff --git a/REPORT.md b/REPORT.md index 6e8ef4e..3c1ec3e 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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://: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. diff --git a/documentation/architecture/00-capability-map.md b/documentation/architecture/00-capability-map.md index c3fce95..5212a3a 100644 --- a/documentation/architecture/00-capability-map.md +++ b/documentation/architecture/00-capability-map.md @@ -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 diff --git a/documentation/audits/host-addresses-visible-2026-07-31.md b/documentation/audits/host-addresses-visible-2026-07-31.md new file mode 100644 index 0000000..25d9f45 --- /dev/null +++ b/documentation/audits/host-addresses-visible-2026-07-31.md @@ -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/` 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.