Enforce Docker agent API token uniqueness (related to #658)

Problem: Multiple Docker agents can share the same API token, which causes
serious operational and security issues:

1. Host identity collision - agents overwrite each other in state (the bug
   fixed in aa0aa7d4f only addressed the symptom, not the root cause)
2. Security/audit gap - can't attribute actions to specific agents
3. User confusion - easy mistake that causes subtle, hard-to-debug issues
4. State corruption - race conditions on startup and racey metric updates

Root cause: The system treats API tokens as the agent's identity credential,
but never enforced uniqueness. This allowed users to accidentally (or
intentionally) reuse tokens across multiple agents, breaking the 1:1
token-to-agent relationship that the architecture assumes.

Solution: Enforce token uniqueness at the agent report ingestion point.

Implementation:
- Add dockerTokenBindings map[tokenID]agentID to Monitor state
- In ApplyDockerReport, check if token is already bound to a different agent
- On first report from a token, bind it to that agent's ID
- On subsequent reports, verify the binding matches
- Reject mismatches with clear error naming the conflicting host
- Unbind tokens when hosts are removed (allows token reuse after cleanup)

Error message example:
  "API token (pk_abc…xyz) is already in use by agent 'agent-123'
  (host: docker-host-1). Each Docker agent must use a unique API token.
  Generate a new token for this agent"

Why fail-fast instead of phased rollout:
- Shared tokens are architecturally wrong and cannot work correctly
- The system cannot safely multiplex state for duplicate identities
- A clear, immediate error is better UX than silent corruption
- Users would need to generate per-agent tokens eventually anyway

Why in-memory instead of persisted:
- Aligns with Pulse's existing state model (JSON config + in-memory state)
- Bindings naturally rebuild as agents report in after restart
- No schema migration or additional persistence complexity needed
- Sufficient for correctness since overwrite can't happen until both
  agents report, at which point the binding exists and rejects duplicates

Migration path for existing users with shared tokens:
- Generate new unique token for each agent
- Update agent configuration with new token
- Restart agents one at a time

This enforces the token-as-identity invariant and prevents users from
creating unsupportable configurations.
This commit is contained in:
rcourtman 2025-11-07 15:19:42 +00:00
parent 48fabdd827
commit 19091d47c9

View file

@ -539,6 +539,7 @@ type Monitor struct {
rrdCacheMu sync.RWMutex // Protects RRD memavailable cache
nodeRRDMemCache map[string]rrdMemCacheEntry
removedDockerHosts map[string]time.Time // Track deliberately removed Docker hosts (ID -> removal time)
dockerTokenBindings map[string]string // Track token ID -> agent ID bindings to enforce uniqueness
dockerCommands map[string]*dockerHostCommand
dockerCommandIndex map[string]string
guestMetadataMu sync.RWMutex
@ -993,6 +994,14 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) {
m.mu.Lock()
m.removedDockerHosts[hostID] = removedAt
// Unbind the token so it can be reused with a different agent if needed
if host.TokenID != "" {
delete(m.dockerTokenBindings, host.TokenID)
log.Debug().
Str("tokenID", host.TokenID).
Str("dockerHostID", hostID).
Msg("Unbound Docker agent token from removed host")
}
if cmd, ok := m.dockerCommands[hostID]; ok {
delete(m.dockerCommandIndex, cmd.status.ID)
}
@ -1519,6 +1528,56 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
return models.DockerHost{}, fmt.Errorf("docker host %q was removed at %v and cannot report again", identifier, removedAt.Format(time.RFC3339))
}
// Enforce token uniqueness: each token can only be bound to one agent
if tokenRecord != nil && tokenRecord.ID != "" {
tokenID := strings.TrimSpace(tokenRecord.ID)
agentID := strings.TrimSpace(report.Agent.ID)
if agentID == "" {
agentID = identifier
}
m.mu.Lock()
if boundAgentID, exists := m.dockerTokenBindings[tokenID]; exists {
if boundAgentID != agentID {
m.mu.Unlock()
// Find the conflicting host to provide helpful error message
conflictingHostname := "unknown"
for _, host := range hostsSnapshot {
if host.AgentID == boundAgentID || host.ID == boundAgentID {
conflictingHostname = host.Hostname
if host.CustomDisplayName != "" {
conflictingHostname = host.CustomDisplayName
} else if host.DisplayName != "" {
conflictingHostname = host.DisplayName
}
break
}
}
tokenHint := tokenHintFromRecord(tokenRecord)
if tokenHint != "" {
tokenHint = " (" + tokenHint + ")"
}
log.Warn().
Str("tokenID", tokenID).
Str("tokenHint", tokenHint).
Str("reportingAgentID", agentID).
Str("boundAgentID", boundAgentID).
Str("conflictingHost", conflictingHostname).
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)
}
} else {
// First time seeing this token - bind it to this agent
m.dockerTokenBindings[tokenID] = agentID
log.Debug().
Str("tokenID", tokenID).
Str("agentID", agentID).
Str("hostname", report.Host.Hostname).
Msg("Bound Docker agent token to agent identity")
}
m.mu.Unlock()
}
hostname := strings.TrimSpace(report.Host.Hostname)
if hostname == "" {
return models.DockerHost{}, fmt.Errorf("docker report missing hostname")
@ -3376,6 +3435,7 @@ func New(cfg *config.Config) (*Monitor, error) {
guestSnapshots: make(map[string]GuestMemorySnapshot),
nodeRRDMemCache: make(map[string]rrdMemCacheEntry),
removedDockerHosts: make(map[string]time.Time),
dockerTokenBindings: make(map[string]string),
dockerCommands: make(map[string]*dockerHostCommand),
dockerCommandIndex: make(map[string]string),
guestMetadataCache: make(map[string]guestMetadataCacheEntry),