hostagent: avoid identity collisions with MAC fallback (Related to #836)

This commit is contained in:
rcourtman 2025-12-17 20:09:55 +00:00
parent 834549875d
commit 93ac3232c8
5 changed files with 282 additions and 26 deletions

View file

@ -7,9 +7,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
"sort"
"strings" "strings"
"time" "time"
@ -71,6 +73,7 @@ type Agent struct {
const defaultInterval = 30 * time.Second const defaultInterval = 30 * time.Second
var readFile = os.ReadFile var readFile = os.ReadFile
var netInterfaces = net.Interfaces
var ( var (
hostInfoWithContext = gohost.InfoWithContext hostInfoWithContext = gohost.InfoWithContext
@ -698,7 +701,7 @@ func isLXCContainer() bool {
func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string { func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
gopsutilID := strings.TrimSpace(gopsutilHostID) gopsutilID := strings.TrimSpace(gopsutilHostID)
// On Linux, always prefer /etc/machine-id as it's guaranteed unique per installation. // On Linux, prefer /etc/machine-id when available.
// This avoids ID collisions from: // This avoids ID collisions from:
// - LXC containers sharing host's DMI product UUID // - LXC containers sharing host's DMI product UUID
// - Cloned VMs with identical hardware UUIDs // - Cloned VMs with identical hardware UUIDs
@ -706,9 +709,9 @@ func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
if runtime.GOOS == "linux" { if runtime.GOOS == "linux" {
if data, err := readFile("/etc/machine-id"); err == nil { if data, err := readFile("/etc/machine-id"); err == nil {
machineID := strings.TrimSpace(string(data)) machineID := strings.TrimSpace(string(data))
if len(machineID) >= 32 { if machineID != "" && len(machineID) >= 8 {
// Format as UUID if it's a 32-char hex string (like machine-id typically is) // Format as UUID if it's a 32-char hex string (like machine-id typically is).
if len(machineID) == 32 { if len(machineID) == 32 && isHexString(machineID) {
machineID = fmt.Sprintf("%s-%s-%s-%s-%s", machineID = fmt.Sprintf("%s-%s-%s-%s-%s",
machineID[0:8], machineID[8:12], machineID[12:16], machineID[0:8], machineID[8:12], machineID[12:16],
machineID[16:20], machineID[20:32]) machineID[16:20], machineID[20:32])
@ -725,7 +728,90 @@ func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
return machineID return machineID
} }
} }
if macID := getPrimaryMACIdentifier(); macID != "" {
logger.Debug().
Str("machineID", macID).
Msg("Linux host missing usable /etc/machine-id, using MAC address for unique identification")
return macID
}
} }
return gopsutilID return gopsutilID
} }
func isHexString(input string) bool {
for i := 0; i < len(input); i++ {
ch := input[i]
switch {
case ch >= '0' && ch <= '9':
case ch >= 'a' && ch <= 'f':
case ch >= 'A' && ch <= 'F':
default:
return false
}
}
return input != ""
}
func getPrimaryMACIdentifier() string {
interfaces, err := netInterfaces()
if err != nil {
return ""
}
sort.Slice(interfaces, func(i, j int) bool {
return interfaces[i].Name < interfaces[j].Name
})
// Prefer a stable-looking interface name first to avoid selecting docker bridges
// or other virtual interfaces when physical interfaces are present.
for pass := 0; pass < 2; pass++ {
for _, iface := range interfaces {
if len(iface.HardwareAddr) == 0 {
continue
}
if iface.Flags&net.FlagLoopback != 0 {
continue
}
if pass == 0 && isLikelyVirtualInterfaceName(iface.Name) {
continue
}
mac := strings.ToLower(iface.HardwareAddr.String())
normalized := strings.NewReplacer(":", "", "-", "", ".", "").Replace(mac)
if normalized == "" {
continue
}
return "mac-" + normalized
}
}
return ""
}
func isLikelyVirtualInterfaceName(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
switch {
case name == "":
return true
case name == "lo":
return true
case strings.HasPrefix(name, "docker"):
return true
case strings.HasPrefix(name, "veth"):
return true
case strings.HasPrefix(name, "br-"):
return true
case strings.HasPrefix(name, "cni"):
return true
case strings.HasPrefix(name, "flannel"):
return true
case strings.HasPrefix(name, "virbr"):
return true
case strings.HasPrefix(name, "zt"):
return true
default:
return false
}
}

View file

@ -3,6 +3,8 @@ package hostagent
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"net"
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
@ -137,12 +139,14 @@ func TestNew_DefaultPulseURL(t *testing.T) {
} }
} }
func TestNew_FallsBackToHostnameWhenMachineIDEmpty(t *testing.T) { func TestNew_FallsBackToHostnameWhenMachineIDAndMACEmpty(t *testing.T) {
originalHostInfo := hostInfoWithContext originalHostInfo := hostInfoWithContext
originalReadFile := readFile originalReadFile := readFile
originalNetInterfaces := netInterfaces
t.Cleanup(func() { t.Cleanup(func() {
hostInfoWithContext = originalHostInfo hostInfoWithContext = originalHostInfo
readFile = originalReadFile readFile = originalReadFile
netInterfaces = originalNetInterfaces
}) })
hostInfoWithContext = func(context.Context) (*gohost.InfoStat, error) { hostInfoWithContext = func(context.Context) (*gohost.InfoStat, error) {
@ -153,6 +157,7 @@ func TestNew_FallsBackToHostnameWhenMachineIDEmpty(t *testing.T) {
}, nil }, nil
} }
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist } readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel}) agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel})
if err != nil { if err != nil {
@ -165,3 +170,40 @@ func TestNew_FallsBackToHostnameWhenMachineIDEmpty(t *testing.T) {
t.Fatalf("agentID = %q, want %q", agent.agentID, "host-from-info") t.Fatalf("agentID = %q, want %q", agent.agentID, "host-from-info")
} }
} }
func TestNew_FallsBackToMACWhenMachineIDEmpty(t *testing.T) {
originalHostInfo := hostInfoWithContext
originalReadFile := readFile
originalNetInterfaces := netInterfaces
t.Cleanup(func() {
hostInfoWithContext = originalHostInfo
readFile = originalReadFile
netInterfaces = originalNetInterfaces
})
hostInfoWithContext = func(context.Context) (*gohost.InfoStat, error) {
return &gohost.InfoStat{
Hostname: "host-from-info",
HostID: "",
KernelArch: runtime.GOARCH,
}, nil
}
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
netInterfaces = func() ([]net.Interface, error) {
return []net.Interface{
{Name: "eth0", HardwareAddr: net.HardwareAddr{0x02, 0x00, 0x00, 0x00, 0x00, 0x01}},
}, nil
}
agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel})
if err != nil {
t.Fatalf("New: %v", err)
}
if agent.machineID != "mac-020000000001" {
t.Fatalf("machineID = %q, want %q", agent.machineID, "mac-020000000001")
}
if agent.agentID != "mac-020000000001" {
t.Fatalf("agentID = %q, want %q", agent.agentID, "mac-020000000001")
}
}

