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
@@ -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.