Skip to content

Commit d6f26a3

Browse files
authored
feat(api): add configurable per-IP rate limiting (#12)
## Summary - Add IP-based rate limiting middleware with configurable limits per endpoint tier - Rate limiting is **disabled by default** and opt-in via configuration - Updates OpenAPI documentation with rate limit info and 429 responses ## Rate Limit Tiers | Tier | Endpoints | Default Limit | Purpose | |------|-----------|---------------|---------| | Auth | `/auth/login`, `/auth/github`, `/auth/exchange` | 10 req/min | Brute force protection | | Public | `/health`, `/metrics`, `/openapi.json` | 60 req/min | Moderate protection | | Authenticated | All `/api/v1/*` protected routes | 120 req/min | Relaxed for trusted users | ## Configuration ```yaml server: rate_limit: enabled: true auth: requests_per_minute: 10 public: requests_per_minute: 60 authenticated: requests_per_minute: 120 ``` ## Implementation Details - Uses `golang.org/x/time/rate` token bucket algorithm - Per-IP tracking with automatic cleanup of stale entries (every 10 minutes) - Returns HTTP 429 with `Retry-After` header when limit exceeded - Chi's `RealIP` middleware extracts client IP from `X-Forwarded-For`/`X-Real-IP` headers ## Test plan - [x] Build passes - [x] Test with rate limiting disabled (default) - no rate limiting applied - [x] Test auth endpoint rate limit: `for i in {1..15}; do curl -X POST localhost:9090/api/v1/auth/login; done` - [x] Test public endpoint rate limit: `for i in {1..65}; do curl localhost:9090/health; done` - [x] Verify 429 response includes `Retry-After` header - [x] Verify OpenAPI docs show rate limit info and 429 responses
1 parent baff303 commit d6f26a3

10 files changed

Lines changed: 556 additions & 24 deletions

File tree

config.example.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ server:
55
listen: ":9090"
66
cors_origins:
77
- "*"
8+
# Rate limiting per IP address (disabled by default)
9+
rate_limit:
10+
enabled: false
11+
auth:
12+
requests_per_minute: 10 # Strict for login/OAuth endpoints
13+
public:
14+
requests_per_minute: 60 # Moderate for health/metrics
15+
authenticated:
16+
requests_per_minute: 120 # Relaxed for authenticated users
817

918
database:
1019
driver: sqlite

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/swaggo/swag v1.16.6
1818
golang.org/x/crypto v0.46.0
1919
golang.org/x/oauth2 v0.34.0
20+
golang.org/x/time v0.14.0
2021
gopkg.in/yaml.v3 v3.0.1
2122
)
2223

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115115
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
116116
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
117117
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
118+
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
119+
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
118120
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
119121
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
120122
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=

pkg/api/api.go

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type server struct {
4242
hub *Hub
4343
srv *http.Server
4444
router chi.Router
45+
46+
// Rate limiters for different endpoint tiers.
47+
authRateLimiter *IPRateLimiter
48+
publicRateLimiter *IPRateLimiter
49+
authenticatedRateLimiter *IPRateLimiter
4550
}
4651

4752
// Ensure server implements Server.
@@ -63,6 +68,19 @@ func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q que
6368
hub: hub,
6469
}
6570

