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:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user