Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func main() {
}

// Start HTTP server
srv, err := startServer(cfg, logger, svc)
srv, err := startServer(cfg, logger, infra, svc)
if err != nil {
logger.WithError(err).Fatal("Server startup failed")
}
Expand Down Expand Up @@ -243,9 +243,10 @@ func setupServices(
func startServer(
cfg *config.Config,
logger *logrus.Logger,
infra *infrastructure,
svc *services,
) (*server.Server, error) {
srv, err := server.New(logger, cfg, svc.cartographoorProvider, svc.boundsProvider)
srv, err := server.New(logger, cfg, infra.redisClient, svc.cartographoorProvider, svc.boundsProvider)
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
Expand Down
38 changes: 38 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,44 @@ bounds:
request_timeout: 30s # HTTP request timeout for fetching bounds (minimum 5s)
bounds_ttl: 0s # Redis TTL for bounds data (0s = no expiration)

# Rate limiting configuration
# IP-based rate limiting using Redis for distributed state across multiple instances
rate_limiting:
enabled: true

# Behavior when Redis is unavailable
# Options: "fail_open" (allow requests, prioritize availability) or "fail_closed" (deny requests, prioritize security)
failure_mode: "fail_open"

# IPs/CIDR ranges that bypass rate limiting (e.g., monitoring, internal services)
exempt_ips:
- "127.0.0.1" # Localhost IPv4
- "::1" # Localhost IPv6
# Add your internal network ranges here, e.g.:
# - "10.0.0.0/8" # Internal network
# - "172.16.0.0/12" # Docker default
# - "192.168.0.0/16" # Private network

# Rate limit rules (evaluated in order, first match wins)
rules:
# Expensive bounds queries - stricter limit
- name: "bounds_endpoint"
path_pattern: "^/api/v1/.*/bounds$"
limit: 60 # 60 requests per minute per IP
window: "1m"

# API proxy passthrough - moderate limit
- name: "api_proxy"
path_pattern: "^/api/v1/.*"
limit: 300 # 300 requests per minute per IP
window: "1m"

# Default catch-all for other endpoints
- name: "default"
path_pattern: ".*"
limit: 100 # 100 requests per minute per IP
window: "1m"

# Network configuration (optional overrides and additions)
# Cartographoor provides base networks - use this section to:
# 1. Disable specific cartographoor networks
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
)

require (
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand All @@ -23,6 +24,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
Expand Down Expand Up @@ -46,6 +48,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
Expand Down
71 changes: 71 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package config

import (
"fmt"
"net"
"net/http"
"os"
"regexp"
"time"

"github.com/ethpandaops/lab-backend/internal/cartographoor"
Expand All @@ -20,6 +22,7 @@ type Config struct {
Experiments map[string]ExperimentSettings `yaml:"experiments"`
Cartographoor cartographoor.Config `yaml:"cartographoor"`
Bounds BoundsConfig `yaml:"bounds"`
RateLimiting RateLimitingConfig `yaml:"rate_limiting"`
}

// ServerConfig contains HTTP server settings.
Expand Down Expand Up @@ -58,6 +61,22 @@ type BoundsConfig struct {
BoundsTTL time.Duration `yaml:"bounds_ttl"` // Redis TTL for bounds data (0 = no expiration)
}

// RateLimitingConfig holds rate limiting configuration.
type RateLimitingConfig struct {
Enabled bool `yaml:"enabled"`
FailureMode string `yaml:"failure_mode"` // "fail_open" or "fail_closed"
ExemptIPs []string `yaml:"exempt_ips"` // CIDR ranges to whitelist
Rules []RateLimitRule `yaml:"rules"`
}

// RateLimitRule defines a single rate limit rule.
type RateLimitRule struct {
Name string `yaml:"name"`
PathPattern string `yaml:"path_pattern"` // Regex pattern
Limit int `yaml:"limit"` // Max requests
Window time.Duration `yaml:"window"` // Time window
}

// Validate validates the configuration and sets defaults.
func (c *BoundsConfig) Validate() error {
// Set defaults
Expand Down Expand Up @@ -199,5 +218,57 @@ func (c *Config) Validate() error {
return fmt.Errorf("bounds: %w", err)
}

// Validate rate limiting config
if c.RateLimiting.Enabled {
if err := c.validateRateLimiting(); err != nil {
return fmt.Errorf("rate_limiting: %w", err)
}
}

return nil
}

func (c *Config) validateRateLimiting() error {
if c.RateLimiting.FailureMode != "fail_open" && c.RateLimiting.FailureMode != "fail_closed" {
return fmt.Errorf("failure_mode must be 'fail_open' or 'fail_closed'")
}

if len(c.RateLimiting.Rules) == 0 {
return fmt.Errorf("rules must have at least one rule")
}

for i, rule := range c.RateLimiting.Rules {
if rule.Name == "" {
return fmt.Errorf("rules[%d].name is required", i)
}

if rule.PathPattern == "" {
return fmt.Errorf("rules[%d].path_pattern is required", i)
}

if rule.Limit <= 0 {
return fmt.Errorf("rules[%d].limit must be positive", i)
}

if rule.Window <= 0 {
return fmt.Errorf("rules[%d].window must be positive", i)
}

// Validate regex pattern compiles
if _, err := regexp.Compile(rule.PathPattern); err != nil {
return fmt.Errorf("rules[%d].path_pattern invalid regex: %w", i, err)
}
}

// Validate CIDR ranges
for i, cidr := range c.RateLimiting.ExemptIPs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
// Try parsing as single IP
if net.ParseIP(cidr) == nil {
return fmt.Errorf("exempt_ips[%d] invalid IP or CIDR: %s", i, cidr)
}
}
}

return nil
}
26 changes: 26 additions & 0 deletions internal/middleware/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)

var (
Expand Down Expand Up @@ -41,6 +42,31 @@ var (
},
[]string{"method", "path"},
)

// Rate limiting metrics.
RateLimitAllowedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_rate_limit_allowed_total",
Help: "Total number of requests allowed by rate limiter",
},
[]string{"rule", "path_pattern"},
)

RateLimitDeniedTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_rate_limit_denied_total",
Help: "Total number of requests denied by rate limiter",
},
[]string{"rule", "path_pattern"},
)

RateLimitErrorsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "http_rate_limit_errors_total",
Help: "Total number of rate limiter errors",
},
[]string{"error_type"},
)
)

func init() {
Expand Down
Loading
Loading