munin/internal/auth/ratelimit.go
2026-10-19 10:13:56 +02:00

249 lines
8.4 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 ──────────────────────────────────────────────────
// 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
}