From 2ef582a3de6be7836d38717dd94bf78434a51b91 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 13 May 2026 11:46:18 -0700 Subject: [PATCH] feat(suspension): opt-in routing for suspended hosts via bk_suspended backend Adds a new env var HAPROXY_SUSPENSION_BACKEND (default unset). When set (e.g. "whp-suspended:80"), generate_config() renders: - A bk_suspended backend pointing at the configured upstream - An ACL `acl is_suspended_domain hdr(host),lower -f /etc/haproxy/suspended_domains.list` + `use_backend bk_suspended if is_suspended_domain` in the frontend, sitting after IP-blocking and before any per-domain routing - An empty /etc/haproxy/suspended_domains.list if missing (haproxy refuses to start with -f pointing at a non-existent file) External tooling (e.g. WHP's site_disable.php) maintains the list via `docker cp` and HUP-reloads the container. Non-WHP deployments (home networks, standalone use) leave the env var unset and see byte-identical haproxy.cfg output. Same opt-in shape as the existing HAPROXY_CORAZA_SPOE_BACKEND integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- haproxy_manager.py | 27 +++++++++++++++++++++++++++ templates/hap_listener.tpl | 13 +++++++++++++ templates/hap_suspended_backend.tpl | 13 +++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 templates/hap_suspended_backend.tpl diff --git a/haproxy_manager.py b/haproxy_manager.py index 504d43b..52d3743 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -1718,6 +1718,24 @@ def generate_config(): # image) -> the generated haproxy.cfg is byte-identical to today's. coraza_spoe_backend = os.environ.get('HAPROXY_CORAZA_SPOE_BACKEND') + # Optional site-suspension routing. When HAPROXY_SUSPENSION_BACKEND is + # set (e.g. "whp-suspended:80"), we render bk_suspended + a frontend + # ACL that routes hosts in /etc/haproxy/suspended_domains.list to it. + # Same opt-in shape as Coraza: unset -> config byte-identical to today. + # The list file is maintained by external tooling; we just ensure it + # exists (haproxy refuses to start with -f pointing at a missing file). + suspension_backend_target = os.environ.get('HAPROXY_SUSPENSION_BACKEND') + if suspension_backend_target: + suspended_list_path = '/etc/haproxy/suspended_domains.list' + if not os.path.exists(suspended_list_path): + try: + with open(suspended_list_path, 'w') as f: + f.write('') + os.chmod(suspended_list_path, 0o644) + logger.info(f"Created empty {suspended_list_path}") + except Exception as e: + logger.error(f"Failed to create {suspended_list_path}: {e}") + # Add Haproxy Default Headers default_headers = template_env.get_template('hap_header.tpl').render() config_parts.append(default_headers) @@ -1729,6 +1747,7 @@ def generate_config(): listener_block = template_env.get_template('hap_listener.tpl').render( crt_path = SSL_CERTS_DIR, coraza_spoe_backend = coraza_spoe_backend, + suspension_enabled = bool(suspension_backend_target), ) config_parts.append(listener_block) @@ -1849,6 +1868,14 @@ backend default-backend # Add Backends config_parts.append('\n' .join(config_backends) + '\n') + # Suspended-site backend (only when env var set). Inserted before the + # Coraza backend so config_parts ordering remains deterministic. + if suspension_backend_target: + suspended_backend_block = template_env.get_template( + 'hap_suspended_backend.tpl' + ).render(target=suspension_backend_target) + config_parts.append(suspended_backend_block + '\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. diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 18bcca8..e45abe7 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -53,6 +53,19 @@ 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 suspension_enabled %} + + # Site suspension routing. Any Host header listed in + # /etc/haproxy/suspended_domains.list is routed to bk_suspended (a + # backend serving a static 503 "site unavailable" page). External + # tooling (e.g. WHP's site_disable.php) maintains the list file via + # `docker cp`. An empty list is safe — the ACL simply doesn't match. + # Sits after IP-blocking (so 429/403 still trigger first) and before + # any per-domain use_backend rules, so suspension takes precedence + # over normal site routing. + acl is_suspended_domain hdr(host),lower -f /etc/haproxy/suspended_domains.list + use_backend bk_suspended if is_suspended_domain +{%- endif %} {%- if coraza_spoe_backend %} # Coraza WAF inspection via SPOE. Runs AFTER rate-limit and IP-block diff --git a/templates/hap_suspended_backend.tpl b/templates/hap_suspended_backend.tpl new file mode 100644 index 0000000..b8621e4 --- /dev/null +++ b/templates/hap_suspended_backend.tpl @@ -0,0 +1,13 @@ +# Suspended-site backend. Used when external tooling adds a host to +# /etc/haproxy/suspended_domains.list (read by an ACL in the frontend). +# The backend points at a single upstream that serves a static 503 +# "site temporarily unavailable" page. Only rendered when the +# HAPROXY_SUSPENSION_BACKEND env var is set on the haproxy-manager +# container; non-WHP deployments (home networks, standalone use) see +# no change to haproxy.cfg. +backend bk_suspended + mode http + option http-server-close + http-request set-header X-Forwarded-Proto https if { ssl_fc } + http-request set-header X-Forwarded-For %[src] + server suspended {{ target }} check inter 30s