Skip to content

Commit fd81787

Browse files
committed
[major] Prefer multi-layer routing for v2
Breaking changes: - Requires Traefik v3.6 or newer so deployments can use multi-layer routing parentRefs. - Removes ipv4subnetMask and ipv6subnetMask; rate limiting is now keyed by the exact client IP. - Removes enableStateReconciliation and all state reconciliation code. persistentStateFile is now restart persistence only, not multi-instance coordination. - Changes the default rateLimit from 20 to 0, so non-exempt clients are challenged on the first protected request unless configured otherwise. - Makes Traefik multi-layer routing the preferred multi-service protection model: attach captcha-protect to one parent router and route accepted traffic through child routers. Implementation notes: - Adds first-class CI coverage for the documented file-provider multi-layer routing config across Traefik v3.6, v3.7, and latest. - Drops older Traefik integration targets below v3.6. - Keeps Docker label smoke coverage for single-router provider configuration. - Updates README and plugin metadata for the v2 behavior and Traefik baseline. Validation: - go test -skip 'TestStateOperationsWithinThreshold' ./... - go test -v -skip 'TestStateOperationsWithinThreshold' -race ./...
1 parent 89cec5a commit fd81787

14 files changed

Lines changed: 252 additions & 1417 deletions

File tree

.github/workflows/lint-test.yml

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,43 +53,60 @@ jobs:
5353
env:
5454
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
5555

56-
integration-test-latest:
56+
integration-test-labels:
5757
needs: [run]
5858
permissions:
5959
contents: read
6060
runs-on: ubuntu-24.04
61+
strategy:
62+
fail-fast: false
63+
matrix:
64+
traefik: [v3.6, v3.7, latest]
65+
env:
66+
TRAEFIK_TAG: ${{ matrix.traefik }}
6167
steps:
6268
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
6369

6470
- name: run
6571
run: go run test.go
6672
working-directory: ./ci
67-
env:
68-
TRAEFIK_TAG: latest
73+
74+
- name: capture logs
75+
if: ${{ always() }}
76+
run: docker compose logs --tail 100 nginx nginx2 traefik
77+
working-directory: ./ci
6978

7079
- name: cleanup
7180
if: ${{ always() }}
72-
run: docker compose logs --tail 100 nginx nginx2 traefik && docker compose down
81+
run: docker compose down
7382
working-directory: ./ci
7483

75-
integration-test:
76-
needs: [integration-test-latest]
84+
integration-test-multilayer-routing:
85+
needs: [run]
7786
permissions:
7887
contents: read
7988
runs-on: ubuntu-24.04
8089
strategy:
90+
fail-fast: false
8191
matrix:
82-
traefik: [v2.11, v3.0, v3.1, v3.2, v3.3, v3.4, v3.5, v3.6, v3.7]
92+
traefik: [v3.6, v3.7, latest]
93+
env:
94+
COMPOSE_FILE: docker-compose.multilayer.yml
95+
MULTILAYER_ROUTING: "true"
96+
TRAEFIK_TAG: ${{ matrix.traefik }}
8397
steps:
8498
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
8599

86100
- name: run
87101
run: go run test.go
88102
working-directory: ./ci
89-
env:
90-
TRAEFIK_TAG: ${{ matrix.traefik }}
103+
104+
- name: capture logs
105+
if: ${{ always() }}
106+
run: docker compose logs --tail 100 nginx nginx2 traefik
107+
working-directory: ./ci
91108

92109
- name: cleanup
93110
if: ${{ always() }}
94-
run: docker compose logs --tail 100 nginx nginx2 traefik && docker compose down
111+
run: docker compose down
95112
working-directory: ./ci

.traefik.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
displayName: Captcha Protect
2-
summary: Add an anti-bot challenge to individual IPs in a subnet when traffic spikes are detected from that subnet
2+
summary: Add an anti-bot challenge to client IPs when protected routes are requested too often
33
type: middleware
44
import: github.com/libops/captcha-protect
55
testData:
66
ProtectRoutes: /
77
ChallengeTmpl: challenge.tmpl.html
88
Window: 86400
9-
RateLimit: 20
9+
RateLimit: 0
1010
IPForwardedHeader: "X-Forwarded-For"
1111
CaptchaProvider: turnstile
1212
SiteKey: 1x00000000000000000000AA
1313
SecretKey: 1x0000000000000000000000000000000AA
14-
EnableStateReconciliation: "false"

README.md

Lines changed: 15 additions & 317 deletions
Large diffs are not rendered by default.

ci/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
TRAEFIK_TAG=v3.5
1+
TRAEFIK_TAG=v3.6
22
NGINX_TAG=1.27.4-alpine3.21
33
TURNSTILE_SITE_KEY=1x00000000000000000000AA
44
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

