diff --git a/README.md b/README.md index e4475bb..edc4a4c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ This module implements both internal and distributed HTTP rate limiting. Request **PLANNED:** -- Ability to define matchers in zones with Caddyfile - Smoothed estimates of distributed rate limiting - RL state persisted in storage for resuming after restarts - Admin API endpoints to inspect or modify rate limits @@ -52,7 +51,7 @@ The `rate_limit` HTTP handler module lets you define rate limit zones, which hav A zone also has a key, which is different from its name. Keys associate 1:1 with rate limiters, implemented as ring buffers; i.e. a new key implies allocating a new ring buffer. Keys can be static (no placeholders; same for every request), in which case only one rate limiter will be allocated for the whole zone. Or, keys can contain placeholders which can be different for every request, in which case a zone may contain numerous rate limiters depending on the result of expanding the key. -A zone is synomymous with a rate limit, being a number of events per duration. Both `window` and `max_events` are required configuration for a zone. For example: 100 events every 1 minute. Because this module uses a sliding window algorithm, it works by looking back `` duration and seeing if `` events have already happened in that timeframe. If so, an internal HTTP 429 error is generated and returned, invoking error routes which you have defined (if any). Otherwise, the a reservation is made and the event is allowed through. +A zone is synonymous with a rate limit, being a number of events per duration. Both `window` and `max_events` are required configuration for a zone. For example: 100 events every 1 minute. Because this module uses a sliding window algorithm, it works by looking back `` duration and seeing if `` events have already happened in that timeframe. If so, an internal HTTP 429 error is generated and returned, invoking error routes which you have defined (if any). Otherwise, a reservation is made and the event is allowed through. Each zone may optionally filter the requests it applies to by specifying [request matchers](https://caddyserver.com/docs/modules/http#servers/routes/match). @@ -121,6 +120,9 @@ Here is the syntax. See the JSON config section above for explanations about eac ``` rate_limit { zone { + match { + + } key window events @@ -197,8 +199,6 @@ We also enable distributed rate limiting. By deploying this config to two or mor ### Caddyfile example -(The Caddyfile does not yet support defining matchers for RL zones, so that has been omitted from this example.) - ``` { order rate_limit before basicauth @@ -209,6 +209,9 @@ We also enable distributed rate limiting. By deploying this config to two or mor rate_limit { distributed zone static_example { + match { + method GET + } key static events 100 window 1m diff --git a/caddyfile.go b/caddyfile.go index 0a93e23..1fa2c39 100644 --- a/caddyfile.go +++ b/caddyfile.go @@ -102,6 +102,13 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.Errf("invalid max events integer '%s': %v", d.Val(), err) } zone.MaxEvents = maxEvents + case "match": + matcherSet, err := caddyhttp.ParseCaddyfileNestedMatcherSet(d) + if err != nil { + return d.Errf("failed to parse match: %w", err) + } + + zone.MatcherSetsRaw = append(zone.MatcherSetsRaw, matcherSet) } } if zone.Window == 0 || zone.MaxEvents == 0 { diff --git a/caddyfile_test.go b/caddyfile_test.go new file mode 100644 index 0000000..ef7029d --- /dev/null +++ b/caddyfile_test.go @@ -0,0 +1,77 @@ +// Copyright 2023 Matthew Holt + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddyrl + +import ( + "bytes" + "fmt" + "testing" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +func TestCaddyfileRateLimits(t *testing.T) { + window := 60 + maxEvents := 10 + // Admin API must be exposed on port 2999 to match what caddytest.Tester does + config := fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 8080 + + order rate_limit before basicauth + } + + localhost:8080 + + rate_limit { + zone zone1 { + match { + method GET + } + key static + window %ds + events %d + } + } + + respond 200 + `, window, maxEvents) + + initTime() + + tester := caddytest.NewTester(t) + tester.InitServer(config, "caddyfile") + + for i := 0; i < maxEvents; i++ { + tester.AssertGetResponse("http://localhost:8080", 200, "") + } + + assert429Response(t, tester, int64(window)) + tester.AssertPostResponseBody("http://localhost:8080", nil, &bytes.Buffer{}, 200, "") + + // After advancing time by half the window, the retry-after value should + // change accordingly + advanceTime(window / 2) + + assert429Response(t, tester, int64(window/2)) + + // Advance time beyond the window where the events occurred. We should now + // be able to make requests again. + advanceTime(window) + + tester.AssertGetResponse("http://localhost:8080", 200, "") +}