Skip to content
Draft
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
35 changes: 31 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ See [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) for:

1. TLS termination (autocert or ingress)
2. Route lookup by hostname and path
3. IP validation against configured allowlist
4. Signature verification using provider-specific algorithm
5. Either:
3. Rate limiting (if configured) - returns 429 with Retry-After header if exceeded
4. IP validation against configured allowlist
5. Signature verification using provider-specific algorithm
6. Either:
- Forward to destination (transparent proxy), or
- Deliver via relay to waiting relay client
6. Log result with minimal information (IP, path, success/failure)
7. Log result with minimal information (IP, path, success/failure)

### Delivery Modes

Expand Down Expand Up @@ -109,6 +110,31 @@ In relay mode, the relay client inside the private network initiates an outbound
| json_field | Microsoft Graph | Token embedded in JSON body at configurable path |
| noop | Testing | Always succeeds |

### Rate Limiting

Rate limiting protects against abuse using a token bucket algorithm. Configure named limiters and reference them from routes or set a global default.

```yaml
rate_limiters:
default:
total_rps: 100 # Total requests per second across all IPs
per_ip_rps: 10 # Per client IP (0 = disabled)
burst: 20 # Spike allowance
cleanup_interval: 5m # Stale entry cleanup interval (default: 5m)
idle_timeout: 10m # Remove idle per-IP entries after (default: 10m)

global:
default_rate_limiter: default # Apply to all routes without explicit limiter

routes:
- hostname: example.com
path: /webhook
rate_limiter: default # Override or specify per-route
destination: http://backend:8080
```

When rate limited, returns HTTP 429 with `Retry-After: 1` header. Metrics: `gatekeeper_rate_limited_total{route,limiter,reason}` where reason is `total` or `per_ip`.

### Configuration Loading

Configuration can be loaded from file or from environment variables:
Expand Down Expand Up @@ -155,6 +181,7 @@ These are user-facing interactive wizards, not coding agent instructions. In Cla
- Relay client config: internal/relayclient/config.go
- Verifier interface: internal/verifier/verifier.go
- HTTP handler: internal/proxy/handler.go
- Rate limiter: internal/ratelimit/limiter.go, internal/ratelimit/set.go
- Relay manager: internal/relay/manager.go
- Redis relay manager: internal/relay/redis_manager.go
- Relay handler: internal/relay/handler.go
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Rate limiting support with token bucket algorithm: configure global and per-route rate limiters with total and per-IP limits, burst allowance, and automatic cleanup of stale entries. Returns HTTP 429 with Retry-After header when exceeded. New metric: `gatekeeper_rate_limited_total{route,limiter,reason}`

## [0.2.6] - 2026-01-27
### Added
- Microsoft Graph subscription validation handling: automatically responds to `validationToken` query parameter on `json_field` verifier routes, enabling webhook setup without backend involvement (similar to Slack URL verification)
Expand Down
39 changes: 39 additions & 0 deletions cmd/gatekeeperd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/tight-line/gatekeeper/internal/ipfilter"
"github.com/tight-line/gatekeeper/internal/metrics"
"github.com/tight-line/gatekeeper/internal/proxy"
"github.com/tight-line/gatekeeper/internal/ratelimit"
"github.com/tight-line/gatekeeper/internal/relay"
"github.com/tight-line/gatekeeper/internal/server"
)
Expand Down Expand Up @@ -95,6 +96,13 @@ func run() error {
logger.Info("debug payloads enabled - request/response bodies will be logged")
}

// Setup rate limiters if configured
rateLimiters := buildRateLimiters(cfg, logger)
if rateLimiters != nil {
handler.SetRateLimiters(rateLimiters, cfg.Global.DefaultRateLimiter)
defer rateLimiters.Stop()
}

// Setup relay manager if any routes use relay tokens
relayHandler, cleanup, err := setupRelayManager(cfg, handler, logger, *redisURI)
if err != nil {
Expand Down Expand Up @@ -377,6 +385,37 @@ func runHTTPServer(ctx context.Context, addr string, handler http.Handler, logge
}
}

