Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: ">=1.25.0"
go-version: "1.26.2"

- name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stress-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version: ">=1.25.0"
go-version: "1.26.2"

- name: Run stress tests
id: stress_test
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ services:
--providers.docker=true
--providers.docker.network=default
--experimental.plugins.captcha-protect.modulename=github.com/libops/captcha-protect
--experimental.plugins.captcha-protect.version=v1.12.2
--experimental.plugins.captcha-protect.version=v1.12.3
volumes:
- /var/run/docker.sock:/var/run/docker.sock:z
- /CHANGEME/TO/A/HOST/PATH/FOR/STATE/FILE:/tmp/state.json:rw
Expand Down Expand Up @@ -122,7 +122,7 @@ services:
| `window` | `int` | `86400` | Duration (in seconds) for monitoring requests per subnet. |
| `ipv4subnetMask` | `int` | `16` | CIDR subnet mask to group IPv4 addresses for rate limiting. |
| `ipv6subnetMask` | `int` | `64` | CIDR subnet mask to group IPv6 addresses for rate limiting. |
| `ipForwardedHeader` | `string` | `""` | Header to check for the original client IP if Traefik is behind a load balancer. |
| `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. |
| `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 |
| `goodBots` | `[]string` (encouraged) | *see below* | List of second-level domains for bots that are never challenged or rate-limited. |
| `enableGooglebotIPCheck`| `string`. | `"false"` | Treat IPs coming from googlebot's known IP ranges as good bots |
Expand All @@ -136,8 +136,8 @@ services:
| `challengeStatusCode` | `int` | `200` | HTTP Response status code to return when serving a challenge |
| `enableStatsPage` | `string` | `"false"` | Allows `exemptIps` to access `/captcha-protect/stats` to monitor the rate limiter. |
| `logLevel` | `string` | `"INFO"` | Log level for the middleware. Options: `ERROR`, `WARNING`, `INFO`, or `DEBUG`. |
| `persistentStateFile` | `string` | `""` | File path to persist rate limiter state across Traefik restarts. In Docker, mount this file from the host. |
| `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). |
| `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. |
| `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. |

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

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

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.
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.

## Troubleshooting

Expand Down
4 changes: 2 additions & 2 deletions ci/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ services:
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.logLevel: "DEBUG"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectParameters: "${PROTECT_PARAMETERS:-false}"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: ""
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "true"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "false"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest"
Expand Down Expand Up @@ -54,7 +54,7 @@ services:
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.logLevel: "DEBUG"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectParameters: "${PROTECT_PARAMETERS:-false}"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.goodBots: ""
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "true"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.enableGooglebotIPCheck: "false"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.mode: "regex"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.protectRoutes: "^/"
traefik.http.middlewares.captcha-protect.plugin.captcha-protect.excludeRoutes: "\\/oai\\/request,\\/node\\/\\d+\\/(book-)?manifest"
Expand Down
14 changes: 7 additions & 7 deletions ci/parse-stress-results/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ func main() {
scanner := bufio.NewScanner(os.Stdin)

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

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

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

for scanner.Scan() {
line := scanner.Text()
Expand All @@ -65,9 +65,9 @@ func main() {

// Extract size from Marshal test
if event.Output != "" && strings.Contains(event.Output, "Marshal took") && strings.Contains(event.Output, "size:") {
if matches := sizePattern.FindStringSubmatch(event.Output); len(matches) > 1 {
if matches := sizePattern.FindStringSubmatch(event.Output); len(matches) > 2 {
if currentTest != "" && results[currentTest] != nil {
results[currentTest].Size = matches[1] + " MB"
results[currentTest].Size = matches[1] + " " + matches[2]
}
}
}
Expand Down
Loading