From 8292cc8ffb929a92981106ea1a174dece1457c06 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 18 Jul 2026 18:37:56 +0200 Subject: [PATCH] fix(infra): serve full TLS chain for epicnabbo.nl to resolve Cloudflare 525 Traefik's ACME resolver stored the epicnabbo.nl leaf certificate without the Let's Encrypt intermediate. With Cloudflare in Full (strict) mode the origin TLS handshake failed (HTTP 525), breaking every asset and the whole site layout (JS/CSS chunks, Nitro client, toolbar). Configure the epicnabbo router to use a file-based certificate that includes the full chain (leaf + LE YR2 intermediate), referenced from dynamic/epicnabbo-tls.yml. Add a regeneration script and README. --- infra/traefik/.gitignore | 5 ++ infra/traefik/README.md | 42 +++++++++++++ infra/traefik/dynamic/epicnabbo-tls.yml | 4 ++ infra/traefik/dynamic/epicnabbo.nl.yml | 22 +++++++ infra/traefik/regenerate-epicnabbo-chain.sh | 65 +++++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 infra/traefik/.gitignore create mode 100644 infra/traefik/README.md create mode 100644 infra/traefik/dynamic/epicnabbo-tls.yml create mode 100644 infra/traefik/dynamic/epicnabbo.nl.yml create mode 100755 infra/traefik/regenerate-epicnabbo-chain.sh diff --git a/infra/traefik/.gitignore b/infra/traefik/.gitignore new file mode 100644 index 00000000..af2309bd --- /dev/null +++ b/infra/traefik/.gitignore @@ -0,0 +1,5 @@ +# Secrets / generated TLS material — never commit these. +epicnabbo-fullchain.pem +epicnabbo-key.pem +*.pem +*.key diff --git a/infra/traefik/README.md b/infra/traefik/README.md new file mode 100644 index 00000000..d7e33ad0 --- /dev/null +++ b/infra/traefik/README.md @@ -0,0 +1,42 @@ +# Traefik infrastructure (epicnabbo.nl) + +This directory mirrors the live Traefik dynamic configuration used to proxy +`epicnabbo.nl` (and subdomains) on the production host. The dynamic config +lives on the server at `/docker/proxyserver/dynamic/`. + +## Fix: HTTP 525 / broken layout (origin TLS chain) + +The site returned **HTTP 525 (Cloudflare SSL handshake failed)** for every +asset, which broke the whole UI (JS/CSS chunks, the Nitro client, the +toolbar, the "Enter Hotel" button, etc.). + +Root cause: Traefik's ACME resolver stored the `epicnabbo.nl` leaf +certificate in `/letsencrypt/acme.json` **without** the Let's Encrypt +intermediate. When Cloudflare connects to the origin in "Full (strict)" mode +it cannot build the certificate chain and aborts the TLS handshake → 525. + +Fix: the `epicnabbo` router serves a **file-based certificate** that includes +the full chain (leaf + LE YR2 intermediate), configured in +`dynamic/epicnabbo-tls.yml` and referenced from `dynamic/epicnabbo.nl.yml` +(`tls: {}` enables TLS on the router so the SNI matches the file cert). + +### Files + +- `dynamic/epicnabbo.nl.yml` — router/service/serversTransport for epicnabbo.nl. +- `dynamic/epicnabbo-tls.yml` — file-based certificate (leaf + intermediate). +- `regenerate-epicnabbo-chain.sh` — rebuilds the full chain from `acme.json`. + +### NOT committed (contain secrets) + +- `epicnabbo-fullchain.pem` — leaf + intermediate. +- `epicnabbo-key.pem` — private key. + +### Re-generating the chain (e.g. after cert renewal, before 2026-10-03) + +```bash +./infra/traefik/regenerate-epicnabbo-chain.sh +docker restart traefik +``` + +The `epicnabbo.nl` entry must be **absent** from `acme.json` so Traefik does +not prefer the (incomplete-chain) ACME certificate over the file certificate. diff --git a/infra/traefik/dynamic/epicnabbo-tls.yml b/infra/traefik/dynamic/epicnabbo-tls.yml new file mode 100644 index 00000000..b1ef9bcd --- /dev/null +++ b/infra/traefik/dynamic/epicnabbo-tls.yml @@ -0,0 +1,4 @@ +tls: + certificates: + - certFile: /etc/traefik/dynamic/epicnabbo-fullchain.pem + keyFile: /etc/traefik/dynamic/epicnabbo-key.pem diff --git a/infra/traefik/dynamic/epicnabbo.nl.yml b/infra/traefik/dynamic/epicnabbo.nl.yml new file mode 100644 index 00000000..6b9b4c08 --- /dev/null +++ b/infra/traefik/dynamic/epicnabbo.nl.yml @@ -0,0 +1,22 @@ +http: + routers: + epicnabbo: + entryPoints: + - websecure + rule: "Host(`epicnabbo.nl`) || Host(`www.epicnabbo.nl`)" + service: epicnabbo-svc + middlewares: + - default-security-headers + tls: {} + + services: + epicnabbo-svc: + loadBalancer: + passHostHeader: true + serversTransport: epicnabbo-transport + servers: + - url: "https://172.21.0.1:9443" + + serversTransports: + epicnabbo-transport: + insecureSkipVerify: true diff --git a/infra/traefik/regenerate-epicnabbo-chain.sh b/infra/traefik/regenerate-epicnabbo-chain.sh new file mode 100755 index 00000000..1f725bcb --- /dev/null +++ b/infra/traefik/regenerate-epicnabbo-chain.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# +# regenerate-epicnabbo-chain.sh +# +# Builds a complete TLS chain (leaf + Let's Encrypt intermediate) for +# epicnabbo.nl and writes it next to the Traefik dynamic config so the +# origin presents a full chain to Cloudflare. +# +# Why: Traefik's ACME resolver stored the leaf certificate in +# /letsencrypt/acme.json without the issuing intermediate. When Cloudflare +# talks to the origin in "Full (strict)" mode it cannot build the chain and +# returns HTTP 525 (SSL handshake failed), which broke every asset on the +# site (JS/CSS chunks, the Nitro client, etc.). Serving the full chain from +# a file-based certificate fixes the handshake. +# +# Usage (run on the host as root): +# ./infra/traefik/regenerate-epicnabbo-chain.sh +# +# The generated files (epicnabbo-fullchain.pem / epicnabbo-key.pem) contain +# the private key and MUST NOT be committed to git. +set -euo pipefail + +TRAEFIK_DYNAMIC="/docker/proxyserver/dynamic" +ACME_JSON="/docker/proxyserver/letsencrypt/acme.json" +INTERMEDIATE_URL="http://yr2.i.lencr.org/" + +if [ ! -f "$ACME_JSON" ]; then + echo "acme.json not found at $ACME_JSON" >&2 + exit 1 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Extract the epicnabbo.nl leaf certificate + private key from acme.json. +docker exec traefik cat /letsencrypt/acme.json 2>/dev/null > "$WORK/acme.json" +python3 - "$WORK/acme.json" "$WORK/leaf.pem" "$WORK/key.pem" <<'PY' +import sys, json, base64 +path, leaf_out, key_out = sys.argv[1], sys.argv[2], sys.argv[3] +data = json.load(open(path)) +found = False +for _resolver, v in data.items(): + for c in (v.get("Certificates") or []): + if c.get("domain", {}).get("main") == "epicnabbo.nl": + open(leaf_out, "wb").write(base64.b64decode(c["certificate"])) + open(key_out, "wb").write(base64.b64decode(c["key"])) + found = True + break + if found: + break +if not found: + sys.exit("epicnabbo.nl certificate not found in acme.json") +PY + +# Fetch the Let's Encrypt YR2 intermediate (issuer of the leaf). +curl -fsSL "$INTERMEDIATE_URL" -o "$WORK/intermediate.der" +openssl x509 -inform der -in "$WORK/intermediate.der" -out "$WORK/intermediate.pem" + +# Assemble leaf + intermediate into a full chain. +cat "$WORK/leaf.pem" "$WORK/intermediate.pem" > "$TRAEFIK_DYNAMIC/epicnabbo-fullchain.pem" +cp "$WORK/key.pem" "$TRAEFIK_DYNAMIC/epicnabbo-key.pem" +chmod 644 "$TRAEFIK_DYNAMIC/epicnabbo-fullchain.pem" "$TRAEFIK_DYNAMIC/epicnabbo-key.pem" + +echo "Regenerated full chain at $TRAEFIK_DYNAMIC/epicnabbo-fullchain.pem" +echo "Restart Traefik for the change to take effect."