Neues Paket mail/internal/ratelimit: Token-Bucket je (Mandant, Quelle)-Schlüssel. ConfigProvider/StaticConfig liefern Burst/Nachfüllrate je Mandant mit Fallback auf eine Default-Konfiguration (Akzeptanzkriterium 2/3: begrenzt, konfigurierbar, je Mandant getrennt). Limiter.Allow liefert bei Ablehnung eine konkrete positive Wartezeit statt nur false — Grundlage für die geforderte definierte Ablehnung MIT Wartezeit-Hinweis statt Verbindungsabbruch (Akzeptanzkriterium 3). IMAP LOGIN und POP3 PASS begrenzen Anmeldeversuche pro (Mandant, Quell-IP). SMTP MAIL FROM begrenzt die Annahmerate pro (Mandant, Absenderadresse+Quell-IP). Bei Überschreitung: POP3 -ERR, IMAP NO, SMTP 451 (temporärer Fehlercode) mit Wartezeit in Sekunden, Verbindung bleibt in allen drei Fällen offen und weiter nutzbar. loginLimiter/ acceptLimiter sind optional (nil = deaktiviert, Rückwärtskompatibilität zu ING-01..ING-08) — neue Konstruktoren NewServerWithGuardTLSLoggerAndRateLimit (IMAP/POP3) und NewServerWithMaxMessageBytesTLSLoggerAndRateLimit (SMTP). Jeder Server bekommt eine tenantID, konsistent mit dem in ING-10 etablierten Muster "ein Server je Mandant". Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils in IMAP, POP3 und SMTP einzeln: 20 reale Versuche bei Burst=5 -> exakt 5 akzeptiert, 15 mit Wartezeit-Hinweis abgelehnt, keine Verbindung getrennt; legitime Nutzung unterhalb der Schwelle unbeeinträchtigt; zwei Mandanten mit unterschiedlichem Burst über denselben Limiter unabhängig und exakt wirksam (2 vs. 8 akzeptierte Versuche). go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~31 Pakete) regressionsfrei getestet — insbesondere QA-07-Lasttests bleiben grün, da Rate-Limiting standardmäßig deaktiviert ist.
110 lines
3.1 KiB
Go
110 lines
3.1 KiB
Go
// Package ratelimit implementiert ING-09: Token-Bucket-Rate-Limiting
|
|
// auf Protokollebene für Login-Versuche (IMAP/POP3) und SMTP-Annahme,
|
|
// je Mandant getrennt konfigurierbar (Akzeptanzkriterium 3).
|
|
package ratelimit
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Config ist die Token-Bucket-Konfiguration EINES Limits
|
|
// (Akzeptanzkriterium 1/2: begrenzt und konfigurierbar). Burst ist die
|
|
// Anzahl sofort verfügbarer Versuche, RefillEvery die Zeit, die ein
|
|
// neuer Versuch nachwächst.
|
|
type Config struct {
|
|
Burst int
|
|
RefillEvery time.Duration
|
|
}
|
|
|
|
// ConfigProvider liefert die Rate-Limit-Konfiguration für einen
|
|
// Mandanten (Akzeptanzkriterium 3: je Mandant getrennt konfigurierbar).
|
|
type ConfigProvider interface {
|
|
ConfigFor(tenant string) Config
|
|
}
|
|
|
|
// StaticConfig ist ein einfacher ConfigProvider: feste Konfiguration je
|
|
// Mandant, mit Fallback auf Default für unbekannte/nicht gesondert
|
|
// konfigurierte Mandanten.
|
|
type StaticConfig struct {
|
|
Default Config
|
|
PerTenant map[string]Config
|
|
}
|
|
|
|
func (s StaticConfig) ConfigFor(tenant string) Config {
|
|
if cfg, ok := s.PerTenant[tenant]; ok {
|
|
return cfg
|
|
}
|
|
return s.Default
|
|
}
|
|
|
|
// tokenBucket ist EIN Token-Bucket-Zähler für einen Schlüssel
|
|
// (Mandant+Quelle).
|
|
type tokenBucket struct {
|
|
mu sync.Mutex
|
|
tokens float64
|
|
lastRefill time.Time
|
|
cfg Config
|
|
}
|
|
|
|
func newTokenBucket(cfg Config) *tokenBucket {
|
|
return &tokenBucket{tokens: float64(cfg.Burst), lastRefill: time.Now(), cfg: cfg}
|
|
}
|
|
|
|
// allow entscheidet über EINEN Versuch zum Zeitpunkt now. Bei
|
|
// Ablehnung liefert retryAfter eine konkrete, positive Wartezeit
|
|
// (Akzeptanzkriterium 1: definierte Ablehnung MIT Wartezeit-Hinweis,
|
|
// nicht bloßer Verbindungsabbruch).
|
|
func (b *tokenBucket) allow(now time.Time) (ok bool, retryAfter time.Duration) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
refillPerSecond := 1.0 / b.cfg.RefillEvery.Seconds()
|
|
elapsed := now.Sub(b.lastRefill).Seconds()
|
|
b.tokens += elapsed * refillPerSecond
|
|
if b.tokens > float64(b.cfg.Burst) {
|
|
b.tokens = float64(b.cfg.Burst)
|
|
}
|
|
b.lastRefill = now
|
|
|
|
if b.tokens >= 1 {
|
|
b.tokens--
|
|
return true, 0
|
|
}
|
|
missing := 1 - b.tokens
|
|
wait := time.Duration(missing / refillPerSecond * float64(time.Second))
|
|
if wait <= 0 {
|
|
wait = time.Millisecond
|
|
}
|
|
return false, wait
|
|
}
|
|
|
|
// Limiter verwaltet Token-Buckets je (Mandant, Quelle)-Schlüssel —
|
|
// EIN Limiter deckt EINEN Limit-Zweck ab (z. B. "Login-Versuche" oder
|
|
// "SMTP-Annahme"); ein Server verwendet für unterschiedliche Zwecke
|
|
// unterschiedliche Limiter-Instanzen.
|
|
type Limiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string]*tokenBucket
|
|
provider ConfigProvider
|
|
}
|
|
|
|
func NewLimiter(provider ConfigProvider) *Limiter {
|
|
return &Limiter{buckets: map[string]*tokenBucket{}, provider: provider}
|
|
}
|
|
|
|
// Allow prüft, ob EIN Versuch von source innerhalb des Mandanten
|
|
// tenant aktuell erlaubt ist.
|
|
func (l *Limiter) Allow(tenant, source string) (ok bool, retryAfter time.Duration) {
|
|
key := fmt.Sprintf("%s|%s", tenant, source)
|
|
l.mu.Lock()
|
|
b, exists := l.buckets[key]
|
|
if !exists {
|
|
b = newTokenBucket(l.provider.ConfigFor(tenant))
|
|
l.buckets[key] = b
|
|
}
|
|
l.mu.Unlock()
|
|
return b.allow(time.Now())
|
|
}
|