Cap-challenge reverse proxy: humans in, bots out.
  • Go 89.8%
  • HTML 6.8%
  • CSS 2.3%
  • Makefile 0.7%
  • Dockerfile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
sophie 9b12f02b78
All checks were successful
test / test (push) Successful in 1m17s
build / docker (push) Successful in 1m7s
ver 0.5 add ip whitelist
2026-10-02 18:44:33 +03:00
.forgejo/workflows ver 0.5 add ip whitelist 2026-10-02 18:44:33 +03:00
docs ver 0.5 add ip whitelist 2026-10-02 18:44:33 +03:00
src ver 0.5 add ip whitelist 2026-10-02 18:44:33 +03:00
.gitignore First commit! 2026-09-14 10:54:11 +03:00
docker-compose.yml First commit! 2026-09-14 10:54:11 +03:00
Dockerfile First commit! 2026-09-14 10:54:11 +03:00
go.mod First commit! 2026-09-14 10:54:11 +03:00
go.sum First commit! 2026-09-14 10:54:11 +03:00
Makefile ver 0.5 add ip whitelist 2026-10-02 18:44:33 +03:00
README.md ver 0.5 add ip whitelist 2026-10-02 18:44:33 +03:00

lra logo

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.


Go Docker Platform Version

Quickstart | Configuration | Development


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
  1. Request hits lra, site matched by Host header.
  2. Exempt paths (/robots.txt, /.well-known/*, ...) proxy straight through.
  3. Over-limit IPs get a 429 re-challenge; valid sessions pass through.
  4. Everyone else sees the challenge page: auto-solve first, widget fallback if needed.
  5. 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:

  • lra looks for config in -config flag, then proxy.toml, config.toml, docs/config.toml.
  • session.secret must be at least 32 chars, no CHANGE_ME placeholders.
  • server.tls_cert + tls_key must be set together or both empty (terminate TLS at Traefik/Caddy if empty).
  • Wildcard hosts work: host = "*.sad.ovh".
  • policy.whitelist_ips accepts exact IPs and CIDRs (10.0.0.5, 10.0.0.0/8, 2001:db8::/32); whitelisted IPs skip both rate_limit_per_ip and the /_lra/verify 30/min budget. Requires trust_proxy_headers = true behind 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 logo small

lra: built with Go, shielded by Cap.