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) }