From d9cc5311de575c591030fb0368542e49a69814a9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 24 Jun 2026 13:40:11 -0700 Subject: [PATCH] feat(quic): enable HTTP/3 over QUIC on the edge + versioned images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + : + : 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) --- .gitea/workflows/build-push.yaml | 15 ++++++++++ Dockerfile | 8 +++++- README.md | 8 +++--- VERSION | 1 + haproxy_manager.py | 47 +++++++++++++++++++++++++++++++- templates/hap_header.tpl | 17 ++++++++++++ templates/hap_listener.tpl | 15 ++++++++++ 7 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 VERSION diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 609b28f..5a2049e 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -36,11 +36,26 @@ jobs: username: shadowdao 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 uses: docker/build-push-action@v6 with: platforms: linux/amd64 push: true + build-args: | + VERSION=${{ steps.ver.outputs.version }} + # Three tags per registry: :latest (moving), : (human-readable + # release), : (immutable, guaranteed-unique rollback target). tags: | 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:${{ steps.ver.outputs.version }} + ghcr.io/shadowdao/haproxy-manager-base:${{ gitea.sha }} diff --git a/Dockerfile b/Dockerfile index b624766..8223bef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 # canonical source-of-truth git remote is still Gitea, but Gitea's registry # 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" \ 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.version="${VERSION}" \ 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/* @@ -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 && \ chmod 600 /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 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 diff --git a/README.md b/README.md index 4c09917..3ccea2d 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ A Flask-based API service for managing HAProxy configurations with dynamic SSL c To run the container: ```bash # 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) -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 @@ -394,7 +394,7 @@ You can customize the default page by setting environment variables: ```bash 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 haproxy:/etc/haproxy \ -e HAPROXY_API_KEY=your-secure-api-key-here \ @@ -411,7 +411,7 @@ docker run -d \ ```bash # Start container with API key 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 haproxy:/etc/haproxy \ -e HAPROXY_API_KEY=your-secure-api-key-here \ diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..fe271d6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2026.06.1 diff --git a/haproxy_manager.py b/haproxy_manager.py index 659a4a0..f935ab0 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -75,6 +75,10 @@ BLOCKED_IPS_MAP_PATH = '/etc/haproxy/blocked_ips.map' BLOCKED_IPS_MAP_BACKUP_PATH = '/etc/haproxy/blocked_ips.map.backup' HAPROXY_SOCKET_PATH = '/var/run/haproxy.sock' 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 # Setup logging @@ -1687,6 +1691,45 @@ def dns_challenge_verify(): log_operation('dns_challenge_verify', False, str(e)) 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(): try: conn = sqlite3.connect(DB_FILE) @@ -1749,7 +1792,9 @@ def generate_config(): logger.error(f"Failed to create {suspended_list_path}: {e}") # 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) # Update blocked IPs map file first diff --git a/templates/hap_header.tpl b/templates/hap_header.tpl index 9373f25..322ae0e 100644 --- a/templates/hap_header.tpl +++ b/templates/hap_header.tpl @@ -27,6 +27,23 @@ global # SSL and Performance 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 tune.h2.fe.max-total-streams 2000 tune.h2.fe.glitches-threshold 50 diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index e828eb8..67cdef6 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -4,6 +4,21 @@ frontend web # 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 + # 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) http-request capture req.hdr(Host) len 64