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:
parent
48fabdd827
commit
19091d47c9
1 changed files with 60 additions and 0 deletions
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue