14642e3c7b
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.
135 lines
5.5 KiB
Go
135 lines
5.5 KiB
Go
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)
|
|
}
|