Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
94 changes: 80 additions & 14 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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"})
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions pkg/api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading