// Dieses File enthält zwei Begrenzungen, die sich dieselbe Mechanik teilen: // // - RateLimiter sperrt eine IP nach zu vielen fehlgeschlagenen Anmeldungen. // - WriteBudget deckelt, wie oft ein Projekt je Stunde schreiben darf. // // Beide sind Fixed-Window-Zähler: Ein Zeitfenster beginnt beim ersten Ereignis // und endet nach einer festen Dauer; danach fängt die Zählung bei null an. package auth import ( "sync" "time" ) // ── Der gemeinsame Unterbau ────────────────────────────────────────────────── // windowState ist der Zählerstand eines Schlüssels im laufenden Fenster. type windowState struct { count int windowEnd time.Time } // windowCounter zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters. // // Was der Schlüssel bedeutet, entscheidet der Benutzer des Zählers: beim // RateLimiter ist es eine IP-Adresse, beim WriteBudget eine Projekt-ID. Für den // Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe // Mechanik für beides verwenden. type windowCounter struct { mu sync.Mutex window time.Duration states map[string]*windowState } // newCounter erstellt einen Zähler und startet einen Hintergrund-Job, der // abgelaufene Einträge regelmäßig entfernt. // // Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal // vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind // das schnell sehr viele. func newCounter(window time.Duration) *windowCounter { z := &windowCounter{ window: window, states: make(map[string]*windowState), } go func() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for range ticker.C { z.cleanup() } }() return z } // peek gibt den aktuellen Stand zurück, ohne zu zählen. // remaining ist die verbleibende Dauer des Fensters. func (z *windowCounter) peek(key string) (count int, remaining time.Duration) { z.mu.Lock() defer z.mu.Unlock() state, existing := z.states[key] now := time.Now() // Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null. if !existing || now.After(state.windowEnd) { return 0, 0 } return state.count, state.windowEnd.Sub(now) } // count erhöht den Zähler um eins und gibt den neuen Stand zurück. func (z *windowCounter) count(key string) (count int, remaining time.Duration) { z.mu.Lock() defer z.mu.Unlock() now := time.Now() state, existing := z.states[key] // Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein // frisches ersetzt — sonst würde der alte Stand weitergezählt. if !existing || now.After(state.windowEnd) { state = &windowState{windowEnd: now.Add(z.window)} z.states[key] = state } state.count++ return state.count, state.windowEnd.Sub(now) } // forget löscht den Zähler eines Schlüssels. func (z *windowCounter) forget(key string) { z.mu.Lock() defer z.mu.Unlock() delete(z.states, key) } // cleanup entfernt abgelaufene Einträge aus der Map. func (z *windowCounter) cleanup() { z.mu.Lock() defer z.mu.Unlock() now := time.Now() for key, state := range z.states { if now.After(state.windowEnd) { delete(z.states, key) } } } // ── Anmeldeversuche ────────────────────────────────────────────────────────── const ( rateLimitMax = 5 // erlaubte Fehlversuche pro Fenster rateLimitWindow = 1 * time.Minute // Fenstergröße ) // RateLimiter begrenzt fehlgeschlagene Anmeldungen pro Client-IP — // ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys // durchprobieren. type RateLimiter struct { counter *windowCounter } // NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche. func NewRateLimiter() *RateLimiter { return &RateLimiter{counter: newCounter(rateLimitWindow)} } // Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen. // retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss. func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) { count, remaining := l.counter.peek(ip) if count >= rateLimitMax { return false, remaining } return true, 0 } // RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP. func (l *RateLimiter) RecordFailure(ip string) { l.counter.count(ip) } // Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff. func (l *RateLimiter) Reset(ip string) { l.counter.forget(ip) } // ── Schreibbudget je Projekt ───────────────────────────────────────────────── // WriteBudget deckelt, wie oft ein Projekt innerhalb eines Zeitfensters // schreiben darf. // // # Warum je Projekt und nicht je Key? // // Ein Limit je Key ließe sich umgehen, indem man einen zweiten Key anlegt — das // darf jeder Projekt-Admin selbst. Damit schützte es nur vor dem Versehen (eine // Schleife im Agenten), nicht vor dem Verbrauch. Das Projekt ist die Einheit der // Abschottung, also gehört auch das Budget dorthin. // // # Warum überhaupt? // // Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in // internal/web/middleware.go): Deploy-Skript, MCP-Client und Browser eines Büros // teilen sich nach außen oft eine Adresse. Damit hätte aber eine durchdrehende // Schleife im Agenten überhaupt keine Grenze mehr. // // # Was das Budget NICHT ist // // Der Zählerstand liegt im Arbeitsspeicher und ist nach einem Neustart weg. Bei // Fehlversuchen ist das gleichgültig, bei einem Stundenbudget lädt es zum // Umgehen ein. Für eine verbindliche Zusage müsste der Zähler in die Datenbank — // siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier. type WriteBudget struct { counter *windowCounter standard int } // NewWriteBudget erstellt ein Schreibbudget. // // standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben. // Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab. func NewWriteBudget(standard int, window time.Duration) *WriteBudget { return &WriteBudget{ counter: newCounter(window), standard: standard, } } // Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins // Budget passte. // // projectLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard // aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im // Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne // dass hier irgendein zwischengespeicherter Wert veralten könnte. // // Gezählt wird auch der abgelehnte Aufruf. Sonst könnte ein Agent, der stur // weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre // keine ehrliche Auskunft darüber, wie viel gerade los ist. // // excess sagt, der wievielte Aufruf über der Grenze das war: 0 solange // alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend. // // Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten // Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei // JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife // erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben // im Protokoll statt in den Einträgen. Mit excess == 1 als Bedingung // entsteht je Fenster genau ein Vermerk. func (b *WriteBudget) Consume(projectID string, projectLimit int) (ok bool, retryAfter time.Duration, excess int) { limit := b.limitFor(projectLimit) if limit <= 0 { return true, 0, 0 // Begrenzung abgeschaltet } count, remaining := b.counter.count(projectID) if count > limit { return false, remaining, count - limit } return true, 0, 0 } // Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind, // ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung. func (b *WriteBudget) Remaining(projectID string, projectLimit int) (free int, limit int) { limit = b.limitFor(projectLimit) if limit <= 0 { return 0, 0 } count, _ := b.counter.peek(projectID) free = limit - count if free < 0 { free = 0 } return free, limit } // limitFor löst die geltende Obergrenze auf: erst das Projekt, dann der Standard. func (b *WriteBudget) limitFor(projectLimit int) int { if projectLimit > 0 { return projectLimit } return b.standard }