Default splash for unrouted hosts (404+noindex, answered before the WAF) and in-container access log (2026.11.2) (#15)
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 3m36s

This commit was merged in pull request #15.
This commit is contained in:
jknapp committed 2026-11-08 20:03:23 +00:00
1 parent 1ee5760c1b
commit 50c58c4e9e
16 files changed
+1078 -59

No files matched your search

+23 -32
View File
@@ -2,42 +2,33 @@
# Global settings
#---------------------------------------------------------------------
global
# ACCESS LOG DESTINATION.
# ACCESS LOG DESTINATION: a UNIX datagram socket INSIDE this container.
#
# This used to be `log 127.0.0.1 local2`, which was a silent black hole:
# 127.0.0.1 is the CONTAINER's own loopback, nothing has ever listened on
# udp/514 in the container netns, and there is no /dev/log in the image.
# Every access log line -- ~1.5M/day across the whole edge -- was written
# to a socket with no receiver and dropped. Nothing errored, nothing
# warned, and `haproxy -c` was perfectly happy. The cost only shows up
# during an incident: per-IP 429s, tarpits, wp-admin gate redirects,
# WAF 403s and `silent-drop`s left no record anywhere, so the edge could
# not be asked what it had rejected. Only aggregate stick-table counters
# survived.
# scripts/haproxy-log-receiver.py (supervised by start-up.sh) reads it and
# writes a size-capped, rotating /var/log/haproxy/access.log in the
# haproxy-logs volume. No network listener anywhere.
#
# Now points at the DOCKER BRIDGE GATEWAY, where the host's rsyslog has an
# imudp listener bound (installed idempotently by WHP's
# setup-haproxy-logrotate.sh, which also writes the logrotate stanza).
# The host writes local2 to /var/log/haproxy.log and stops it there, so it
# does not also flood /var/log/messages or the Graylog forwarder.
# History: `log 127.0.0.1 local2` was a black hole (nothing listened in
# the container netns); its successor, the docker bridge gateway
# 172.18.0.1:514 with a host rsyslog imudp listener, had no listener on
# the fleet either (probable cause, not measured: rsyslog binds that
# address once at boot, before docker has created the bridge, and does
# not retry). Either way per-IP
# 429s, tarpits, gate redirects and WAF 403s left no record.
#
# WHY NOT `log stdout format raw local0`: it is INCOMPATIBLE with the
# `daemon` keyword below, and incompatible SILENTLY. Verified on the
# pinned 3.0.11 binary: with `daemon` set, a `log stdout` config serves
# traffic normally and emits ZERO log lines, and `haproxy -c` returns 0
# with no error and no warning -- so the CI config gate
# (scripts/validate-rendered-config.py) cannot catch it either. Making it
# work means dropping `daemon` / adding -db so haproxy stays in the
# foreground, which in turn breaks the three synchronous
# `subprocess.run(['haproxy', '-W', ...], check=True)` launch sites in
# haproxy_manager.py (they would block until the 180s timeout and then be
# killed). That is a change to the exact code path whose failure mode is
# "container Up, ports 80/443 never bound, every site down, /health still
# 200". Not worth it for a logging change.
# WHY NOT `log stdout`: silently emits NOTHING with `daemon` (below) on
# the pinned 3.0.11 (`haproxy -c` still passes), and dropping `daemon`
# breaks the synchronous `haproxy -W` launch sites in haproxy_manager.py.
# Also, `docker logs haproxy-manager` feeds WHP's AI log monitor, which
# must not get a per-request firehose.
#
# UDP means a dead listener degrades to dropped log lines, never to a
# stalled or failing request path -- the correct failure direction for an
# edge fronting ~60 customer sites.
# The path is relative to the `chroot` below: HAProxy resolves it after
# chrooting, so `/log.sock` here is /var/lib/haproxy/log.sock on disk,
# which is where the receiver binds (HAPROXY_LOG_SOCKET).
#
# Sends are non-blocking: a dead receiver or a full socket buffer drops
# log lines, never stalls a request. HAPROXY_SYSLOG_TARGET still
# overrides the target (`<ip>:<port>` for a remote collector).
#
# len 2048 accommodates the enriched log-format in hap_listener.tpl
# (URL + User-Agent + UUID); the default 1024 would truncate long ones.
+41
View File
@@ -616,6 +616,47 @@ frontend web
http-request set-path /suspended if is_suspended_domain
use_backend default-backend if is_suspended_domain
{%- endif %}
{%- if default_splash %}
# Default splash for traffic that is NOT routed to any site: the server's
# own hostname (registered cert-only, no backend), a bare IP, a stale or
# mistyped name. HAProxy answers it ITSELF with a static page and the
# request goes no further -- in particular it never reaches the Coraza
# SPOE call below.
#
# Why: `use_backend default-backend` does not short-circuit anything --
# HAProxy runs EVERY http-request rule (Coraza included) before it picks
# a backend. So the catch-all used to be WAF-inspected with the full
# server-wide rule set like customer traffic, and a browser visiting the
# server hostname also carries the control panel's cookies (cookies are
# not port-scoped: whp01:8443 cookies are sent to whp01:443). Anything
# Coraza matched there showed the WAF block page for a host that has no
# site behind it to protect. A fixed static reply has nothing to protect.
#
# "Routed" is decided from two lists generate_config() writes from the
# SAME domain rows it renders the routing ACLs from (exact names, and
# ".base" suffixes for wildcards), port stripped and lowercased. They
# are a SUPERSET of what routes -- the routing ACLs below compare the raw
# Host (port included) -- so a host that routes is never splashed; the
# worst a mismatch can do is send an unrouted request through the WAF to
# default-backend, which is the pre-splash behaviour. Both lists are part
# of the rollback set (_config_backup_pairs) so they cannot drift from
# the haproxy.cfg that is actually loaded.
#
# Kept OUT of the splash: ACME http-01 (the server hostname's own cert is
# issued through it), blocked IPs (blocked-ip page) and suspended hosts
# (suspended page). Rate limits, the IP block and the wp-* gates above
# still apply. EVERY path is 404 with the same page, plus
# X-Robots-Tag noindex and /robots.txt "Disallow: /", so search engines
# never index the server's IP or hostname as a site.
# Disable with HAPROXY_DEFAULT_SPLASH=off (back to default-backend).
acl splash_routed_host req.hdr(host),field(1,:),lower -f /etc/haproxy/routed_hosts.list
acl splash_routed_host req.hdr(host),field(1,:),lower -m end -f /etc/haproxy/routed_wildcards.list
acl splash_acme path_beg /.well-known/acme-challenge/
acl splash_robots path /robots.txt
http-request return status 200 content-type "text/plain; charset=utf-8" hdr cache-control "no-store" hdr x-robots-tag "noindex, nofollow, noarchive" hdr x-content-type-options "nosniff" file /haproxy/errors/robots-unrouted.txt if !splash_routed_host !splash_acme !is_blocked_ip{% if suspension_enabled %} !is_suspended_domain{% endif %} splash_robots
http-request return status 404 content-type "text/html; charset=utf-8" hdr cache-control "no-store" hdr x-robots-tag "noindex, nofollow, noarchive" hdr x-content-type-options "nosniff" file /haproxy/errors/default-splash.html if !splash_routed_host !splash_acme !is_blocked_ip{% if suspension_enabled %} !is_suspended_domain{% endif %} !splash_robots
{%- endif %}
# CVE-2026-109618 (GHSA-w8hw-w9fx-j79w, PHP "RCE via OOM corruption"),
# vector (a): a multipart/form-data body with no declared length is not