Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c43c867
Migrate SPOE implementation to dropmorepackets/haproxy-go
LaurenceJJones Dec 4, 2025
024eb70
Fix linter issues
LaurenceJJones Dec 4, 2025
3917cd1
fix(spoe): address Copilot PR review comments
LaurenceJJones Dec 4, 2025
8cb3212
fix(spoe): remove unused error variables
LaurenceJJones Dec 4, 2025
1426965
refactor(spoe): add reset() method to IPMessageData for consistency
LaurenceJJones Dec 4, 2025
fcb7dc4
perf(spoe): optimize readHeaders to avoid full byte slice to string c…
LaurenceJJones Dec 4, 2025
c40999c
perf(spoe): use bytes.SplitSeq for more efficient header parsing
LaurenceJJones Dec 4, 2025
c5aae82
fix(spoe): address memory safety and code quality issues
LaurenceJJones Dec 4, 2025
c58ae76
refactor(SPOE): use message groups (#141)
LaurenceJJones Dec 16, 2025
37012a2
Merge main into feat/migrate-to-dropmorepackets-haproxy-go
LaurenceJJones Dec 16, 2025
280ed7a
refactor(spoa): clean up and simplify SPOA functions
LaurenceJJones Dec 16, 2025
5421eb9
perf(spoa): remove redundant reset() calls after pool Get()
LaurenceJJones Dec 16, 2025
0dcb763
refactor: use ptr.Of and extract host/cookie from headers
LaurenceJJones Dec 16, 2025
374fde2
config: increase buffer size and timeouts for WAF body inspection
LaurenceJJones Dec 16, 2025
c17be62
feat: improve HAProxy buffer config and add debug tooling
LaurenceJJones Dec 17, 2025
225a912
perf: remove unnecessary body copy for AppSec requests
LaurenceJJones Dec 17, 2025
3e955ab
refactor: simplify AppSec config logic and fix metrics counting
LaurenceJJones Dec 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions config/crowdsec-upstreamproxy.cfg

This file was deleted.

39 changes: 28 additions & 11 deletions config/crowdsec.cfg
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
# /etc/haproxy/crowdsec.cfg
# SPOE configuration for CrowdSec HAProxy bouncer
# Used for both standard and upstream proxy deployments
# IP extraction is handled by HAProxy ACLs (see haproxy-upstreamproxy.cfg for upstream proxy setup)
[crowdsec]
spoe-agent crowdsec-agent
messages crowdsec-ip crowdsec-http
messages crowdsec-tcp
groups crowdsec-http-body crowdsec-http-no-body

option var-prefix crowdsec
option set-on-error error
timeout hello 100ms
timeout idle 30s
timeout hello 200ms
timeout idle 55s
timeout processing 500ms
use-backend crowdsec-spoa
log global

## This message is used to customise the remediation from crowdsec-ip based on the host header
## src-ip is included as fallback in case crowdsec-ip message didn't fire
spoe-message crowdsec-http
args remediation=var(txn.crowdsec.remediation) crowdsec_captcha_cookie=req.cook(crowdsec_captcha_cookie) id=unique-id host=hdr(Host) method=method path=path query=query version=req.ver headers=req.hdrs body=req.body url=url ssl=ssl_fc src-ip=src src-port=src_port
event on-frontend-http-request

## This message should be the first to trigger in the chain
spoe-message crowdsec-ip
## TCP/IP level check - runs early to check IP remediation
## Uses event directive to trigger on each new client session (not sent as a group)
spoe-message crowdsec-tcp
args id=unique-id src-ip=src src-port=src_port
event on-client-session

## HTTP message with body - used when body size is within limit for AppSec
## Note: Host and captcha cookie are extracted from headers=req.hdrs, no need to send separately
spoe-message crowdsec-http-body
args remediation=var(txn.crowdsec.remediation) id=unique-id method=method path=path query=query version=req.ver headers=req.hdrs body=req.body url=url ssl=ssl_fc src-ip=src src-port=src_port

## HTTP message without body - used when body is too large or not needed
## Note: Host and captcha cookie are extracted from headers=req.hdrs, no need to send separately
spoe-message crowdsec-http-no-body
args remediation=var(txn.crowdsec.remediation) id=unique-id method=method path=path query=query version=req.ver headers=req.hdrs url=url ssl=ssl_fc src-ip=src src-port=src_port

## Group for HTTP message with body - used when body size is within limit for AppSec
spoe-group crowdsec-http-body
messages crowdsec-http-body

## Group for HTTP message without body - used when body is too large or not needed
spoe-group crowdsec-http-no-body
messages crowdsec-http-no-body
30 changes: 26 additions & 4 deletions config/haproxy-upstreamproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

global
log stdout format raw local0
tune.bufsize 65536 # 64KB - increased for WAF body inspection
stats socket /tmp/haproxy.sock mode 660 level admin
stats timeout 30s
lua-prepend-path /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/?.lua
lua-load /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/crowdsec.lua
setenv CROWDSEC_BAN_TEMPLATE_PATH /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.html
Expand All @@ -27,11 +30,20 @@ frontend test
unique-id-format %[uuid()]
unique-id-header X-Unique-ID

# IMPORTANT: When behind a reverse proxy, use req.hdr_ip() in SPOE config
# to extract real client IP from headers (X-Real-IP, X-Forwarded-For, CF-Connecting-IP)
# See crowdsec-upstreamproxy.cfg for the SPOE configuration example
# Extract real client IP from proxy headers (runs before SPOE groups)
# This allows SPOE groups to use src-ip=src which will have the correct IP
# Priority: X-Real-IP > CF-Connecting-IP > X-Forwarded-For > direct src
http-request set-src hdr_ip(X-Real-IP) if { req.hdr(X-Real-IP) -m found }
http-request set-src hdr_ip(CF-Connecting-IP) if { req.hdr(CF-Connecting-IP) -m found } !{ req.hdr(X-Real-IP) -m found }
http-request set-src hdr_ip(X-Forwarded-For) if { req.hdr(X-Forwarded-For) -m found } !{ req.hdr(X-Real-IP) -m found } !{ req.hdr(CF-Connecting-IP) -m found }

filter spoe engine crowdsec config /etc/haproxy/crowdsec.cfg

# ACL for body size limit (100000 bytes = ~100KB) - adjust here to change limit globally
# Note spop protocol has limitations on the size of the message, so altering this value will not ensure the whole
# body is sent for processing but should be enough to prevent overwhelming the SPOA bouncer.
acl body_within_limit req.body_size -m int le 51200 # 50KB - stay safely under SPOE frame limit

# Debug headers to verify IP extraction
http-request set-header X-Debug-Direct-IP %[src]

Expand All @@ -41,6 +53,15 @@ frontend test
## Drop ban requests before http handler is called
# tcp-request content reject if { var(txn.crowdsec.remediation) -m str "ban" }

# Send HTTP group conditionally based on body size
# Only the HTTP handler is used in upstream proxy mode to ensure the real client IP (from headers) is checked.
# The TCP handler still runs via on-client-session event, but checks the proxy IP before http-request set-src
# extracts the real IP from headers, so HTTP handler always re-checks with the correct IP.
# Send group with body when body size <= limit (or no body present)
http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found }
# Send group without body when body exists and size > limit
http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found }

