Files
pulse/internal/monitoring/monitor_polling_node_helpers.go
T
courtmanr@gmail.com 07d2f98455 Gate same-name cross-instance node aggregation on TLS identity
The config layer already refuses to consolidate two same-named clusters
whose TOFU-captured TLS fingerprints contradict, and node aggregation
keeps any two same-named clusters from different connection instances
apart unconditionally. Two gaps remained one layer down. First, the
endpoint-IP agent match bypassed the contradiction guard entirely, so
two sites reusing RFC1918 addressing (the MSP support case: pve01 on
192.168.1.11 at both sites) still bound the second site's node to the
first site's host agent, attaching the wrong machine's telemetry.
Second, the unconditional split had no way to recognize the legitimate
duplicate - the same cluster added twice through different member
addresses with no config-level endpoint overlap could never fold back
into one node slot.

The aggregation layer now receives the config layer's identity
evidence: each PVE node carries the TLS certificate fingerprint of its
own named endpoint record (standalone nodes carry the instance
fingerprint; a cluster member never inherits the instance-level
fingerprint, which pins whichever member the connection URL reaches).
Same-named clusters from different instances merge only when both views
carry the same fingerprint; contradicting or unknown evidence keeps the
fail-safe split. Agent binding applies the identical doctrine: hostname
and address matches are rejected when the candidate agent's linked
nodes live in a different named cluster or carry a different
fingerprint, closing the previously unguarded endpoint-IP path.

Reported via support by an MSP whose sites reuse cluster names, node
names, and RFC1918 ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:00:33 +01:00

451 lines
15 KiB
Go

