Merge pull request #2007 from rcourtman/maintainer/20260909T134704Z

Prevent unrelated Proxmox providers from sharing agent identity
This commit is contained in:
pulse-triage[bot]
2026-09-09 14:21:58 +00:00
committed by GitHub
3 changed files with 126 additions and 23 deletions
@@ -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.
+39 -23
View File
@@ -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
}
@@ -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")
}
}