refactor(suspension): serve via /suspended route on default-backend, drop bk_suspended
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 53s
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 53s
The previous design used a separate whp-suspended container (nginx:alpine serving a static 503 page) reachable via a dedicated bk_suspended backend. That was over-engineered — haproxy-manager-base already ships a default-app Flask server on :8080 that serves /default-page and /blocked-ip via path-rewrite ACLs. Mirroring that pattern lets the suspension page live in the SAME container, no extra image to build, no extra container to run/health-monitor. Changes: - Add /suspended Flask route on default_app returning 503 + suspended_page.html - Add templates/suspended_page.html (dark-themed 503 page) - hap_listener.tpl: 'http-request set-path /suspended' + 'use_backend default-backend' when host is in suspended_domains.list (same pattern as is_blocked_ip) - Rename env var from HAPROXY_SUSPENSION_BACKEND (a target hostport) to HAPROXY_SUSPENSION_ENABLED (a bool); accepts 1/true/yes/on (case-insensitive) - Remove hap_suspended_backend.tpl and its rendering in generate_config Non-WHP deployments (env var unset) see byte-identical haproxy.cfg as before (verified via jinja2 render diff). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,14 @@ def blocked_ip_page():
|
||||
return render_template('blocked_ip_page.html'), 403
|
||||
|
||||
|
||||
@default_app.route('/suspended', methods=_ANY_METHOD)
|
||||
def suspended_page():
|
||||
"""Serve the suspended-site page (HTTP 503) for hosts listed in
|
||||
/etc/haproxy/suspended_domains.list. Routed here via the frontend
|
||||
path-rewrite ACL when HAPROXY_SUSPENSION_ENABLED=true."""
|
||||
return render_template('suspended_page.html'), 503
|
||||
|
||||
|
||||
# Configuration
|
||||
DB_FILE = '/etc/haproxy/haproxy_config.db'
|
||||
TEMPLATE_DIR = Path('templates')
|
||||
@@ -1718,14 +1726,18 @@ 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.
|
||||
# Optional site-suspension routing. When HAPROXY_SUSPENSION_ENABLED is
|
||||
# set (any truthy value), the frontend gets an ACL that rewrites the
|
||||
# path to /suspended and routes through default-backend for any host
|
||||
# listed in /etc/haproxy/suspended_domains.list. The /suspended Flask
|
||||
# route in this same process returns HTTP 503 + a static page — no
|
||||
# separate container needed (mirrors the existing /blocked-ip pattern).
|
||||
# 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:
|
||||
# We just ensure the list file exists (haproxy refuses to start with
|
||||
# `-f` pointing at a missing file).
|
||||
suspension_raw = os.environ.get('HAPROXY_SUSPENSION_ENABLED', '').strip().lower()
|
||||
suspension_enabled = suspension_raw in ('1', 'true', 'yes', 'on')
|
||||
if suspension_enabled:
|
||||
suspended_list_path = '/etc/haproxy/suspended_domains.list'
|
||||
if not os.path.exists(suspended_list_path):
|
||||
try:
|
||||
@@ -1747,7 +1759,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),
|
||||
suspension_enabled = suspension_enabled,
|
||||
)
|
||||
config_parts.append(listener_block)
|
||||
|
||||
@@ -1868,14 +1880,6 @@ 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.
|
||||
|
||||
@@ -56,15 +56,17 @@ frontend web
|
||||
{%- 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.
|
||||
# /etc/haproxy/suspended_domains.list is rewritten to /suspended and
|
||||
# routed through default-backend, which is the same Flask app that
|
||||
# serves the default page and blocked-ip page (port 8080 inside this
|
||||
# container). The `/suspended` route returns HTTP 503 with a static
|
||||
# suspension 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.
|
||||
acl is_suspended_domain hdr(host),lower -f /etc/haproxy/suspended_domains.list
|
||||
use_backend bk_suspended if is_suspended_domain
|
||||
http-request set-path /suspended if is_suspended_domain
|
||||
use_backend default-backend if is_suspended_domain
|
||||
{%- endif %}
|
||||
{%- if coraza_spoe_backend %}
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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]
|
||||
# init-addr last,none: tolerate startup-time DNS resolution failure
|
||||
# (the upstream container may not be up yet when haproxy-manager starts).
|
||||
# resolvers docker_dns: re-resolve via Docker's embedded DNS at 127.0.0.11
|
||||
# so the server picks up the real IP once the upstream becomes available
|
||||
# (the docker_dns block is defined in hap_header.tpl).
|
||||
server suspended {{ target }} check inter 30s init-addr last,none resolvers docker_dns
|
||||
58
templates/suspended_page.html
Normal file
58
templates/suspended_page.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Site temporarily unavailable</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.container {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 20px;
|
||||
font-size: 1.75em;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
color: #cbd5e1;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.note {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9em;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #334155;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>This site is temporarily unavailable.</h1>
|
||||
<p>The site you are trying to reach is currently offline.</p>
|
||||
<p class="note">Site owners: please contact support to restore service.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user