## Set a custom header on the request for upstream services to use
http-request set-header X-Crowdsec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found }
## Set a custom header on the request for upstream services to use
Expand All @@ -67,5 +88,6 @@ backend test_backend

backend crowdsec-spoa
mode tcp
balance roundrobin
timeout connect 2s
timeout server 60s
server s2 spoa:9000
20 changes: 18 additions & 2 deletions config/haproxy.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# https://www.haproxy.com/documentation/hapee/latest/onepage/#home
global
log stdout format raw local0
tune.bufsize 65536 # 64KB - increased for WAF body inspection
stats socket /tmp/haproxy.sock mode 660 level admin
stats timeout 30s
lua-prepend-path /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/?.lua
lua-load /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/crowdsec.lua
setenv CROWDSEC_BAN_TEMPLATE_PATH /var/lib/crowdsec-haproxy-spoa-bouncer/html/ban.html
Expand All @@ -25,12 +28,24 @@ frontend test
unique-id-header X-Unique-ID
filter spoe engine crowdsec config /etc/haproxy/crowdsec.cfg

## If you dont want to render any content, you can use the following line
# ACL for body size limit (100000 bytes = ~100KB) - adjust here to change limit globally
# Note spop protocol has limitations on the size of the message, so altering this value will not ensure the whole
# body is sent for processing but should be enough to prevent overwhelming the SPOA bouncer.
acl body_within_limit req.body_size -m int le 51200 # 50KB - stay safely under SPOE frame limit

