131 lines
6.8 KiB
Markdown
131 lines
6.8 KiB
Markdown
# 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`, the `AddressEnumerator` seam,
|
|
`systemInterfaces`, `filterHostAddresses`, `Collector.collectAddresses`
|
|
- `internal/hub/hostaddr_test.go` (**new**)
|
|
- `internal/hub/report.go`, `internal/hub/collect.go` — the field + its population
|
|
- `internal/hub/contract_test.go`, `internal/hub/report_test.go`,
|
|
`internal/hub/testdata/host-report.golden.json`
|
|
- `CHANGELOG.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:
|
|
|
|
```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" }
|
|
]
|
|
```
|
|
|
|
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.
|