From 63c9c0e495f4d6f49612612fcb1056a1884b1476 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:26:52 +0100 Subject: [PATCH] Reject host-local networks in Proxmox provider inference The report matcher already filters host-local interfaces, but registry inference could lend a trusted agent identity to an unrelated provider using shared loopback, link-local or Docker bridge addresses. Apply the same bounded identity policy to strong and one-way provider corroboration, preserving reciprocal links, private management networks and explicit report-IP hints. Four cross-provider cases and a one-way mismatched-hostname case fail before the repair. Focused race tests pass after it, including presentation separation and management compatibility. This is a synthetic defect reproduction, not proof of the cause or resolution of issue #1930. Change-source: pulse-maintainer --- .../internal/subsystems/unified-resources.md | 18 +++++ .../unifiedresources/proxmox_node_links.go | 62 ++++++++++------- .../registry_merge_policy_test.go | 69 +++++++++++++++++++ 3 files changed, 126 insertions(+), 23 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 0ab64f428..4985b05ba 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5169,3 +5169,21 @@ them. Overview rows may still truncate identity; keyboard expansion exposes the full heading. This shared drawer rule applies across resource types. The 390px browser qualification checks heading fit and keyboard expansion; no data model, authorisation, action dispatch or resource admission contract changes. + +### Provider link network corroboration + +An existing reciprocal host/provider link remains authoritative, including a +manual link whose provider name differs from the agent hostname. Lending that +agent identity to another configured provider, or accepting a one-way link, +must not use loopback, link-local, Docker interfaces or generated Docker bridge +addresses as machine corroboration. These addresses recur independently on +unrelated hosts. Ordinary private management addresses, vmbr0/custom management +bridges and explicit global-unicast report-IP hints remain eligible; this rule +must not erase retained link intent or change the public resource schema. + +Validation: `TestProxmoxInferenceRejectsSharedHostLocalNetworks` covers the +inference and presentation path, including preservation of the reciprocal link; +`TestProxmoxInferencePreservesManagementNetworkCorroboration` and +`TestProxmoxOneWayLinkRejectsHostLocalEndpointCorroboration` protect management +network and explicit report-IP compatibility. These synthetic cases do not +establish the cause of a particular installation's association. diff --git a/internal/unifiedresources/proxmox_node_links.go b/internal/unifiedresources/proxmox_node_links.go index 081c4a318..e38e0d2de 100644 --- a/internal/unifiedresources/proxmox_node_links.go +++ b/internal/unifiedresources/proxmox_node_links.go @@ -1,6 +1,7 @@ package unifiedresources import ( + "net" "strings" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -262,17 +263,10 @@ func proxmoxProviderNodesProveSameMachine(left, right models.Node, host *models. } func proxmoxNodeStronglyCorroboratesHost(node models.Node, host models.Host) bool { - hostIPs := make(map[string]struct{}) - if ip := NormalizeIP(host.ReportIP); ip != "" { + hostIPs := providerLinkNetworkIPs(host.NetworkInterfaces) + if ip := providerLinkIP(host.ReportIP); ip != "" { hostIPs[ip] = struct{}{} } - for _, network := range host.NetworkInterfaces { - for _, address := range network.Addresses { - if ip := NormalizeIP(address); ip != "" { - hostIPs[ip] = struct{}{} - } - } - } endpoint := strings.TrimSpace(strings.ToLower(extractHostname(node.Host))) if endpointIP := NormalizeIP(endpoint); endpointIP != "" { @@ -286,13 +280,9 @@ func proxmoxNodeStronglyCorroboratesHost(node models.Node, host models.Host) boo if nodeName := NormalizeFullHostname(node.Name); strings.Contains(nodeName, ".") && nodeName == hostname { return true } - for _, network := range node.NetworkInterfaces { - for _, address := range network.Addresses { - if ip := NormalizeIP(address); ip != "" { - if _, ok := hostIPs[ip]; ok { - return true - } - } + for ip := range providerLinkNetworkIPs(node.NetworkInterfaces) { + if _, ok := hostIPs[ip]; ok { + return true } } return false @@ -364,15 +354,11 @@ func proxmoxNodeCorroboratesHost(node models.Node, host models.Host) bool { } if ip := NormalizeIP(endpoint); ip != "" { - if NormalizeIP(host.ReportIP) == ip { + if providerLinkIP(host.ReportIP) == ip { return true } - for _, iface := range host.NetworkInterfaces { - for _, address := range iface.Addresses { - if NormalizeIP(address) == ip { - return true - } - } + if _, ok := providerLinkNetworkIPs(host.NetworkInterfaces)[ip]; ok { + return true } return false } @@ -380,3 +366,33 @@ func proxmoxNodeCorroboratesHost(node models.Node, host models.Host) bool { endpointHost := NormalizeHostname(endpoint) return endpointHost != "" && endpointHost == hostName } + +// Provider inference must not treat addresses repeated independently on each +// host as corroboration. Keep management bridges and private management IPs; +// reject only non-unicast addresses and recognisable host-local interfaces. +func providerLinkIP(address string) string { + normalized := NormalizeIP(address) + if ip := net.ParseIP(normalized); ip != nil && ip.IsGlobalUnicast() { + return normalized + } + return "" +} + +func providerLinkNetworkIPs(network []models.HostNetworkInterface) map[string]struct{} { + ips := make(map[string]struct{}) + for _, nic := range network { + name := strings.ToLower(strings.TrimSpace(nic.Name)) + if name == "lo" || strings.HasPrefix(name, "docker") { + continue + } + if id, ok := strings.CutPrefix(name, "br-"); ok && len(id) == 12 && strings.Trim(id, "0123456789abcdef") == "" { + continue + } + for _, address := range nic.Addresses { + if ip := providerLinkIP(address); ip != "" { + ips[ip] = struct{}{} + } + } + } + return ips +} diff --git a/internal/unifiedresources/registry_merge_policy_test.go b/internal/unifiedresources/registry_merge_policy_test.go index ba1729d5f..51b27e416 100644 --- a/internal/unifiedresources/registry_merge_policy_test.go +++ b/internal/unifiedresources/registry_merge_policy_test.go @@ -488,3 +488,72 @@ func TestRegistryMemoryUnavailableClearsOnlySameSourceMetric(t *testing.T) { t.Fatalf("cross-source memory = %+v, want trusted agent metric preserved", merged.Memory) } } + +func TestProxmoxInferenceRejectsSharedHostLocalNetworks(t *testing.T) { + for _, tc := range []struct{ name, nic, address string }{ + {"loopback", "lo", "127.0.0.1/8"}, + {"link-local", "eth0", "fe80::1/64"}, + {"docker", "docker0", "172.17.0.1/16"}, + {"generated-bridge", "br-0123456789ab", "192.0.2.1/24"}, + } { + t.Run(tc.name, func(t *testing.T) { + networks := []models.HostNetworkInterface{{Name: tc.nic, Addresses: []string{tc.address}}} + host := &models.Host{ID: "agent", Hostname: "pve", LinkedNodeID: "site-a", NetworkInterfaces: networks} + left := models.Node{ID: "site-a", Name: "pve", Instance: "a", Host: "https://a.example:8006", LinkedAgentID: host.ID, NetworkInterfaces: networks} + right := models.Node{ID: "site-b", Name: "pve", Instance: "b", Host: "https://b.example:8006", NetworkInterfaces: networks} + got := inferLinkedHostsForProxmoxNodes([]models.Node{left, right}, map[string]*models.Host{host.ID: host}) + if got[left.ID] != host { + t.Fatal("lost explicit reciprocal link") + } + registry := NewRegistry(NewMemoryStore()) + registry.IngestSnapshot(models.StateSnapshot{Nodes: []models.Node{left, right}, Hosts: []models.Host{*host}}) + seenUnrelatedProvider := false + for _, resource := range registry.ListForPresentation() { + if resource.Proxmox != nil && resource.Proxmox.SourceID == right.ID { + seenUnrelatedProvider = true + } + if resource.Proxmox != nil && resource.Proxmox.SourceID == right.ID && resource.Agent != nil && resource.Agent.AgentID == host.ID { + t.Fatal("presentation attached linked agent to unrelated provider") + } + } + if !seenUnrelatedProvider { + t.Fatal("unrelated provider disappeared from presentation") + } + if got[right.ID] != nil { + t.Fatal("shared host-local network lent agent identity to another provider") + } + }) + } +} + +func TestProxmoxInferencePreservesManagementNetworkCorroboration(t *testing.T) { + for _, nic := range []string{"eth0", "vmbr0", "br-mgmt", ""} { + t.Run(nic, func(t *testing.T) { + networks := []models.HostNetworkInterface{{Name: nic, Addresses: []string{"172.17.1.2/24"}}} + host := &models.Host{ID: "agent", Hostname: "pve", LinkedNodeID: "a", NetworkInterfaces: networks} + left := models.Node{ID: "a", Name: "pve", Instance: "a", Host: "https://a.example:8006", LinkedAgentID: host.ID, NetworkInterfaces: networks} + right := models.Node{ID: "b", Name: "pve", Instance: "b", Host: "https://b.example:8006", NetworkInterfaces: networks} + got := inferLinkedHostsForProxmoxNodes([]models.Node{left, right}, map[string]*models.Host{host.ID: host}) + if got[left.ID] != host || got[right.ID] != host { + t.Fatal("lost corroborated duplicate provider") + } + }) + } +} + +func TestProxmoxOneWayLinkRejectsHostLocalEndpointCorroboration(t *testing.T) { + host := models.Host{ID: "agent", Hostname: "nas", NetworkInterfaces: []models.HostNetworkInterface{{Name: "docker0", Addresses: []string{"172.17.0.1/16"}}}} + node := models.Node{ID: "node", Name: "pve", Host: "https://172.17.0.1:8006", LinkedAgentID: host.ID} + if trustedProxmoxNodeHostLink(node, host) { + t.Fatal("uncorroborated one-way link trusted via Docker bridge") + } + host.LinkedNodeID = node.ID + if !trustedProxmoxNodeHostLink(node, host) { + t.Fatal("reciprocal explicit link lost") + } + host.LinkedNodeID = "" + host.ReportIP = "172.17.0.1" + if !trustedProxmoxNodeHostLink(node, host) { + t.Fatal("explicit private report-IP corroboration lost") + } +}