HAProxy
Bot protection for HAProxy 2.4+ using SPOE and Lua hooks.
Overview
Centinel integrates with HAProxy via the SPOE (Stream Processing Offload Engine) filter. HAProxy sends request metadata to the Centinel SPOA over SPOP, receives a decision, and Lua hooks enforce it — routing blocked or challenged requests to the built-in block service before they reach your backend.
Prerequisites
- Secret key from your dashboard
- HAProxy 2.4+ compiled with Lua 5.3 and SPOE support
Install
Download centinel-haproxy.zip and extract the three files to your HAProxy configuration directory:
curl -O https://docs.centinelanalytica.com/downloads/centinel-haproxy.zip
unzip centinel-haproxy.zip -d /etc/haproxy/lua/
mv /etc/haproxy/lua/spoe-centinel.conf /etc/haproxy/The zip contains:
centinel-spoe.lua— Lua hooks for request routing, response headers, and block pagesjson.lua— JSON encoder/decoder used by the Lua hooksspoe-centinel.conf— SPOE filter configuration
global
lua-prepend-path /etc/haproxy/lua/?.lua
lua-load /etc/haproxy/lua/centinel-spoe.lua
defaults
mode http
timeout connect 5s
timeout client 30s
timeout server 30s
frontend http-in
bind *:80
filter spoe engine centinel config /etc/haproxy/spoe-centinel.conf
http-request lua.centinel-request-hook
http-response lua.centinel-response-hook
use_backend centinel-block if { var(txn.centinel_status) -m str blocked }
default_backend servers
backend servers
server app1 127.0.0.1:8080 check
backend centinel-block
http-request use-service lua.centinel-block-service
backend spoe-centinel
mode tcp
server spoa validator.centinelanalytica.com:9000 ssl sni str(validator.centinelanalytica.com) verify required verifyhost validator.centinelanalytica.com ca-file /etc/ssl/certs/ca-certificates.crtThe defaults block is required
Centinel's hooks are HTTP-mode rules. Without mode http, HAProxy discards every http-request and http-response line, reports the config as valid, starts normally, and enforces nothing. Run haproxy -c -f /etc/haproxy/haproxy.cfg and read the warnings: a line saying rules were "ignored for frontend" means protection is off.
Fail-open by design
If the SPOA is unreachable or doesn't respond within timeout processing (500ms default), HAProxy lets the request through. A down SPOA never blocks traffic.
verifyhost and sni are not optional. The secret key travels on this connection as a SPOE
argument, and verify required on its own checks the certificate chain without checking the
hostname.
export CENTINEL_SECRET_KEY="sk_live_your_key_here"
haproxy -f /etc/haproxy/haproxy.cfg -sf $(cat /var/run/haproxy.pid)For systemd:
[Service]
Environment="CENTINEL_SECRET_KEY=sk_live_your_key_here"Configure
How it works
HAProxy's SPOE filter sends request metadata to the hosted SPOA over SPOP. The SPOA validates the request and returns a decision. Lua hooks then route the request:
- allow / not_matched — request goes to your backend. Response headers and cookies from the validator get applied on the way out.
- block / redirect — request hits the
centinel-block-serviceLua applet, which serves the block page or challenge HTML.
Static assets (images, fonts, CSS, JS, media files) are excluded by an ACL in spoe-centinel.conf and never reach the SPOA.
Timeouts are configured in spoe-centinel.conf:
spoe-agent centinel-agent
timeout hello 500ms
timeout idle 30s
timeout processing 500ms| Timeout | Default | Description |
|---|---|---|
timeout processing | 500ms | Max wait for the SPOA response. If exceeded, request is allowed (fail-open). Increase if you see frequent timeouts. |
timeout hello | 500ms | TLS + SPOP handshake timeout. Only matters on first connection — subsequent requests reuse the persistent connection. |
timeout idle | 30s | Idle connection timeout before HAProxy drops the SPOA connection. |
Static files are excluded by the static_file ACL in the spoe-message section of spoe-centinel.conf. These requests skip the SPOE pipeline entirely.
Default exclusions: .avi, .avif, .bmp, .css, .eot, .flac, .flv, .gif, .gz, .ico, .jpeg, .jpg, .js, .less, .map, .mka, .mkv, .mov, .mp3, .mp4, .mpeg, .mpg, .ogg, .ogm, .opus, .otf, .png, .svg, .svgz, .swf, .ttf, .wav, .webm, .webp, .woff, .woff2.
Edit the path_reg ACL in spoe-centinel.conf to add or remove extensions.
Path exclusions belong in spoe-centinel.conf, next to the static_file ACL. Add your own ACL in
the spoe-message section and extend the event condition:
[centinel]
spoe-message check-centinel
args secret_key=str(...) url=... method=... ip=... cookie=... referrer=... headers=...
acl static_file path_reg \.(css|js|png|jpg|woff2)$
acl no_centinel path_beg /health /metrics /internal/
event on-frontend-http-request unless static_file or no_centinelA frontend ACL is not enough
Adding unless no_centinel to the http-request hooks in haproxy.cfg only skips enforcement. HAProxy filters take no conditions, so the SPOE event still fires and the request is still sent to the SPOA and on to /validate. Excluding a path from the event, as above, is what actually keeps it out of the pipeline.
Configuration reference
Environment variables
| Variable | Description |
|---|---|
CENTINEL_SECRET_KEY | API key from the dashboard. Required. |
HAProxy config directives
| Directive | Purpose |
|---|---|
lua-prepend-path | Path to Lua module directory |
lua-load | Loads the Centinel Lua hooks at startup |
filter spoe | Activates the SPOE filter with spoe-centinel.conf |
http-request lua.centinel-request-hook | Routes requests based on SPOA decision |
http-response lua.centinel-response-hook | Applies response headers/cookies on allow |
use_backend centinel-block | Routes blocked requests to the block service |
backend spoe-centinel | TLS+TCP backend pointing to the hosted SPOA |
Verify
Check the config first, and read the warnings rather than only the exit status:
haproxy -c -f /etc/haproxy/haproxy.cfgAny warning that http-request or http-response rules were "ignored for frontend" means the
frontend is in TCP mode and Centinel is doing nothing. Add the defaults block from Install.
Then reload and send a request that should be denied:
curl -s -o /dev/null -w '%{http_code}\n' -A 'python-requests/2.31' http://localhost/If everything is allowed, the usual causes are a missing mode http, a failed SPOA TLS handshake, or
timeout processing being exceeded, which fails open by design.
Changelog
- v1.1.0 — TLS for SPOA connection
- v1.0.0 — Initial release