View file

@ -1,6 +1,8 @@
package hostagent package hostagent
import ( import (
"errors"
"net"
"os" "os"
"runtime" "runtime"
"testing" "testing"
@ -80,10 +82,15 @@ func TestGetReliableMachineID(t *testing.T) {
logger := zerolog.Nop() logger := zerolog.Nop()
originalReadFile := readFile originalReadFile := readFile
t.Cleanup(func() { readFile = originalReadFile }) originalNetInterfaces := netInterfaces
t.Cleanup(func() {
readFile = originalReadFile
netInterfaces = originalNetInterfaces
})
t.Run("trims whitespace", func(t *testing.T) { t.Run("trims whitespace", func(t *testing.T) {
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist } readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
result := getReliableMachineID(" test-id ", logger) result := getReliableMachineID(" test-id ", logger)
if result != "test-id" { if result != "test-id" {
t.Errorf("getReliableMachineID trimmed result = %q, want %q", result, "test-id") t.Errorf("getReliableMachineID trimmed result = %q, want %q", result, "test-id")
@ -100,6 +107,7 @@ func TestGetReliableMachineID(t *testing.T) {
} }
return nil, os.ErrNotExist return nil, os.ErrNotExist
} }
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
result := getReliableMachineID("gopsutil-product-uuid", logger) result := getReliableMachineID("gopsutil-product-uuid", logger)
const want = "01234567-89ab-cdef-0123-456789abcdef" const want = "01234567-89ab-cdef-0123-456789abcdef"
@ -108,24 +116,49 @@ func TestGetReliableMachineID(t *testing.T) {
} }
}) })
t.Run("Linux falls back to gopsutil ID when /etc/machine-id missing", func(t *testing.T) { t.Run("Linux falls back to MAC when /etc/machine-id missing", func(t *testing.T) {
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist } readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
netInterfaces = func() ([]net.Interface, error) {
return []net.Interface{
{
Name: "eth0",
HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0xAA, 0xBB, 0xCC},
},
}, nil
}
result := getReliableMachineID("gopsutil-product-uuid", logger)
if result != "mac-001122aabbcc" {
t.Errorf("getReliableMachineID() = %q, want %q", result, "mac-001122aabbcc")
}
})
t.Run("Linux falls back to gopsutil ID when machine-id missing and MAC unavailable", func(t *testing.T) {
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
result := getReliableMachineID("gopsutil-product-uuid", logger) result := getReliableMachineID("gopsutil-product-uuid", logger)
if result != "gopsutil-product-uuid" { if result != "gopsutil-product-uuid" {
t.Errorf("getReliableMachineID() = %q, want %q", result, "gopsutil-product-uuid") t.Errorf("getReliableMachineID() = %q, want %q", result, "gopsutil-product-uuid")
} }
}) })
t.Run("Linux falls back when machine-id is too short", func(t *testing.T) { t.Run("Linux falls back to MAC when machine-id is too short", func(t *testing.T) {
readFile = func(name string) ([]byte, error) { readFile = func(name string) ([]byte, error) {
if name == "/etc/machine-id" { if name == "/etc/machine-id" {
return []byte("short\n"), nil return []byte("short\n"), nil
} }
return nil, os.ErrNotExist return nil, os.ErrNotExist
} }
netInterfaces = func() ([]net.Interface, error) {
return []net.Interface{
{
Name: "eth0",
HardwareAddr: net.HardwareAddr{0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01},
},
}, nil
}
result := getReliableMachineID("gopsutil-product-uuid", logger) result := getReliableMachineID("gopsutil-product-uuid", logger)
if result != "gopsutil-product-uuid" { if result != "mac-deadbeef0001" {
t.Errorf("getReliableMachineID() = %q, want %q", result, "gopsutil-product-uuid") t.Errorf("getReliableMachineID() = %q, want %q", result, "mac-deadbeef0001")
} }
}) })
} else { } else {

View file

@ -655,8 +655,8 @@ type Monitor struct {
dlqInsightMap map[string]*dlqInsight dlqInsightMap map[string]*dlqInsight
nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period) nodeLastOnline map[string]time.Time // Track last time each node was seen online (for grace period)
resourceStore ResourceStoreInterface // Optional unified resource store for polling optimization resourceStore ResourceStoreInterface // Optional unified resource store for polling optimization
mockMetricsCancel context.CancelFunc mockMetricsCancel context.CancelFunc
mockMetricsWg sync.WaitGroup mockMetricsWg sync.WaitGroup
} }
type rrdMemCacheEntry struct { type rrdMemCacheEntry struct {
@ -1939,26 +1939,26 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
return models.Host{}, fmt.Errorf("host report missing hostname") return models.Host{}, fmt.Errorf("host report missing hostname")
} }
identifier := strings.TrimSpace(report.Host.ID) baseIdentifier := strings.TrimSpace(report.Host.ID)
if identifier != "" { if baseIdentifier != "" {
identifier = sanitizeDockerHostSuffix(identifier) baseIdentifier = sanitizeDockerHostSuffix(baseIdentifier)
} }
if identifier == "" { if baseIdentifier == "" {
if machine := sanitizeDockerHostSuffix(report.Host.MachineID); machine != "" { if machine := sanitizeDockerHostSuffix(report.Host.MachineID); machine != "" {
identifier = machine baseIdentifier = machine
} }
} }
if identifier == "" { if baseIdentifier == "" {
if agentID := sanitizeDockerHostSuffix(report.Agent.ID); agentID != "" { if agentID := sanitizeDockerHostSuffix(report.Agent.ID); agentID != "" {
identifier = agentID baseIdentifier = agentID
} }
} }
if identifier == "" { if baseIdentifier == "" {
if hostName := sanitizeDockerHostSuffix(hostname); hostName != "" { if hostName := sanitizeDockerHostSuffix(hostname); hostName != "" {
identifier = hostName baseIdentifier = hostName
} }
} }
if identifier == "" { if baseIdentifier == "" {
seedParts := uniqueNonEmptyStrings( seedParts := uniqueNonEmptyStrings(
report.Host.MachineID, report.Host.MachineID,
report.Agent.ID, report.Agent.ID,
@ -1969,14 +1969,38 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
} }
seed := strings.Join(seedParts, "|") seed := strings.Join(seedParts, "|")
sum := sha1.Sum([]byte(seed)) sum := sha1.Sum([]byte(seed))
identifier = fmt.Sprintf("host-%s", hex.EncodeToString(sum[:6])) baseIdentifier = fmt.Sprintf("host-%s", hex.EncodeToString(sum[:6]))
} }
existingHosts := m.state.GetHosts() existingHosts := m.state.GetHosts()
if tokenRecord != nil && tokenRecord.ID != "" { identifier := baseIdentifier
if tokenRecord != nil && strings.TrimSpace(tokenRecord.ID) != "" {
tokenID := strings.TrimSpace(tokenRecord.ID) tokenID := strings.TrimSpace(tokenRecord.ID)
bindingID := identifier
bindingID := baseIdentifier
for _, candidate := range existingHosts {
if candidate.ID != bindingID {
continue
}
if strings.TrimSpace(candidate.Hostname) == hostname && strings.TrimSpace(candidate.TokenID) == tokenID {
break
}
seed := strings.Join([]string{tokenID, hostname, bindingID}, "|")
sum := sha1.Sum([]byte(seed))
suffix := hex.EncodeToString(sum[:4])
base := bindingID
if base == "" {
base = "host"
}
if len(base) > 40 {
base = base[:40]
}
bindingID = fmt.Sprintf("%s-%s", base, suffix)
break
}
m.mu.Lock() m.mu.Lock()
if m.hostTokenBindings == nil { if m.hostTokenBindings == nil {
@ -2003,6 +2027,8 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
Msg("Bound host agent token to hostname") Msg("Bound host agent token to hostname")
} }
m.mu.Unlock() m.mu.Unlock()
identifier = bindingID
} }
var previous models.Host var previous models.Host
@ -5317,7 +5343,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
pollErr = ctx.Err() pollErr = ctx.Err()
return return
default: default:
// Always try the efficient cluster/resources endpoint first // Always try the efficient cluster/resources endpoint first
// This endpoint works on both clustered and standalone nodes // This endpoint works on both clustered and standalone nodes
// Testing confirmed it works on standalone nodes like pimox // Testing confirmed it works on standalone nodes like pimox
useClusterEndpoint := m.pollVMsAndContainersEfficient(ctx, instanceName, instanceCfg.ClusterName, instanceCfg.IsCluster, client, nodeEffectiveStatus) useClusterEndpoint := m.pollVMsAndContainersEfficient(ctx, instanceName, instanceCfg.ClusterName, instanceCfg.IsCluster, client, nodeEffectiveStatus)