package monitoring
import (
"context"
stderrors "errors"
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
"github.com/rs/zerolog/log"
)
func resolveNodeConnectionInfo(instanceCfg *config.PVEInstance, discoveryCfg config.DiscoveryConfig, nodeName string) (string, string) {
connectionHost := instanceCfg.Host
guestURL := instanceCfg.GuestURL
if instanceCfg.IsCluster && len(instanceCfg.ClusterEndpoints) > 0 {
hasFingerprint := instanceCfg.Fingerprint != ""
for _, ep := range instanceCfg.ClusterEndpoints {
if strings.EqualFold(ep.NodeName, nodeName) {
if effective := clusterEndpointRuntimeURL(ep, instanceCfg.VerifySSL, hasFingerprint, discoveryCfg); effective != "" {
connectionHost = effective
}
if ep.GuestURL != "" {
guestURL = ep.GuestURL
}
break
}
}
}
return connectionHost, guestURL
}
func (m *Monitor) determineNodeIDAndStatus(instanceName string, instanceCfg *config.PVEInstance, node proxmox.Node) (string, string) {
// Apply grace period for node status to prevent flapping
// The configured instance is the canonical provider identity. Normally a
// cluster name remains the stable scope used by earlier Pulse releases,
// but two unrelated clusters are allowed to share that display name. In
// that ambiguous case the instance name prevents cross-cluster resource
// collisions without churning IDs for established unambiguous clusters.
nodeID := m.pveNodeID(instanceName, instanceCfg, node.Node)
effectiveStatus := node.Status
now := time.Now()
gracePeriod := m.pveNodeOfflineGracePeriod()
m.mu.Lock()
if strings.ToLower(node.Status) == "online" {
// Node is online - update last-online timestamp
m.nodeLastOnline[nodeID] = now
} else {
// Node is reported as offline - check grace period
lastOnline, exists := m.nodeLastOnline[nodeID]
if exists && now.Sub(lastOnline) < gracePeriod {
// Still within grace period - preserve online status
effectiveStatus = "online"
log.Debug().
Str("instance", instanceName).
Str("node", node.Node).
Dur("timeSinceOnline", now.Sub(lastOnline)).
Dur("gracePeriod", gracePeriod).
Msg("Node offline but within grace period - preserving online status")
} else {
// Grace period expired or never seen online - mark as offline
if exists {
log.Info().
Str("instance", instanceName).
Str("node", node.Node).
Dur("timeSinceOnline", now.Sub(lastOnline)).
Msg("Node offline and grace period expired - marking as offline")
}
}
}
m.mu.Unlock()
return nodeID, effectiveStatus
}
func (m *Monitor) pveNodeID(instanceName string, instanceCfg *config.PVEInstance, nativeName string) string {
if instanceCfg != nil && instanceCfg.IsCluster {
if identityID := config.PVEClusterNodeIdentityForName(instanceCfg, nativeName); identityID != "" {
return identityID
}
}
return m.pveNodeIdentityScope(instanceName, instanceCfg) + "-" + nativeName
}
// pveNodeTLSFingerprint resolves the TOFU-captured TLS certificate
// fingerprint recorded in configuration for one node's API endpoint. The
// fingerprint is per-machine identity evidence for node state aggregation, so
// a cluster node only inherits the fingerprint of its own named endpoint -
// never the instance-level fingerprint, which pins whichever member the
// connection URL happens to reach. Unknown stays empty and asserts nothing.
func pveNodeTLSFingerprint(instanceCfg *config.PVEInstance, nodeName string) string {
if instanceCfg == nil {
return ""
}
if !instanceCfg.IsCluster {
return strings.TrimSpace(instanceCfg.Fingerprint)
}
nodeName = strings.TrimSpace(nodeName)
match := ""
for _, endpoint := range instanceCfg.ClusterEndpoints {
if !strings.EqualFold(strings.TrimSpace(endpoint.NodeName), nodeName) {
continue
}
fingerprint := strings.TrimSpace(endpoint.Fingerprint)
if fingerprint == "" {
continue
}
if match != "" && !strings.EqualFold(match, fingerprint) {
// Ambiguous duplicate endpoint records fail unknown.
return ""
}
match = fingerprint
}
return match
}
func (m *Monitor) pveNodeUsesProviderScopedIdentity(instanceName string, instanceCfg *config.PVEInstance) bool {
if instanceCfg == nil || !instanceCfg.IsCluster || strings.TrimSpace(instanceCfg.ClusterName) == "" {
return false
}
return !strings.EqualFold(
strings.TrimSpace(m.pveNodeIdentityScope(instanceName, instanceCfg)),
strings.TrimSpace(instanceCfg.ClusterName),
)
}
func (m *Monitor) collectNodeTemperatureData(
ctx context.Context,
instanceName string,
instanceCfg *config.PVEInstance,
node proxmox.Node,
modelNode *models.Node,
prevInstanceNodes []models.Node,
effectiveStatus string,
) {
if modelNode == nil {
return
}
// Collect temperature data via SSH (non-blocking, best effort)
// Only attempt for online nodes when temperature monitoring is enabled
// Check per-node setting first, fall back to global setting
tempMonitoringEnabled := m.config.TemperatureMonitoringEnabled
if instanceCfg.TemperatureMonitoringEnabled != nil {
tempMonitoringEnabled = *instanceCfg.TemperatureMonitoringEnabled
}
if effectiveStatus == "online" && tempMonitoringEnabled {
// First, check if there's a matching host agent with temperature data.
// Host agent temperatures are preferred because they don't require SSH access.
// Use getHostAgentTemperatureByID with the unique node ID to correctly handle
// duplicate hostname scenarios (e.g., two "px1" nodes on different IPs).
hostAgentTemp := m.getHostAgentTemperatureByID(modelNode.ID, node.Node)
if hostAgentTemp != nil {
log.Debug().
Str("node", node.Node).
Float64("cpuPackage", hostAgentTemp.CPUPackage).
Float64("cpuMax", hostAgentTemp.CPUMax).
Int("nvmeCount", len(hostAgentTemp.NVMe)).
Msg("Using temperature data from host agent")
}
// If no host agent temp or we need additional data (SMART), try SSH/proxy collection
var sshTemp *models.Temperature
var err error
if m.tempCollector != nil {
// Temperature collection is best-effort - use a short timeout to avoid blocking node polling
// Use context.Background() so the timeout is truly independent of the parent polling context
// If SSH is slow or unresponsive, we'll preserve previous temperature data
tempCtx, tempCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer tempCancel()
// Determine SSH hostname to use (most robust approach):
// Prefer the resolved host for this node, with cluster overrides when available.
sshHost := modelNode.Host
foundNodeEndpoint := false
if modelNode.IsClusterMember && instanceCfg.IsCluster {
// Try to find specific endpoint configuration for this node
if len(instanceCfg.ClusterEndpoints) > 0 {
hasFingerprint := instanceCfg.Fingerprint != ""
for _, ep := range instanceCfg.ClusterEndpoints {
if strings.EqualFold(ep.NodeName, node.Node) {
if effective := clusterEndpointEffectiveURL(ep, instanceCfg.VerifySSL, hasFingerprint); effective != "" {
sshHost = effective
foundNodeEndpoint = true
}
break
}
}
}
// If no specific endpoint found, fall back to node name
if !foundNodeEndpoint {
sshHost = node.Node
log.Debug().
Str("node", node.Node).
Str("instance", instanceCfg.Name).
Msg("Node endpoint not found in cluster metadata - falling back to node name for temperature collection")
}
}
if strings.TrimSpace(sshHost) == "" {
sshHost = node.Node
}
// A recent host agent payload is authoritative for this node.
// Legacy SSH remains only as a fallback when no usable local
// temperature reading has arrived from the linked agent.
skipSSHCollection := shouldSkipTemperatureSSHCollection(hostAgentTemp)
if !skipSSHCollection {
sshTemp, err = m.tempCollector.CollectTemperature(tempCtx, sshHost, node.Node)
if err != nil && hostAgentTemp == nil {
log.Debug().
Str("node", node.Node).
Str("sshHost", sshHost).
Bool("isCluster", modelNode.IsClusterMember).
Int("endpointCount", len(instanceCfg.ClusterEndpoints)).
Msg("Temperature collection failed - check SSH access")
}
}
// Debug: log SSH temp details before merge
if sshTemp != nil {
log.Debug().
Str("node", node.Node).
Bool("sshTempAvailable", sshTemp.Available).
Bool("sshHasSMART", sshTemp.HasSMART).
Int("sshSMARTCount", len(sshTemp.SMART)).
Bool("sshHasNVMe", sshTemp.HasNVMe).
Int("sshNVMeCount", len(sshTemp.NVMe)).
Msg("SSH temperature data before merge")
} else {
log.Debug().
Str("node", node.Node).
Msg("SSH temperature data is nil")
}
}
// Merge host agent and SSH temperatures
temp := mergeTemperatureData(hostAgentTemp, sshTemp)
if temp != nil && temp.Available {
// Get the current CPU temperature (prefer package, fall back to max)
currentTemp := temp.CPUPackage
if currentTemp == 0 && temp.CPUMax > 0 {
currentTemp = temp.CPUMax
}
// Find previous temperature data for this node to preserve min/max
var prevTemp *models.Temperature
for _, prevNode := range prevInstanceNodes {
if prevNode.ID == modelNode.ID && prevNode.Temperature != nil {
prevTemp = prevNode.Temperature
break
}
}
// Initialize or update min/max tracking
if prevTemp != nil && prevTemp.CPUMin > 0 {
// Preserve existing min/max and update if necessary
temp.CPUMin = prevTemp.CPUMin
temp.CPUMaxRecord = prevTemp.CPUMaxRecord
temp.MinRecorded = prevTemp.MinRecorded
temp.MaxRecorded = prevTemp.MaxRecorded
// Update min if current is lower
if currentTemp > 0 && currentTemp < temp.CPUMin {
temp.CPUMin = currentTemp
temp.MinRecorded = time.Now()
}
// Update max if current is higher
if currentTemp > temp.CPUMaxRecord {
temp.CPUMaxRecord = currentTemp
temp.MaxRecorded = time.Now()
}
} else if currentTemp > 0 {
// First reading - initialize min/max to current value
temp.CPUMin = currentTemp
temp.CPUMaxRecord = currentTemp
temp.MinRecorded = time.Now()
temp.MaxRecorded = time.Now()
}
modelNode.Temperature = temp
// Determine source for logging
tempSource := "ssh"
if hostAgentTemp != nil && sshTemp == nil {
tempSource = "agent"
} else if hostAgentTemp != nil && sshTemp != nil {
tempSource = "agent+ssh"
}
log.Debug().
Str("node", node.Node).
Str("source", tempSource).
Float64("cpuPackage", temp.CPUPackage).
Float64("cpuMax", temp.CPUMax).
Float64("cpuMin", temp.CPUMin).
Float64("cpuMaxRecord", temp.CPUMaxRecord).
Int("nvmeCount", len(temp.NVMe)).
Msg("Collected temperature data")
} else {
// Temperature data returned but not available (temp != nil && !temp.Available)
// OR no temperature data from any source - preserve previous temperature if available
// This prevents the temperature column from flickering when collection temporarily fails
var prevTemp *models.Temperature
for _, prevNode := range prevInstanceNodes {
if prevNode.ID == modelNode.ID && prevNode.Temperature != nil && prevNode.Temperature.Available {
prevTemp = prevNode.Temperature
break
}
}
if prevTemp != nil {
// Clone the previous temperature to avoid modifying historical data
preserved := *prevTemp
preserved.LastUpdate = prevTemp.LastUpdate // Keep original update time to indicate staleness
modelNode.Temperature = &preserved
log.Debug().
Str("node", node.Node).
Bool("isCluster", modelNode.IsClusterMember).
Float64("cpuPackage", preserved.CPUPackage).
Time("lastUpdate", preserved.LastUpdate).
Msg("Preserved previous temperature data (current collection failed or unavailable)")
} else {
log.Debug().
Str("node", node.Node).
Bool("isCluster", modelNode.IsClusterMember).
Msg("No temperature data available (collection failed, no previous data to preserve)")
}
}
}
}
func (m *Monitor) applyNodePendingUpdates(ctx context.Context, instanceName string, client PVEClientInterface, node proxmox.Node, nodeID string, effectiveStatus string, modelNode *models.Node) {
if modelNode == nil {
return
}
// Poll pending apt updates (less frequently - every 30 minutes)
// Only for online nodes to avoid wasting API calls on offline nodes
if effectiveStatus == "online" {
now := time.Now()
m.mu.RLock()
if m.nodePendingUpdatesCache == nil {
m.mu.RUnlock()
m.mu.Lock()
if m.nodePendingUpdatesCache == nil {
m.nodePendingUpdatesCache = make(map[string]pendingUpdatesCache)
}
m.mu.Unlock()
m.mu.RLock()
}
cached, hasCached := m.nodePendingUpdatesCache[nodeID]
m.mu.RUnlock()
if !hasCached || now.Sub(cached.checkedAt) >= pendingUpdatesCacheTTL {
// Time to check for updates
pendingPkgs, err := client.GetNodePendingUpdates(ctx, node.Node)
if err != nil {
// API call failed - preserve cached value if available, don't spam logs
log.Debug().
Err(err).
Str("node", node.Node).
Str("instance", instanceName).
Msg("Could not check pending apt updates (may require Sys.Audit permission)")
if hasCached {
modelNode.PendingUpdates = cached.count
modelNode.PendingUpdatesCheckedAt = cached.checkedAt
}
} else {
updateCount := len(pendingPkgs)
modelNode.PendingUpdates = updateCount
modelNode.PendingUpdatesCheckedAt = now
// Cache the result
m.mu.Lock()
m.nodePendingUpdatesCache[nodeID] = pendingUpdatesCache{
count: updateCount,
checkedAt: now,
}
m.mu.Unlock()
log.Debug().
Str("node", node.Node).
Str("instance", instanceName).
Int("pendingUpdates", updateCount).
Msg("Checked pending apt updates")
}
} else {
// Use cached value
modelNode.PendingUpdates = cached.count
modelNode.PendingUpdatesCheckedAt = cached.checkedAt
}
}
}
func (m *Monitor) recordNodePollMetrics(instanceName string, node proxmox.Node, modelNode *models.Node, nodeStart time.Time) {
if m.pollMetrics == nil || modelNode == nil {
return
}
nodeNameLabel := strings.TrimSpace(node.Node)
if nodeNameLabel == "" {
nodeNameLabel = strings.TrimSpace(modelNode.DisplayName)
}
if nodeNameLabel == "" {
nodeNameLabel = "unknown-node"
}
success := true
nodeErrReason := ""
health := strings.ToLower(strings.TrimSpace(modelNode.ConnectionHealth))
if health != "" && health != "healthy" {
success = false
nodeErrReason = fmt.Sprintf("connection health %s", health)
}
status := strings.ToLower(strings.TrimSpace(modelNode.Status))
if success && status != "" && status != "online" {
success = false
nodeErrReason = fmt.Sprintf("status %s", status)
}
var nodeErr error
if !success {
if nodeErrReason == "" {
nodeErrReason = "unknown node error"
}
nodeErr = stderrors.New(nodeErrReason)
}
m.pollMetrics.RecordNodeResult(NodePollResult{
InstanceName: instanceName,
InstanceType: "pve",
NodeName: nodeNameLabel,
Success: success,
Error: nodeErr,
StartTime: nodeStart,
EndTime: time.Now(),
})
}