diff --git a/internal/monitoring/docker_host_identity.go b/internal/monitoring/docker_host_identity.go index adfc6fba9..ca6195553 100644 --- a/internal/monitoring/docker_host_identity.go +++ b/internal/monitoring/docker_host_identity.go @@ -87,6 +87,9 @@ func findMatchingDockerHost(hosts []models.DockerHost, report agentsdocker.Repor existingToken := strings.TrimSpace(host.TokenID) if tokenID == "" || existingToken == tokenID { + if dockerHostIdentityConflicts(host, report) { + continue + } return host, true } } @@ -132,6 +135,41 @@ func findMatchingDockerHost(hosts []models.DockerHost, report agentsdocker.Repor return models.DockerHost{}, false } +// dockerHostIdentityConflicts reports whether an incoming report is clearly from +// a different physical/swarm node than the existing Docker host record. +func dockerHostIdentityConflicts(existing models.DockerHost, report agentsdocker.Report) bool { + existingMachineID := strings.TrimSpace(existing.MachineID) + reportMachineID := strings.TrimSpace(report.Host.MachineID) + if existingMachineID != "" && reportMachineID != "" && existingMachineID != reportMachineID { + return true + } + + existingSwarmNodeID := "" + if existing.Swarm != nil { + existingSwarmNodeID = strings.TrimSpace(existing.Swarm.NodeID) + } + reportSwarmNodeID := "" + if report.Host.Swarm != nil { + reportSwarmNodeID = strings.TrimSpace(report.Host.Swarm.NodeID) + } + if existingSwarmNodeID != "" && reportSwarmNodeID != "" && existingSwarmNodeID != reportSwarmNodeID { + return true + } + + // When we do not have stronger stable IDs, a hostname change is ambiguous + // and should not be treated as the same reporting host automatically. + existingHostname := strings.TrimSpace(existing.Hostname) + reportHostname := strings.TrimSpace(report.Host.Hostname) + if existingMachineID == "" && reportMachineID == "" && + existingSwarmNodeID == "" && reportSwarmNodeID == "" && + existingHostname != "" && reportHostname != "" && + !strings.EqualFold(existingHostname, reportHostname) { + return true + } + + return false +} + // dockerHostIDExists checks if a host ID is already in use. func dockerHostIDExists(id string, hosts []models.DockerHost) bool { if strings.TrimSpace(id) == "" { diff --git a/internal/monitoring/docker_host_identity_test.go b/internal/monitoring/docker_host_identity_test.go index 9a6ebbb46..098e07ee8 100644 --- a/internal/monitoring/docker_host_identity_test.go +++ b/internal/monitoring/docker_host_identity_test.go @@ -804,6 +804,22 @@ func TestResolveDockerHostIdentifier(t *testing.T) { expectedID: "existing-host", expectFallbacks: 1, }, + { + name: "agent id match is ignored when host identity conflicts", + report: agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ID: "agent-1"}, + Host: agentsdocker.HostInfo{ + Hostname: "node-b", + }, + }, + tokenRecord: &config.APITokenRecord{ID: "token-1"}, + hosts: []models.DockerHost{ + {ID: "existing-host", AgentID: "agent-1", TokenID: "token-1", Hostname: "node-a"}, + }, + expectMatch: false, + expectedID: "agent-1", + expectFallbacks: 2, + }, { name: "no match, base available", report: agentsdocker.Report{ diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 1768268f7..5aa502a98 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2520,6 +2520,34 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con Msg("Rejecting Docker report: token already bound to different agent") return models.DockerHost{}, fmt.Errorf("API token%s is already in use by agent %q (host: %s). Each Docker agent must use a unique API token. Generate a new token for this agent", tokenHint, boundAgentID, conflictingHostname) } + + for _, host := range hostsSnapshot { + if strings.TrimSpace(host.TokenID) != tokenID || strings.TrimSpace(host.AgentID) != agentID { + continue + } + if !dockerHostIdentityConflicts(host, report) { + continue + } + m.mu.Unlock() + conflictingHost := host.Hostname + if host.CustomDisplayName != "" { + conflictingHost = host.CustomDisplayName + } else if host.DisplayName != "" { + conflictingHost = host.DisplayName + } + tokenHint := tokenHintFromRecord(tokenRecord) + if tokenHint != "" { + tokenHint = " (" + tokenHint + ")" + } + log.Warn(). + Str("tokenID", tokenID). + Str("agentID", agentID). + Str("existingHostID", host.ID). + Str("existingHostname", host.Hostname). + Str("reportingHostname", report.Host.Hostname). + Msg("Rejecting Docker report: shared token/agent identity conflicts with existing host") + return models.DockerHost{}, fmt.Errorf("API token%s is already bound to Docker host %q. Reusing the same token on another node is not supported. Generate a dedicated token for this Docker agent", tokenHint, conflictingHost) + } } else { // First time seeing this token - bind it to this agent m.dockerTokenBindings[tokenID] = agentID diff --git a/internal/monitoring/monitor_docker_test.go b/internal/monitoring/monitor_docker_test.go index fa7378c7d..97dc9af14 100644 --- a/internal/monitoring/monitor_docker_test.go +++ b/internal/monitoring/monitor_docker_test.go @@ -848,6 +848,54 @@ func TestApplyDockerReport_TokenBoundToDifferentAgent(t *testing.T) { } } +func TestApplyDockerReport_SameTokenAndAgentIdentityDifferentHostnameRejected(t *testing.T) { + monitor := newTestMonitor(t) + + token := &config.APITokenRecord{ID: "shared-token", Name: "Shared Token"} + baseTime := time.Now().UTC() + + firstReport := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "shared-agent", + Version: "1.0.0", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "swarm-node-a", + MachineID: "", + }, + Timestamp: baseTime, + } + + host, err := monitor.ApplyDockerReport(firstReport, token) + if err != nil { + t.Fatalf("first ApplyDockerReport failed: %v", err) + } + if host.ID == "" { + t.Fatal("expected first host id") + } + + secondReport := firstReport + secondReport.Host.Hostname = "swarm-node-b" + secondReport.Timestamp = baseTime.Add(30 * time.Second) + + _, err = monitor.ApplyDockerReport(secondReport, token) + if err == nil { + t.Fatal("expected error for shared token reused across different hosts") + } + if !strings.Contains(err.Error(), "Generate a dedicated token") { + t.Fatalf("expected dedicated token guidance, got: %v", err) + } + + hosts := monitor.state.GetDockerHosts() + if len(hosts) != 1 { + t.Fatalf("expected original host state to remain intact, got %d hosts", len(hosts)) + } + if hosts[0].Hostname != "swarm-node-a" { + t.Fatalf("expected original host to remain swarm-node-a, got %q", hosts[0].Hostname) + } +} + func TestApplyDockerReport_MissingHostname(t *testing.T) { monitor := newTestMonitor(t)