// buildRateLimiters builds the rate limiter set from config
func buildRateLimiters(cfg *config.Config, logger *slog.Logger) *ratelimit.Set {
if len(cfg.RateLimiters) == 0 {
return nil
}

limiters := ratelimit.NewSet()
for name, rlCfg := range cfg.RateLimiters {
limiter := ratelimit.New(name, ratelimit.Config{
TotalRPS: rlCfg.TotalRPS,
PerIPRPS: rlCfg.PerIPRPS,
Burst: rlCfg.Burst,
CleanupInterval: rlCfg.CleanupInterval,
IdleTimeout: rlCfg.IdleTimeout,
})
limiters.Add(name, limiter)
logger.Info("rate limiter loaded",
"name", name,
"total_rps", rlCfg.TotalRPS,
"per_ip_rps", rlCfg.PerIPRPS,
"burst", rlCfg.Burst,
)
}

if cfg.Global.DefaultRateLimiter != "" {
logger.Info("global default rate limiter set", "limiter", cfg.Global.DefaultRateLimiter)
}

return limiters
}

func init() {
fmt.Fprintf(os.Stderr, "gatekeeperd %s\n", version)
}
19 changes: 19 additions & 0 deletions config/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ global:
acme_cache_dir: "/var/cache/gatekeeper/certs"
metrics_port: 9090
log_level: info
# default_rate_limiter: default # Optional: apply this limiter to all routes by default

# Named IP allowlists (CIDRs)
# Each list can be static or dynamically fetched
Expand Down Expand Up @@ -68,6 +69,23 @@ verifiers:
none:
type: noop

# Rate limiters - define named rate limiters that can be applied to routes
# Each limiter uses a token bucket algorithm with total and per-IP limits
rate_limiters:
# Default rate limiter with reasonable limits
default:
total_rps: 100 # Total requests per second across all IPs
per_ip_rps: 10 # Requests per second per client IP (0 = disabled)
burst: 20 # Spike allowance (token bucket capacity)
cleanup_interval: 5m # How often to scan for stale per-IP entries (default: 5m)
idle_timeout: 10m # Remove per-IP limiter after idle time (default: 10m)

# Strict rate limiter for high-risk endpoints
strict:
total_rps: 10
per_ip_rps: 2
burst: 5

# Proxy routes - each hostname is explicitly enumerated
# ACME certs are obtained automatically for each hostname when using -tls flag
routes:
Expand All @@ -76,6 +94,7 @@ routes:
path: /events
ip_allowlist: aws
verifier: avvo-slack
rate_limiter: default # Optional: apply rate limiting to this route
destination: http://10.1.1.50:8080/webhooks/slack

# Avvo Google Calendar
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ go 1.25.0
toolchain go1.25.1

