fix(agent): use /etc/machine-id in LXC containers to avoid ID collisions

LXC containers share the host's /sys/class/dmi/id/product_uuid, which
causes gopsutil to return identical HostIDs for all LXC containers on
the same physical host. This results in agent ID collisions where
multiple LXC containers appear as a single host in Pulse.

The fix detects LXC containers and prefers /etc/machine-id (which is
unique per container) over gopsutil's HostID.

Related to #773
This commit is contained in:
rcourtman 2025-12-14 23:05:32 +00:00
parent 9e7240042f
commit 02c4131853
2 changed files with 125 additions and 1 deletions

View file

@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"runtime"
"strings"
"time"
@ -118,7 +119,7 @@ func New(cfg Config) (*Agent, error) {
displayName := hostname
machineID := strings.TrimSpace(info.HostID)
machineID := getReliableMachineID(info.HostID, logger)
agentID := strings.TrimSpace(cfg.AgentID)
if agentID == "" {
@ -643,3 +644,62 @@ func (a *Agent) runProxmoxSetup(ctx context.Context) {
Msg("Proxmox token created but registration failed (node may need manual configuration)")
}
}
// isLXCContainer detects if we're running inside an LXC container.
// LXC containers share the host's /sys/class/dmi/id/product_uuid, which causes
// gopsutil to return identical HostIDs for all LXC containers on the same host.
func isLXCContainer() bool {
// Check systemd-detect-virt if available
if data, err := os.ReadFile("/run/systemd/container"); err == nil {
container := strings.TrimSpace(string(data))
if strings.Contains(container, "lxc") {
return true
}
}
// Check /proc/1/environ for container=lxc
if data, err := os.ReadFile("/proc/1/environ"); err == nil {
if strings.Contains(string(data), "container=lxc") {
return true
}
}
// Check /proc/1/cgroup for lxc markers
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
text := string(data)
if strings.Contains(text, "/lxc/") || strings.Contains(text, "lxc.payload") {
return true
}
}
return false
}
// getReliableMachineID returns a machine ID that's unique per container/host.
// In LXC containers, gopsutil's HostID may use /sys/class/dmi/id/product_uuid
// which is shared with the host, causing ID collisions. This function detects
// LXC and prefers /etc/machine-id which is unique per container.
func getReliableMachineID(gopsutilHostID string, logger zerolog.Logger) string {
gopsutilID := strings.TrimSpace(gopsutilHostID)
// For LXC containers, prefer /etc/machine-id to avoid ID collisions
if isLXCContainer() {
if data, err := os.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 {
machineID = fmt.Sprintf("%s-%s-%s-%s-%s",
machineID[0:8], machineID[8:12], machineID[12:16],
machineID[16:20], machineID[20:32])
}
logger.Debug().
Str("machineID", machineID).
Msg("LXC container detected, using /etc/machine-id for unique identification")
return machineID
}
}
}
return gopsutilID
}

View file

@ -1,7 +1,10 @@
package hostagent
import (
"os"
"testing"
"github.com/rs/zerolog"
)
func TestNormalisePlatform(t *testing.T) {
@ -71,3 +74,64 @@ func TestNormalisePlatform(t *testing.T) {
})
}
}
func TestGetReliableMachineID(t *testing.T) {
logger := zerolog.Nop()
// Check if we're running in an LXC container
inLXC := isLXCContainer()
t.Run("returns non-empty ID", func(t *testing.T) {
result := getReliableMachineID("test-gopsutil-id", logger)
if result == "" {
t.Error("getReliableMachineID returned empty string")
}
})
t.Run("trims whitespace", func(t *testing.T) {
result := getReliableMachineID(" test-id ", logger)
if result == " test-id " {
t.Error("getReliableMachineID did not trim whitespace")
}
})
if inLXC {
t.Run("LXC uses machine-id", func(t *testing.T) {
// In LXC, we should get a machine-id regardless of gopsutil input
result := getReliableMachineID("gopsutil-product-uuid", logger)
if result == "gopsutil-product-uuid" {
t.Error("In LXC, getReliableMachineID should use /etc/machine-id, not gopsutil ID")
}
// Verify it looks like a formatted UUID
if len(result) < 32 {
t.Errorf("Expected UUID-like result, got %q", result)
}
})
} else {
t.Run("non-LXC uses gopsutil ID", func(t *testing.T) {
result := getReliableMachineID("12345678-1234-1234-1234-123456789abc", logger)
if result != "12345678-1234-1234-1234-123456789abc" {
t.Errorf("Expected gopsutil ID, got %q", result)
}
})
}
}
func TestIsLXCContainer(t *testing.T) {
// This test documents the detection behavior.
// On non-LXC systems, isLXCContainer should return false.
// We can't easily test the true case without mocking filesystem.
result := isLXCContainer()
// Check if we're actually in an LXC container
isActuallyLXC := false
if data, err := os.ReadFile("/run/systemd/container"); err == nil {
if string(data) == "lxc" || string(data) == "lxc\n" {
isActuallyLXC = true
}
}
if result != isActuallyLXC {
t.Logf("isLXCContainer() = %v (expected %v based on environment)", result, isActuallyLXC)
}
}