// 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 ────────────────────────────────────────────────── // fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster. type fensterStand struct { anzahl int fensterBis time.Time } // fensterZaehler 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 fensterZaehler struct { mu sync.Mutex fenster time.Duration staende map[string]*fensterStand } // neuerZaehler 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 neuerZaehler(fenster time.Duration) *fensterZaehler { z := &fensterZaehler{ fenster: fenster, staende: make(map[string]*fensterStand), } go func() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for range ticker.C { z.aufraeumen() } }() return z } // lies gibt den aktuellen Stand zurück, ohne zu zählen. // restlaufzeit ist die verbleibende Dauer des Fensters. func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) { z.mu.Lock() defer z.mu.Unlock() stand, vorhanden := z.staende[schluessel] now := time.Now() // Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null. if !vorhanden || now.After(stand.fensterBis) { return 0, 0 } return stand.anzahl, stand.fensterBis.Sub(now) } // zaehle erhöht den Zähler um eins und gibt den neuen Stand zurück. func (z *fensterZaehler) zaehle(schluessel string) (anzahl int, restlaufzeit time.Duration) { z.mu.Lock() defer z.mu.Unlock() now := time.Now() stand, vorhanden := z.staende[schluessel] // Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein // frisches ersetzt — sonst würde der alte Stand weitergezählt. if !vorhanden || now.After(stand.fensterBis) { stand = &fensterStand{fensterBis: now.Add(z.fenster)} z.staende[schluessel] = stand } stand.anzahl++ return stand.anzahl, stand.fensterBis.Sub(now) } // vergiss löscht den Zähler eines Schlüssels. func (z *fensterZaehler) vergiss(schluessel string) { z.mu.Lock() defer z.mu.Unlock() delete(z.staende, schluessel) } // aufraeumen entfernt abgelaufene Einträge aus der Map. func (z *fensterZaehler) aufraeumen() { z.mu.Lock() defer z.mu.Unlock() now := time.Now() for schluessel, stand := range z.staende { if now.After(stand.fensterBis) { delete(z.staende, schluessel) } } } // ── 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 { zaehler *fensterZaehler } // NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche. func NewRateLimiter() *RateLimiter { return &RateLimiter{zaehler: neuerZaehler(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) { anzahl, restlaufzeit := l.zaehler.lies(ip) if anzahl >= rateLimitMax { return false, restlaufzeit } return true, 0 } // RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP. func (l *RateLimiter) RecordFailure(ip string) { l.zaehler.zaehle(ip) } // Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff. func (l *RateLimiter) Reset(ip string) { l.zaehler.vergiss(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 { zaehler *fensterZaehler 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, fenster time.Duration) *WriteBudget { return &WriteBudget{ zaehler: neuerZaehler(fenster), standard: standard, } } // Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins // Budget passte. // // projektLimit 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. // // ueberschreitung 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 ueberschreitung == 1 als Bedingung // entsteht je Fenster genau ein Vermerk. func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) { grenze := b.grenzeFuer(projektLimit) if grenze <= 0 { return true, 0, 0 // Begrenzung abgeschaltet } anzahl, restlaufzeit := b.zaehler.zaehle(projektID) if anzahl > grenze { return false, restlaufzeit, anzahl - grenze } 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(projektID string, projektLimit int) (frei int, grenze int) { grenze = b.grenzeFuer(projektLimit) if grenze <= 0 { return 0, 0 } anzahl, _ := b.zaehler.lies(projektID) frei = grenze - anzahl if frei < 0 { frei = 0 } return frei, grenze } // grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard. func (b *WriteBudget) grenzeFuer(projektLimit int) int { if projektLimit > 0 { return projektLimit } return b.standard }