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."