ci/conf/traefik/multilayer.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
http:
2+
routers:
3+
protected-parent:
4+
entryPoints:
5+
- http
6+
rule: "Host(`localhost`)"
7+
middlewares:
8+
- captcha-protect
9+
10+
nginx:
11+
rule: "PathPrefix(`/`)"
12+
priority: 1
13+
service: nginx
14+
parentRefs:
15+
- protected-parent
16+
17+
nginx2:
18+
rule: "PathPrefix(`/app2`)"
19+
priority: 100
20+
service: nginx2
21+
parentRefs:
22+
- protected-parent
23+
24+
services:
25+
nginx:
26+
loadBalancer:
27+
servers:
28+
- url: "http://nginx:80"
29+
nginx2:
30+
loadBalancer:
31+
servers:
32+
- url: "http://nginx2:80"
33+
34+
middlewares:
35+
captcha-protect:
36+
plugin:
37+
captcha-protect:
38+
captchaProvider: turnstile
39+
window: 120
40+
rateLimit: 5
41+
siteKey: 1x00000000000000000000AA
42+
secretKey: 1x0000000000000000000000000000000AA
43+
enableStatsPage: "true"
44+
ipForwardedHeader: X-Forwarded-For
45+
logLevel: DEBUG
46+
protectParameters: "false"
47+
goodBots: []
48+
enableGooglebotIPCheck: "false"
49+
enableUptimeRobotBypass: "false"
50+
mode: regex
51+
protectRoutes:
52+
- "^/"
53+
excludeRoutes:
54+
- "\\/oai\\/request"
55+
- "\\/node\\/\\d+\\/(book-)?manifest"

ci/docker-compose.multilayer.yml

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
networks:
2+
default:
3+
4+
services:
5+
nginx:
6+
image: nginx:${NGINX_TAG}
7+
healthcheck:
8+
test: curl -fs http://localhost/healthz | grep -q OK || exit 1
9+
start_period: 5s
10+
volumes:
11+
- ./conf/nginx/default.conf:/etc/nginx/conf.d/default.conf:r
12+
networks:
13+
default:
14+
aliases:
15+
- nginx
16+
17+
nginx2:
18+
image: nginx:${NGINX_TAG}
19+
healthcheck:
20+
test: curl -fs http://localhost/healthz | grep -q OK || exit 1
21+
start_period: 5s
22+
volumes:
23+
- ./conf/nginx/default.conf:/etc/nginx/conf.d/default.conf:r
24+
networks:
25+
default:
26+
aliases:
27+
- nginx2
28+
29+
traefik:
30+
image: traefik:${TRAEFIK_TAG}
31+
command: >-
32+
--api.insecure=true
33+
--api.dashboard=true
34+
--api.debug=true
35+
--ping=true
36+
--entryPoints.http.address=:80
37+
--entryPoints.http.forwardedHeaders.insecure=true
38+
--entryPoints.http.forwardedHeaders.trustedIPs=127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
39+
--providers.file.filename=/etc/traefik/dynamic/multilayer.yml
40+
--experimental.localPlugins.captcha-protect.moduleName=github.com/libops/captcha-protect
41+
volumes:
42+
- ./conf/traefik/multilayer.yml:/etc/traefik/dynamic/multilayer.yml:r
43+
- ./../:/plugins-local/src/github.com/libops/captcha-protect:r
44+
ports:
45+
- "80:80"
46+
- "8080:8080"
47+
mem_limit: 256m
48+
mem_reservation: 128m
49+
networks:
50+
default:
51+
aliases:
52+
- traefik
53+
healthcheck:
54+
test: traefik healthcheck --ping
55+
start_period: 5s
56+
depends_on:
57+
nginx:
58+
condition: service_healthy
59+
nginx2:
60+
condition: service_healthy

ci/docker-compose.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ services:
2525
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex"
2626
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/"
2727
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest"
28-
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.persistentStateFile: "/tmp/state.json"
29-
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableStateReconciliation: "true"
3028
healthcheck:
3129
test: curl -fs http://localhost/healthz | grep -q OK || exit 1
3230
start_period: 5s
@@ -60,8 +58,6 @@ services:
6058
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex"
6159
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/"
6260
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest"
63-
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.persistentStateFile: "/tmp/state.json"
64-
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableStateReconciliation: "true"
6561
healthcheck:
6662
test: curl -fs http://localhost/healthz | grep -q OK || exit 1
6763
start_period: 5s
@@ -86,7 +82,6 @@ services:
8682
--experimental.localPlugins.captcha-protect.moduleName=github.com/libops/captcha-protect
8783
volumes:
8884
- /var/run/docker.sock:/var/run/docker.sock:z
89-
- ./tmp:/tmp:rw
9085
- ./../:/plugins-local/src/github.com/libops/captcha-protect:r
9186
ports:
9287
- "80:80"

