# HAProxy

> Bot protection for HAProxy 2.4+ using SPOE and Lua hooks.

Source: https://docs.centinelanalytica.com/platforms/web-server/haproxy

## 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](https://docs.centinelanalytica.com/install/dashboard.md)
* HAProxy 2.4+ compiled with Lua 5.3 and SPOE support

## Install

1. **Download and extract the module**

   Download [centinel-haproxy.zip](https://docs.centinelanalytica.com/downloads/centinel-haproxy.zip) and extract the three files to your HAProxy configuration directory:

   ```bash
   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 pages
   * `json.lua` — JSON encoder/decoder used by the Lua hooks
   * `spoe-centinel.conf` — SPOE filter configuration

2. **Configure haproxy.cfg**

   ```yaml
   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.crt
   ```

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

3. **Set your secret key and reload**

   ```bash
   export CENTINEL_SECRET_KEY="sk_live_your_key_here"
   haproxy -f /etc/haproxy/haproxy.cfg -sf $(cat /var/run/haproxy.pid)
   ```

   For systemd:

   ```ini
   [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-service` Lua 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.

HAProxy does not send a SPOE message that is larger than one frame (16,380 bytes by default).
It lets that request through without a check. The Lua hooks cap each argument so that the
message always fits: 2 KiB for the URL and the referrer, 1 KiB for the `_centinel` cookie and
for each header value, and 6 KiB for all headers together. Keep your `tune.bufsize` at 16384 or higher.

The cap removes the bytes after the limit, and the validator does not know that it removed them.
Policy rules on the URL, the query or the cookie see only the first 2 KiB of the URL and the first
1 KiB of the `Cookie` header. A bot can put a query parameter or a cookie after the limit. A rule
for that parameter or cookie then does not match.

**SPOE timeouts**

Timeouts are configured in `spoe-centinel.conf`:

```text
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 asset bypass**

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

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:

```text
[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_centinel
```

> **A 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](https://docs.centinelanalytica.com/install/dashboard.md). 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:

```bash
haproxy -c -f /etc/haproxy/haproxy.cfg
```

Any 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:

```bash
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.2.0** — Bounded SPOE message size
* **v1.1.0** — TLS for SPOA connection
* **v1.0.0** — Initial release