require (
github.com/alicebob/miniredis/v2 v2.36.0
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.18
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.17.2
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
golang.org/x/crypto v0.47.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/alicebob/miniredis/v2 v2.36.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
Expand All @@ -24,7 +26,6 @@ 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/redis/go-redis/v9 v9.17.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.48.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ github.com/alicebob/miniredis/v2 v2.36.0 h1:yKczg+ez0bQYsG/PrgqtMMmCfl820RPu27kV
github.com/alicebob/miniredis/v2 v2.36.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=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
Expand Down Expand Up @@ -59,6 +63,8 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
72 changes: 62 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,22 @@ import (

// Config is the root configuration structure
type Config struct {
Global GlobalConfig `yaml:"global"`
IPAllowlists map[string]IPAllowlist `yaml:"ip_allowlists"`
Verifiers map[string]VerifierConfig `yaml:"verifiers"`
Validators map[string]ValidatorConfig `yaml:"validators"`
Routes []RouteConfig `yaml:"routes"`
Global GlobalConfig `yaml:"global"`
IPAllowlists map[string]IPAllowlist `yaml:"ip_allowlists"`
Verifiers map[string]VerifierConfig `yaml:"verifiers"`
Validators map[string]ValidatorConfig `yaml:"validators"`
RateLimiters map[string]RateLimiterConfig `yaml:"rate_limiters"`
Routes []RouteConfig `yaml:"routes"`
}

// GlobalConfig contains global settings
type GlobalConfig struct {
ACMEEmail string `yaml:"acme_email"`
ACMECacheDir string `yaml:"acme_cache_dir"`
MetricsPort int `yaml:"metrics_port"`
LogLevel string `yaml:"log_level"`
MaxBodySize int64 `yaml:"max_body_size"` // Maximum request body size in bytes (default: 10MB)
ACMEEmail string `yaml:"acme_email"`
ACMECacheDir string `yaml:"acme_cache_dir"`
MetricsPort int `yaml:"metrics_port"`
LogLevel string `yaml:"log_level"`
MaxBodySize int64 `yaml:"max_body_size"` // Maximum request body size in bytes (default: 10MB)
DefaultRateLimiter string `yaml:"default_rate_limiter"` // Optional default rate limiter for all routes
}

// DefaultMaxBodySize is the default maximum request body size (10MB)
Expand All @@ -41,6 +43,15 @@ type IPAllowlist struct {
RefreshInterval time.Duration `yaml:"refresh_interval,omitempty"`
}

// RateLimiterConfig defines a named rate limiter
type RateLimiterConfig struct {
TotalRPS float64 `yaml:"total_rps"` // Total requests per second across all IPs
PerIPRPS float64 `yaml:"per_ip_rps"` // Requests per second per client IP (0 = disabled)
Burst int `yaml:"burst"` // Burst allowance for spike handling
CleanupInterval time.Duration `yaml:"cleanup_interval"` // How often to scan for stale per-IP entries (default: 5m)
IdleTimeout time.Duration `yaml:"idle_timeout"` // Remove per-IP limiter after idle time (default: 10m)
}

// VerifierConfig defines a webhook signature verifier
type VerifierConfig struct {
Type string `yaml:"type"` // slack, github, shopify, api_key, hmac, json_field, query_param, header_query_param, noop
Expand Down Expand Up @@ -83,6 +94,7 @@ type RouteConfig struct {
IPAllowlist string `yaml:"ip_allowlist"`
Verifier string `yaml:"verifier"`
Validator string `yaml:"validator,omitempty"` // Optional payload structure validator
RateLimiter string `yaml:"rate_limiter,omitempty"` // Optional rate limiter (falls back to global default)
Destination string `yaml:"destination,omitempty"` // Direct delivery URL
RelayToken string `yaml:"relay_token,omitempty"` // Relay delivery token (mutually exclusive with destination)
PreserveHost bool `yaml:"preserve_host,omitempty"` // Pass original Host header to destination (default: false)
Expand Down Expand Up @@ -169,6 +181,9 @@ func (c *Config) Validate() error {
if err := c.validateValidators(); err != nil {
return err
}
if err := c.validateRateLimiters(); err != nil {
return err
}
return nil
}

Expand Down Expand Up @@ -211,6 +226,11 @@ func (c *Config) validateRoute(i int, route RouteConfig) error {
return fmt.Errorf("route %d: validator %q not found", i, route.Validator)
}
}
if route.RateLimiter != "" {
if _, ok := c.RateLimiters[route.RateLimiter]; !ok {
return fmt.Errorf("route %d: rate_limiter %q not found", i, route.RateLimiter)
}
}
return nil
}

Expand Down Expand Up @@ -357,6 +377,38 @@ func validateValidator(name string, v ValidatorConfig) error {
return nil
}

// validateRateLimiters checks that all rate limiter configs are valid
func (c *Config) validateRateLimiters() error {
// Validate global default rate limiter reference
if c.Global.DefaultRateLimiter != "" {
if _, ok := c.RateLimiters[c.Global.DefaultRateLimiter]; !ok {
return fmt.Errorf("global: default_rate_limiter %q not found", c.Global.DefaultRateLimiter)
}
}

// Validate each rate limiter config
for name, rl := range c.RateLimiters {
if err := validateRateLimiter(name, rl); err != nil {
return err
}
}
return nil
}

// validateRateLimiter validates a single rate limiter configuration
func validateRateLimiter(name string, rl RateLimiterConfig) error {
if rl.TotalRPS <= 0 {
return fmt.Errorf("rate_limiter %q: total_rps must be positive", name)
}
if rl.PerIPRPS < 0 {
return fmt.Errorf("rate_limiter %q: per_ip_rps cannot be negative", name)
}
if rl.Burst <= 0 {
return fmt.Errorf("rate_limiter %q: burst must be positive", name)
}
return nil
}

// GetHostnames returns all unique hostnames configured in routes
func (c *Config) GetHostnames() []string {
seen := make(map[string]bool)
Expand Down
Loading
Loading