ci/test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
const rateLimit = 5
1414
const rootSmokeIP = "192.0.2.10"
1515
const app2SmokeIP = "198.51.100.10"
16+
const multiLayerSharedIP = "203.0.113.20"
1617

1718
func main() {
1819
_ = os.Remove("./tmp/state.json")
@@ -29,6 +30,9 @@ func main() {
2930
assertNoRedirect(rootSmokeIP, "http://localhost/node/123/manifest")
3031
assertNoRedirect(rootSmokeIP, "http://localhost/oai/request?foo=bar")
3132
assertProtectedRoute(app2SmokeIP, "http://localhost/app2", "http://localhost/challenge?destination=%2Fapp2")
33+
if os.Getenv("MULTILAYER_ROUTING") == "true" {
34+
assertSharedRateLimitAcrossChildren(multiLayerSharedIP)
35+
}
3236
assertTraefikPluginLogsClean()
3337

3438
_ = os.Remove("./tmp/state.json")
@@ -70,6 +74,22 @@ func assertProtectedRoute(ip, url, expectedURL string) {
7074
}
7175
}
7276

77+
func assertSharedRateLimitAcrossChildren(ip string) {
78+
for i := 0; i < rateLimit; i++ {
79+
assertNoRedirect(ip, "http://localhost")
80+
}
81+
82+
output, err := httpRequest(ip, "http://localhost/app2")
83+
if err != nil {
84+
slog.Error("Request failed", "ip", ip, "url", "http://localhost/app2", "err", err)
85+
os.Exit(1)
86+
}
87+
if output != "http://localhost/challenge?destination=%2Fapp2" {
88+
slog.Error("Expected parent middleware rate state to apply to child router", "ip", ip, "output", output)
89+
os.Exit(1)
90+
}
91+
}
92+
7393
func assertNoRedirect(ip, url string) {
7494
output, err := httpRequest(ip, url)
7595
if err != nil {

ci_behavior_test.go

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
package captcha_protect
22

33
import (
4-
"context"
54
"io"
65
"log/slog"
76
"net/http"
87
"net/http/httptest"
9-
"path/filepath"
108
"testing"
11-
"testing/synctest"
129
"time"
1310

1411
"github.com/libops/captcha-protect/internal/helper"
@@ -87,41 +84,6 @@ func TestCILabelEquivalentUptimeRobotBypassBehavior(t *testing.T) {
8784
assertRedirect(t, disabled, uptimeRobotIP, "/", "/challenge?destination=%2F")
8885
}
8986

90-
func TestPersistentStateSharingWithSynctest(t *testing.T) {
91-
synctest.Test(t, func(t *testing.T) {
92-
stateFile := filepath.Join(t.TempDir(), "state.json")
93-
writer := newStateOnlyCaptchaProtect(stateFile, 2)
94-
reader := newStateOnlyCaptchaProtect(stateFile, 2)
95-
96-
ctx, cancel := context.WithCancel(t.Context())
97-
done := make(chan struct{}, 1)
98-
go func() {
99-
writer.saveState(ctx)
100-
done <- struct{}{}
101-
}()
102-
103-
for i := uint(0); i < writer.config.RateLimit+1; i++ {
104-
writer.registerRequest("192.0.0.0")
105-
}
106-
107-
time.Sleep(stateSaveInterval(writer.config) + StateSaveJitter + 3*time.Second)
108-
synctest.Wait()
109-
reader.reconcileStateFromFileIfChanged()
110-
111-
v, ok := reader.rateCache.Get("192.0.0.0")
112-
if !ok {
113-
t.Fatal("expected reader instance to reconcile writer state")
114-
}
115-
if got, want := v.(uint), writer.config.RateLimit+1; got != want {
116-
t.Fatalf("reconciled rate = %d, want %d", got, want)
117-
}
118-
119-
cancel()
120-
synctest.Wait()
121-
<-done
122-
})
123-
}
124-
12587
func newCILabelEquivalentMiddleware(t *testing.T, mutate func(*Config)) *CaptchaProtect {
12688
t.Helper()
12789

@@ -167,7 +129,6 @@ func ciLabelEquivalentConfig() *Config {
167129
func newStateOnlyCaptchaProtect(stateFile string, rateLimit uint) *CaptchaProtect {
168130
config := ciLabelEquivalentConfig()
169131
config.PersistentStateFile = stateFile
170-
config.EnableStateReconciliation = "true"
171132
config.RateLimit = rateLimit
172133

173134
return &CaptchaProtect{

0 commit comments

Comments
 (0)