71+
// Initialize rate limiters if enabled.
72+
if cfg.Server.RateLimit.Enabled {
73+
s.authRateLimiter = NewIPRateLimiter(cfg.Server.RateLimit.Auth.RequestsPerMinute)
74+
s.publicRateLimiter = NewIPRateLimiter(cfg.Server.RateLimit.Public.RequestsPerMinute)
75+
s.authenticatedRateLimiter = NewIPRateLimiter(cfg.Server.RateLimit.Authenticated.RequestsPerMinute)
76+
77+
log.WithFields(logrus.Fields{
78+
"auth_rpm": cfg.Server.RateLimit.Auth.RequestsPerMinute,
79+
"public_rpm": cfg.Server.RateLimit.Public.RequestsPerMinute,
80+
"authenticated_rpm": cfg.Server.RateLimit.Authenticated.RequestsPerMinute,
81+
}).Info("Rate limiting enabled")
82+
}
83+
6684
// Set up callback to broadcast job state changes via WebSocket.
6785
q.SetJobChangeCallback(func(job *store.Job) {
6886
hub.BroadcastJobState(job)
@@ -150,29 +168,54 @@ func (s *server) setupRouter() {
150168
r.Use(corsMiddleware(s.cfg.Server.CORSOrigins))
151169
}
152170

153-
// Health check (public).
154-
r.Get("/health", s.handleHealth)
171+
// Public endpoints with public rate limit.
172+
r.Group(func(r chi.Router) {
173+
if s.publicRateLimiter != nil {
174+
r.Use(s.publicRateLimiter.Middleware)
175+
}
176+
177+
// Health check (public).
178+
r.Get("/health", s.handleHealth)
155179

156-
// Metrics endpoint (public).
157-
r.Handle("/metrics", promhttp.Handler())
180+
// Metrics endpoint (public).
181+
r.Handle("/metrics", promhttp.Handler())
182+
})
158183

159184
// API v1.
160185
r.Route("/api/v1", func(r chi.Router) {
161-
// OpenAPI spec (public).
162-
r.Get("/openapi.json", s.handleOpenAPISpec)
186+
// OpenAPI spec (public rate limit).
187+
r.Group(func(r chi.Router) {
188+
if s.publicRateLimiter != nil {
189+
r.Use(s.publicRateLimiter.Middleware)
190+
}
191+
r.Get("/openapi.json", s.handleOpenAPISpec)
192+
})
163193

164-
// Auth routes (public).
165-
r.Post("/auth/login", s.handleLogin)
166-
r.Get("/auth/github", s.handleGitHubAuth)
167-
r.Get("/auth/github/callback", s.handleGitHubCallback)
168-
r.Post("/auth/exchange", s.handleExchangeCode)
194+
// Auth routes with strict rate limit.
195+
r.Group(func(r chi.Router) {
196+
if s.authRateLimiter != nil {
197+
r.Use(s.authRateLimiter.Middleware)
198+
}
199+
r.Post("/auth/login", s.handleLogin)
200+
r.Get("/auth/github", s.handleGitHubAuth)
201+
r.Get("/auth/github/callback", s.handleGitHubCallback)
202+
r.Post("/auth/exchange", s.handleExchangeCode)
203+
})
169204

170-
// WebSocket (authentication handled in handler).
171-
r.Get("/ws", s.handleWebSocket)
205+
// WebSocket (authentication handled in handler, uses authenticated rate limit).
206+
r.Group(func(r chi.Router) {
207+
if s.authenticatedRateLimiter != nil {
208+
r.Use(s.authenticatedRateLimiter.Middleware)
209+
}
210+
r.Get("/ws", s.handleWebSocket)
211+
})
172212

173-
// Protected routes.
213+
// Protected routes with authenticated rate limit.
174214
r.Group(func(r chi.Router) {
175215
r.Use(auth.AuthMiddleware(s.auth))
216+
if s.authenticatedRateLimiter != nil {
217+
r.Use(s.authenticatedRateLimiter.Middleware)
218+
}
176219

177220
// Auth (authenticated).
178221
r.Post("/auth/logout", s.handleLogout)
@@ -303,6 +346,11 @@ type HealthResponse struct {
303346
Status string `json:"status" example:"ok"`
304347
}
305348

349+
// RateLimitErrorResponse is returned when rate limit is exceeded.
350+
type RateLimitErrorResponse struct {
351+
Error string `json:"error" example:"rate limit exceeded"`
352+
}
353+
306354
// handleOpenAPISpec godoc
307355
//
308356
// @Summary OpenAPI specification
@@ -325,6 +373,7 @@ func (s *server) handleOpenAPISpec(w http.ResponseWriter, _ *http.Request) {
325373
// @Tags system
326374
// @Produce json
327375
// @Success 200 {object} HealthResponse
376+
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
328377
// @Router /health [get]
329378
func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) {
330379
s.writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"})
@@ -339,6 +388,7 @@ func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) {
339388
// @Produce json
340389
// @Success 200 {object} SystemStatusResponse
341390
// @Failure 401 {object} ErrorResponse
391+
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
342392
// @Router /status [get]
343393
func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {
344394
ctx := r.Context()
@@ -1685,6 +1735,7 @@ type LoginResponse struct {
16851735
// @Success 200 {object} LoginResponse
16861736
// @Failure 400 {object} ErrorResponse
16871737
// @Failure 401 {object} ErrorResponse
1738+
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
16881739
// @Router /auth/login [post]
16891740
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
16901741
var req LoginRequest
@@ -1798,6 +1849,7 @@ func (s *server) handleMe(w http.ResponseWriter, r *http.Request) {
17981849
// @Param state query string false "OAuth state for CSRF protection"
17991850
// @Success 307 "Redirect to GitHub"
18001851
// @Failure 404 {object} ErrorResponse "GitHub auth not enabled"
1852+
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
18011853
// @Router /auth/github [get]
18021854
func (s *server) handleGitHubAuth(w http.ResponseWriter, r *http.Request) {
18031855
if !s.cfg.Auth.GitHub.Enabled {
@@ -1833,6 +1885,7 @@ func (s *server) handleGitHubAuth(w http.ResponseWriter, r *http.Request) {
18331885
// @Failure 400 {object} ErrorResponse
18341886
// @Failure 401 {object} ErrorResponse
18351887
// @Failure 404 {object} ErrorResponse "GitHub auth not enabled"
1888+
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
18361889
// @Router /auth/github/callback [get]
18371890
func (s *server) handleGitHubCallback(w http.ResponseWriter, r *http.Request) {
18381891
if !s.cfg.Auth.GitHub.Enabled {
@@ -1926,6 +1979,19 @@ type exchangeCodeRequest struct {
19261979
Code string `json:"code"`
19271980
}
19281981

1982+
// handleExchangeCode godoc
1983+
//
1984+
// @Summary Exchange auth code for token
1985+
// @Description Exchanges a one-time authorization code for a session token
1986+
// @Tags auth
1987+
// @Accept json
1988+
// @Produce json
1989+
// @Param body body exchangeCodeRequest true "Auth code"
1990+
// @Success 200 {object} LoginResponse
1991+
// @Failure 400 {object} ErrorResponse
1992+
// @Failure 401 {object} ErrorResponse
1993+
// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded"
1994+
// @Router /auth/exchange [post]
19291995
func (s *server) handleExchangeCode(w http.ResponseWriter, r *http.Request) {
19301996
var req exchangeCodeRequest
19311997
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

pkg/api/docs.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
// @description GitHub Actions workflow dispatch queue management API.
66
// @description Dispatchoor helps you manage and schedule GitHub Actions workflow dispatches
77
// @description across multiple runner pools with fine-grained control.
8+
// @description
9+
// @description ## Rate Limiting
10+
// @description When enabled, the API enforces per-IP rate limits on three tiers:
11+
// @description - **Auth endpoints** (`/auth/*`): 10 requests/minute (protects against brute force)
12+
// @description - **Public endpoints** (`/health`, `/metrics`): 60 requests/minute
13+
// @description - **Authenticated endpoints**: 120 requests/minute
14+
// @description
15+
// @description When rate limited, the API returns HTTP 429 with a `Retry-After` header.
816
//
917
// @contact.name ethPandaOps
1018
// @contact.url https://github.com/ethpandaops/dispatchoor

0 commit comments

Comments
 (0)