diff --git a/haproxy_manager.py b/haproxy_manager.py index 0a40db7..183cfca 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -1710,16 +1710,25 @@ def generate_config(): 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 default_headers = template_env.get_template('hap_header.tpl').render() config_parts.append(default_headers) # Update blocked IPs map file first update_blocked_ips_map() - + # Add Listener Block 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) @@ -1839,6 +1848,25 @@ backend default-backend config_parts.append(fallback_backend) # Add Backends 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 temp_config_path = "/etc/haproxy/haproxy.cfg" diff --git a/templates/hap_coraza_spoa_backend.tpl b/templates/hap_coraza_spoa_backend.tpl new file mode 100644 index 0000000..10d86b9 --- /dev/null +++ b/templates/hap_coraza_spoa_backend.tpl @@ -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 diff --git a/templates/hap_coraza_spoe_engine.tpl b/templates/hap_coraza_spoe_engine.tpl new file mode 100644 index 0000000..d8809df --- /dev/null +++ b/templates/hap_coraza_spoe_engine.tpl @@ -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 diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 22070ef..e0d6852 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -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 http-request set-path /blocked-ip 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 %}