196 lines
7.5 KiB
Go
196 lines
7.5 KiB
Go
package ratelimit
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
)
|
|
|
|
// SettingsStore is the interface for reading/writing server_settings.
|
|
// Satisfied by *catalog.ServerSettingsRepo.
|
|
type SettingsStore interface {
|
|
Get(ctx context.Context, key string) (string, error)
|
|
Set(ctx context.Context, key, value string) error
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
}
|
|
|
|
type settingsBatchWriter interface {
|
|
SetMany(ctx context.Context, values map[string]string) error
|
|
}
|
|
|
|
// LoadConfig reads rate limit settings from the settings store.
|
|
// Missing keys fall back to DefaultConfig() values.
|
|
func LoadConfig(ctx context.Context, store SettingsStore) (Config, error) {
|
|
all, err := store.GetAll(ctx)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("load rate limit config: %w", err)
|
|
}
|
|
return ConfigFromSettings(all), nil
|
|
}
|
|
|
|
// ConfigFromSettings resolves rate-limit configuration from an already-read
|
|
// server_settings snapshot. Admin mutations use it while holding the shared
|
|
// settings transaction lock so validation and writes cannot race.
|
|
func ConfigFromSettings(all map[string]string) Config {
|
|
defaults := DefaultConfig()
|
|
cfg := Config{
|
|
Enabled: parseBool(all, "ratelimit.enabled", defaults.Enabled),
|
|
GlobalReqPerSecond: parseRate(all, "ratelimit.global.requests_per_second", defaults.GlobalReqPerSecond, MaxGlobalRequestsPerSecond),
|
|
Tiers: make(map[string]TierConfig),
|
|
}
|
|
|
|
for name, tier := range defaults.Tiers {
|
|
prefix := "ratelimit.tier." + name + "."
|
|
cfg.Tiers[name] = TierConfig{
|
|
RequestsPerSecond: parseRate(all, prefix+"requests_per_second", tier.RequestsPerSecond, MaxRequestsPerWindow),
|
|
RequestsPerMinute: parseRate(all, prefix+"requests_per_minute", tier.RequestsPerMinute, MaxRequestsPerWindow),
|
|
Burst: parseInt(all, prefix+"burst", tier.Burst),
|
|
}
|
|
}
|
|
|
|
cfg.IPReqPerSecond = parseRate(all, "ratelimit.ip.requests_per_second", defaults.IPReqPerSecond, MaxRequestsPerWindow)
|
|
cfg.IPReqPerMinute = parseRate(all, "ratelimit.ip.requests_per_minute", defaults.IPReqPerMinute, MaxRequestsPerWindow)
|
|
cfg.IPBurst = parseInt(all, "ratelimit.ip.burst", defaults.IPBurst)
|
|
|
|
cfg.AuthEndpoints = make(map[string]AuthEndpointConfig)
|
|
for name, ep := range defaults.AuthEndpoints {
|
|
prefix := "ratelimit.auth." + name + "."
|
|
cfg.AuthEndpoints[name] = AuthEndpointConfig{
|
|
RequestsPerMinute: parseRate(all, prefix+"requests_per_minute", ep.RequestsPerMinute, MaxRequestsPerWindow),
|
|
Burst: parseInt(all, prefix+"burst", ep.Burst),
|
|
}
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// SeedDefaults writes default rate limit settings if they don't exist yet.
|
|
func SeedDefaults(ctx context.Context, store SettingsStore) error {
|
|
all, err := store.GetAll(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("seed rate limit defaults: %w", err)
|
|
}
|
|
|
|
defaults := map[string]string{
|
|
"ratelimit.enabled": "true",
|
|
"ratelimit.global.requests_per_second": "1000",
|
|
"ratelimit.tier.standard.requests_per_second": "20",
|
|
"ratelimit.tier.standard.requests_per_minute": "1200",
|
|
"ratelimit.tier.standard.burst": "20",
|
|
"ratelimit.tier.elevated.requests_per_second": "100",
|
|
"ratelimit.tier.elevated.requests_per_minute": "6000",
|
|
"ratelimit.tier.elevated.burst": "100",
|
|
"ratelimit.ip.requests_per_second": "120",
|
|
"ratelimit.ip.requests_per_minute": "6000",
|
|
"ratelimit.ip.burst": "120",
|
|
"ratelimit.auth.login.requests_per_minute": "20",
|
|
"ratelimit.auth.login.burst": "10",
|
|
"ratelimit.auth.signup.requests_per_minute": "10",
|
|
"ratelimit.auth.signup.burst": "6",
|
|
"ratelimit.auth.setup.requests_per_minute": "10",
|
|
"ratelimit.auth.setup.burst": "6",
|
|
"ratelimit.auth.device_start.requests_per_minute": "20",
|
|
"ratelimit.auth.device_start.burst": "10",
|
|
"ratelimit.auth.device_lookup.requests_per_minute": "60",
|
|
"ratelimit.auth.device_lookup.burst": "20",
|
|
"ratelimit.auth.device_poll.requests_per_minute": "120",
|
|
"ratelimit.auth.device_poll.burst": "30",
|
|
}
|
|
|
|
// Sort keys for deterministic seeding order
|
|
keys := make([]string, 0, len(defaults))
|
|
for k := range defaults {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, key := range keys {
|
|
if _, exists := all[key]; !exists {
|
|
if err := store.Set(ctx, key, defaults[key]); err != nil {
|
|
return fmt.Errorf("seed default %s: %w", key, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ConfigSettings serializes a runtime rate-limit config into server_settings
|
|
// values. Admin callers use the same mapping when they include the backend in
|
|
// one atomic transaction, avoiding drift between the dedicated endpoint and
|
|
// the runtime loader.
|
|
func ConfigSettings(cfg Config) map[string]string {
|
|
pairs := map[string]string{
|
|
"ratelimit.enabled": strconv.FormatBool(cfg.Enabled),
|
|
"ratelimit.global.requests_per_second": strconv.FormatFloat(cfg.GlobalReqPerSecond, 'f', -1, 64),
|
|
}
|
|
for name, tier := range cfg.Tiers {
|
|
prefix := "ratelimit.tier." + name + "."
|
|
pairs[prefix+"requests_per_second"] = strconv.FormatFloat(tier.RequestsPerSecond, 'f', -1, 64)
|
|
pairs[prefix+"requests_per_minute"] = strconv.FormatFloat(tier.RequestsPerMinute, 'f', -1, 64)
|
|
pairs[prefix+"burst"] = strconv.Itoa(tier.Burst)
|
|
}
|
|
pairs["ratelimit.ip.requests_per_second"] = strconv.FormatFloat(cfg.IPReqPerSecond, 'f', -1, 64)
|
|
pairs["ratelimit.ip.requests_per_minute"] = strconv.FormatFloat(cfg.IPReqPerMinute, 'f', -1, 64)
|
|
pairs["ratelimit.ip.burst"] = strconv.Itoa(cfg.IPBurst)
|
|
for name, ep := range cfg.AuthEndpoints {
|
|
prefix := "ratelimit.auth." + name + "."
|
|
pairs[prefix+"requests_per_minute"] = strconv.FormatFloat(ep.RequestsPerMinute, 'f', -1, 64)
|
|
pairs[prefix+"burst"] = strconv.Itoa(ep.Burst)
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
// SaveConfig persists rate limit settings to the store. Production stores
|
|
// support SetMany, so the full config changes atomically; the per-key fallback
|
|
// keeps small test and embedding stores backwards-compatible.
|
|
func SaveConfig(ctx context.Context, store SettingsStore, cfg Config) error {
|
|
pairs := ConfigSettings(cfg)
|
|
if writer, ok := store.(settingsBatchWriter); ok {
|
|
if err := writer.SetMany(ctx, pairs); err != nil {
|
|
return fmt.Errorf("save rate limit config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
for key, value := range pairs {
|
|
if err := store.Set(ctx, key, value); err != nil {
|
|
return fmt.Errorf("save rate limit config %s: %w", key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseBool/parseRate/parseInt are lenient helpers (silent fallback on parse error).
|
|
// This is intentional: runtime config should not crash on a bad value in the DB.
|
|
|
|
func parseBool(m map[string]string, key string, def bool) bool {
|
|
if v, ok := m[key]; ok {
|
|
if b, err := strconv.ParseBool(v); err == nil {
|
|
return b
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
// parseRate returns the stored value for key, falling back to def if the key
|
|
// is missing, unparseable, outside the limiter's safe integer range, or <= 0.
|
|
func parseRate(m map[string]string, key string, def, maxValue float64) float64 {
|
|
if v, ok := m[key]; ok {
|
|
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= maxValue {
|
|
return f
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
// parseInt returns the stored value for key, falling back to def if the key is
|
|
// missing, unparseable, non-positive, or outside the limiter's portable range.
|
|
func parseInt(m map[string]string, key string, def int) int {
|
|
if v, ok := m[key]; ok {
|
|
if i, err := strconv.Atoi(v); err == nil && i > 0 && i <= MaxBurst {
|
|
return i
|
|
}
|
|
}
|
|
return def
|
|
}
|