View file

@ -181,6 +181,75 @@ func TestApplyHostReportAllowsTokenReuseAcrossHosts(t *testing.T) {
} }
} }
func TestApplyHostReportDisambiguatesCollidingIdentifiersAcrossTokens(t *testing.T) {
t.Helper()
monitor := &Monitor{
state: models.NewState(),
alertManager: alerts.NewManager(),
hostTokenBindings: make(map[string]string),
config: &config.Config{},
}
t.Cleanup(func() { monitor.alertManager.Stop() })
now := time.Now().UTC()
baseReport := agentshost.Report{
Agent: agentshost.AgentInfo{
ID: "agent-one",
Version: "1.0.0",
IntervalSeconds: 30,
},
Host: agentshost.HostInfo{
ID: "colliding-machine-id",
Hostname: "nas-one",
Platform: "linux",
OSName: "synology",
OSVersion: "7.0",
},
Timestamp: now,
Metrics: agentshost.Metrics{
CPUUsagePercent: 1.0,
},
}
hostOne, err := monitor.ApplyHostReport(baseReport, &config.APITokenRecord{ID: "token-one"})
if err != nil {
t.Fatalf("ApplyHostReport hostOne: %v", err)
}
if hostOne.ID == "" {
t.Fatalf("expected hostOne to have an identifier")
}
secondReport := baseReport
secondReport.Agent.ID = "agent-two"
secondReport.Host.Hostname = "nas-two"
secondReport.Timestamp = now.Add(30 * time.Second)
hostTwo, err := monitor.ApplyHostReport(secondReport, &config.APITokenRecord{ID: "token-two"})
if err != nil {
t.Fatalf("ApplyHostReport hostTwo: %v", err)
}
if hostTwo.ID == "" {
t.Fatalf("expected hostTwo to have an identifier")
}
if hostTwo.ID == hostOne.ID {
t.Fatalf("expected disambiguated host IDs, got %q", hostTwo.ID)
}
hostTwoRepeat, err := monitor.ApplyHostReport(secondReport, &config.APITokenRecord{ID: "token-two"})
if err != nil {
t.Fatalf("ApplyHostReport hostTwo repeat: %v", err)
}
if hostTwoRepeat.ID != hostTwo.ID {
t.Fatalf("expected stable host ID for repeated reports, got %q want %q", hostTwoRepeat.ID, hostTwo.ID)
}
snapshot := monitor.state.GetSnapshot()
if got := len(snapshot.Hosts); got != 2 {
t.Fatalf("expected 2 hosts in state, got %d", got)
}
}
func TestRemoveHostAgentUnbindsToken(t *testing.T) { func TestRemoveHostAgentUnbindsToken(t *testing.T) {
t.Helper() t.Helper()