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"
"errors"
"fmt"
"net"
"net/http"
"os"
"runtime"
"sort"
"strings"
"time"
@ -71,6 +73,7 @@ type Agent struct {
const defaultInterval = 30 * time.Second
var readFile = os.ReadFile
var netInterfaces = net.Interfaces
var (
hostInfoWithContext = gohost.InfoWithContext
@ -698,7 +701,7 @@ func isLXCContainer() bool {
func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
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:
// - LXC containers sharing host's DMI product UUID
// - Cloned VMs with identical hardware UUIDs
@ -706,9 +709,9 @@ func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
if runtime.GOOS == "linux" {
if data, err := readFile("/etc/machine-id"); err == nil {
machineID := strings.TrimSpace(string(data))
if len(machineID) >= 32 {
// Format as UUID if it's a 32-char hex string (like machine-id typically is)
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).
if len(machineID) == 32 && isHexString(machineID) {
machineID = fmt.Sprintf("%s-%s-%s-%s-%s",
machineID[0:8], machineID[8:12], machineID[12:16],
machineID[16:20], machineID[20:32])
@ -725,7 +728,90 @@ func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
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
}
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 (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"os"
"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
originalReadFile := readFile
originalNetInterfaces := netInterfaces
t.Cleanup(func() {
hostInfoWithContext = originalHostInfo
readFile = originalReadFile
netInterfaces = originalNetInterfaces
})
hostInfoWithContext = func(context.Context) (*gohost.InfoStat, error) {
@ -153,6 +157,7 @@ func TestNew_FallsBackToHostnameWhenMachineIDEmpty(t *testing.T) {
}, nil
}
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})
if err != nil {
@ -165,3 +170,40 @@ func TestNew_FallsBackToHostnameWhenMachineIDEmpty(t *testing.T) {
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
import (
"errors"
"net"
"os"
"runtime"
"testing"
@ -80,10 +82,15 @@ func TestGetReliableMachineID(t *testing.T) {
logger := zerolog.Nop()
originalReadFile := readFile
t.Cleanup(func() { readFile = originalReadFile })
originalNetInterfaces := netInterfaces
t.Cleanup(func() {
readFile = originalReadFile
netInterfaces = originalNetInterfaces
})
t.Run("trims whitespace", 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(" test-id ", logger)
if 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
}
netInterfaces = func() ([]net.Interface, error) { return nil, errors.New("no interfaces") }
result := getReliableMachineID("gopsutil-product-uuid", logger)
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 }
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)
if 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) {
if name == "/etc/machine-id" {
return []byte("short\n"), nil
}
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)
if result != "gopsutil-product-uuid" {
t.Errorf("getReliableMachineID() = %q, want %q", result, "gopsutil-product-uuid")
if result != "mac-deadbeef0001" {
t.Errorf("getReliableMachineID() = %q, want %q", result, "mac-deadbeef0001")
}
})
} else {

View file

@ -655,8 +655,8 @@ type Monitor struct {
dlqInsightMap map[string]*dlqInsight
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
mockMetricsCancel context.CancelFunc
mockMetricsWg sync.WaitGroup
mockMetricsCancel context.CancelFunc
mockMetricsWg sync.WaitGroup
}
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")
}
identifier := strings.TrimSpace(report.Host.ID)
if identifier != "" {
identifier = sanitizeDockerHostSuffix(identifier)
baseIdentifier := strings.TrimSpace(report.Host.ID)
if baseIdentifier != "" {
baseIdentifier = sanitizeDockerHostSuffix(baseIdentifier)
}
if identifier == "" {
if baseIdentifier == "" {
if machine := sanitizeDockerHostSuffix(report.Host.MachineID); machine != "" {
identifier = machine
baseIdentifier = machine
}
}
if identifier == "" {
if baseIdentifier == "" {
if agentID := sanitizeDockerHostSuffix(report.Agent.ID); agentID != "" {
identifier = agentID
baseIdentifier = agentID
}
}
if identifier == "" {
if baseIdentifier == "" {
if hostName := sanitizeDockerHostSuffix(hostname); hostName != "" {
identifier = hostName
baseIdentifier = hostName
}
}
if identifier == "" {
if baseIdentifier == "" {
seedParts := uniqueNonEmptyStrings(
report.Host.MachineID,
report.Agent.ID,
@ -1969,14 +1969,38 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
}
seed := strings.Join(seedParts, "|")
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()
if tokenRecord != nil && tokenRecord.ID != "" {
identifier := baseIdentifier
if tokenRecord != nil && 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()
if m.hostTokenBindings == nil {
@ -2003,6 +2027,8 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
Msg("Bound host agent token to hostname")
}
m.mu.Unlock()
identifier = bindingID
}
var previous models.Host
@ -5317,7 +5343,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
pollErr = ctx.Err()
return
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
// Testing confirmed it works on standalone nodes like pimox
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) {
t.Helper()