diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index 6efbd8f..e75c0b3 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -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 +} diff --git a/internal/hostagent/agent_test.go b/internal/hostagent/agent_test.go index 393e278..266a92d 100644 --- a/internal/hostagent/agent_test.go +++ b/internal/hostagent/agent_test.go @@ -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) + } +}