Skip to content

Commit aa8d77b

Browse files
authored
Fix state persistence sharing and harden CI smoke coverage (#83)
* Improve state persistence and slim CI smoke test * Harden state persistence and redact secrets * Speed up persisted state snapshots * Wait for captcha middleware in CI smoke test * Restore fast CI smoke failure signal * Restore Yaegi compatibility for state persistence * Address state persistence review findings * Sync lock files and remove bot state persistence * Update captcha-protect plugin version to v1.12.3 * Test disabled state persistence path * Slow local state persistence cadence * Document state persistence cadence
1 parent cd88f16 commit aa8d77b

14 files changed

Lines changed: 1389 additions & 851 deletions

File tree

.github/workflows/lint-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313

1414
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
1515
with:
16-
go-version: ">=1.25.0"
16+
go-version: "1.26.2"
1717

1818
- name: golangci-lint
1919
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9

.github/workflows/stress-test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414

1515
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
1616
with:
17-
go-version: ">=1.25.0"
17+
go-version: "1.26.2"
1818

1919
- name: Run stress tests
2020
id: stress_test

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ services:
9090
--providers.docker=true
9191
--providers.docker.network=default
9292
--experimental.plugins.captcha-protect.modulename=github.com/libops/captcha-protect
93-
--experimental.plugins.captcha-protect.version=v1.12.2
93+
--experimental.plugins.captcha-protect.version=v1.12.3
9494
volumes:
9595
- /var/run/docker.sock:/var/run/docker.sock:z
9696
- /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw
@@ -122,7 +122,7 @@ services:
122122
| `window` | `int` | `86400` | Duration (in seconds) for monitoring requests per subnet. |
123123
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
124124
| `ipv6subnetMask` | `int` | `64` | CIDR subnet mask to group IPv6 addresses for rate limiting. |
125-
| `ipForwardedHeader` | `string` | `""` | Header to check for the original client IP if Traefik is behind a load balancer. |
125+
| `ipForwardedHeader` | `string` | `""` | Header to check for the original client IP if Traefik is behind a load balancer. Only set this when Traefik receives the header from a trusted proxy/load balancer; otherwise clients can spoof their IP. |
126126
| `ipDepth` | `int` | `0` | How deep past the last non-exempt IP to fetch the real IP from `ipForwardedHeader`. Default 0 returns the last IP in the forward header |
127127
| `goodBots` | `[]string` (encouraged) | *see below* | List of second-level domains for bots that are never challenged or rate-limited. |
128128
| `enableGooglebotIPCheck`| `string`. | `"false"` | Treat IPs coming from googlebot's known IP ranges as good bots |
@@ -136,8 +136,8 @@ services:
136136
| `challengeStatusCode` | `int` | `200` | HTTP Response status code to return when serving a challenge |
137137
| `enableStatsPage` | `string` | `"false"` | Allows `exemptIps` to access `/captcha-protect/stats` to monitor the rate limiter. |
138138
| `logLevel` | `string` | `"INFO"` | Log level for the middleware. Options: `ERROR`, `WARNING`, `INFO`, or `DEBUG`. |
139-
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |
140-
| `enableStateReconciliation` | `string` | `"false"` | When `"true"`, reads and merges disk state before each save to prevent multiple instances from overwriting data. Adds extra I/O overhead. Only enable for multi-instance deployments sharing state. **Performance warning**: Not recommended for sites with >1M unique visitors due to reconciliation overhead (5-8s per cycle at scale). |
139+
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter and verified challenge state across Traefik restarts. When unset, no state load/save goroutine is started. Dirty local state is saved about every 60s plus 0-2s jitter. Derived bot lookup cache entries are not persisted. In Docker, mount this file from the host. |
140+
| `enableStateReconciliation` | `string` | `"false"` | When `"true"`, polls the shared state file for changes and merges newer disk state into memory, then reconciles again before dirty snapshots are saved. Enable for multi-instance deployments sharing state. Dirty shared state is saved about every 10s plus 0-2s jitter. |
141141

142142
### Circuit Breaker (failover if a captcha provider is unavailable)
143143

@@ -272,7 +272,7 @@ If you have use a computer within the `exemptIps`, and access to the command lin
272272
curl -s https://example.com/captcha-protect/stats | jq -r '.rate | to_entries | sort_by(.value) | .[] | "\(.key): \(.value)"' | tail -25
273273
```
274274

275-
This JSON state data is also found in the `state.json` file that you should have configured in your `docker-compose.yml` using the `persistentStateFile` setting and volume definition. NOTE: this file should only be changed by `captcha-protect` and not manually.
275+
The rate limiter and verified challenge portions of this JSON state data are also found in the `state.json` file that you should have configured in your `docker-compose.yml` using the `persistentStateFile` setting and volume definition. When `enableStateReconciliation` is `"false"`, dirty state is saved roughly every 60 seconds plus 0-2 seconds of jitter. When `enableStateReconciliation` is `"true"` for multi-instance shared state, dirty state is saved roughly every 10 seconds plus 0-2 seconds of jitter. If `persistentStateFile` is unset, state persistence is disabled. NOTE: this file should only be changed by `captcha-protect` and not manually.
276276

277277
## Troubleshooting
278278

ci/docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ services:
2020
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.logLevel: "DEBUG"
2121
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectParameters: "${PROTECT_PARAMETERS:-false}"
2222
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: ""
23-
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "true"
23+
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "false"
2424
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex"
2525
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/"
2626
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest"
@@ -54,7 +54,7 @@ services:
5454
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.logLevel: "DEBUG"
5555
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectParameters: "${PROTECT_PARAMETERS:-false}"
5656
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: ""
57-
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "true"
57+
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "false"
5858
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex"
5959
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/"
6060
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest"

ci/parse-stress-results/main.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,17 @@ func main() {
3232
scanner := bufio.NewScanner(os.Stdin)
3333

3434
// Patterns to extract data
35-
sizePattern := regexp.MustCompile(`size: ([\d.]+) MB`)
35+
sizePattern := regexp.MustCompile(`size: ([\d.]+) (KB|MB)`)
3636
timePattern := regexp.MustCompile(`took (\d+)ms`)
3737

3838
results := make(map[string]*TestResult)
3939
currentTest := ""
4040

4141
// Initialize known tests
42-
results["Small"] = &TestResult{Name: "Small", Entries: "16 rate / 65K bots / 256 verified", Threshold: 500}
43-
results["Medium"] = &TestResult{Name: "Medium", Entries: "256 rate / 262K bots / 65K verified", Threshold: 1000}
44-
results["Large"] = &TestResult{Name: "Large", Entries: "1K rate / 1M bots / 262K verified", Threshold: 3000}
45-
results["XLarge"] = &TestResult{Name: "XLarge", Entries: "4K rate / 4.2M bots / 1M verified", Threshold: 10000}
42+
results["Small"] = &TestResult{Name: "Small", Entries: "16 rate / 256 verified", Threshold: 500}
43+
results["Medium"] = &TestResult{Name: "Medium", Entries: "256 rate / 65K verified", Threshold: 1000}
44+
results["Large"] = &TestResult{Name: "Large", Entries: "1K rate / 262K verified", Threshold: 3000}
45+
results["XLarge"] = &TestResult{Name: "XLarge", Entries: "4K rate / 1M verified", Threshold: 10000}
4646

4747
for scanner.Scan() {
4848
line := scanner.Text()
@@ -65,9 +65,9 @@ func main() {
6565

6666
// Extract size from Marshal test
6767
if event.Output != "" && strings.Contains(event.Output, "Marshal took") && strings.Contains(event.Output, "size:") {
68-
if matches := sizePattern.FindStringSubmatch(event.Output); len(matches) > 1 {
68+
if matches := sizePattern.FindStringSubmatch(event.Output); len(matches) > 2 {
6969
if currentTest != "" && results[currentTest] != nil {
70-
results[currentTest].Size = matches[1] + " MB"
70+
results[currentTest].Size = matches[1] + " " + matches[2]
7171
}
7272
}
7373
}

0 commit comments

Comments
 (0)