#!/bin/bash # End-to-end runtime test of the HTTP/HTTPS + fallback behaviour. # # Spins up a stub backend + Caddy on a throwaway Docker network and proves: # 1. HTTPS disabled -> http://:PORT answers on plain HTTP # 2. HTTPS enabled -> https://:PORT answers over TLS (internal CA) # 3. HTTPS enabled -> http://:PORT STILL answers (fallback) # 4. Both the IP and a hostname resolve to the app # # Uses a stub backend so no 1.17 GB app image build is needed; the reverse-proxy # behaviour under test is entirely Caddy's. set -u NET=e2e-caddy-net BACKEND=e2e-backend CADDY=e2e-caddy HTTP_PORT=18080 HTTPS_PORT=18443 IP=127.0.0.1 cleanup() { docker rm -f "$CADDY" "$BACKEND" >/dev/null 2>&1 || true docker network rm "$NET" >/dev/null 2>&1 || true rm -f /tmp/e2e_Caddyfile } trap cleanup EXIT cleanup docker network create "$NET" >/dev/null # Stub "digiserver-app" serving a recognisable body on :5000 docker run -d --name "$BACKEND" --network "$NET" --network-alias digiserver-app \ python:3.13-slim \ python -c "from http.server import BaseHTTPRequestHandler,HTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200); self.send_header('Content-Type','text/plain'); self.end_headers() self.wfile.write(b'BACKEND-OK') def log_message(self,*a): pass HTTPServer(('0.0.0.0',5000),H).serve_forever()" >/dev/null echo "waiting for stub backend..." for i in $(seq 1 20); do docker exec "$BACKEND" python -c " import urllib.request,sys try: urllib.request.urlopen('http://localhost:5000/',timeout=1); sys.exit(0) except Exception: sys.exit(1)" 2>/dev/null && break sleep 1 done echo "stub backend ready" echo start_caddy() { # $1 = caddyfile content printf '%s' "$1" > /tmp/e2e_Caddyfile docker rm -f "$CADDY" >/dev/null 2>&1 || true docker run -d --name "$CADDY" --network "$NET" \ -p "${HTTP_PORT}:80" -p "${HTTPS_PORT}:443" \ -v /tmp/e2e_Caddyfile:/etc/caddy/Caddyfile:ro \ caddy:2-alpine >/dev/null # wait for the admin API to accept connections for i in $(seq 1 25); do docker exec "$CADDY" wget -q -O- http://localhost:2019/config/ >/dev/null 2>&1 && return 0 sleep 1 done return 1 } result() { # $1 label, $2 expected substring, $3 actual body if printf '%s' "$3" | grep -q "$2"; then echo " PASS $1" return 0 fi echo " FAIL $1 (got: $(printf '%s' "$3" | head -c 80))" return 1 } FAILED=0 # ── Case 1: HTTPS disabled -> plain HTTP only ──────────────────────────────── echo "CASE 1: HTTPS disabled -> plain HTTP on port $HTTP_PORT" start_caddy '{ admin 0.0.0.0:2019 } :80 { reverse_proxy digiserver-app:5000 } ' || echo " (caddy admin not ready; continuing)" BODY=$(curl -sS -m 5 "http://${IP}:${HTTP_PORT}/" 2>&1) result "http://IP answers" "BACKEND-OK" "$BODY" || FAILED=1 BODY=$(curl -sS -m 5 -H "Host: digiserver" "http://${IP}:${HTTP_PORT}/" 2>&1) result "http with Host: digiserver answers (catch-all)" "BACKEND-OK" "$BODY" || FAILED=1 echo # ── Case 2/3: HTTPS on, internal CA, with HTTP fallback ────────────────────── echo "CASE 2+3: HTTPS on (internal CA) + HTTP fallback" # Generated by CaddyConfigGenerator for ip=127.0.0.1, hostname=digiserver. # `default_sni` is REQUIRED: browsers send no SNI when the URL is an IP, so # without it Caddy matches no certificate and aborts with # "no certificate available for ''". start_caddy "{ admin 0.0.0.0:2019 email admin@example.com default_sni ${IP} } :80 { reverse_proxy digiserver-app:5000 } http://${IP} { reverse_proxy digiserver-app:5000 } http://digiserver { reverse_proxy digiserver-app:5000 } https://${IP} { tls internal reverse_proxy digiserver-app:5000 } https://digiserver { tls internal reverse_proxy digiserver-app:5000 } " || echo " (caddy admin not ready; continuing)" echo " (waiting for internal CA issuance)" for i in $(seq 1 15); do OUT=$(curl -sS -k -m 4 "https://${IP}:${HTTPS_PORT}/" 2>&1) printf '%s' "$OUT" | grep -q "BACKEND-OK" && break sleep 1 done result "https://IP answers over TLS (SNI-less)" "BACKEND-OK" "$OUT" || FAILED=1 OUT2=$(curl -sS -k -m 6 --resolve "digiserver:${HTTPS_PORT}:${IP}" \ "https://digiserver:${HTTPS_PORT}/" 2>&1) result "https://hostname answers over TLS (with SNI)" "BACKEND-OK" "$OUT2" || FAILED=1 BODY=$(curl -sS -m 5 "http://${IP}:${HTTP_PORT}/" 2>&1) result "http://IP STILL answers (fallback)" "BACKEND-OK" "$BODY" || FAILED=1 BODY=$(curl -sS -m 5 -H "Host: digiserver" "http://${IP}:${HTTP_PORT}/" 2>&1) result "http:// with Host: digiserver answers" "BACKEND-OK" "$BODY" || FAILED=1 HANDSHAKE_ERRORS=$(docker logs "$CADDY" 2>&1 | grep -ci "handshake error" || true) if [ "$HANDSHAKE_ERRORS" -eq 0 ]; then echo " PASS no TLS handshake errors logged" else echo " FAIL $HANDSHAKE_ERRORS TLS handshake error(s) logged" FAILED=1 fi # Certificate must come from Caddy's local CA ISSUER=$(echo | openssl s_client -connect "${IP}:${HTTPS_PORT}" -servername localhost 2>/dev/null \ | openssl x509 -noout -issuer 2>/dev/null) if printf '%s' "$ISSUER" | grep -qi "local\|caddy"; then echo " PASS certificate issued by the internal CA" echo " $ISSUER" else echo " WARN unexpected issuer: ${ISSUER:-none}" fi echo docker logs "$CADDY" 2>&1 | grep -iE "error|cannot|fail" | head -5 || true echo if [ "$FAILED" -eq 0 ]; then echo "ALL RUNTIME CHECKS PASSED" else echo "SOME RUNTIME CHECKS FAILED" fi exit "$FAILED"