Reject shared Docker token host identity collisions (#1366)

This commit is contained in:
rcourtman 2026-03-25 23:36:57 +00:00
parent 6c03706b6f
commit 333e66a8e9
4 changed files with 130 additions and 0 deletions

View file

@ -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) == "" {

View file

@ -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{

View file

@ -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

View file

@ -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)