mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(monitoring): reject host-local addresses in agent auto-linking
A Docker bridge or link-local address can be unique among monitored PVE nodes but also exist on an unrelated NAS. Counting PVE owners alone then creates a false reciprocal agent association. Exclude host-local IPs and known Docker bridge interfaces from automatic network evidence, preserving management bridges and explicit unicast report IPs. Seven synthetic negative cases fail before this repair and pass afterwards. Focused matcher and host-report tests pass under the race detector. This prevents a reproduced backend misassociation; it does not establish the cause or resolution of issue #1930, whose diagnostic payload remains unavailable. Change-source: pulse-maintainer
This commit is contained in:
@@ -15,6 +15,24 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Automatic PVE association identity boundary
|
||||
|
||||
Host ingestion must not create a host-to-PVE or reciprocal PVE-to-agent link
|
||||
solely from a shared host-local network address. Uniqueness among monitored
|
||||
PVE nodes does not establish uniqueness across unrelated agent hosts.
|
||||
Automatic network evidence excludes loopback, unspecified, multicast and
|
||||
link-local IPs, and interfaces named lo or prefixed docker/br-. This changes
|
||||
association evidence only: enrollment, token binding, removal, re-enrollment
|
||||
and command authority remain unchanged. It does not migrate or repair
|
||||
persisted incorrect links.
|
||||
|
||||
Verification: `TestApplyHostReportDoesNotLinkUnrelatedDockerBridge` in
|
||||
`internal/monitoring/monitor_host_agents_test.go` ingests repeated synthetic
|
||||
NAS reports and requires both association directions to remain absent.
|
||||
The adjacent matcher and filtering tests exercise host-local rejection and
|
||||
retained management-address evidence. These are local fixture proofs, not
|
||||
reporter confirmation of #1930 or installed lifecycle qualification.
|
||||
|
||||
The internal Patrol request bridge carries explicit execution limits and
|
||||
capability allowlists without a diagnostic report-count budget. Finding writes
|
||||
retain their server-owned scope and cannot enter or satisfy the infrastructure
|
||||
|
||||
@@ -17,6 +17,27 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Host-local addresses are not PVE identity
|
||||
|
||||
Automatic host/PVE network matching excludes non-global-unicast addresses
|
||||
and lo/docker*/br-* interfaces from both reported-host and provider-node
|
||||
address inventories. A Docker bridge address seen on only one PVE node can
|
||||
still exist on an unrelated NAS; provider-only owner counts cannot make it
|
||||
machine identity. Management bridges such as vmbr0, unnamed legacy interfaces,
|
||||
private IPv4 and IPv6/ULA remain eligible. Explicit unicast report-IP hints
|
||||
remain eligible independently of inferred interface evidence. No blanket
|
||||
private-subnet exclusion is permitted.
|
||||
|
||||
Verification in `internal/monitoring/monitor_host_agents_test.go`:
|
||||
`TestFindLinkedProxmoxEntityWithHints_RejectsHostLocalNetworkIdentity` covers
|
||||
seven previously false associations; `TestAgentLinkNetworkIdentityFiltering`
|
||||
pins both-side exclusions and positive management controls;
|
||||
`TestApplyHostReportDoesNotLinkUnrelatedDockerBridge` requires repeated
|
||||
ingestion to leave both link directions absent. Existing endpoint and
|
||||
ambiguous-name tests remain required. These checks prevent a reproduced
|
||||
synthetic misassociation, not all private-address collisions or unknown
|
||||
custom bridge names, and do not prove the cause of #1930.
|
||||
|
||||
Physical disk inventory has an independent collector schedule. The PVE poller
|
||||
carries its default five-minute or configured interval with each disk record,
|
||||
while keeping the last successful observation timestamp on retained records.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -4389,18 +4390,38 @@ func (m *Monitor) findLinkedProxmoxEntity(hostname string) (nodeID, vmID, contai
|
||||
return m.findLinkedProxmoxEntityWithHints(hostname, "", nil)
|
||||
}
|
||||
|
||||
// Host-local addresses may be unique among the monitored PVE nodes while also
|
||||
// existing on an unrelated agent host. They are not machine identity evidence.
|
||||
func normalizeAgentLinkIP(address string) string {
|
||||
normalized := unifiedresources.NormalizeIP(address)
|
||||
if ip := net.ParseIP(normalized); ip != nil && ip.IsGlobalUnicast() {
|
||||
return normalized
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Do not discard all bridges: Proxmox management addresses commonly use vmbr0.
|
||||
// Explicit report-IP and endpoint hints remain usable for routable addresses.
|
||||
func isHostLocalAgentLinkInterface(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
return name == "lo" || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-")
|
||||
}
|
||||
|
||||
func collectReportedHostIPs(
|
||||
reportIP string,
|
||||
network []agentshost.NetworkInterface,
|
||||
) map[string]struct{} {
|
||||
ips := make(map[string]struct{})
|
||||
if normalized := unifiedresources.NormalizeIP(reportIP); normalized != "" {
|
||||
if normalized := normalizeAgentLinkIP(reportIP); normalized != "" {
|
||||
ips[normalized] = struct{}{}
|
||||
}
|
||||
|
||||
for _, nic := range network {
|
||||
if isHostLocalAgentLinkInterface(nic.Name) {
|
||||
continue
|
||||
}
|
||||
for _, address := range nic.Addresses {
|
||||
if normalized := unifiedresources.NormalizeIP(address); normalized != "" {
|
||||
if normalized := normalizeAgentLinkIP(address); normalized != "" {
|
||||
ips[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
@@ -4412,8 +4433,11 @@ func collectReportedHostIPs(
|
||||
func collectNodeNetworkIPs(network []unifiedresources.NetworkInterface) map[string]struct{} {
|
||||
ips := make(map[string]struct{})
|
||||
for _, nic := range network {
|
||||
if isHostLocalAgentLinkInterface(nic.Name) {
|
||||
continue
|
||||
}
|
||||
for _, address := range nic.Addresses {
|
||||
if normalized := unifiedresources.NormalizeIP(address); normalized != "" {
|
||||
if normalized := normalizeAgentLinkIP(address); normalized != "" {
|
||||
ips[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5685,3 +5685,100 @@ func TestApplyHostReportPreservesPoolOnlyUnraidCount(t *testing.T) {
|
||||
t.Fatalf("pool-only host raised storage risk: %+v", assessment)
|
||||
}
|
||||
}
|
||||
|
||||
// Uniqueness among PVE nodes does not make host-local addresses machine identity:
|
||||
// a non-PVE host is not included in that ownership count.
|
||||
func TestFindLinkedProxmoxEntityWithHints_RejectsHostLocalNetworkIdentity(t *testing.T) {
|
||||
for _, tc := range []struct{ name, nic, address string }{
|
||||
{"docker", "docker0", "172.17.0.1/16"},
|
||||
{"custom docker bridge", "br-123abc", "192.0.2.1/24"},
|
||||
{"loopback", "lo", "127.0.0.1/8"},
|
||||
{"IPv6 loopback", "lo", "::1/128"},
|
||||
{"link local", "eth0", "169.254.1.2/16"},
|
||||
{"IPv6 link local", "eth0", "fe80::1/64"},
|
||||
{"unspecified", "eth0", "0.0.0.0"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
monitor := &Monitor{state: models.NewState()}
|
||||
monitor.state.UpdateNodes([]models.Node{{
|
||||
ID: "pve-node", Name: "pve", Instance: "cluster",
|
||||
Host: "https://pve.example:8006",
|
||||
NetworkInterfaces: []models.HostNetworkInterface{{Name: tc.nic, Addresses: []string{tc.address}}},
|
||||
}})
|
||||
node, vm, ct := monitor.findLinkedProxmoxEntityWithHints("nas", "", []agentshost.NetworkInterface{
|
||||
{Name: tc.nic, Addresses: []string{tc.address}},
|
||||
{Name: "eth1", Addresses: []string{"198.51.100.20/24"}},
|
||||
})
|
||||
if node != "" || vm != "" || ct != "" {
|
||||
t.Fatalf("unrelated NAS linked by host-local address: node=%q vm=%q ct=%q", node, vm, ct)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLinkNetworkIdentityFiltering(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, nic, address string
|
||||
want bool
|
||||
}{
|
||||
{"management bridge", "vmbr0", "192.0.2.10/24", true},
|
||||
{"private management", "eth0", "172.17.0.1/16", true},
|
||||
{"IPv6 management", "vmbr0", "2001:db8::10/64", true},
|
||||
{"ULA management", "eth0", "fd00::10/64", true},
|
||||
{"unnamed legacy interface", "", "192.0.2.10", true},
|
||||
{"docker", "docker0", "172.17.0.1/16", false},
|
||||
{"custom docker", "br-abc", "192.0.2.1/24", false},
|
||||
{"loopback", "eth0", "127.1.2.3/8", false},
|
||||
{"IPv6 unspecified", "eth0", "::", false},
|
||||
{"multicast", "eth0", "224.0.0.1", false},
|
||||
{"IPv6 link local", "eth0", "fe90::1/64", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
agent := collectReportedHostIPs("", []agentshost.NetworkInterface{{Name: tc.nic, Addresses: []string{tc.address}}})
|
||||
node := collectNodeNetworkIPs([]unifiedresources.NetworkInterface{{Name: tc.nic, Addresses: []string{tc.address}}})
|
||||
if (len(agent) == 1) != tc.want || (len(node) == 1) != tc.want {
|
||||
t.Fatalf("agent=%v node=%v want accepted=%v", agent, node, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := collectReportedHostIPs("172.17.0.1", nil); len(got) != 1 {
|
||||
t.Fatalf("explicit private report IP must remain usable: %v", got)
|
||||
}
|
||||
if got := collectReportedHostIPs("::1", nil); len(got) != 0 {
|
||||
t.Fatalf("explicit loopback cannot identify a machine: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise ingestion as well as the matcher: no reciprocal PVE link may be
|
||||
// created from a bridge address shared with an unrelated host.
|
||||
func TestApplyHostReportDoesNotLinkUnrelatedDockerBridge(t *testing.T) {
|
||||
monitor := issue1654Monitor()
|
||||
monitor.state.UpdateNodes([]models.Node{{
|
||||
ID: "pve-node", Name: "pve", Instance: "cluster",
|
||||
Host: "https://pve.example:8006",
|
||||
NetworkInterfaces: []models.HostNetworkInterface{
|
||||
{Name: "vmbr0", Addresses: []string{"192.0.2.10/24"}},
|
||||
{Name: "docker0", Addresses: []string{"172.17.0.1/16"}},
|
||||
},
|
||||
}})
|
||||
report := issue1654Report(time.Now().UTC())
|
||||
report.Host.Hostname = "nas.example"
|
||||
report.Network = []agentshost.NetworkInterface{
|
||||
{Name: "eth0", Addresses: []string{"198.51.100.20/24"}},
|
||||
{Name: "docker0", Addresses: []string{"172.17.0.1/16"}},
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
report.Timestamp = report.Timestamp.Add(time.Second)
|
||||
host, err := monitor.ApplyHostReport(report, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host.LinkedNodeID != "" {
|
||||
t.Fatalf("NAS linked to %q", host.LinkedNodeID)
|
||||
}
|
||||
nodes := monitor.state.GetSnapshot().Nodes
|
||||
if len(nodes) != 1 || nodes[0].LinkedAgentID != "" {
|
||||
t.Fatalf("PVE node acquired an unrelated agent link: %+v", nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user