## If you don't want to render any content, you can use the following line
# tcp-request content reject if !{ var(txn.crowdsec.remediation) -m str "allow" }

## Drop ban requests before http handler is called
# tcp-request content reject if { var(txn.crowdsec.remediation) -m str "ban" }

# Send HTTP group conditionally based on body size
# TCP handler already checked IP (triggered on client session), HTTP handler will use that remediation
# Send group with body when body size <= limit (or no body present)
http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found }
# Send group without body when body exists and size > limit
http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found }

## Set a custom header on the request for upstream services to use
http-request set-header X-Crowdsec-Remediation %[var(txn.crowdsec.remediation)] if { var(txn.crowdsec.remediation) -m found }
## Set a custom header on the request for upstream services to use
Expand All @@ -57,5 +72,6 @@ backend test_backend

backend crowdsec-spoa
mode tcp
balance roundrobin
timeout connect 2s
timeout server 60s
server s2 spoa:9000
58 changes: 58 additions & 0 deletions docker-compose.dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Development/Debug overlay for docker-compose.yaml
# Usage: podman compose -f docker-compose.yaml -f docker-compose.dev.yaml up -d
#
# This adds a debug container with network tools for troubleshooting

services:
# Debug sidecar with network tools
debug:
image: alpine:latest
container_name: debug
command: >
sh -c "apk add --no-cache tcpdump socat curl bind-tools netcat-openbsd strace && sleep infinity"
networks:
- crowdsec
cap_add:
- NET_RAW # Required for tcpdump
- NET_ADMIN # Required for some network debugging
volumes:
# Mount shared sockets volume
- sockets:/run:ro
# Mount HAProxy tmp for stats socket access
- haproxy-tmp:/haproxy-tmp:ro
# Mount configs for inspection
- ./config:/config:ro
depends_on:
- haproxy
- spoa

# Override HAProxy to share /tmp via named volume
haproxy:
volumes:
- haproxy-tmp:/tmp

volumes:
haproxy-tmp:

# Example debug commands:
#
# Enter debug container:
# podman compose -f docker-compose.yaml -f docker-compose.dev.yaml exec debug ash
#
# Install tools (once inside):
# apk add --no-cache tcpdump socat curl bind-tools netcat-openbsd strace
#
# Capture SPOE traffic:
# tcpdump -i any -X port 9000
#
# Test HAProxy stats socket:
# echo "show info" | socat /haproxy-tmp/haproxy.sock stdio
#
# DNS debugging:
# dig crowdsec
# nslookup spoa
#
# Test connectivity:
# curl -v http://haproxy:8080/
# nc -zv spoa 9000

