6.8 KiB
REPORT — the host report carries the box's addresses (agent v0.119.0, 2026-07-31)
Overwritten per the standing rule. Pairs with hub v0.85.0; the agent half is useless alone.
Full live evidence: felhom.eu/documentation/audits/host-addresses-visible-2026-07-31.md.
1. Baselines
| Repo | main @ start |
Version before → after |
|---|---|---|
| felhom-agent | 6b5dade4dcc5 |
0.118.1 → 0.119.0 |
Clean-tree gate held before the build (git status --porcelain empty, HEAD == origin/main).
2. What changed and why
The hub could not show a managed box's LAN IP anywhere, because nothing reported one:
HostMetrics carried node/cpu/memory/disk/loadavg/uptime/temp/wrapper-sha and no address of any kind.
Two plausible sources were checked and rejected before any code was written — this is the
substantive part. lan_resolver.host_ip is an OPTIONAL config value, absent unless that feature is
configured. DeriveHostIP(local_api.listen_addr) returns 169.254.253.1, because since the R-50
island migration the local API binds a link-local address that is byte-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,
and is silently wrong on a box whose bridge is not vmbr0. The agent reports what exists; the hub
labels.
3. The filter was chosen by measurement, not reasoning
ip -o addr show on both demo boxes established that IsGlobalUnicast() is the whole rule: it
drops loopback, IPv6 link-local (fe80::/10, one per bridge) and IPv4 link-local (169.254/16 —
exactly the island literal 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.
The unit fixtures are those two interface tables transcribed verbatim, including the address-less rows — the no-denylist claim rests on those rows really being empty, and a fixture that omitted them would have proved the claim by assuming it.
4. Files
internal/hub/hostaddr.go(new) —HostAddress, theAddressEnumeratorseam,systemInterfaces,filterHostAddresses,Collector.collectAddressesinternal/hub/hostaddr_test.go(new)internal/hub/report.go,internal/hub/collect.go— the field + its populationinternal/hub/contract_test.go,internal/hub/report_test.go,internal/hub/testdata/host-report.golden.jsonCHANGELOG.md,REUSE.md
5. Commit
14642e3 — v0.119.0 — the host report carries the box's addresses, pushed to main (trunk-based).
6. Tests + red-proofs
Suite 874 → 883 (+9), go build ./... && go vet ./... && go test ./... rc=0, 29 packages ok.
Run as a command separate from the commit, per standing rule 1.
| # | Mutation | Observed | Reverted |
|---|---|---|---|
| 1 | drop the !IsGlobalUnicast() continue |
FAIL — the island address and every fe80:: leak through, on both host fixtures |
yes |
| 2 | drop the !i.Up guard |
FAIL … a DOWN interface's address was reported |
yes |
| 3 | make collectAddresses return []HostAddress{} (the inert-seam shape) |
FAIL … the collector did not run the filter over the enumerator's output |
yes |
The repo's own no-nulls invariant caught the new field on the first run —
TestHostReport_FieldNamesAndEmptyCollections failed because addresses marshalled as null. Fixed
by making the collector return a non-nil slice and setting [] in the fixture, matching how every
other collection is handled. That guard did its job unprompted.
The seam DEFAULTS to the real enumerator (addrEnum == nil → systemInterfaces), deliberately
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 recorded four times in this project.
7. Cross-repo contract
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 were diffed to confirm they stayed identical;
addresses[0]'s key set is now asserted bidirectionally alongside the existing sections.
8. Live validation
Method: the agent's own --selftest=hub, which prints "the report it would send" — the wire
itself, not a proxy. On demo-felhom running the deployed 0.119.0:
"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" }
]
Present: the LAN address. Absent: lo, vmbr9's 169.254.253.1, every fe80::. Matches the
ip addr ground truth exactly.
9. Deploy + publish + vouch — all three, deliberately (R-115)
| Leg | Result |
|---|---|
Deployed felhom-pve (demo-felhom) |
0.118.1 → 0.119.0, systemctl is-active → active, backup kept as .bak-0.118.1 |
Deployed demo-hp |
0.118.1 → 0.119.0, active, backup kept |
| Published to Gitea | sha256 e37aca824d0f57bb13b859a9a3cfcc472e84395d6f290ee90228b1933ab9fa43, upload HTTP 201, round-trip GET verified |
| Vouched in the Day-0 manifest | read back: agent=0.119.0; golden 0.188.0, min_agent 0.113.0, wrapper_sha256 all preserved verbatim |
min_agent deliberately not raised — addresses[] has no controller coupling, so holding the
controller floor for it would be an unnecessary fleet-wide write.
Trap hit and recorded: the Gitea package listing sorted lexicographically ranks 0.96.0 above
0.118.1, which read as "nothing since 0.96.0 was ever published" — the exact shape R-120 recorded
for SQL MAX(). Re-sorted by semver before picking the version. Anyone auditing that endpoint by eye
will hit it too.
10. Not covered
- A box with two LAN bridges — handled by design (one row per interface) and unit-tested, but no such box exists to observe.
- IPv4-only / IPv6-only hosts; every box here is dual-stacked via tailscale's ULA.
- No new privilege was taken:
net.Interfaces()is a netlink/procfs read, so the sudoers fence is untouched and the "a health check issues no block I/O" rule is honoured.
11. Teardown
This run provisioned nothing — no guest, no VM, no scratch customer. The only mutations are the two
in-place binary upgrades (previous binaries retained as .bak-0.118.1 on both boxes) and the Day-0
manifest vouch.