From 0e126cc64eb6f36a131ef677f91a1f4ed6ddcfc6 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Mon, 12 Jan 2026 16:27:08 +0100 Subject: [PATCH] feat(api): add configurable per-IP rate limiting Add IP-based rate limiting with configurable limits for three tiers: - Auth endpoints (/auth/*): 10 req/min default (brute force protection) - Public endpoints (/health, /metrics): 60 req/min default - Authenticated endpoints (/api/v1/*): 120 req/min default Rate limiting is disabled by default and can be enabled via config: server: rate_limit: enabled: true auth: requests_per_minute: 10 public: requests_per_minute: 60 authenticated: requests_per_minute: 120 Also updates OpenAPI documentation with rate limit info and 429 responses. --- config.example.yaml | 9 +++ go.mod | 1 + go.sum | 2 + pkg/api/api.go | 94 ++++++++++++++++++++++++----- pkg/api/docs.go | 8 +++ pkg/api/docs/docs.go | 122 +++++++++++++++++++++++++++++++++++++- pkg/api/docs/swagger.json | 122 +++++++++++++++++++++++++++++++++++++- pkg/api/docs/swagger.yaml | 88 ++++++++++++++++++++++++++- pkg/api/ratelimit.go | 103 ++++++++++++++++++++++++++++++++ pkg/config/config.go | 31 +++++++++- 10 files changed, 556 insertions(+), 24 deletions(-) create mode 100644 pkg/api/ratelimit.go diff --git a/config.example.yaml b/config.example.yaml index 319493b..741b14f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,15 @@ server: listen: ":9090" cors_origins: - "*" + # Rate limiting per IP address (disabled by default) + rate_limit: + enabled: false + auth: + requests_per_minute: 10 # Strict for login/OAuth endpoints + public: + requests_per_minute: 60 # Moderate for health/metrics + authenticated: + requests_per_minute: 120 # Relaxed for authenticated users database: driver: sqlite diff --git a/go.mod b/go.mod index 2f11e86..9cd9c15 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/swaggo/swag v1.16.6 golang.org/x/crypto v0.46.0 golang.org/x/oauth2 v0.34.0 + golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index bcc9cd7..9137d6e 100644 --- a/go.sum +++ b/go.sum @@ -115,6 +115,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= diff --git a/pkg/api/api.go b/pkg/api/api.go index 7c6ea98..709fabf 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -42,6 +42,11 @@ type server struct { hub *Hub srv *http.Server router chi.Router + + // Rate limiters for different endpoint tiers. + authRateLimiter *IPRateLimiter + publicRateLimiter *IPRateLimiter + authenticatedRateLimiter *IPRateLimiter } // Ensure server implements Server. @@ -63,6 +68,19 @@ func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q que hub: hub, } + // Initialize rate limiters if enabled. + if cfg.Server.RateLimit.Enabled { + s.authRateLimiter = NewIPRateLimiter(cfg.Server.RateLimit.Auth.RequestsPerMinute) + s.publicRateLimiter = NewIPRateLimiter(cfg.Server.RateLimit.Public.RequestsPerMinute) + s.authenticatedRateLimiter = NewIPRateLimiter(cfg.Server.RateLimit.Authenticated.RequestsPerMinute) + + log.WithFields(logrus.Fields{ + "auth_rpm": cfg.Server.RateLimit.Auth.RequestsPerMinute, + "public_rpm": cfg.Server.RateLimit.Public.RequestsPerMinute, + "authenticated_rpm": cfg.Server.RateLimit.Authenticated.RequestsPerMinute, + }).Info("Rate limiting enabled") + } + // Set up callback to broadcast job state changes via WebSocket. q.SetJobChangeCallback(func(job *store.Job) { hub.BroadcastJobState(job) @@ -150,29 +168,54 @@ func (s *server) setupRouter() { r.Use(corsMiddleware(s.cfg.Server.CORSOrigins)) } - // Health check (public). - r.Get("/health", s.handleHealth) + // Public endpoints with public rate limit. + r.Group(func(r chi.Router) { + if s.publicRateLimiter != nil { + r.Use(s.publicRateLimiter.Middleware) + } + + // Health check (public). + r.Get("/health", s.handleHealth) - // Metrics endpoint (public). - r.Handle("/metrics", promhttp.Handler()) + // Metrics endpoint (public). + r.Handle("/metrics", promhttp.Handler()) + }) // API v1. r.Route("/api/v1", func(r chi.Router) { - // OpenAPI spec (public). - r.Get("/openapi.json", s.handleOpenAPISpec) + // OpenAPI spec (public rate limit). + r.Group(func(r chi.Router) { + if s.publicRateLimiter != nil { + r.Use(s.publicRateLimiter.Middleware) + } + r.Get("/openapi.json", s.handleOpenAPISpec) + }) - // Auth routes (public). - r.Post("/auth/login", s.handleLogin) - r.Get("/auth/github", s.handleGitHubAuth) - r.Get("/auth/github/callback", s.handleGitHubCallback) - r.Post("/auth/exchange", s.handleExchangeCode) + // Auth routes with strict rate limit. + r.Group(func(r chi.Router) { + if s.authRateLimiter != nil { + r.Use(s.authRateLimiter.Middleware) + } + r.Post("/auth/login", s.handleLogin) + r.Get("/auth/github", s.handleGitHubAuth) + r.Get("/auth/github/callback", s.handleGitHubCallback) + r.Post("/auth/exchange", s.handleExchangeCode) + }) - // WebSocket (authentication handled in handler). - r.Get("/ws", s.handleWebSocket) + // WebSocket (authentication handled in handler, uses authenticated rate limit). + r.Group(func(r chi.Router) { + if s.authenticatedRateLimiter != nil { + r.Use(s.authenticatedRateLimiter.Middleware) + } + r.Get("/ws", s.handleWebSocket) + }) - // Protected routes. + // Protected routes with authenticated rate limit. r.Group(func(r chi.Router) { r.Use(auth.AuthMiddleware(s.auth)) + if s.authenticatedRateLimiter != nil { + r.Use(s.authenticatedRateLimiter.Middleware) + } // Auth (authenticated). r.Post("/auth/logout", s.handleLogout) @@ -303,6 +346,11 @@ type HealthResponse struct { Status string `json:"status" example:"ok"` } +// RateLimitErrorResponse is returned when rate limit is exceeded. +type RateLimitErrorResponse struct { + Error string `json:"error" example:"rate limit exceeded"` +} + // handleOpenAPISpec godoc // // @Summary OpenAPI specification @@ -325,6 +373,7 @@ func (s *server) handleOpenAPISpec(w http.ResponseWriter, _ *http.Request) { // @Tags system // @Produce json // @Success 200 {object} HealthResponse +// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" // @Router /health [get] func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) { s.writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"}) @@ -339,6 +388,7 @@ func (s *server) handleHealth(w http.ResponseWriter, _ *http.Request) { // @Produce json // @Success 200 {object} SystemStatusResponse // @Failure 401 {object} ErrorResponse +// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" // @Router /status [get] func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -1685,6 +1735,7 @@ type LoginResponse struct { // @Success 200 {object} LoginResponse // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse +// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" // @Router /auth/login [post] func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { var req LoginRequest @@ -1798,6 +1849,7 @@ func (s *server) handleMe(w http.ResponseWriter, r *http.Request) { // @Param state query string false "OAuth state for CSRF protection" // @Success 307 "Redirect to GitHub" // @Failure 404 {object} ErrorResponse "GitHub auth not enabled" +// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" // @Router /auth/github [get] func (s *server) handleGitHubAuth(w http.ResponseWriter, r *http.Request) { if !s.cfg.Auth.GitHub.Enabled { @@ -1833,6 +1885,7 @@ func (s *server) handleGitHubAuth(w http.ResponseWriter, r *http.Request) { // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 404 {object} ErrorResponse "GitHub auth not enabled" +// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" // @Router /auth/github/callback [get] func (s *server) handleGitHubCallback(w http.ResponseWriter, r *http.Request) { if !s.cfg.Auth.GitHub.Enabled { @@ -1926,6 +1979,19 @@ type exchangeCodeRequest struct { Code string `json:"code"` } +// handleExchangeCode godoc +// +// @Summary Exchange auth code for token +// @Description Exchanges a one-time authorization code for a session token +// @Tags auth +// @Accept json +// @Produce json +// @Param body body exchangeCodeRequest true "Auth code" +// @Success 200 {object} LoginResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 429 {object} RateLimitErrorResponse "Rate limit exceeded" +// @Router /auth/exchange [post] func (s *server) handleExchangeCode(w http.ResponseWriter, r *http.Request) { var req exchangeCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/pkg/api/docs.go b/pkg/api/docs.go index d81b54b..3c8c50c 100644 --- a/pkg/api/docs.go +++ b/pkg/api/docs.go @@ -5,6 +5,14 @@ // @description GitHub Actions workflow dispatch queue management API. // @description Dispatchoor helps you manage and schedule GitHub Actions workflow dispatches // @description across multiple runner pools with fine-grained control. +// @description +// @description ## Rate Limiting +// @description When enabled, the API enforces per-IP rate limits on three tiers: +// @description - **Auth endpoints** (`/auth/*`): 10 requests/minute (protects against brute force) +// @description - **Public endpoints** (`/health`, `/metrics`): 60 requests/minute +// @description - **Authenticated endpoints**: 120 requests/minute +// @description +// @description When rate limited, the API returns HTTP 429 with a `Retry-After` header. // // @contact.name ethPandaOps // @contact.url https://github.com/ethpandaops/dispatchoor diff --git a/pkg/api/docs/docs.go b/pkg/api/docs/docs.go index 4fed977..fd6455b 100644 --- a/pkg/api/docs/docs.go +++ b/pkg/api/docs/docs.go @@ -22,6 +22,58 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/auth/exchange": { + "post": { + "description": "Exchanges a one-time authorization code for a session token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Exchange auth code for token", + "parameters": [ + { + "description": "Auth code", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pkg_api.exchangeCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/pkg_api.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } + } + } + } + }, "/auth/github": { "get": { "description": "Initiates GitHub OAuth flow by redirecting to GitHub authorization page", @@ -46,6 +98,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -108,6 +166,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -154,6 +218,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -838,6 +908,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/pkg_api.HealthResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -1438,6 +1514,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -1903,9 +1985,15 @@ const docTemplate = `{ } } }, - "pkg_api.GitHubStatus": { + "pkg_api.GitHubClientStatus": { "type": "object", "properties": { + "connected": { + "type": "boolean" + }, + "error": { + "type": "string" + }, "rate_limit_remaining": { "type": "integer" }, @@ -1920,6 +2008,17 @@ const docTemplate = `{ } } }, + "pkg_api.GitHubClientsStatus": { + "type": "object", + "properties": { + "dispatch": { + "$ref": "#/definitions/pkg_api.GitHubClientStatus" + }, + "runners": { + "$ref": "#/definitions/pkg_api.GitHubClientStatus" + } + } + }, "pkg_api.GroupWithStats": { "type": "object", "properties": { @@ -2119,6 +2218,15 @@ const docTemplate = `{ } } }, + "pkg_api.RateLimitErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "rate limit exceeded" + } + } + }, "pkg_api.ReorderQueueRequest": { "type": "object", "properties": { @@ -2142,7 +2250,7 @@ const docTemplate = `{ "$ref": "#/definitions/pkg_api.DatabaseStatus" }, "github": { - "$ref": "#/definitions/pkg_api.GitHubStatus" + "$ref": "#/definitions/pkg_api.GitHubClientsStatus" }, "queue": { "$ref": "#/definitions/pkg_api.QueueStats" @@ -2221,6 +2329,14 @@ const docTemplate = `{ "type": "string" } } + }, + "pkg_api.exchangeCodeRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } } }, "securityDefinitions": { @@ -2240,7 +2356,7 @@ var SwaggerInfo = &swag.Spec{ BasePath: "/api/v1", Schemes: []string{}, Title: "Dispatchoor API", - Description: "GitHub Actions workflow dispatch queue management API.\nDispatchoor helps you manage and schedule GitHub Actions workflow dispatches\nacross multiple runner pools with fine-grained control.", + Description: "GitHub Actions workflow dispatch queue management API.\nDispatchoor helps you manage and schedule GitHub Actions workflow dispatches\nacross multiple runner pools with fine-grained control.\n\n## Rate Limiting\nWhen enabled, the API enforces per-IP rate limits on three tiers:\n- **Auth endpoints** (`/auth/*`): 10 requests/minute (protects against brute force)\n- **Public endpoints** (`/health`, `/metrics`): 60 requests/minute\n- **Authenticated endpoints**: 120 requests/minute\n\nWhen rate limited, the API returns HTTP 429 with a `Retry-After` header.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/pkg/api/docs/swagger.json b/pkg/api/docs/swagger.json index e549b25..aabf027 100644 --- a/pkg/api/docs/swagger.json +++ b/pkg/api/docs/swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "GitHub Actions workflow dispatch queue management API.\nDispatchoor helps you manage and schedule GitHub Actions workflow dispatches\nacross multiple runner pools with fine-grained control.", + "description": "GitHub Actions workflow dispatch queue management API.\nDispatchoor helps you manage and schedule GitHub Actions workflow dispatches\nacross multiple runner pools with fine-grained control.\n\n## Rate Limiting\nWhen enabled, the API enforces per-IP rate limits on three tiers:\n- **Auth endpoints** (`/auth/*`): 10 requests/minute (protects against brute force)\n- **Public endpoints** (`/health`, `/metrics`): 60 requests/minute\n- **Authenticated endpoints**: 120 requests/minute\n\nWhen rate limited, the API returns HTTP 429 with a `Retry-After` header.", "title": "Dispatchoor API", "contact": { "name": "ethPandaOps", @@ -16,6 +16,58 @@ "host": "localhost:9090", "basePath": "/api/v1", "paths": { + "/auth/exchange": { + "post": { + "description": "Exchanges a one-time authorization code for a session token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Exchange auth code for token", + "parameters": [ + { + "description": "Auth code", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pkg_api.exchangeCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/pkg_api.LoginResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } + } + } + } + }, "/auth/github": { "get": { "description": "Initiates GitHub OAuth flow by redirecting to GitHub authorization page", @@ -40,6 +92,12 @@ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -102,6 +160,12 @@ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -148,6 +212,12 @@ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -832,6 +902,12 @@ "schema": { "$ref": "#/definitions/pkg_api.HealthResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -1432,6 +1508,12 @@ "schema": { "$ref": "#/definitions/pkg_api.ErrorResponse" } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/pkg_api.RateLimitErrorResponse" + } } } } @@ -1897,9 +1979,15 @@ } } }, - "pkg_api.GitHubStatus": { + "pkg_api.GitHubClientStatus": { "type": "object", "properties": { + "connected": { + "type": "boolean" + }, + "error": { + "type": "string" + }, "rate_limit_remaining": { "type": "integer" }, @@ -1914,6 +2002,17 @@ } } }, + "pkg_api.GitHubClientsStatus": { + "type": "object", + "properties": { + "dispatch": { + "$ref": "#/definitions/pkg_api.GitHubClientStatus" + }, + "runners": { + "$ref": "#/definitions/pkg_api.GitHubClientStatus" + } + } + }, "pkg_api.GroupWithStats": { "type": "object", "properties": { @@ -2113,6 +2212,15 @@ } } }, + "pkg_api.RateLimitErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "rate limit exceeded" + } + } + }, "pkg_api.ReorderQueueRequest": { "type": "object", "properties": { @@ -2136,7 +2244,7 @@ "$ref": "#/definitions/pkg_api.DatabaseStatus" }, "github": { - "$ref": "#/definitions/pkg_api.GitHubStatus" + "$ref": "#/definitions/pkg_api.GitHubClientsStatus" }, "queue": { "$ref": "#/definitions/pkg_api.QueueStats" @@ -2215,6 +2323,14 @@ "type": "string" } } + }, + "pkg_api.exchangeCodeRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/pkg/api/docs/swagger.yaml b/pkg/api/docs/swagger.yaml index 0a4e335..4a9bc92 100644 --- a/pkg/api/docs/swagger.yaml +++ b/pkg/api/docs/swagger.yaml @@ -261,8 +261,12 @@ definitions: example: Something went wrong type: string type: object - pkg_api.GitHubStatus: + pkg_api.GitHubClientStatus: properties: + connected: + type: boolean + error: + type: string rate_limit_remaining: type: integer rate_limit_reset: @@ -272,6 +276,13 @@ definitions: status: $ref: '#/definitions/pkg_api.ComponentStatus' type: object + pkg_api.GitHubClientsStatus: + properties: + dispatch: + $ref: '#/definitions/pkg_api.GitHubClientStatus' + runners: + $ref: '#/definitions/pkg_api.GitHubClientStatus' + type: object pkg_api.GroupWithStats: properties: busy_runners: @@ -409,6 +420,12 @@ definitions: triggered_jobs: type: integer type: object + pkg_api.RateLimitErrorResponse: + properties: + error: + example: rate limit exceeded + type: string + type: object pkg_api.ReorderQueueRequest: properties: job_ids: @@ -425,7 +442,7 @@ definitions: database: $ref: '#/definitions/pkg_api.DatabaseStatus' github: - $ref: '#/definitions/pkg_api.GitHubStatus' + $ref: '#/definitions/pkg_api.GitHubClientsStatus' queue: $ref: '#/definitions/pkg_api.QueueStats' status: @@ -479,6 +496,11 @@ definitions: version: type: string type: object + pkg_api.exchangeCodeRequest: + properties: + code: + type: string + type: object host: localhost:9090 info: contact: @@ -488,12 +510,54 @@ info: GitHub Actions workflow dispatch queue management API. Dispatchoor helps you manage and schedule GitHub Actions workflow dispatches across multiple runner pools with fine-grained control. + + ## Rate Limiting + When enabled, the API enforces per-IP rate limits on three tiers: + - **Auth endpoints** (`/auth/*`): 10 requests/minute (protects against brute force) + - **Public endpoints** (`/health`, `/metrics`): 60 requests/minute + - **Authenticated endpoints**: 120 requests/minute + + When rate limited, the API returns HTTP 429 with a `Retry-After` header. license: name: MIT url: https://github.com/ethpandaops/dispatchoor/blob/main/LICENSE title: Dispatchoor API version: "1.0" paths: + /auth/exchange: + post: + consumes: + - application/json + description: Exchanges a one-time authorization code for a session token + parameters: + - description: Auth code + in: body + name: body + required: true + schema: + $ref: '#/definitions/pkg_api.exchangeCodeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/pkg_api.LoginResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/pkg_api.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/pkg_api.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/pkg_api.RateLimitErrorResponse' + summary: Exchange auth code for token + tags: + - auth /auth/github: get: description: Initiates GitHub OAuth flow by redirecting to GitHub authorization @@ -510,6 +574,10 @@ paths: description: GitHub auth not enabled schema: $ref: '#/definitions/pkg_api.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/pkg_api.RateLimitErrorResponse' summary: GitHub OAuth initiation tags: - auth @@ -551,6 +619,10 @@ paths: description: GitHub auth not enabled schema: $ref: '#/definitions/pkg_api.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/pkg_api.RateLimitErrorResponse' summary: GitHub OAuth callback tags: - auth @@ -581,6 +653,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/pkg_api.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/pkg_api.RateLimitErrorResponse' summary: Login with username and password tags: - auth @@ -1019,6 +1095,10 @@ paths: description: OK schema: $ref: '#/definitions/pkg_api.HealthResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/pkg_api.RateLimitErrorResponse' summary: Health check tags: - system @@ -1404,6 +1484,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/pkg_api.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/pkg_api.RateLimitErrorResponse' security: - BearerAuth: [] summary: System status diff --git a/pkg/api/ratelimit.go b/pkg/api/ratelimit.go new file mode 100644 index 0000000..5c4fe3e --- /dev/null +++ b/pkg/api/ratelimit.go @@ -0,0 +1,103 @@ +package api + +import ( + "net/http" + "strconv" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// IPRateLimiter provides per-IP rate limiting middleware. +type IPRateLimiter struct { + visitors map[string]*visitorEntry + mu sync.RWMutex + rate rate.Limit + burst int +} + +// visitorEntry holds the rate limiter and last seen time for a visitor. +type visitorEntry struct { + limiter *rate.Limiter + lastSeen time.Time +} + +// NewIPRateLimiter creates a new IP-based rate limiter. +func NewIPRateLimiter(requestsPerMinute int) *IPRateLimiter { + rl := &IPRateLimiter{ + visitors: make(map[string]*visitorEntry, 256), + rate: rate.Limit(float64(requestsPerMinute) / 60.0), + burst: requestsPerMinute, + } + + // Start cleanup goroutine to remove stale entries. + go rl.cleanupLoop() + + return rl +} + +// getLimiter returns the rate limiter for the given IP, creating one if necessary. +func (l *IPRateLimiter) getLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + entry, exists := l.visitors[ip] + if !exists { + limiter := rate.NewLimiter(l.rate, l.burst) + l.visitors[ip] = &visitorEntry{ + limiter: limiter, + lastSeen: time.Now(), + } + + return limiter + } + + entry.lastSeen = time.Now() + + return entry.limiter +} + +// Middleware returns an HTTP middleware that enforces rate limiting per IP. +func (l *IPRateLimiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr // chi's RealIP middleware sets this + limiter := l.getLimiter(ip) + + if !limiter.Allow() { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Retry-After", strconv.Itoa(int(time.Minute.Seconds()))) + w.WriteHeader(http.StatusTooManyRequests) + //nolint:errcheck // Response writing errors are not recoverable + w.Write([]byte(`{"error":"rate limit exceeded"}`)) + + return + } + + next.ServeHTTP(w, r) + }) +} + +// cleanupLoop periodically removes stale IP entries. +func (l *IPRateLimiter) cleanupLoop() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + l.cleanup(10 * time.Minute) + } +} + +// cleanup removes entries that haven't been seen for longer than maxAge. +func (l *IPRateLimiter) cleanup(maxAge time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + + cutoff := time.Now().Add(-maxAge) + + for ip, entry := range l.visitors { + if entry.lastSeen.Before(cutoff) { + delete(l.visitors, ip) + } + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 73ee6f6..d479df6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -26,8 +26,22 @@ type Config struct { // ServerConfig contains HTTP server settings. type ServerConfig struct { - Listen string `yaml:"listen"` - CORSOrigins []string `yaml:"cors_origins"` + Listen string `yaml:"listen"` + CORSOrigins []string `yaml:"cors_origins"` + RateLimit RateLimitConfig `yaml:"rate_limit"` +} + +// RateLimitConfig contains rate limiting settings for different endpoint tiers. +type RateLimitConfig struct { + Enabled bool `yaml:"enabled"` + Auth RateLimitTierConfig `yaml:"auth"` + Public RateLimitTierConfig `yaml:"public"` + Authenticated RateLimitTierConfig `yaml:"authenticated"` +} + +// RateLimitTierConfig contains rate limit settings for a specific tier. +type RateLimitTierConfig struct { + RequestsPerMinute int `yaml:"requests_per_minute"` } // DatabaseConfig contains database connection settings. @@ -359,6 +373,19 @@ func applyDefaults(cfg *Config) { cfg.History.CleanupInterval = time.Hour } + // Set default rate limits per endpoint tier. + if cfg.Server.RateLimit.Auth.RequestsPerMinute == 0 { + cfg.Server.RateLimit.Auth.RequestsPerMinute = 10 + } + + if cfg.Server.RateLimit.Public.RequestsPerMinute == 0 { + cfg.Server.RateLimit.Public.RequestsPerMinute = 60 + } + + if cfg.Server.RateLimit.Authenticated.RequestsPerMinute == 0 { + cfg.Server.RateLimit.Authenticated.RequestsPerMinute = 120 + } + // Set default refs for workflow dispatch templates. for i := range cfg.Groups.GitHub { for j := range cfg.Groups.GitHub[i].WorkflowDispatchTemplates {