From 19091d47c933096fad7b55c22ed100b44e2bfa46 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 7 Nov 2025 15:19:42 +0000 Subject: [PATCH] Enforce Docker agent API token uniqueness (related to #658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/monitoring/monitor.go | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0a42b39..9699cfb 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -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),