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>
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
+46
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user