diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd1f6a..2362f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/REUSE.md b/REUSE.md index 775820d..208c4e4 100644 --- a/REUSE.md +++ b/REUSE.md @@ -156,6 +156,7 @@ | `guestnet.Runner` / `guestnet.GuestSource` (R-54, v0.92.0) | internal/guestnet/{probe,watchdog}.go | `*proxmox.ExecRunner`; the POOL-VERIFIED `localapi.StaleLockController.Guests` (ListLXC ∩ felhom pool, audit A1) | `scriptedRunner` + `fakeGuests` internal/guestnet/watchdog_test.go. **Never wire a bare `ListLXC` here** — under a broad token that would run dhclient inside a co-tenant's container. Every assertion is an exec COUNT, and the load-bearing ones are the negatives: a static guest, an unprobeable guest, a boot-race guest and an unproven guest list must record **zero** heal calls | | `guestnet.Watchdog.SetDampers` / `now` (clock seam) | internal/guestnet/watchdog.go | config `guest_net.*`; `now` defaults to `time.Now` | tests advance a manual clock (the storage-watchdog pattern) and assert the heal ceilings EXACTLY — ≥10 min apart, ≤3/hour, and ≤30 over a scripted 10 hours of permanent failure. A damper with no test is a comment | | `hub.GuestNetReporter` (R-54) | internal/hub/collect.go | `*guestnet.Watchdog` (`GuestNetStatus`) | internal/hub/collect_guestnet_test.go asserts the stanza through the PRODUCTION `Collect` path AND that the `guest_net` key is ABSENT from the wire when no reporter is wired — an always-present empty stanza would make "not wired" and "found nothing" the same signal, which is the shape v0.91.0 hid behind | +| `hub.AddressEnumerator` (v0.119.0) | internal/hub/hostaddr.go | **defaults to the REAL `systemInterfaces`** when `Collector.addrEnum` is nil — deliberately inverting the nil-reporter-means-off convention, because this stanza has no config gate and a forgotten wiring call would otherwise ship silently empty (the inert-seam shape, four instances on record) | internal/hub/hostaddr_test.go drives fixtures TRANSCRIBED from `ip -o addr show` on demo-felhom AND demo-hp, including the address-less veth/NIC rows — the "no denylist needed" claim rests on those rows really being empty, so omitting them would prove the claim by assuming it. `filterHostAddresses` keeps GLOBAL UNICAST only: one predicate that drops loopback, `fe80::/10`, and `169.254/16` — the last being the R-50 island literal, identical on every box and actively misleading if surfaced | | `reconcile.OpVerifier` | internal/reconcile/gate.go | `*authz.Verifier` | fake verifier in internal/reconcile gate tests | | `signedjobs.WipeOps` / `Executor` (`ExecutorChain`) | internal/signedjobs/wipe.go + runner.go | `*storage.SudoHostOps`; `WipeExecutor`+`DecommissionExecutor` | internal/signedjobs wipe/runner/decommission tests | | `hub.reporter` / `collectorIface` / `EnvelopeObserver` | internal/hub/loop.go | `*hub.Client`, `*hub.Collector`; `desired.Syncer` + `signedjobs.Runner` | `fakeReporter`/`fakeCollector` internal/hub/loop_test.go; `recordingReporter` loop_logtail_test.go | diff --git a/internal/hub/collect.go b/internal/hub/collect.go index 10bfcf5..7f90636 100644 --- a/internal/hub/collect.go +++ b/internal/hub/collect.go @@ -83,6 +83,7 @@ type Collector struct { temp TempReader // slice 9: host CPU/chassis temp (nil-safe → nil temp) capProbe func(ctx context.Context) []capability.Status // v0.44.0: privileged-capability self-check (nil → empty) leafFP string // v0.48.0: served local-API leaf fp (static per process; "" when local API disabled) + addrEnum AddressEnumerator // v0.119.0: host interface enumeration; nil => the REAL one (see collectAddresses) wg WireguardReporter // S3: offsite-tunnel status (nil → stanza omitted) pbsdr PBSDRReporter // slice 2: PBS DR tier bridge state (nil → stanza omitted) guestNet GuestNetReporter // R-54: per-guest network watchdog (nil → stanza omitted) @@ -253,6 +254,7 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) { Cloudflared: Cloudflared{Status: c.cloudflaredStatus(ctx)}, Capabilities: c.capabilities(ctx), LeafFingerprint: c.leafFP, + Addresses: c.collectAddresses(), } // DR recipe host-half — derived from the just-collected guest/storage/PBS facts (no new reads). // Secret-free by construction (identifiers/intents/sizes/coordinates only). diff --git a/internal/hub/contract_test.go b/internal/hub/contract_test.go index 1acddea..33b308d 100644 --- a/internal/hub/contract_test.go +++ b/internal/hub/contract_test.go @@ -80,6 +80,9 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) { }, AuditTail: []AuditEntry{}, Cloudflared: Cloudflared{Status: "active"}, + // v0.119.0: host addresses. Populated so the bidirectional key-set guard exercises the new + // element keys, not just the presence of the array. + Addresses: []HostAddress{{Iface: "vmbr0", CIDR: "192.168.0.162/24"}}, } // dr_recipe host-half: built from the same guest/storage/pbs facts (the production path). report.DRRecipe = BuildDRRecipeHostHalf(report.Guests, report.StorageTargets, report.PBSSnapshots, @@ -106,6 +109,9 @@ func TestHostReport_ContractMatchesGolden(t *testing.T) { assertSameKeys(t, "restore_tests[0]", firstElem(golden["restore_tests"]), firstElem(got["restore_tests"])) // slice-6-Phase-B addition — pbs_snapshots[0] key set. assertSameKeys(t, "pbs_snapshots[0]", firstElem(golden["pbs_snapshots"]), firstElem(got["pbs_snapshots"])) + // v0.119.0 addition — addresses[0] key set (iface/cidr), the cross-repo wire for the hub's + // Network card. + assertSameKeys(t, "addresses[0]", firstElem(golden["addresses"]), firstElem(got["addresses"])) // DR-recipe host-half — the agent's secret-free reconstruction-scaffolding section. Assert the // dr_recipe key set + each sub-array's element key set (the cross-repo wire pinned in the golden). diff --git a/internal/hub/hostaddr.go b/internal/hub/hostaddr.go new file mode 100644 index 0000000..f351121 --- /dev/null +++ b/internal/hub/hostaddr.go @@ -0,0 +1,134 @@ +package hub + +import ( + "net" + "net/netip" + "sort" +) + +// Host addresses (v0.119.0) — "which addresses does this box actually hold?" +// +// The hub could not answer that at all: HostMetrics carried node/cpu/mem/disk/load/uptime/temp and +// no address of any kind, so the LAN IP of a managed host was invisible in every operator surface. +// Two sources looked like answers and are not: `lan_resolver.host_ip` is an OPTIONAL config value +// (absent unless that feature is configured), and DeriveHostIP(local_api.listen_addr) yields the +// R-50 island literal 169.254.253.1 — a link-local address that is the same on every box. Reporting +// either would have produced a confident wrong answer, which is worse than the blank it replaces. +// +// This reads the kernel's own view instead, and it issues NO block I/O (the CLAUDE.md health-check +// rule): net.Interfaces() is a netlink/procfs read, needs no privilege, and touches no filesystem. + +// HostAddress is one routable address the host holds, tagged with the interface carrying it. +// +// 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 of them 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. +type HostAddress struct { + Iface string `json:"iface"` // e.g. "vmbr0", "wg-felhom", "tailscale0" + CIDR string `json:"cidr"` // e.g. "192.168.0.162/24" — prefix length kept, it is operator-relevant +} + +// ifaceAddrs is one enumerated interface: the ONLY facts the filter needs. Keeping the seam this +// narrow is what lets the filter be tested against real measured shapes without a network stack. +type ifaceAddrs struct { + Name string + Up bool + Loopback bool + CIDRs []string +} + +// AddressEnumerator returns the host's interfaces. Injectable so the filter can be driven with the +// shapes measured on real hardware (see hostaddr_test.go) instead of whatever the test box happens +// to have. +type AddressEnumerator func() ([]ifaceAddrs, error) + +// systemInterfaces is the production enumerator: the kernel's interface table. +func systemInterfaces() ([]ifaceAddrs, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + out := make([]ifaceAddrs, 0, len(ifaces)) + for _, i := range ifaces { + e := ifaceAddrs{ + Name: i.Name, + Up: i.Flags&net.FlagUp != 0, + Loopback: i.Flags&net.FlagLoopback != 0, + } + // A per-interface error is not fatal: one unreadable interface must not cost the report + // every other address (serve-degraded, as everywhere else in the collector). + addrs, aerr := i.Addrs() + if aerr != nil { + out = append(out, e) + continue + } + for _, a := range addrs { + e.CIDRs = append(e.CIDRs, a.String()) + } + out = append(out, e) + } + return out, nil +} + +// filterHostAddresses keeps every GLOBAL UNICAST address on an up, non-loopback interface. +// +// IsGlobalUnicast() is the whole rule, and it was chosen by measuring both demo hosts rather than by +// listing interface names to exclude. It drops, in one predicate: +// - loopback (127.0.0.1, ::1) +// - IPv6 link-local (fe80::/10) — every bridge carries one, pure noise +// - IPv4 link-local (169.254.0.0/16) — which is exactly the R-50 island address on vmbr9, an +// identical constant on every box and therefore actively misleading if surfaced +// +// It needs NO veth/fwbr/tap denylist: on a Proxmox host that per-guest plumbing carries no IP at +// all, so it self-excludes by having nothing to report. Verified on demo-felhom and demo-hp — +// veth9201i0/i1, fwbr*, and the unused NICs all appear in `ip link` and in no `ip addr` output. +// +// What survives on a real box: vmbr0's LAN address, wg-felhom's tunnel address, and tailscale0's +// tailnet addresses. All three are true and useful; none is labelled here. +func filterHostAddresses(in []ifaceAddrs) []HostAddress { + out := []HostAddress{} + for _, i := range in { + if i.Loopback || !i.Up { + continue + } + for _, c := range i.CIDRs { + p, err := netip.ParsePrefix(c) + if err != nil { + continue // not a CIDR we understand — skip it, never fail the report + } + if !p.Addr().IsGlobalUnicast() { + continue + } + out = append(out, HostAddress{Iface: i.Name, CIDR: p.String()}) + } + } + // Deterministic order so a report diff reflects a real change, not interface-table ordering. + sort.Slice(out, func(a, b int) bool { + if out[a].Iface != out[b].Iface { + return out[a].Iface < out[b].Iface + } + return out[a].CIDR < out[b].CIDR + }) + return out +} + +// collectAddresses is the collector's entry point. It returns a non-nil slice so the field always +// marshals as [] — an absent key and "this box has no routable address" must not look alike to the +// hub, and [] is the honest encoding of the latter. +func (c *Collector) collectAddresses() []HostAddress { + enum := c.addrEnum + if enum == nil { + // Default to the REAL enumerator, deliberately inverting the nil-reporter-means-off + // convention used by the optional stanzas above. Those gate on a config feature; this has + // no dependency and no feature flag, so a forgotten wiring call in main.go would produce a + // silently empty field — the inert-seam failure this repo has shipped four times. + enum = systemInterfaces + } + ifaces, err := enum() + if err != nil { + c.logger.Warn("host addresses: interface enumeration failed", "err", err) + return []HostAddress{} + } + return filterHostAddresses(ifaces) +} diff --git a/internal/hub/hostaddr_test.go b/internal/hub/hostaddr_test.go new file mode 100644 index 0000000..b611a9f --- /dev/null +++ b/internal/hub/hostaddr_test.go @@ -0,0 +1,214 @@ +package hub + +import ( + "errors" + "log/slog" + "strings" + "testing" +) + +// The fixtures below are MEASURED, not invented: `ip -o addr show` on demo-felhom (N100) and +// demo-hp (HP t740) on 2026-07-31, transcribed verbatim including the interfaces that carry no +// address. That matters — the filter's claim that it needs no veth/fwbr denylist rests on those +// interfaces genuinely having nothing to report, and a hand-written fixture that omitted them would +// have proved the claim by assuming it. + +// demoFelhomIfaces is demo-felhom's real interface table. +func demoFelhomIfaces() []ifaceAddrs { + return []ifaceAddrs{ + {Name: "lo", Up: true, Loopback: true, CIDRs: []string{"127.0.0.1/8", "::1/128"}}, + {Name: "enp1s0", Up: false}, // physical NIC, no address + {Name: "wlp2s0", Up: false}, // wifi, no address + {Name: "tailscale0", Up: true, CIDRs: []string{ + "100.70.170.35/32", "fd7a:115c:a1e0::5236:aa24/128", "fe80::4197:26fc:ccba:b0d9/64"}}, + {Name: "vmbr0", Up: true, CIDRs: []string{"192.168.0.162/24", "fe80::6a1d:efff:fe5d:a664/64"}}, + {Name: "veth9201i0", Up: true}, // per-guest plumbing — no address + {Name: "veth9201i1", Up: true}, // per-guest plumbing — no address + {Name: "vmbr9", Up: true, CIDRs: []string{"169.254.253.1/30", "fe80::48d4:f6ff:fe05:2f98/64"}}, + {Name: "wg-felhom", Up: true, CIDRs: []string{"10.77.0.2/32"}}, + } +} + +func hasAddr(got []HostAddress, iface, cidr string) bool { + for _, a := range got { + if a.Iface == iface && a.CIDR == cidr { + return true + } + } + return false +} + +func flatten(got []HostAddress) string { + var b strings.Builder + for _, a := range got { + b.WriteString(a.Iface + "=" + a.CIDR + " ") + } + return b.String() +} + +// The LAN address is the whole point of the feature — it must survive the filter. +// RED-PROOF 1: drop the `!p.Addr().IsGlobalUnicast()` continue → the vmbr9 + fe80 assertions below +// go red (the LAN one still passes, which is exactly why the negatives are asserted too). +func TestFilterHostAddresses_RealHost(t *testing.T) { + got := filterHostAddresses(demoFelhomIfaces()) + + // --- what MUST be there --- + if !hasAddr(got, "vmbr0", "192.168.0.162/24") { + t.Fatalf("the LAN address was filtered away — the feature reports nothing: %s", flatten(got)) + } + if !hasAddr(got, "wg-felhom", "10.77.0.2/32") { + t.Errorf("the WireGuard address was filtered away: %s", flatten(got)) + } + if !hasAddr(got, "tailscale0", "100.70.170.35/32") { + t.Errorf("the tailnet address was filtered away: %s", flatten(got)) + } + + // --- what MUST NOT be there, each for its own reason --- + for _, bad := range []struct{ iface, cidr, why string }{ + {"lo", "127.0.0.1/8", "loopback is not an address of the host on any network"}, + {"lo", "::1/128", "IPv6 loopback"}, + {"vmbr9", "169.254.253.1/30", "the R-50 island literal — IDENTICAL on every box, so surfacing it is actively misleading"}, + {"vmbr0", "fe80::6a1d:efff:fe5d:a664/64", "IPv6 link-local, one per bridge, pure noise"}, + {"tailscale0", "fe80::4197:26fc:ccba:b0d9/64", "IPv6 link-local"}, + } { + if hasAddr(got, bad.iface, bad.cidr) { + t.Errorf("%s %s must be filtered (%s); got: %s", bad.iface, bad.cidr, bad.why, flatten(got)) + } + } + + // The no-denylist claim: not one veth/physical interface contributed a row. + for _, a := range got { + if strings.HasPrefix(a.Iface, "veth") || a.Iface == "enp1s0" || a.Iface == "wlp2s0" { + t.Errorf("%s produced a row — the fixture says it has no address, so the filter invented one", a.Iface) + } + } +} + +// demo-hp is different hardware (4 unused NICs, different ordering) and must filter identically — +// the rule is about address CLASS, not about one box's interface names. +func TestFilterHostAddresses_SecondHostFiltersIdentically(t *testing.T) { + got := filterHostAddresses([]ifaceAddrs{ + {Name: "lo", Up: true, Loopback: true, CIDRs: []string{"127.0.0.1/8", "::1/128"}}, + {Name: "enp2s0f0", Up: false}, {Name: "enp1s0f0", Up: false}, + {Name: "enp1s0f1", Up: false}, {Name: "enp1s0f2", Up: false}, + {Name: "enp1s0f3", Up: false}, {Name: "wlo1", Up: false}, + {Name: "tailscale0", Up: true, CIDRs: []string{ + "100.76.96.79/32", "fd7a:115c:a1e0::ce36:6051/128", "fe80::e06a:ce64:80e1:7821/64"}}, + {Name: "vmbr0", Up: true, CIDRs: []string{"192.168.0.87/24", "fe80::7ed3:aff:fe77:d976/64"}}, + {Name: "wg-felhom", Up: true, CIDRs: []string{"10.77.0.3/32"}}, + {Name: "vmbr9", Up: true, CIDRs: []string{"169.254.253.1/30", "fe80::2484:92ff:fe7d:52a5/64"}}, + {Name: "veth9201i0", Up: true}, {Name: "veth9201i1", Up: true}, + }) + if !hasAddr(got, "vmbr0", "192.168.0.87/24") { + t.Fatalf("demo-hp's LAN address was filtered away: %s", flatten(got)) + } + if hasAddr(got, "vmbr9", "169.254.253.1/30") { + t.Errorf("demo-hp's island address leaked through: %s", flatten(got)) + } + // The island address is byte-identical on both boxes — the strongest argument for excluding it. + if strings.Contains(flatten(got), "169.254.") { + t.Errorf("a link-local IPv4 survived: %s", flatten(got)) + } +} + +// A DOWN interface holding a stale address must not be reported as if the box were reachable there. +// RED-PROOF 2: drop `|| !i.Up` → this goes red. +func TestFilterHostAddresses_DownInterfaceExcluded(t *testing.T) { + got := filterHostAddresses([]ifaceAddrs{ + {Name: "vmbr0", Up: true, CIDRs: []string{"192.168.0.162/24"}}, + {Name: "vmbr1", Up: false, CIDRs: []string{"10.9.9.9/24"}}, + }) + if hasAddr(got, "vmbr1", "10.9.9.9/24") { + t.Errorf("a DOWN interface's address was reported: %s", flatten(got)) + } + if len(got) != 1 { + t.Errorf("want exactly the one up interface, got: %s", flatten(got)) + } +} + +// Order must be deterministic, or every report diff shows phantom churn. +func TestFilterHostAddresses_DeterministicOrder(t *testing.T) { + a := filterHostAddresses(demoFelhomIfaces()) + // Same facts, opposite enumeration order. + rev := demoFelhomIfaces() + for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 { + rev[i], rev[j] = rev[j], rev[i] + } + b := filterHostAddresses(rev) + if flatten(a) != flatten(b) { + t.Errorf("interface-table order changed the report:\n a=%s\n b=%s", flatten(a), flatten(b)) + } +} + +// A host with nothing routable yields [] and never nil — an absent key and "no addresses" must not +// look alike on the wire. +func TestFilterHostAddresses_EmptyIsNonNil(t *testing.T) { + got := filterHostAddresses([]ifaceAddrs{{Name: "lo", Up: true, Loopback: true, CIDRs: []string{"127.0.0.1/8"}}}) + if got == nil { + t.Fatal("filter returned nil — it would marshal as null, not []") + } + if len(got) != 0 { + t.Errorf("want no addresses, got %s", flatten(got)) + } +} + +// A malformed entry is skipped, never fatal — one bad address must not cost the report the others. +func TestFilterHostAddresses_MalformedSkipped(t *testing.T) { + got := filterHostAddresses([]ifaceAddrs{ + {Name: "vmbr0", Up: true, CIDRs: []string{"not-an-address", "192.168.0.162/24"}}, + }) + if len(got) != 1 || !hasAddr(got, "vmbr0", "192.168.0.162/24") { + t.Errorf("a malformed sibling address broke the good one: %s", flatten(got)) + } +} + +// --- the WIRING half: the collector must actually call the filter --- + +// The seam defaults to the REAL enumerator, so a forgotten wiring call cannot make this inert. +// RED-PROOF 3: replace the collectAddresses body with `return []HostAddress{}` → red. +func TestCollectAddresses_UsesTheInjectedEnumerator(t *testing.T) { + c := &Collector{logger: slog.Default()} + c.addrEnum = func() ([]ifaceAddrs, error) { return demoFelhomIfaces(), nil } + got := c.collectAddresses() + if !hasAddr(got, "vmbr0", "192.168.0.162/24") { + t.Fatalf("the collector did not run the filter over the enumerator's output: %s", flatten(got)) + } +} + +// An enumeration failure degrades to [] and a WARN — never a failed report. +func TestCollectAddresses_EnumerationErrorDegrades(t *testing.T) { + c := &Collector{logger: slog.Default()} + c.addrEnum = func() ([]ifaceAddrs, error) { return nil, errors.New("netlink is unhappy") } + got := c.collectAddresses() + if got == nil { + t.Fatal("an enumeration error produced nil, which marshals as null") + } + if len(got) != 0 { + t.Errorf("want [] on error, got %s", flatten(got)) + } +} + +// The production enumerator must return SOMETHING on the machine running the tests, and must not +// panic. This is the only test that touches the real network stack; it asserts the contract +// (non-nil, no error, loopback correctly flagged) rather than any specific address, because the +// test host's addresses are not ours to predict. +func TestSystemInterfaces_ProductionEnumeratorWorks(t *testing.T) { + ifaces, err := systemInterfaces() + if err != nil { + t.Fatalf("systemInterfaces: %v", err) + } + if len(ifaces) == 0 { + t.Fatal("no interfaces at all — even a container has lo") + } + var sawLoopback bool + for _, i := range ifaces { + if i.Loopback { + sawLoopback = true + } + } + if !sawLoopback { + t.Error("no interface reported the loopback flag — the flag mapping is wrong") + } + // And the filter must survive real input without panicking. + _ = filterHostAddresses(ifaces) +} diff --git a/internal/hub/report.go b/internal/hub/report.go index 1938bb8..78df5c1 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -43,6 +43,12 @@ type HostReport struct { // alert. Not a secret (the fp is public; the token is never reported). LeafFingerprint string `json:"leaf_fingerprint"` + // Addresses are the host's routable addresses, one entry per (interface, address) — the LAN + // bridge, the WG tunnel, a tailnet. Added v0.119.0 because the hub could not show a managed + // box's IP anywhere: nothing in this report carried one. Non-nil so it marshals as []; + // see hostaddr.go for why it is iface+cidr rather than a single lan_ip. + Addresses []HostAddress `json:"addresses"` + // DR recipe — the agent (storage/guest/PBS) half of the secret-free reconstruction recipe // (SPIKE-dr-recipe-2026-06-16). Derived from the facts above; carries ONLY identifiers/intents/ // sizes/coordinates, never a secret. The hub assembles it with the controller's app half. diff --git a/internal/hub/report_test.go b/internal/hub/report_test.go index eb216d3..32d7bc8 100644 --- a/internal/hub/report_test.go +++ b/internal/hub/report_test.go @@ -32,6 +32,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) { Cloudflared: Cloudflared{Status: "active"}, Capabilities: []capability.Status{}, LeafFingerprint: "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245", + Addresses: []HostAddress{}, } // dr_recipe is always set on the real path (Collect); set it here too so the "no null" invariant // covers it (empty pbs is omitempty → omitted, never null). @@ -51,6 +52,7 @@ func TestHostReport_FieldNamesAndEmptyCollections(t *testing.T) { // empty collections must be [] not null `"storage_targets":[]`, `"backups":[]`, `"restore_tests":[]`, `"pbs_snapshots":[]`, `"audit_tail":[]`, `"capabilities":[]`, + `"addresses":[]`, `"leaf_fingerprint":"60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"`, } { if !strings.Contains(got, field) { diff --git a/internal/hub/testdata/host-report.golden.json b/internal/hub/testdata/host-report.golden.json index 3ba3c29..3d6a2d9 100644 --- a/internal/hub/testdata/host-report.golden.json +++ b/internal/hub/testdata/host-report.golden.json @@ -134,6 +134,9 @@ "audit_tail": [], "capabilities": [], "leaf_fingerprint": "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245", + "addresses": [ + { "iface": "vmbr0", "cidr": "192.168.0.162/24" } + ], "dr_recipe": { "recipe_version": 1, "guests": [