munin/internal/auth/ratelimit.go
2026-09-15 20:36:26 +02:00

249 lines
8.6 KiB
Go

// 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
}