- Go 89.8%
- HTML 6.8%
- CSS 2.3%
- Makefile 0.7%
- Dockerfile 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| src | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
lra
Cap-challenge reverse proxy: humans in, bots out.
Lightweight Go gateway that sits in front of your apps, solves a Cap proof-of-work challenge once, then proxies with a signed session cookie.
Features
| Cap shield | Automatic PoW challenge + manual widget fallback per site |
| Multi-site proxy | Route by Host, each with its own upstream + Cap keys + policy override |
| Signed sessions | HMAC session cookie (lra_session): solve once, trusted for ttl_seconds |
| Rate limiting | Per-IP token bucket + hardened /_lra/verify budgets (body cap, per-minute limit, concurrency semaphore) |
| UA policy | allow / deny / challenge with allow/deny user-agent lists and exempt paths |
| Proxy-safe | Correct X-Forwarded-For / Forwarded handling behind Traefik/Caddy, Secure + SameSite aware cookies |
| Observable | Access logs, GET /_lra/healthz with version + uptime + site count |
| Tiny deploy | Single static binary, alpine-based image, TOML config |
How it works:
flowchart LR
Visitor --> LRA["lra :8080"]
LRA -- "no session?" --> Challenge["Cap challenge page"]
Challenge -- "POST /_lra/verify token" --> Cap["Cap server"]
Cap -- "valid" --> Cookie["Set-Cookie: lra_session"]
Cookie --> Upstream["your app"]
LRA -- "valid session / allowlisted" --> Upstream
- Request hits
lra, site matched byHostheader. - Exempt paths (
/robots.txt,/.well-known/*, ...) proxy straight through. - Over-limit IPs get a
429re-challenge; valid sessions pass through. - Everyone else sees the challenge page: auto-solve first, widget fallback if needed.
- Solved token is verified server-side, signed cookie issued, request proxied to upstream.
Quickstart
1. Configure
cp docs/config.example.toml proxy.toml
# edit proxy.toml: cap keys, session secret, [[site]] host + upstream
openssl rand -hex 32 # paste into session.secret
2. Run locally
make dev # go run ./src -config docs/config.toml
# or
go run ./src -config proxy.toml
3. Run with Docker
docker build -t lra:latest .
docker run --rm -p 8080:8080 \
-v ./docs/config.toml:/etc/lra/proxy.toml:ro \
lra:latest -config /etc/lra/proxy.toml
Or with compose (see docker-compose.yml):
docker compose up --build
Then open http://<your-site-host>:8080/_lra/healthz:
{
"status": "ok",
"version": "0.5",
"time": "2026-09-14T12:00:00Z",
"uptime_seconds": 42,
"sites": 1
}
Configuration
All config is TOML. Full annotated example: docs/config.example.toml.
[server]
listen = "0.0.0.0:8080"
tls_cert = ""
tls_key = ""
trust_proxy_headers = true # honor X-Forwarded-For from Traefik/Caddy
log_file = "" # empty = stderr (use for docker)
[cap]
endpoint = "https://cap.sad.ovh"
site_key = "..."
secret_key = "..."
token_ttl_seconds = 300
[session]
cookie_name = "lra_session"
secret = "<openssl rand -hex 32>"
ttl_seconds = 86400
domain_scope = "sad.ovh" # one pass covers subdomains
secure = true
same_site = "Lax" # Lax | Strict | None (None requires secure)
[policy]
default_action = "challenge" # allow | deny | challenge
allow_user_agents = ["Googlebot", "Bingbot"]
deny_user_agents = ["curl", "python-requests"]
exempt_paths = ["/robots.txt", "/favicon.ico", "/.well-known/*"]
rate_limit_per_ip = 20
whitelist_ips = [] # IPs/CIDRs that skip ALL rate limits (still need session/challenge); per-site override replaces global
[[site]]
host = "bin.sad.ovh"
upstream = "http://127.0.0.1:8443"
policy_override = { default_action = "challenge" }
# per-site cap keys also supported:
# site_key = "..." / secret_key = "..."
Notes:
lralooks for config in-configflag, thenproxy.toml,config.toml,docs/config.toml.session.secretmust be at least 32 chars, noCHANGE_MEplaceholders.server.tls_cert+tls_keymust be set together or both empty (terminate TLS at Traefik/Caddy if empty).- Wildcard hosts work:
host = "*.sad.ovh". policy.whitelist_ipsaccepts exact IPs and CIDRs (10.0.0.5,10.0.0.0/8,2001:db8::/32); whitelisted IPs skip bothrate_limit_per_ipand the/_lra/verify30/min budget. Requirestrust_proxy_headers = truebehind Traefik/Caddy to see the real client IP.
Policy engine (per request, after exempt-path bypass):
| Step | Outcome |
|---|---|
| Over rate limit | 429 + re-challenge (Retry-After: 1); deny sites keep refusing instead |
Valid lra_session cookie |
Proxy to upstream |
| UA in allowlist (and not in denylist) | Proxy to upstream |
default_action = allow |
Proxy, unless explicitly denied, then challenge |
default_action = deny |
403, unless explicitly allowed, then proxy |
default_action = challenge (default) |
Cap challenge page (401) |
Endpoints:
| Path | Method | Description |
|---|---|---|
/_lra/healthz |
GET |
Liveness: {status, version, time, uptime_seconds, sites} |
/_lra/metrics |
GET |
Prometheus exposition (basic-auth if server.metrics_user/password set, else open): challenges served / rate-limited, verify outcomes, allowed / denied, blocked UA families + top raw UAs |
/_lra/verify |
POST |
Submit {"token":"..."} for 200 {"status":"ok"} + session cookie. Same-origin enforced, 429 when over budget |
/_lra/logo.svg |
GET |
The logo above, served with Cache-Control: public, max-age=86400 |
Scrape with Prometheus (scrape_interval: 15s on /_lra/metrics, basic-auth from server.metrics_user/password if set) and import the ready-made dashboard docs/grafana-dashboard.json (Grafana → Dashboards → Import). It covers challenges served / rate-limited, verify outcomes + success rate, allowed / denied by reason, and top blocked UA families + raw User-Agents (e.g. meta-externalagent). Raw PromQL starters: sum by (site) (rate(lra_challenges_served_total[5m])), sum by (outcome) (rate(lra_verify_total[5m])), topk(10, sum by (ua) (lra_blocked_ua_total)).
Development
make dev # run with docs/config.toml
make test # go vet + go test ./... -v
make vet # go vet ./...
make build # bin/lra (override VERSION=0.5)
make docker-build # docker build --build-arg VERSION=...
make clean # rm -rf bin
Config resolution, validation errors, and access-log format (IP METHOD HOST URI STATUS BYTES DURATION "REFERER" "USER-AGENT") are all covered by the *_test.go suite: run make test before pushing.
Project layout:
.
src/ # Go service
main.go # startup, TLS, graceful shutdown, access logs
proxy.go # routing, challenge/verify flow, health, logo
challenge.go # challenge page rendering (+ embedded logo)
cap.go # Cap siteverify client
config.go # TOML load + validation
policy.go # allow/deny/challenge, UA + exempt matching
session.go # HMAC session sign/validate
ratelimit.go # per-IP limiter
forwarded.go # X-Forwarded-*/Forwarded handling
assets/
logo.svg # the logo
index.html # challenge page template
docs/
config.example.toml
config.toml
grafana-dashboard.json # Grafana dashboard for /_lra/metrics
Dockerfile
docker-compose.yml
Makefile
lra: built with Go, shielded by Cap.