4 changes: 2 additions & 2 deletions docker-compose.proxy-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ services:
image: haproxy:2.9.7-alpine
volumes:
- ./config/haproxy-upstreamproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg
- ./config/crowdsec-upstreamproxy.cfg:/etc/haproxy/crowdsec.cfg
- ./config/crowdsec.cfg:/etc/haproxy/crowdsec.cfg
- sockets:/run/
- templates:/var/lib/crowdsec-haproxy-spoa-bouncer/html/
- lua:/usr/lib/crowdsec-haproxy-spoa-bouncer/lua/
Expand All @@ -51,7 +51,7 @@ services:
volumes:
- ./config/nginx-proxy.conf:/etc/nginx/nginx.conf:ro
ports:
- "8080:80"
- "9090:80"
depends_on:
- haproxy
networks:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ require (
github.com/crowdsecurity/crowdsec v1.7.3
github.com/crowdsecurity/go-cs-bouncer v0.0.19
github.com/crowdsecurity/go-cs-lib v0.0.23
github.com/dropmorepackets/haproxy-go v0.0.7
github.com/gaissmai/bart v0.25.0
github.com/google/uuid v1.6.0
github.com/negasus/haproxy-spoe-go v1.0.7
github.com/oschwald/geoip2-golang/v2 v2.0.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dropmorepackets/haproxy-go v0.0.7 h1:atXkB0MSRBZrAgpq+Vj/E4KysQ4CiI0O5QGUr+HvfTw=
github.com/dropmorepackets/haproxy-go v0.0.7/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
Expand Down Expand Up @@ -79,8 +81,6 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/negasus/haproxy-spoe-go v1.0.7 h1:OhRY0zapeHudrRqoblI9DjIolJjWI0s/TO6kT/va0ao=
github.com/negasus/haproxy-spoe-go v1.0.7/go.mod h1:ZrBizxtx2EeLN37Jkg9w9g32a1AFCJizA8vg46PaAp4=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/oschwald/geoip2-golang/v2 v2.0.0 h1:1GZ7MsQsbIKeOXMDV2MqBVfV8NuCIqWatomkS67LwQo=
Expand Down
6 changes: 3 additions & 3 deletions internal/remediation/ban/root.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package ban

import (
"github.com/negasus/haproxy-spoe-go/action"
"github.com/dropmorepackets/haproxy-go/pkg/encoding"
log "github.com/sirupsen/logrus"
)

Expand All @@ -19,6 +19,6 @@ func (b *Ban) InitLogger(logger *log.Entry) {
b.logger = logger.WithField("module", "ban")
}

func (b *Ban) InjectKeyValues(actions *action.Actions) {
actions.SetVar(action.ScopeTransaction, "contact_us_url", b.ContactUsURL)
func (b *Ban) InjectKeyValues(writer *encoding.ActionWriter) {
_ = writer.SetString(encoding.VarScopeTransaction, "contact_us_url", b.ContactUsURL)
}
10 changes: 5 additions & 5 deletions internal/remediation/captcha/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

"github.com/crowdsecurity/crowdsec-spoa/internal/cookie"
"github.com/crowdsecurity/crowdsec-spoa/internal/remediation"
"github.com/negasus/haproxy-spoe-go/action"
"github.com/dropmorepackets/haproxy-go/pkg/encoding"
log "github.com/sirupsen/logrus"
)

Expand Down Expand Up @@ -94,16 +94,16 @@ func (c *Captcha) getTimeout() int {
}

// Inject key values injects the captcha provider key values into the HAProxy transaction
func (c *Captcha) InjectKeyValues(actions *action.Actions) error {
func (c *Captcha) InjectKeyValues(writer *encoding.ActionWriter) error {

// We check if the captcha configuration is valid for the front-end
if err := c.IsFrontEndValid(); err != nil {
return err
}

actions.SetVar(action.ScopeTransaction, "captcha_site_key", c.SiteKey)
actions.SetVar(action.ScopeTransaction, "captcha_frontend_key", providers[c.Provider].key)
actions.SetVar(action.ScopeTransaction, "captcha_frontend_js", providers[c.Provider].js)
_ = writer.SetString(encoding.VarScopeTransaction, "captcha_site_key", c.SiteKey)
_ = writer.SetString(encoding.VarScopeTransaction, "captcha_frontend_key", providers[c.Provider].key)
_ = writer.SetString(encoding.VarScopeTransaction, "captcha_frontend_js", providers[c.Provider].js)

return nil
}
Expand Down
Loading