Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for matchers in Caddyfile format #45

Merged
merged 1 commit into from
Apr 25, 2024
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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<window>` duration and seeing if `<max_events>` 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 `<window>` duration and seeing if `<max_events>` 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).

Expand Down Expand Up @@ -121,6 +120,9 @@ Here is the syntax. See the JSON config section above for explanations about eac
```
rate_limit {
zone <name> {
match {
<matchers>
}
key <string>
window <duration>
events <max_events>
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
77 changes: 77 additions & 0 deletions caddyfile_test.go
Original file line number Diff line number Diff line change
@@ -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, "")
}
Loading