v0.119.0 — the host report carries the box's addresses

A managed box's IP was invisible in every operator surface because nothing
reported one: HostMetrics carried node/cpu/mem/disk/load/uptime/temp/wrapper-sha
and no address of any kind. The hub could not show a host's LAN IP anywhere.

Two things that looked like the answer are traps, both checked before writing
code: lan_resolver.host_ip is an OPTIONAL config value absent unless that feature
is configured, and DeriveHostIP(local_api.listen_addr) returns 169.254.253.1 —
since R-50 the local API binds a link-local address identical on every box. Both
would have produced a confident wrong answer.

New wire field addresses[], one entry per (interface, address). Deliberately
iface+cidr rather than a single lan_ip: a Proxmox host legitimately holds several
(management bridge, tailnet, WG tunnel) and picking one to call "the" LAN IP is a
guess the agent is not entitled to make — silently wrong on a box whose bridge is
not vmbr0. The agent reports what exists; the hub does the labelling.

The filter is one predicate, chosen by MEASURING both demo hosts rather than by
reasoning about interface names. IsGlobalUnicast() alone drops loopback, IPv6
link-local (one per bridge, pure noise) and IPv4 link-local (169.254/16 — exactly
the island address above). It needs no veth/fwbr/tap denylist: that per-guest
plumbing carries no IP at all and self-excludes, verified on both boxes.

No new privilege and no block I/O — net.Interfaces() is a netlink/procfs read, so
the sudoers fence is untouched and the health-check rule is honoured.

The seam DEFAULTS to the real enumerator, inverting the nil-reporter-means-off
convention: this stanza has no config gate, so a forgotten wiring call would have
shipped it silently empty — the inert-seam failure recorded four times here.

Cross-repo: the golden is duplicated byte-identically in felhom.eu and the
contract test fails on top-level key drift, so both goldens moved together and
addresses[0]'s key set is asserted bidirectionally. The field marshals as [],
never null — the repo's own no-nulls invariant caught that on the first run.

Tests +9; three red-proofs (global-unicast filter, down-interface guard, inert
collectAddresses) each run, observed failing, and reverted.
This commit is contained in:
2026-07-31 08:40:58 +02:00
parent 6b5dade4dc
commit 14642e3c7b
9 changed files with 418 additions and 0 deletions
+50
View File
@@ -1,3 +1,53 @@
## v0.119.0 — the host report carries the box's addresses (2026-07-31)
**Pairs with hub v0.85.0.** The agent half is useless without it — the hub is what renders these.
**A managed box's IP was invisible in every operator surface, because nothing reported one.**
`HostMetrics` carried node, cpu, memory, disk, loadavg, uptime, temperature and the wrapper sha —
and no address of any kind. The hub therefore could not show a host's LAN IP anywhere; the only IP
reachable from the UI at all was the WireGuard one, and only on `/offsite`'s peer table keyed by
pubkey, so you could go peer→host and never host→peer.
**Two things that looked like the answer are traps, and both were checked before writing code.**
`lan_resolver.host_ip` is an OPTIONAL config value, absent unless that feature is configured. And
`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 **identical on every box**. Either would have produced a
confident wrong answer, which is worse than the blank it replaces.
**New wire field `addresses[]`, one entry per (interface, address).** Deliberately iface+cidr rather
than a single `lan_ip`: a Proxmox host legitimately holds several — a management bridge, a tailnet,
the WG tunnel — and picking one to call "the" LAN IP is a guess the agent is not entitled to make. On
a box whose management bridge is not `vmbr0` that guess is silently wrong. The agent reports what
exists; the hub does the labelling.
**The filter is one predicate, and it was chosen by MEASURING both demo hosts, not by reasoning.**
`IsGlobalUnicast()` alone drops loopback, IPv6 link-local (`fe80::/10`, one per bridge, pure noise)
and IPv4 link-local (`169.254/16` — exactly the island address above). It needs **no veth/fwbr/tap
denylist**, because on a Proxmox host that per-guest plumbing carries no IP at all and self-excludes:
`veth9201i0/i1` and the unused NICs appear in `ip link` and in no `ip addr` output on either box. What
survives is `vmbr0`'s LAN address, `wg-felhom`'s tunnel address and `tailscale0`'s tailnet addresses —
all true, all useful, none labelled here.
**No new privilege and no block I/O.** `net.Interfaces()` is a netlink/procfs read: it needs no sudo
grant, touches the sudoers fence not at all, and honours the health-check rule (R-117 spike §6.3) that
liveness is decided from `/proc` and kernel state, never by reading a filesystem.
**The seam defaults to the REAL enumerator.** `Collector.addrEnum == nil` uses `systemInterfaces`,
inverting the nil-reporter-means-off convention the optional stanzas use. Those gate on a config
feature; this one has no dependency and no flag, so a forgotten wiring call in `main.go` would have
shipped it silently empty — the inert-seam failure this repo has now recorded four times.
**Cross-repo, and the contract test enforces it:** `testdata/host-report.golden.json` is duplicated
byte-identically in `felhom.eu/hub/internal/api/testdata/`, and `TestHostReport_ContractMatchesGolden`
fails on any top-level key drift. Both goldens moved in this arc, and `addresses[0]`'s key set is
asserted bidirectionally alongside the existing sections. The field marshals as `[]`, never `null`
the repo's own no-nulls invariant test caught that on the first run.
Files: `internal/hub/hostaddr.go` (new), `internal/hub/report.go`, `internal/hub/collect.go`,
`internal/hub/contract_test.go`, `internal/hub/report_test.go`, both goldens, `REUSE.md`.
Tests +9; three red-proofs (the global-unicast filter, the down-interface guard, and an inert
`collectAddresses`) each run, observed failing, and reverted.
## v0.118.1 — R-106 follow-up: the namespace was still being lost in the merge (2026-07-30)
**v0.118.0's R-106 fix was incomplete and live validation caught it.** Deployed to demo-felhom, the