Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ 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}`
- Helm chart support for rate limiting: `rateLimiters`, `defaultRateLimiter`, and per-route `rateLimiter` values

## [0.2.7] - 2026-02-10

### Added
Expand Down
29 changes: 29 additions & 0 deletions agents/configure-helm.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,30 @@ redis:
# password: "" # or use existingSecret
```

### Step 5b: Rate Limiting (optional)

Ask: "Do you want to configure rate limiting?"

If yes, collect:
- **Name** for the rate limiter (e.g., `default`, `strict`)
- **Total RPS**: Requests per second across all IPs (e.g., 100)
- **Per-IP RPS**: Requests per second per client IP (0 = disabled)
- **Burst**: Spike allowance / token bucket capacity (e.g., 20)

Generate:
```yaml
rateLimiters:
default:
totalRps: 100
perIpRps: 10
burst: 20

# Apply to all routes by default (optional):
defaultRateLimiter: "default"
```

Or apply per-route by adding `rateLimiter: default` to individual routes.

### Step 6: Relay Configuration (if needed)

If any routes use relay mode, generate the gatekeeper-relay values.
Expand Down Expand Up @@ -217,6 +241,11 @@ replicaCount: {replica-count}
# ------------------------------------------------------------------------------
{redis-config-if-needed}

# ------------------------------------------------------------------------------
# RATE LIMITERS (optional)
# ------------------------------------------------------------------------------
{rateLimiters-config-if-needed}

# ------------------------------------------------------------------------------
# IP ALLOWLISTS
# (predefined lists are already included, add custom ones here)
Expand Down
8 changes: 8 additions & 0 deletions agents/configure-route.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ Ask about optional configuration:
- Default: No (destination hostname is used)
- Enable if: Backend needs to see the original public hostname

**Rate Limiting**: "Do you want to rate limit this route?"
- Protects against abuse with token bucket algorithm
- Configure total RPS (across all IPs) and per-IP RPS
- Returns HTTP 429 with Retry-After header when exceeded
- Reference a named rate limiter or use the global default

**Payload Validation**: "Do you want to validate the payload structure with JSON Schema?"
- Optional defense-in-depth against malformed payloads
- Pre-built schemas available for common providers in `schemas/` directory
Expand Down Expand Up @@ -113,6 +119,7 @@ routes:
ip_allowlist: {recommended-allowlist} # if applicable
verifier: {verifier-name}
validator: {validator-name} # if applicable
rate_limiter: {limiter-name} # if applicable
destination: {destination-url}
preserve_host: {true/false} # if enabled
```
Expand All @@ -133,6 +140,7 @@ routes:
path: {path}
ip_allowlist: {recommended-allowlist} # if applicable
verifier: {verifier-name}
rate_limiter: {limiter-name} # if applicable
relay_token: "${RELAY_TOKEN_{PROVIDER}}"
```

Expand Down
24 changes: 24 additions & 0 deletions charts/gatekeeperd/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ data:
acme_cache_dir: {{ .Values.tls.cacheDir | quote }}
metrics_port: {{ .Values.metrics.port }}
log_level: {{ .Values.logging.level }}
{{- if .Values.defaultRateLimiter }}
default_rate_limiter: {{ .Values.defaultRateLimiter | quote }}
{{- end }}

ip_allowlists:
{{- range $name, $allowlist := .Values.ipAllowlists }}
Expand All @@ -37,6 +40,8 @@ data:
{{- if $verifier.maxTimestampAge }}
max_timestamp_age: {{ $verifier.maxTimestampAge }}
{{- end }}
{{- else if eq $verifier.type "gitlab" }}
token: ${{ "{" }}{{ $verifier.tokenKey }}{{ "}" }}
{{- else if eq $verifier.type "github" }}
secret: ${{ "{" }}{{ $verifier.secretKey }}{{ "}" }}
{{- else if eq $verifier.type "shopify" }}
Expand All @@ -62,6 +67,22 @@ data:
{{- end }}
{{- end }}

{{- if .Values.rateLimiters }}
rate_limiters:
{{- range $name, $limiter := .Values.rateLimiters }}
{{ $name }}:
total_rps: {{ $limiter.totalRps }}
per_ip_rps: {{ $limiter.perIpRps | default 0 }}
burst: {{ $limiter.burst }}
{{- if $limiter.cleanupInterval }}
cleanup_interval: {{ $limiter.cleanupInterval }}
{{- end }}
{{- if $limiter.idleTimeout }}
idle_timeout: {{ $limiter.idleTimeout }}
{{- end }}
{{- end }}
{{- end }}

routes:
{{- range .Values.routes }}
- hostname: {{ .hostname | quote }}
Expand All @@ -72,6 +93,9 @@ data:
{{- if .verifier }}
verifier: {{ .verifier | quote }}
{{- end }}
{{- if .rateLimiter }}
rate_limiter: {{ .rateLimiter | quote }}
{{- end }}
{{- if .destination }}
destination: {{ .destination | quote }}
{{- else if .relayTokenKey }}
Expand Down
26 changes: 26 additions & 0 deletions charts/gatekeeperd/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,31 @@ ipAllowlists:
- "172.16.0.0/12"
- "192.168.0.0/16"

# ==============================================================================
# RATE LIMITERS: Protect routes from abuse with token bucket algorithm
# ==============================================================================
#
# Define named rate limiters, then reference them on routes or set a global default.
# Returns HTTP 429 with Retry-After header when limits are exceeded.
#
# Rate limiters support both total (across all IPs) and per-IP limits.
# The burst parameter controls spike allowance (token bucket capacity).
rateLimiters: {}
# default:
# totalRps: 100 # Total requests per second across all IPs
# perIpRps: 10 # Requests per second per client IP (0 = disabled)
# burst: 20 # Spike allowance (token bucket capacity)
# cleanupInterval: 5m # How often to scan for stale per-IP entries (default: 5m)
# idleTimeout: 10m # Remove per-IP limiter after idle time (default: 10m)
# strict:
# totalRps: 10
# perIpRps: 2
# burst: 5

# Default rate limiter applied to all routes that don't specify one
# Must reference a name defined in rateLimiters above
defaultRateLimiter: ""

# Verifier definitions
# Secrets should be provided via existingSecret or secrets values
verifiers: {}
Expand Down Expand Up @@ -267,6 +292,7 @@ routes: []
# path: /events
# ipAllowlist: aws
# verifier: avvo-slack
# rateLimiter: default # Optional: reference a rateLimiters entry
# destination: http://backend.default.svc.cluster.local:8080/webhooks/slack
#
# # Relay delivery example (for private networks)
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 @@ -79,6 +80,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 @@ -87,6 +105,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
10 changes: 9 additions & 1 deletion config/minikube-gatekeeperd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ ipAllowlists:
cidrs:
- "0.0.0.0/0"

# Rate limiter for testing (strict limits to easily trigger 429s with hey)
rateLimiters:
test-strict:
totalRps: 5
perIpRps: 2
burst: 3

# Noop verifier for testing (no signature verification)
verifiers:
noop:
Expand All @@ -60,11 +67,12 @@ verifiers:

# Routes for testing both direct forwarding and relay delivery
routes:
# Direct forwarding route - forwards directly to httpbin.org
# Direct forwarding route - forwards directly to httpbin.org (rate limited)
- hostname: test.local
path: /webhook/direct
ipAllowlist: allow-all
verifier: noop
rateLimiter: test-strict
destination: https://httpbin.org/post

# Relay route - delivered via gatekeeper-relay client
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
Loading
Loading