PR 2/3: opt-in SPOE integration for Coraza WAF
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m59s
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m59s
Adds the plumbing that lets haproxy-manager talk to the coraza-spoa sidecar
added in PR 1, while keeping the default behavior bit-identical for any
deployment that doesn't set the new env var (the home network / standalone
use cases).
Single gate: HAPROXY_CORAZA_SPOE_BACKEND env var on the haproxy-manager
container. Unset (default) = generate_config() renders zero SPOE-related
output. Set (e.g. "coraza-spoa:9000") = three things happen at config
generation time:
1. hap_listener.tpl injects 5 lines at the end of the frontend block:
filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg
http-request send-spoe-group coraza coraza-check
...placed AFTER rate-limit and IP-block guards so we don't waste WAF
calls on requests we were going to drop anyway.
2. A new TCP backend (hap_coraza_spoa_backend.tpl) is appended:
backend coraza-spoa-backend
mode tcp
server coraza-spoa <env-var-target> check ...
3. The SPOE engine config (hap_coraza_spoe_engine.tpl) is rendered and
written to /etc/haproxy/coraza-spoe.cfg, defining the spoe-agent
"coraza" + spoe-message "coraza-check". This sets:
- option set-on-error continue (FAIL-OPEN if SPOA is unreachable)
- timeout processing 100ms (per-request inspection budget)
- app=str(haproxy) (matches sidecar's application name)
Verification (template render only, before staging deploy):
- hap_listener.tpl with no env var: 55 lines, zero SPOE references
- hap_listener.tpl with env var: 62 lines, filter + send-spoe-group present
- Engine cfg + backend block render with correct agent_target substitution
Next: PR 3 wires this into WHP (sidecar deploy via container-manager.sh
extension, server-settings UI for on/off, AI Monitor source for the audit
log). Staging verification of PR 1 + PR 2 together happens after PR 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1710,16 +1710,25 @@ def generate_config():
|
|||||||
|
|
||||||
config_parts = []
|
config_parts = []
|
||||||
|
|
||||||
|
# Optional Coraza WAF integration. When HAPROXY_CORAZA_SPOE_BACKEND is
|
||||||
|
# set on the haproxy-manager container, we render an extra TCP backend
|
||||||
|
# pointing at a coraza-spoa sidecar AND inject a `filter spoe ...` line
|
||||||
|
# into the frontend via hap_listener.tpl. Unset (the default for
|
||||||
|
# standalone deployments, home networks, and any non-WHP use of this
|
||||||
|
# image) -> the generated haproxy.cfg is byte-identical to today's.
|
||||||
|
coraza_spoe_backend = os.environ.get('HAPROXY_CORAZA_SPOE_BACKEND')
|
||||||
|
|
||||||
# Add Haproxy Default Headers
|
# Add Haproxy Default Headers
|
||||||
default_headers = template_env.get_template('hap_header.tpl').render()
|
default_headers = template_env.get_template('hap_header.tpl').render()
|
||||||
config_parts.append(default_headers)
|
config_parts.append(default_headers)
|
||||||
|
|
||||||
# Update blocked IPs map file first
|
# Update blocked IPs map file first
|
||||||
update_blocked_ips_map()
|
update_blocked_ips_map()
|
||||||
|
|
||||||
# Add Listener Block
|
# Add Listener Block
|
||||||
listener_block = template_env.get_template('hap_listener.tpl').render(
|
listener_block = template_env.get_template('hap_listener.tpl').render(
|
||||||
crt_path = SSL_CERTS_DIR
|
crt_path = SSL_CERTS_DIR,
|
||||||
|
coraza_spoe_backend = coraza_spoe_backend,
|
||||||
)
|
)
|
||||||
config_parts.append(listener_block)
|
config_parts.append(listener_block)
|
||||||
|
|
||||||
@@ -1839,6 +1848,25 @@ backend default-backend
|
|||||||
config_parts.append(fallback_backend)
|
config_parts.append(fallback_backend)
|
||||||
# Add Backends
|
# Add Backends
|
||||||
config_parts.append('\n' .join(config_backends) + '\n')
|
config_parts.append('\n' .join(config_backends) + '\n')
|
||||||
|
|
||||||
|
# Coraza WAF backend + SPOE engine config file (only when env var set).
|
||||||
|
# Writing /etc/haproxy/coraza-spoe.cfg here keeps it in sync with the
|
||||||
|
# filter line that hap_listener.tpl just rendered into the frontend.
|
||||||
|
if coraza_spoe_backend:
|
||||||
|
coraza_backend_block = template_env.get_template(
|
||||||
|
'hap_coraza_spoa_backend.tpl'
|
||||||
|
).render(agent_target=coraza_spoe_backend)
|
||||||
|
config_parts.append(coraza_backend_block)
|
||||||
|
|
||||||
|
coraza_spoe_cfg = template_env.get_template(
|
||||||
|
'hap_coraza_spoe_engine.tpl'
|
||||||
|
).render()
|
||||||
|
coraza_spoe_path = '/etc/haproxy/coraza-spoe.cfg'
|
||||||
|
with open(coraza_spoe_path, 'w') as f:
|
||||||
|
f.write(coraza_spoe_cfg)
|
||||||
|
logger.info(f"Coraza SPOE engine config written to {coraza_spoe_path} "
|
||||||
|
f"(SPOA target: {coraza_spoe_backend})")
|
||||||
|
|
||||||
# Write complete configuration to tmp
|
# Write complete configuration to tmp
|
||||||
temp_config_path = "/etc/haproxy/haproxy.cfg"
|
temp_config_path = "/etc/haproxy/haproxy.cfg"
|
||||||
|
|
||||||
|
|||||||
14
templates/hap_coraza_spoa_backend.tpl
Normal file
14
templates/hap_coraza_spoa_backend.tpl
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Coraza-SPOA backend.
|
||||||
|
# Only rendered into haproxy.cfg when HAPROXY_CORAZA_SPOE_BACKEND env var is
|
||||||
|
# set on the haproxy-manager container. SPOE traffic to this backend is TCP,
|
||||||
|
# not HTTP. The agent target comes from the env var so a single image can be
|
||||||
|
# deployed against different sidecar host:port pairs (typically the sidecar
|
||||||
|
# container's name + 9000 inside the shared docker network).
|
||||||
|
backend coraza-spoa-backend
|
||||||
|
mode tcp
|
||||||
|
timeout connect 5s
|
||||||
|
timeout server 30s
|
||||||
|
# Keep-alive connection to the SPOA — saves a TCP handshake on every
|
||||||
|
# request. SPOE protocol multiplexes multiple requests over one
|
||||||
|
# connection so this is normal.
|
||||||
|
server coraza-spoa {{ agent_target }} check inter 30s rise 2 fall 3
|
||||||
50
templates/hap_coraza_spoe_engine.tpl
Normal file
50
templates/hap_coraza_spoe_engine.tpl
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Coraza SPOE engine configuration.
|
||||||
|
#
|
||||||
|
# Written to /etc/haproxy/coraza-spoe.cfg by haproxy_manager.generate_config()
|
||||||
|
# when HAPROXY_CORAZA_SPOE_BACKEND env var is set. Referenced from haproxy.cfg
|
||||||
|
# via `filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg`.
|
||||||
|
#
|
||||||
|
# Engine name "coraza" must match the engine name in the filter line in the
|
||||||
|
# main config and the application name "haproxy" must match the application
|
||||||
|
# block name in coraza-spoa's config.yaml.
|
||||||
|
|
||||||
|
[coraza]
|
||||||
|
|
||||||
|
spoe-agent coraza
|
||||||
|
# The single message we send (defined below) — per-request inspection.
|
||||||
|
messages coraza-check
|
||||||
|
|
||||||
|
# Prefix for any variables the agent sets back on the request.
|
||||||
|
option var-prefix coraza
|
||||||
|
|
||||||
|
# FAIL-OPEN. If the SPOA is unreachable or times out, requests flow
|
||||||
|
# through uninspected rather than failing. For a hosting platform,
|
||||||
|
# availability beats unconditional inspection coverage.
|
||||||
|
option set-on-error continue
|
||||||
|
|
||||||
|
# Aggressive timeouts: we don't want the WAF to materially slow page
|
||||||
|
# loads. processing 100ms is the per-request inspection budget.
|
||||||
|
timeout hello 2s
|
||||||
|
timeout idle 2m
|
||||||
|
timeout processing 100ms
|
||||||
|
|
||||||
|
use-backend coraza-spoa-backend
|
||||||
|
log global
|
||||||
|
|
||||||
|
spoe-message coraza-check
|
||||||
|
# Send the request shape to Coraza for inspection.
|
||||||
|
# `app=str(haproxy)` matches the application named "haproxy" in
|
||||||
|
# coraza-spoa's config.yaml — that's how Coraza picks which ruleset
|
||||||
|
# to apply.
|
||||||
|
args app=str(haproxy) \
|
||||||
|
src-ip=src \
|
||||||
|
src-port=src_port \
|
||||||
|
dest-ip=dst \
|
||||||
|
dest-port=dst_port \
|
||||||
|
method=method \
|
||||||
|
path=path \
|
||||||
|
query=query \
|
||||||
|
version=req.ver \
|
||||||
|
headers=req.hdrs \
|
||||||
|
body=req.body
|
||||||
|
event on-frontend-http-request
|
||||||
@@ -53,3 +53,11 @@ frontend web
|
|||||||
acl is_blocked_ip var(txn.real_ip),map_ip(/etc/haproxy/blocked_ips.map,0) -m int gt 0
|
acl is_blocked_ip var(txn.real_ip),map_ip(/etc/haproxy/blocked_ips.map,0) -m int gt 0
|
||||||
http-request set-path /blocked-ip if is_blocked_ip
|
http-request set-path /blocked-ip if is_blocked_ip
|
||||||
use_backend default-backend if is_blocked_ip
|
use_backend default-backend if is_blocked_ip
|
||||||
|
{% if coraza_spoe_backend %}
|
||||||
|
# Coraza WAF inspection via SPOE. Runs AFTER rate-limit and IP-block
|
||||||
|
# guards (no point asking the WAF about requests we're already dropping)
|
||||||
|
# and AFTER the real-client-IP resolution (so Coraza sees the right src).
|
||||||
|
# Fail-open: see `option set-on-error continue` in /etc/haproxy/coraza-spoe.cfg.
|
||||||
|
filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg
|
||||||
|
http-request send-spoe-group coraza coraza-check
|
||||||
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user