package auth import ( "net" "sort" "strings" "sync" "time" ) type AccessSnapshot struct { KeyID string `json:"key_id"` LastPath string `json:"last_path"` RemoteAddr string `json:"remote_addr"` UserAgent string `json:"user_agent"` RequestCount int `json:"request_count"` LastAccessedAt time.Time `json:"last_accessed_at"` } type AccessTracker struct { mu sync.RWMutex entries map[string]AccessSnapshot ipCounts map[string]int agentCounts map[string]int toolCounts map[string]int totalRequests int } func NewAccessTracker() *AccessTracker { return &AccessTracker{ entries: make(map[string]AccessSnapshot), ipCounts: make(map[string]int), agentCounts: make(map[string]int), toolCounts: make(map[string]int), } } func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent, toolName string, now time.Time) { if t == nil || keyID == "" { return } t.mu.Lock() defer t.mu.Unlock() normalizedRemoteAddr := normalizeRemoteAddr(remoteAddr) entry := t.entries[keyID] entry.KeyID = keyID entry.LastPath = path entry.RemoteAddr = normalizedRemoteAddr entry.UserAgent = userAgent entry.LastAccessedAt = now.UTC() entry.RequestCount++ t.entries[keyID] = entry t.totalRequests++ if normalizedRemoteAddr != "" { t.ipCounts[normalizedRemoteAddr]++ } if userAgent != "" { t.agentCounts[userAgent]++ } if tool := strings.TrimSpace(toolName); tool != "" { t.toolCounts[tool]++ } } func normalizeRemoteAddr(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "" } host, _, err := net.SplitHostPort(trimmed) if err == nil { return host } return trimmed } func (t *AccessTracker) Snapshot() []AccessSnapshot { if t == nil { return nil } t.mu.RLock() defer t.mu.RUnlock() items := make([]AccessSnapshot, 0, len(t.entries)) for _, entry := range t.entries { items = append(items, entry) } sort.Slice(items, func(i, j int) bool { return items[i].LastAccessedAt.After(items[j].LastAccessedAt) }) return items } func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int { if t == nil { return 0 } cutoff := now.UTC().Add(-window) t.mu.RLock() defer t.mu.RUnlock() count := 0 for _, entry := range t.entries { if !entry.LastAccessedAt.Before(cutoff) { count++ } } return count } type RequestAggregate struct { Key string `json:"key"` RequestCount int `json:"request_count"` } type AccessMetrics struct { TotalRequests int `json:"total_requests"` UniquePrincipals int `json:"unique_principals"` UniqueIPs int `json:"unique_ips"` UniqueAgents int `json:"unique_agents"` UniqueTools int `json:"unique_tools"` TopIPs []RequestAggregate `json:"top_ips"` TopAgents []RequestAggregate `json:"top_agents"` TopTools []RequestAggregate `json:"top_tools"` } func (t *AccessTracker) Metrics(topN int) AccessMetrics { if t == nil { return AccessMetrics{} } if topN <= 0 { topN = 10 } t.mu.RLock() defer t.mu.RUnlock() return AccessMetrics{ TotalRequests: t.totalRequests, UniquePrincipals: len(t.entries), UniqueIPs: len(t.ipCounts), UniqueAgents: len(t.agentCounts), UniqueTools: len(t.toolCounts), TopIPs: topAggregates(t.ipCounts, topN), TopAgents: topAggregates(t.agentCounts, topN), TopTools: topAggregates(t.toolCounts, topN), } } func topAggregates(items map[string]int, topN int) []RequestAggregate { out := make([]RequestAggregate, 0, len(items)) for key, count := range items { out = append(out, RequestAggregate{Key: key, RequestCount: count}) } sort.Slice(out, func(i, j int) bool { if out[i].RequestCount == out[j].RequestCount { return out[i].Key < out[j].Key } return out[i].RequestCount > out[j].RequestCount }) if len(out) > topN { out = out[:topN] } return out }