3 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 4.8 3917b6d1ae feat(templates): add hap_backend_longlived override template
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m29s
Generic long-lived backend (template_override='hap_backend_longlived') for apps
whose primary path holds connections open: streaming, large up/downloads,
persistent sessions. Both primary and SSE backends tuned long-lived (no
http-server-close, http-no-delay, 6h server/tunnel/keep-alive). Differs from
hap_backend_websocket (which only long-lives the SSE variant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:40:11 -07:00
shadowdaoandClaude Opus 4.8 d9cc5311de feat(quic): enable HTTP/3 over QUIC on the edge + versioned images
HTTP/3 is config-only — the Debian haproxy package is built +QUIC via the
OpenSSL compat shim. Changes:
- hap_header.tpl: `limited-quic` (required to enable QUIC binds under the
  compat layer) + self-healing `cluster-secret` for QUIC token derivation.
- hap_listener.tpl: `bind quic4@:443 ... alpn h3` in the shared frontend (so
  real-IP/rate-limit/IP-block/Coraza rules apply to H3 too) + alt-svc header.
- Dockerfile/README: publish/document 443/udp; stamp image.version from VERSION.
- CI: tag :latest + :<VERSION> + :<sha> so there's a pinnable rollback target.

No 0-RTT (compat-layer limitation). Validated end-to-end on a standalone edge:
config parses, UDP/443 binds, alt-svc advertised, real curl --http3 -> HTTP/3.
Container must run with `-p 443:443/udp` + host UDP/443 open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:40:11 -07:00
shadowdaoandClaude Opus 4.8 f1c1954378 fix(blocked-ips): correct map format + worker socket in manage-blocked-ips.sh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:40:11 -07:00
9 changed files with 187 additions and 27 deletions
+15
View File
@@ -36,11 +36,26 @@ jobs:
username: shadowdao username: shadowdao
password: ${{ secrets.GHCR_TOKEN }} password: ${{ secrets.GHCR_TOKEN }}
# Read the human-readable release version from the VERSION file so every
# build is pinnable for rollback (alongside the immutable git SHA). Bump
# VERSION (YYYY.MM.N) in the same commit as a release-worthy change.
- name: Read version
id: ver
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- name: Build Image - name: Build Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
build-args: |
VERSION=${{ steps.ver.outputs.version }}
# Three tags per registry: :latest (moving), :<version> (human-readable
# release), :<sha> (immutable, guaranteed-unique rollback target).
tags: | tags: |
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:${{ steps.ver.outputs.version }}
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:${{ gitea.sha }}
ghcr.io/shadowdao/haproxy-manager-base:latest ghcr.io/shadowdao/haproxy-manager-base:latest
ghcr.io/shadowdao/haproxy-manager-base:${{ steps.ver.outputs.version }}
ghcr.io/shadowdao/haproxy-manager-base:${{ gitea.sha }}
+7 -1
View File
@@ -14,9 +14,13 @@ FROM repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
# sidebar; pointing at the public GitHub mirror enables that linking. The # sidebar; pointing at the public GitHub mirror enables that linking. The
# canonical source-of-truth git remote is still Gitea, but Gitea's registry # canonical source-of-truth git remote is still Gitea, but Gitea's registry
# doesn't consume this label, so there's no contention. # doesn't consume this label, so there's no contention.
# Stamped from the VERSION file by CI (build-arg) so `docker inspect` reports
# what's running on any host. Defaults to "dev" for local/manual builds.
ARG VERSION=dev
LABEL org.opencontainers.image.title="haproxy-manager-base" \ LABEL org.opencontainers.image.title="haproxy-manager-base" \
org.opencontainers.image.description="HAProxy management API with Let's Encrypt automation, Coraza WAF integration, and template-driven config" \ org.opencontainers.image.description="HAProxy management API with Let's Encrypt automation, Coraza WAF integration, and template-driven config" \
org.opencontainers.image.source="https://github.com/shadowdao/haproxy-manager-base" \ org.opencontainers.image.source="https://github.com/shadowdao/haproxy-manager-base" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="MIT" org.opencontainers.image.licenses="MIT"
RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot curl jq net-tools -y && apt clean && rm -rf /var/lib/apt/lists/* RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot curl jq net-tools -y && apt clean && rm -rf /var/lib/apt/lists/*
@@ -43,7 +47,9 @@ RUN mkdir -p /var/spool/cron/crontabs && \
echo '0 */12 * * * /haproxy/scripts/renew-certificates.sh >> /var/log/haproxy-manager.log 2>&1' >> /var/spool/cron/crontabs/root && \ echo '0 */12 * * * /haproxy/scripts/renew-certificates.sh >> /var/log/haproxy-manager.log 2>&1' >> /var/spool/cron/crontabs/root && \
chmod 600 /var/spool/cron/crontabs/root && \ chmod 600 /var/spool/cron/crontabs/root && \
chown root:crontab /var/spool/cron/crontabs/root chown root:crontab /var/spool/cron/crontabs/root
EXPOSE 80 443 8000 # 443/udp carries HTTP/3 (QUIC). EXPOSE is documentation only — the container
# must still be run with `-p 443:443/udp` for the UDP listener to be reachable.
EXPOSE 80 443 443/udp 8000
# Add health check # Add health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -sf --max-time 5 http://localhost:8000/health && curl -s --max-time 5 -o /dev/null http://localhost/ || exit 1 CMD curl -sf --max-time 5 http://localhost:8000/health && curl -s --max-time 5 -o /dev/null http://localhost/ || exit 1
+4 -4
View File
@@ -6,10 +6,10 @@ A Flask-based API service for managing HAProxy configurations with dynamic SSL c
To run the container: To run the container:
```bash ```bash
# Without API key authentication (default) # Without API key authentication (default)
docker run -d -p 80:80 -p 443:443 -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy --name haproxy-manager your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest docker run -d -p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy --name haproxy-manager your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest
# With API key authentication (recommended for production) # With API key authentication (recommended for production)
docker run -d -p 80:80 -p 443:443 -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy -e HAPROXY_API_KEY=your-secure-api-key-here --name haproxy-manager your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest docker run -d -p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy -e HAPROXY_API_KEY=your-secure-api-key-here --name haproxy-manager your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest
``` ```
## Features ## Features
@@ -394,7 +394,7 @@ You can customize the default page by setting environment variables:
```bash ```bash
docker run -d \ docker run -d \
-p 80:80 -p 443:443 -p 8000:8000 \ -p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 \
-v lets-encrypt:/etc/letsencrypt \ -v lets-encrypt:/etc/letsencrypt \
-v haproxy:/etc/haproxy \ -v haproxy:/etc/haproxy \
-e HAPROXY_API_KEY=your-secure-api-key-here \ -e HAPROXY_API_KEY=your-secure-api-key-here \
@@ -411,7 +411,7 @@ docker run -d \
```bash ```bash
# Start container with API key # Start container with API key
docker run -d \ docker run -d \
-p 80:80 -p 443:443 -p 8000:8000 \ -p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 \
-v lets-encrypt:/etc/letsencrypt \ -v lets-encrypt:/etc/letsencrypt \
-v haproxy:/etc/haproxy \ -v haproxy:/etc/haproxy \
-e HAPROXY_API_KEY=your-secure-api-key-here \ -e HAPROXY_API_KEY=your-secure-api-key-here \
+1
View File
@@ -0,0 +1 @@
2026.06.1
+46 -1
View File
@@ -75,6 +75,10 @@ BLOCKED_IPS_MAP_PATH = '/etc/haproxy/blocked_ips.map'
BLOCKED_IPS_MAP_BACKUP_PATH = '/etc/haproxy/blocked_ips.map.backup' BLOCKED_IPS_MAP_BACKUP_PATH = '/etc/haproxy/blocked_ips.map.backup'
HAPROXY_SOCKET_PATH = '/var/run/haproxy.sock' HAPROXY_SOCKET_PATH = '/var/run/haproxy.sock'
SSL_CERTS_DIR = '/etc/haproxy/certs' SSL_CERTS_DIR = '/etc/haproxy/certs'
# Stable per-host secret for QUIC Retry/address-validation tokens. Lives in the
# /etc/haproxy named volume so it survives container recreates; self-healed on
# first config render. See get_or_create_cluster_secret().
CLUSTER_SECRET_PATH = '/etc/haproxy/cluster-secret'
API_KEY = os.environ.get('HAPROXY_API_KEY') # Optional API key for authentication API_KEY = os.environ.get('HAPROXY_API_KEY') # Optional API key for authentication
# Setup logging # Setup logging
@@ -1687,6 +1691,45 @@ def dns_challenge_verify():
log_operation('dns_challenge_verify', False, str(e)) log_operation('dns_challenge_verify', False, str(e))
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
def get_or_create_cluster_secret():
"""Return a stable secret for QUIC token derivation, generating it once.
HAProxy uses `cluster-secret` to key QUIC Retry/address-validation tokens.
Without a stable value it picks a random one each (re)start and logs a
notice; tokens then don't survive reloads. We persist one in the
/etc/haproxy named volume so it's stable across container recreates.
Exclusive-create avoids a race if two renders run concurrently. Failure to
read/write is non-fatal: we fall back to an empty string and the template
simply omits the directive (HAProxy reverts to its random-per-process
behaviour), so QUIC still works.
"""
try:
if os.path.exists(CLUSTER_SECRET_PATH):
with open(CLUSTER_SECRET_PATH, 'r') as f:
secret = f.read().strip()
if secret:
return secret
# Generate and persist exclusively (0600). hex => config-safe charset.
secret = os.urandom(32).hex()
fd = os.open(CLUSTER_SECRET_PATH, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
try:
os.write(fd, secret.encode())
finally:
os.close(fd)
logger.info("Generated new QUIC cluster-secret at %s", CLUSTER_SECRET_PATH)
return secret
except FileExistsError:
# Lost the create race — another render just wrote it; read it back.
try:
with open(CLUSTER_SECRET_PATH, 'r') as f:
return f.read().strip()
except Exception as e:
logger.error("Failed to read cluster-secret after race: %s", e)
return ''
except Exception as e:
logger.error("Failed to get/create cluster-secret: %s", e)
return ''
def generate_config(): def generate_config():
try: try:
conn = sqlite3.connect(DB_FILE) conn = sqlite3.connect(DB_FILE)
@@ -1749,7 +1792,9 @@ def generate_config():
logger.error(f"Failed to create {suspended_list_path}: {e}") logger.error(f"Failed to create {suspended_list_path}: {e}")
# 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(
cluster_secret = get_or_create_cluster_secret(),
)
config_parts.append(default_headers) config_parts.append(default_headers)
# Update blocked IPs map file first # Update blocked IPs map file first
+40 -20
View File
@@ -6,22 +6,40 @@
SOCKET="/tmp/haproxy-cli" SOCKET="/tmp/haproxy-cli"
MAP_FILE="/etc/haproxy/blocked_ips.map" MAP_FILE="/etc/haproxy/blocked_ips.map"
# HAProxy runs in master-worker mode here, and /tmp/haproxy-cli is the MASTER
# socket. Data-plane commands (map/table manipulation) are NOT accepted on the
# master socket — they must be routed to a worker with the "@<n>" prefix. "@1"
# targets the current active worker. (A bare "add map ..." on the master socket
# fails with "Unknown command: 'add'".)
cli() { printf '@1 %s\n' "$*" | socat stdio "$SOCKET"; }
# Map lookup in haproxy.cfg is `map_ip(...,0) -m int gt 0`, so each entry MUST be
# "<ip_or_cidr> 1" — a bare IP yields an empty value (0) and is NOT blocked once
# the map file is re-read on reload. The runtime map and the file must agree.
MAP_VALUE=1
# Ensure map file exists # Ensure map file exists
if [ ! -f "$MAP_FILE" ]; then if [ ! -f "$MAP_FILE" ]; then
touch "$MAP_FILE" echo "# Blocked IPs - Format: <ip_or_cidr> 1 (one per line)" > "$MAP_FILE"
echo "# Blocked IPs - Format: IP_ADDRESS" > "$MAP_FILE"
fi fi
# Escape regex metacharacters (notably dots) in an IP/CIDR for anchored matching.
esc_re() { printf '%s' "$1" | sed 's/[.[\*^$/]/\\&/g'; }
case "$1" in case "$1" in
block) block)
if [ -z "$2" ]; then if [ -z "$2" ]; then
echo "Usage: $0 block IP_ADDRESS" echo "Usage: $0 block IP_ADDRESS"
exit 1 exit 1
fi fi
# Add IP to map file re="$(esc_re "$2")"
grep -q "^$2" "$MAP_FILE" || echo "$2" >> "$MAP_FILE" # Persist (idempotent, anchored so 1.2.3.4 doesn't match 1.2.3.45),
# Add to runtime map # always with the trailing value so the block survives a reload.
echo "add map /etc/haproxy/blocked_ips.map $2 1" | socat stdio "$SOCKET" if ! grep -qE "^${re}([[:space:]]|$)" "$MAP_FILE"; then
echo "$2 $MAP_VALUE" >> "$MAP_FILE"
fi
# Apply at runtime immediately (no reload).
cli "add map $MAP_FILE $2 $MAP_VALUE"
echo "Blocked IP: $2" echo "Blocked IP: $2"
;; ;;
@@ -30,31 +48,33 @@ case "$1" in
echo "Usage: $0 unblock IP_ADDRESS" echo "Usage: $0 unblock IP_ADDRESS"
exit 1 exit 1
fi fi
# Remove from map file re="$(esc_re "$2")"
sed -i "/^$2$/d" "$MAP_FILE" # Remove from map file (match "<ip>" optionally followed by a value).
# Remove from runtime map sed -i -E "/^${re}([[:space:]]|$)/d" "$MAP_FILE"
echo "del map /etc/haproxy/blocked_ips.map $2" | socat stdio "$SOCKET" # Remove from runtime map.
cli "del map $MAP_FILE $2"
echo "Unblocked IP: $2" echo "Unblocked IP: $2"
;; ;;
list) list)
echo "Currently blocked IPs:" echo "Currently blocked IPs:"
echo "show map /etc/haproxy/blocked_ips.map" | socat stdio "$SOCKET" | awk '{print $1}' # `show map` output is "<ptr> <key> <value>" — the IP is field 2.
cli "show map $MAP_FILE" | awk 'NF>=2 {print $2}'
;; ;;
clear) clear)
echo "Clearing all blocked IPs..." echo "Clearing all blocked IPs..."
echo "clear map /etc/haproxy/blocked_ips.map" | socat stdio "$SOCKET" cli "clear map $MAP_FILE"
echo "# Blocked IPs - Format: IP_ADDRESS" > "$MAP_FILE" echo "# Blocked IPs - Format: <ip_or_cidr> 1 (one per line)" > "$MAP_FILE"
echo "All IPs unblocked" echo "All IPs unblocked"
;; ;;
stats) stats)
echo "=== HAProxy 3.0.11 Threat Intelligence Dashboard ===" echo "=== HAProxy 3.0.11 Threat Intelligence Dashboard ==="
echo "show table web" | socat stdio "$SOCKET" | awk 'NR<=21' cli "show table web" | awk 'NR<=21'
echo "" echo ""
echo "=== Top Threat Scores ===" echo "=== Top Threat Scores ==="
echo "show table web" | socat stdio "$SOCKET" | awk ' cli "show table web" | awk '
NR>1 { NR>1 {
ip = $1 ip = $1
auth_fail = 0 auth_fail = 0
@@ -84,7 +104,7 @@ case "$1" in
exit 1 exit 1
fi fi
# Add to manual blacklist using GPC(13) # Add to manual blacklist using GPC(13)
echo "set table web key $2 data.gpc(13) 1" | socat stdio "$SOCKET" cli "set table web key $2 data.gpc(13) 1"
echo "Manually blacklisted IP: $2 (GPC(13) = 1)" echo "Manually blacklisted IP: $2 (GPC(13) = 1)"
;; ;;
@@ -94,7 +114,7 @@ case "$1" in
exit 1 exit 1
fi fi
# Clear manual blacklist flag # Clear manual blacklist flag
echo "set table web key $2 data.gpc(13) 0" | socat stdio "$SOCKET" cli "set table web key $2 data.gpc(13) 0"
echo "Removed manual blacklist for IP: $2" echo "Removed manual blacklist for IP: $2"
;; ;;
@@ -104,7 +124,7 @@ case "$1" in
exit 1 exit 1
fi fi
# Add to auto-blacklist using GPC(14) # Add to auto-blacklist using GPC(14)
echo "set table web key $2 data.gpc(14) 1" | socat stdio "$SOCKET" cli "set table web key $2 data.gpc(14) 1"
echo "Auto-blacklisted IP: $2 (GPC(14) = 1)" echo "Auto-blacklisted IP: $2 (GPC(14) = 1)"
;; ;;
@@ -115,14 +135,14 @@ case "$1" in
fi fi
# Show detailed threat breakdown for specific IP # Show detailed threat breakdown for specific IP
echo "Threat analysis for $2:" echo "Threat analysis for $2:"
echo "show table web key $2" | socat stdio "$SOCKET" cli "show table web key $2"
;; ;;
*) *)
echo "Usage: $0 {block|unblock|list|clear|blacklist|unblacklist|auto-blacklist|threat-score|stats} [IP_ADDRESS]" echo "Usage: $0 {block|unblock|list|clear|blacklist|unblacklist|auto-blacklist|threat-score|stats} [IP_ADDRESS]"
echo "" echo ""
echo "HAProxy 3.0.11 Enhanced Security Commands:" echo "HAProxy 3.0.11 Enhanced Security Commands:"
echo " block IP - Block IP via map file (immediate)" echo " block IP - Block IP via map file (immediate + persisted)"
echo " unblock IP - Unblock IP from map file" echo " unblock IP - Unblock IP from map file"
echo " blacklist IP - Manual blacklist via GPC(13) array" echo " blacklist IP - Manual blacklist via GPC(13) array"
echo " unblacklist IP - Remove manual blacklist flag" echo " unblacklist IP - Remove manual blacklist flag"
+41
View File
@@ -0,0 +1,41 @@
# Long-lived backend for {{ name }} (template_override='hap_backend_longlived').
# Use for apps whose PRIMARY traffic holds connections open: media streaming,
# large up/downloads, or persistent viewer/streaming sessions. Both the primary
# and the SSE backend are tuned long-lived here (no http-server-close,
# http-no-delay, 6h server/tunnel/keep-alive timeouts).
#
# Compare hap_backend_websocket.tpl, which keeps the PRIMARY backend standard
# and only makes the -sse-backend long-lived. Pick this one when the main path
# itself needs long-lived connections, not just an SSE side-channel.
backend {{ name }}-backend
no option http-server-close
option http-no-delay
timeout server 6h
timeout tunnel 6h
timeout http-keep-alive 6h
option forwardfor
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
http-request set-header X-Real-IP %[var(txn.real_ip)]
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
{% for server in servers %}
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
{% endfor %}
# SSE variant (Accept: text/event-stream / ?action=stream auto-routes here)
backend {{ name }}-sse-backend
no option http-server-close
option http-no-delay
timeout server 6h
timeout tunnel 6h
timeout http-keep-alive 6h
option forwardfor
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
http-request set-header X-Real-IP %[var(txn.real_ip)]
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
{% for server in servers %}
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
{% endfor %}
+17
View File
@@ -27,6 +27,23 @@ global
# SSL and Performance # SSL and Performance
tune.ssl.default-dh-param 2048 tune.ssl.default-dh-param 2048
# HTTP/3 over QUIC. The Debian haproxy package is built against system
# OpenSSL via the compatibility shim (USE_QUIC_OPENSSL_COMPAT), which is
# not a native QUIC TLS stack. HAProxy therefore rejects `quic*@` binds
# unless this opt-in is set. `limited-quic` enables QUIC through the compat
# layer (no 0-RTT — that needs quictls/aws-lc or native OpenSSL 3.5 QUIC).
# Without this, the quic bind in the frontend fails to start: "this SSL
# library does not support the QUIC protocol".
limited-quic
{%- if cluster_secret %}
# Stable secret keying QUIC Retry/address-validation tokens. Self-healed
# to /etc/haproxy/cluster-secret (named volume) by the manager so it
# survives recreates; without it haproxy picks a random one per process
# and tokens don't survive reloads (benign, just a startup notice).
cluster-secret "{{ cluster_secret }}"
{%- endif %}
# HTTP/2 protection against Rapid Reset (CVE-2023-44487) and stream abuse # HTTP/2 protection against Rapid Reset (CVE-2023-44487) and stream abuse
tune.h2.fe.max-total-streams 2000 tune.h2.fe.max-total-streams 2000
tune.h2.fe.glitches-threshold 50 tune.h2.fe.glitches-threshold 50
+15
View File
@@ -4,6 +4,21 @@ frontend web
# crt can now be a path, so it will load all .pem files in the path # crt can now be a path, so it will load all .pem files in the path
bind 0.0.0.0:443 ssl crt {{ crt_path }} alpn h2,http/1.1 bind 0.0.0.0:443 ssl crt {{ crt_path }} alpn h2,http/1.1
# HTTP/3 over QUIC (UDP/443). Same cert path as the TCP listener above.
# The Debian haproxy package is built +QUIC (QUIC_OPENSSL_COMPAT), so this
# is config-only — no source build. Requires UDP/443 published on the
# container (`-p 443:443/udp`) and open at the host firewall. `h3` is the
# only ALPN QUIC negotiates; h2/http1 stay on the TCP bind above. Sharing
# the frontend means all the real-IP, rate-limit, IP-block and Coraza
# rules below apply identically to H3 traffic.
bind quic4@0.0.0.0:443 ssl crt {{ crt_path }} alpn h3
# Advertise H3 so browsers upgrade their existing TCP (h2) connection to
# QUIC on the next request. `ma` is how long (seconds) the client may
# cache the advertisement. http-after-response applies it to every
# response, including haproxy-generated ones (blocks, default page).
http-after-response set-header alt-svc "h3=\":443\"; ma=86400"
# Capture Host header so it appears in httplog output (in %hr field) # Capture Host header so it appears in httplog output (in %hr field)
http-request capture req.hdr(Host) len 64 http-request capture req.hdr(Host) len 64