A lightweight Stream Processing Offload Agent (SPOA) that contacts the CrowdSec Local API to fetch decisions in real time while aiming to be highly performant and minimize latency for clients. It manages an in-memory cache of bans and captchas, and directs HAProxy on how to treat each connection without blocking the data path.
crowdsec-tcp and HTTP inspection via crowdsec-http-body / crowdsec-http-no-body to protect both web frontends and raw TCP services.The bouncer is a single binary with three key loops:
go-cs-bouncer maintains a long-lived stream to the CrowdSec Local API and feeds new/deleted decisions into the dataset (pkg/dataset).pkg/spoa) listens on TCP and/or Unix sockets, answers HAProxy messages, and applies host-specific logic such as captchas or ban pages.More detailed request/decision flow diagrams are in ARCHITECTURE.md.
1sequenceDiagram2 participant Client3 participant HAProxy4 participant SPOA as SPOA bouncer5 participant Backend67 Note over Client,HAProxy: Client connects8 Client->>HAProxy: Connect (TCP)910 Note over HAProxy,SPOA: Early decision (session-level)11 HAProxy->>SPOA: SPOE: crowdsec-tcp12 SPOA-->>HAProxy: Set txn.crowdsec.* (remediation baseline)1314 Note over Client,HAProxy: HTTP request (per request)15 Client->>HAProxy: HTTP request16 alt body not sent17 HAProxy->>SPOA: SPOE group: crowdsec-http-no-body18 else body sent (required for captcha POST)19 HAProxy->>SPOA: SPOE group: crowdsec-http-body20 end21 SPOA-->>HAProxy: Set txn.crowdsec.* (final remediation + metadata)2223 alt remediation = allow24 HAProxy->>Backend: Forward request25 Backend-->>Client: Response26 else remediation = captcha or ban27 HAProxy->>HAProxy: Render response page with HAProxy lf-file28 HAProxy-->>Client: Response29 end
The official documentation covers packaging and upgrade notes. If you want to manually build, see the build section below. A quick happy path:
make build.config/crowdsec-spoa-bouncer.yaml to /etc/crowdsec/bouncers/ and set your CrowdSec LAPI URL and API key.templates/ to the path used by HAProxy.systemctl start crowdsec-haproxy-spoa-bouncer or run ./crowdsec-spoa-bouncer -c /path/to/config.yaml for local tests.Configuration is YAML. Start from the example in config/crowdsec-spoa-bouncer.yaml and override locally with .yaml.local.
The bouncer supports:
Detailed configuration guides:
pkg/host/README.mdpkg/captcha/README.mdinternal/remediation/ban/README.mdARCHITECTURE.mdAdd the SPOE filter and native HAProxy remediation returns to your frontend. This is the default path and does not require HAProxy Lua support. The config files in config/ show complete examples; the snippet below highlights the essentials:
1frontend www2 bind :803 unique-id-format %[uuid()]4 unique-id-header X-Unique-ID5 filter spoe engine crowdsec config /etc/haproxy/crowdsec.cfg67 acl body_within_limit req.body_size -m int le 512008 http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found }9 http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found }1011 http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found }12 acl render_html req.hdr_cnt(Accept) eq 013 acl render_html req.hdr(Accept) -m sub text/html14 acl render_html req.hdr(Accept) -m sub */*15 acl html_rejected req.hdr(Accept) -m reg -i "text/html[[:space:]]*;[[:space:]]*q=0(\.0+)?([,[:space:]]|$)"1617 acl has_contact_url var(txn.crowdsec.contact_us_url) -m found18 acl empty_contact_url var(txn.crowdsec.contact_us_url) -m str ""1920 http-request return status 200 content-type "text/html; charset=utf-8" hdr Cache-Control "no-cache, no-store" lf-file /var/lib/crowdsec-haproxy-spoa-bouncer/html/captcha.html if { var(txn.crowdsec.remediation) -m str "captcha" } render_html !html_rejected21 http-request return status 403 content-type "text/html; charset=utf-8" hdr Cache-Control "no-cache, no-store" lf-file /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban-with-contact.html if { var(txn.crowdsec.remediation) -m str "ban" } render_html !html_rejected has_contact_url !empty_contact_url22 http-request return status 403 content-type "text/html; charset=utf-8" hdr Cache-Control "no-cache, no-store" lf-file /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.html if { var(txn.crowdsec.remediation) -m str "ban" } render_html !html_rejected !has_contact_url23 http-request return status 403 content-type "text/html; charset=utf-8" hdr Cache-Control "no-cache, no-store" lf-file /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.html if { var(txn.crowdsec.remediation) -m str "ban" } render_html !html_rejected empty_contact_url24 http-request return status 200 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Captcha required\n" if { var(txn.crowdsec.remediation) -m str "captcha" } !render_html25 http-request return status 200 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Captcha required\n" if { var(txn.crowdsec.remediation) -m str "captcha" } html_rejected26 http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html27 http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected2829 http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found }30 http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found }3132 default_backend app
Use a dedicated SPOE section (crowdsec.cfg) to declare the messages HAProxy sends and which request variables are exported. The provided sample uses:
crowdsec-tcp (event on-client-session) for early, connection-level IP decisionscrowdsec-http-body / crowdsec-http-no-body (sent via SPOE groups with the same names) for per-request HTTP inspection, with conditional body forwardingImportant: captcha validation needs the request body (form-encoded POST). Ensure your frontend sends captcha submissions via the crowdsec-http-body group (see http-request send-spoe-group ... crowdsec-http-body in the examples).
For complete, working examples (including optional request-body forwarding, captcha redirects, and cookie management), see config/haproxy.cfg and config/crowdsec.cfg.
The package still ships the legacy Lua handler for users who prefer rendering remediation pages through HAProxy Lua. To enable it, use a HAProxy build with Lua support and start from these examples instead of the default no-Lua config:
The Lua examples load helpers from /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/ and use Lua-compatible templates from /var/lib/crowdsec-haproxy-spoa-bouncer/html/lua/. The default config/haproxy.cfg remains the simpler plain-HAProxy path.
Docker + Lua mode: the default docker-compose.yaml omits the lua: shared volume because the standard path does not need it. When switching to haproxy-lua.cfg, uncomment the lua: volume entries in docker-compose.yaml (see inline comments) so the HAProxy container can access the Lua scripts from the bouncer image.
Template paths (non-Lua path): the lf-file directives in haproxy.cfg hard-code /var/lib/crowdsec-haproxy-spoa-bouncer/html/. If you need custom template locations, edit those lf-file paths directly. Note that HAProxy does not validate lf-file paths at startup; a missing file produces a runtime error on the first blocked request rather than a startup failure.
pprof in non-production environments to inspect CPU, heap, or goroutines via standard Go tooling.log_level: trace to watch BART operations and confirm that lists/ranges are loaded as expected.Everything you need for local development is included in the repository:
1git clone https://github.com/crowdsecurity/cs-haproxy-spoa-bouncer.git2cd cs-haproxy-spoa-bouncer3make build # builds the binary in ./crowdsec-spoa-bouncer4make test # runs Go tests
Docker Compose files under docker/ and docker-compose*.yaml spin up HAProxy, the bouncer, and a CrowdSec LAPI for integration testing.
Contributions are welcome—feel free to open an issue or PR:
git checkout -b feature/my-change).make test (and any relevant integration checks) before submitting.MIT – see LICENSE for the full text.