Skip to content

Commit 7cbf34d

Browse files
authored
Merge pull request #18 from ethpandaops/feat/rate-limiting-middleware
feat: add IP-based rate limiting with Redis-backed sliding window
2 parents 509163a + 252016b commit 7cbf34d

File tree

11 files changed

+1784
-3
lines changed

11 files changed

+1784
-3
lines changed

cmd/server/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func main() {
6565
}
6666

6767
// Start HTTP server
68-
srv, err := startServer(cfg, logger, svc)
68+
srv, err := startServer(cfg, logger, infra, svc)
6969
if err != nil {
7070
logger.WithError(err).Fatal("Server startup failed")
7171
}
@@ -243,9 +243,10 @@ func setupServices(
243243
func startServer(
244244
cfg *config.Config,
245245
logger *logrus.Logger,
246+
infra *infrastructure,
246247
svc *services,
247248
) (*server.Server, error) {
248-
srv, err := server.New(logger, cfg, svc.cartographoorProvider, svc.boundsProvider)
249+
srv, err := server.New(logger, cfg, infra.redisClient, svc.cartographoorProvider, svc.boundsProvider)
249250
if err != nil {
250251
return nil, fmt.Errorf("failed to create server: %w", err)
251252
}

config.example.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,44 @@ bounds:
4747
request_timeout: 30s # HTTP request timeout for fetching bounds (minimum 5s)
4848
bounds_ttl: 0s # Redis TTL for bounds data (0s = no expiration)
4949

50+
# Rate limiting configuration
51+
# IP-based rate limiting using Redis for distributed state across multiple instances
52+
rate_limiting:
53+
enabled: true
54+
55+
# Behavior when Redis is unavailable
56+
# Options: "fail_open" (allow requests, prioritize availability) or "fail_closed" (deny requests, prioritize security)
57+
failure_mode: "fail_open"
58+
59+
# IPs/CIDR ranges that bypass rate limiting (e.g., monitoring, internal services)
60+
exempt_ips:
61+
- "127.0.0.1" # Localhost IPv4
62+
- "::1" # Localhost IPv6
63+
# Add your internal network ranges here, e.g.:
64+
# - "10.0.0.0/8" # Internal network
65+
# - "172.16.0.0/12" # Docker default
66+
# - "192.168.0.0/16" # Private network
67+
68+
# Rate limit rules (evaluated in order, first match wins)
69+
rules:
70+
# Expensive bounds queries - stricter limit
71+
- name: "bounds_endpoint"
72+
path_pattern: "^/api/v1/.*/bounds$"
73+
limit: 60 # 60 requests per minute per IP
74+
window: "1m"
75+
76+
# API proxy passthrough - moderate limit
77+
- name: "api_proxy"
78+
path_pattern: "^/api/v1/.*"
79+
limit: 300 # 300 requests per minute per IP
80+
window: "1m"
81+
82+
# Default catch-all for other endpoints
83+
- name: "default"
84+
path_pattern: ".*"
85+
limit: 100 # 100 requests per minute per IP
86+
window: "1m"
87+
5088
# Network configuration (optional overrides and additions)
5189
# Cartographoor provides base networks - use this section to:
5290
# 1. Disable specific cartographoor networks

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
)
1414

1515
require (
16+
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
1617
github.com/beorn7/perks v1.0.1 // indirect
1718
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1819
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -23,6 +24,7 @@ require (
2324
github.com/prometheus/client_model v0.6.2 // indirect
2425
github.com/prometheus/common v0.66.1 // indirect
2526
github.com/prometheus/procfs v0.16.1 // indirect
27+
github.com/yuin/gopher-lua v1.1.1 // indirect
2628
go.yaml.in/yaml/v2 v2.4.2 // indirect
2729
golang.org/x/sys v0.35.0 // indirect
2830
google.golang.org/protobuf v1.36.8 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
2+
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
13
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
24
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
35
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -46,6 +48,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
4648
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
4749
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
4850
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
51+
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
52+
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
4953
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
5054
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
5155
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=

internal/config/config.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package config
33

44
import (
55
"fmt"
6+
"net"
67
"net/http"
78
"os"
9+
"regexp"
810
"time"
911

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

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

64+
// RateLimitingConfig holds rate limiting configuration.
65+
type RateLimitingConfig struct {
66+
Enabled bool `yaml:"enabled"`
67+
FailureMode string `yaml:"failure_mode"` // "fail_open" or "fail_closed"
68+
ExemptIPs []string `yaml:"exempt_ips"` // CIDR ranges to whitelist
69+
Rules []RateLimitRule `yaml:"rules"`
70+
}
71+
72+
// RateLimitRule defines a single rate limit rule.
73+
type RateLimitRule struct {
74+
Name string `yaml:"name"`
75+
PathPattern string `yaml:"path_pattern"` // Regex pattern
76+
Limit int `yaml:"limit"` // Max requests
77+
Window time.Duration `yaml:"window"` // Time window
78+
}
79+
6180
// Validate validates the configuration and sets defaults.
6281
func (c *BoundsConfig) Validate() error {
6382
// Set defaults
@@ -199,5 +218,57 @@ func (c *Config) Validate() error {
199218
return fmt.Errorf("bounds: %w", err)
200219
}
201220

221+
// Validate rate limiting config
222+
if c.RateLimiting.Enabled {
223+
if err := c.validateRateLimiting(); err != nil {
224+
return fmt.Errorf("rate_limiting: %w", err)
225+
}
226+
}
227+
228+
return nil
229+
}
230+
231+
func (c *Config) validateRateLimiting() error {
232+
if c.RateLimiting.FailureMode != "fail_open" && c.RateLimiting.FailureMode != "fail_closed" {
233+
return fmt.Errorf("failure_mode must be 'fail_open' or 'fail_closed'")
234+
}
235+
236+
if len(c.RateLimiting.Rules) == 0 {
237+
return fmt.Errorf("rules must have at least one rule")
238+
}
239+
240+
for i, rule := range c.RateLimiting.Rules {
241+
if rule.Name == "" {
242+
return fmt.Errorf("rules[%d].name is required", i)
243+
}
244+
245+
if rule.PathPattern == "" {
246+
return fmt.Errorf("rules[%d].path_pattern is required", i)
247+
}
248+
249+
if rule.Limit <= 0 {
250+
return fmt.Errorf("rules[%d].limit must be positive", i)
251+
}
252+
253+
if rule.Window <= 0 {
254+
return fmt.Errorf("rules[%d].window must be positive", i)
255+
}
256+
257+
// Validate regex pattern compiles
258+
if _, err := regexp.Compile(rule.PathPattern); err != nil {
259+
return fmt.Errorf("rules[%d].path_pattern invalid regex: %w", i, err)
260+
}
261+
}
262+
263+
// Validate CIDR ranges
264+
for i, cidr := range c.RateLimiting.ExemptIPs {
265+
if _, _, err := net.ParseCIDR(cidr); err != nil {
266+
// Try parsing as single IP
267+
if net.ParseIP(cidr) == nil {
268+
return fmt.Errorf("exempt_ips[%d] invalid IP or CIDR: %s", i, cidr)
269+
}
270+
}
271+
}
272+
202273
return nil
203274
}

internal/middleware/metrics.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/prometheus/client_golang/prometheus"
9+
"github.com/prometheus/client_golang/prometheus/promauto"
910
)
1011

1112
var (
@@ -41,6 +42,31 @@ var (
4142
},
4243
[]string{"method", "path"},
4344
)
45+
46+
// Rate limiting metrics.
47+
RateLimitAllowedTotal = promauto.NewCounterVec(
48+
prometheus.CounterOpts{
49+
Name: "http_rate_limit_allowed_total",
50+
Help: "Total number of requests allowed by rate limiter",
51+
},
52+
[]string{"rule", "path_pattern"},
53+
)
54+
55+
RateLimitDeniedTotal = promauto.NewCounterVec(
56+
prometheus.CounterOpts{
57+
Name: "http_rate_limit_denied_total",
58+
Help: "Total number of requests denied by rate limiter",
59+
},
60+
[]string{"rule", "path_pattern"},
61+
)
62+
63+
RateLimitErrorsTotal = promauto.NewCounterVec(
64+
prometheus.CounterOpts{
65+
Name: "http_rate_limit_errors_total",
66+
Help: "Total number of rate limiter errors",
67+
},
68+
[]string{"error_type"},
69+
)
4470
)
4571

4672
func init() {

0 commit comments

Comments
 (0)