Files
pulse/internal/models/node_machine_identity.go
T
rcourtman 8c9000ac63 Keep node temperatures off same-named nodes at other sites
Two Proxmox connections can each have a node with the same name, which
is common for MSPs running one px1 per site. The node temperature
lookup fell back to the first host agent whose hostname matched the
node name, even when that agent was linked to the other site's node,
and even when the name was ambiguous enough that the agent linker had
refused to link it. Site B's px1 then showed, alerted on and recorded
history for site A's sensors. The cluster sensor cache leaked the same
way: an agent's SSH readings for its cluster siblings were keyed by
bare node name, so a site A sibling reading fed site B's node.

The poller now hands the lookup the polled node, and the lookup works
on that node's slot. The state folds the views of one machine reached
through two connections (a cluster added twice, or a multi-homed host
added by each address) into one node held under one view's ID, and
the agent link follows it. Those views used to find their agent only
through the loose hostname match, so a polled view the read state does
not hold also takes the one same-named node proven to be the same
machine. models.NodeObservationsSameMachine and
models.HostAgentBridgesNodeViews give that proof from the evidence
UpdateNodesForInstance folds on, and the state's agent corroboration
check now shares its rule with them. Ambiguous candidates are declined,
as the state declines an ambiguous alias.

An agent linked to the slot is the node's agent. The hostname fallback
only takes an agent with no link when no node outside the slot and no
second unlinked agent has the name. Cluster sensor cache entries record
their reporting agent and serve a node only when that agent's linked
node shares one of the slot's connections, or belongs to a connection
whose configured view of the node has a matching TLS fingerprint.

LinkedNodeID holds the Proxmox source node ID for automatic, manual and
continuity-restored links, so the link match needs no normalization.
The physical-disk SMART merge already matches agents by
node.LinkedAgentID within one instance and is unchanged.
2026-10-06 20:50:21 +01:00

83 lines
3.1 KiB
Go

package models
import "strings"
// NodeObservationsSameMachine reports whether two observations of a Proxmox
// node are one machine by the evidence UpdateNodesForInstance folds node views
// into one slot on: the same logical key, or the same node name with no
// provider identity conflict and either positive cross-view proof (the same
// connection, the same cluster with matching TLS fingerprints, matching
// fingerprints, or for unclustered views the same endpoint) or a shared
// endpoint address. A cluster added through two connections is such a pair.
// Callers that see a polled node before it is merged, such as the node's host
// agent lookup, use this so they agree with the state on which views are the
// same node.
func NodeObservationsSameMachine(a, b Node) bool {
if key := nodeLogicalKey(a); key != "" && key == nodeLogicalKey(b) {
return true
}
name := normalizeNodeIdentityPart(a.Name)
if name == "" || name != normalizeNodeIdentityPart(b.Name) || nodeProviderIdentityConflicts(a, b) {
return false
}
if nodeCrossViewMergeProven(a, b) {
return true
}
// An address alias folds on the conflict check alone (resolveNodeMergeKey).
otherAliases := nodeEndpointMergeAliases(b)
for _, alias := range nodeEndpointMergeAliases(a) {
if !strings.HasPrefix(alias, "endpoint-ip:") {
continue
}
for _, other := range otherAliases {
if alias == other {
return true
}
}
}
return false
}
// HostAgentBridgesNodeViews reports whether a host agent linked to view a proves
// view b is the same machine, the way UpdateNodesForInstance lets that agent
// fold b into a's slot: neither view names a cluster, their TLS fingerprints do
// not contradict each other (the state rejects an agent for a node whose known
// fingerprint differs from its linked node's), and the agent's own report names
// both endpoints, by exact address or full hostname. A host with several
// addresses added through two standalone connections is such a pair.
func HostAgentBridgesNodeViews(host Host, a, b Node) bool {
if normalizeNodeIdentityPart(a.ClusterName) != "" || normalizeNodeIdentityPart(b.ClusterName) != "" {
return false
}
fpA := normalizeNodeTLSFingerprint(a.TLSFingerprint)
fpB := normalizeNodeTLSFingerprint(b.TLSFingerprint)
if fpA != "" && fpB != "" && fpA != fpB {
return false
}
return hostReportsNodeEndpoint(host, a) && hostReportsNodeEndpoint(host, b)
}
// hostReportsNodeEndpoint reports whether a host agent's own report names the
// node's endpoint: the endpoint address among the addresses it reports, or the
// endpoint's full (dotted) hostname as its hostname.
func hostReportsNodeEndpoint(host Host, node Node) bool {
endpoint := extractHostEndpoint(node.Host)
if endpoint == "" {
return false
}
if ip := normalizeIPAddress(endpoint); ip != "" {
if normalizeIPAddress(host.ReportIP) == ip {
return true
}
for _, iface := range host.NetworkInterfaces {
for _, address := range iface.Addresses {
if normalizeIPAddress(address) == ip {
return true
}
}
}
return false
}
return strings.Contains(endpoint, ".") && endpoint == strings.TrimSpace(strings.ToLower(host.Hostname))
}