"""Check HTTPS preflight; --docker also exercises Nginx in isolated containers.""" from pathlib import Path import http.client import json import os import shutil import socket import ssl import subprocess import sys import tempfile import time import uuid workspace = Path(__file__).resolve().parents[2] ui = "evening-detective.crabs-games.art" api = "evening-detective-api.crabs-games.art" def run(*args, **kwargs): return subprocess.run(args, check=True, capture_output=True, text=True, **kwargs).stdout.strip() with tempfile.TemporaryDirectory(prefix="venue-https-") as directory: root = Path(directory) venue = root / "venue" venue.mkdir() shutil.copy(workspace / "venue/deploy.sh", venue / "deploy.sh") certificates = venue / "certificates" certificates.mkdir() for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]: path = venue / artifact path.parent.mkdir(parents=True, exist_ok=True) path.touch() (venue / "migrations").mkdir() (venue / "migrations/001.sql").touch() original_env = "EXISTING_SECRET=unchanged\n" (venue / ".env").write_text(original_env) bin_dir = root / "bin" bin_dir.mkdir() log = root / "commands.log" for command in ["docker", "make", "npm"]: stub = bin_dir / command stub.write_text('#!/bin/sh\necho "$(basename "$0") $*" >> "$TEST_LOG"\n' 'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n') stub.chmod(0o755) env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log)} run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", "-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign", "-subj", "/CN=Venue test root", "-keyout", str(root / "ca.key"), "-out", str(root / "ca.pem")) run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=Venue intermediate", "-keyout", str(root / "intermediate.key"), "-out", str(root / "intermediate.csr")) extension = root / "extensions.cnf" extension.write_text("basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n") run("openssl", "x509", "-req", "-in", str(root / "intermediate.csr"), "-CA", str(root / "ca.pem"), "-CAkey", str(root / "ca.key"), "-CAcreateserial", "-days", "2", "-extfile", str(extension), "-out", str(root / "intermediate.pem")) run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=Venue test server", "-keyout", str(certificates / "privkey.pem"), "-out", str(root / "leaf.csr")) def issue(domains, days="1"): extension.write_text("basicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\nsubjectAltName=" + ",".join("DNS:" + domain for domain in domains) + "\n") if days == "-1": (root / "index").touch() (root / "serial").write_text("01\n") config = root / "ca.cnf" config.write_text(f"[ca]\ndefault_ca=issuer\n[issuer]\ndatabase={root}/index\n" f"serial={root}/serial\nnew_certs_dir={root}\ncertificate={root}/intermediate.pem\n" f"private_key={root}/intermediate.key\ndefault_md=sha256\npolicy=policy\n" "x509_extensions=server\n[policy]\ncommonName=supplied\n[server]\n" + extension.read_text()) run("openssl", "ca", "-batch", "-notext", "-config", str(config), "-in", str(root / "leaf.csr"), "-startdate", "20200101000000Z", "-enddate", "20200102000000Z", "-out", str(root / "leaf.pem")) else: run("openssl", "x509", "-req", "-in", str(root / "leaf.csr"), "-CA", str(root / "intermediate.pem"), "-CAkey", str(root / "intermediate.key"), "-CAcreateserial", "-days", days, "-extfile", str(extension), "-out", str(root / "leaf.pem")) (certificates / "fullchain.pem").write_text((root / "leaf.pem").read_text() + (root / "intermediate.pem").read_text()) def check(success, trust=True, cold=False, first=False): log.unlink(missing_ok=True) test_env = {**env, "SSL_CERT_FILE": str(root / "ca.pem" if trust else root / "untrusted.pem"), "TEST_COLD": str(int(cold))} result = subprocess.run(["bash", str(venue / "deploy.sh")], env=test_env, capture_output=True, text=True) assert (result.returncode == 0) == success, result.stderr if first: config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines()) assert len(config["JWT_SECRET"]) == 64 and len(config["VENUE_USER_PASSWORD"]) == 16 assert config["APP_BASE_URL"] == "https://" + ui assert (venue / ".env").stat().st_mode & 0o777 == 0o600 else: assert (venue / ".env").read_text() == original_env if success: commands = log.read_text() prefix = "compose -f docker-compose.yml -f docker-compose.https.yml" assert prefix + " up -d --force-recreate --no-build --pull never" in commands assert prefix + " --profile setup run --rm bootstrap" in commands assert prefix + " exec -T proxy nginx -t" in commands assert prefix + " exec -T proxy nginx -s reload" in commands assert prefix + " build --pull=false" in commands assert commands.index(prefix + " build --pull=false") < commands.index(prefix + " up -d") assert (prefix + " pull " in commands) == cold assert (prefix + " pull dns" in commands) == cold assert ("docker pull alpine:3.22" in commands) == cold assert "make " not in commands and "npm " not in commands else: assert "HTTPS:" in result.stderr assert not log.exists(), "Invalid certificate reached Docker" check(False) # Missing chain. issue([ui, api]) check(True) check(True) # Offline reuse, unchanged settings. check(False, trust=False) chain = (certificates / "fullchain.pem").read_text() (certificates / "fullchain.pem").write_text((root / "leaf.pem").read_text()) check(False) # Missing intermediate. (certificates / "fullchain.pem").write_text("invalid certificate") check(False) (certificates / "fullchain.pem").write_text(chain) key = (certificates / "privkey.pem").read_text() (certificates / "privkey.pem").write_text((root / "ca.key").read_text()) check(False) # Different valid private key. (certificates / "privkey.pem").write_text("invalid key") check(False) (certificates / "privkey.pem").unlink() check(False) (certificates / "privkey.pem").write_text(key) run("openssl", "pkey", "-in", str(certificates / "privkey.pem"), "-aes-256-cbc", "-passout", "pass:test-only", "-out", str(root / "encrypted.pem")) (certificates / "privkey.pem").write_text((root / "encrypted.pem").read_text()) check(False) (certificates / "privkey.pem").write_text(key) issue([ui]) check(False) # API domain missing. issue([api]) check(False) # UI domain missing. issue([ui, api], "-1") check(False) # Expired leaf. issue([ui, api]) (venue / ".env").unlink() check(True, cold=True, first=True) (venue / ".env").write_text(original_env) for missing in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html", "migrations/001.sql"]: path = venue / missing path.unlink() log.unlink(missing_ok=True) result = subprocess.run(["bash", str(venue / "deploy.sh")], env={**env, "SSL_CERT_FILE": str(root / "ca.pem")}, capture_output=True, text=True) assert result.returncode != 0 and "make prod-build" in result.stderr, missing assert not log.exists(), "Missing artifacts reached Docker" assert (venue / ".env").read_text() == original_env path.touch() print("PASS: HTTPS certificates, first setup, offline Docker rebuild, forced restart, missing artifacts and preserved settings") if "--docker" in sys.argv: compose = ["docker", "compose", "--env-file", "/dev/null", "-f", str(workspace / "venue/docker-compose.yml")] compose_env = {**os.environ, **dict.fromkeys([ "POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "JWT_SECRET", "PARTICIPATION_SECRET", "S3_USER", "S3_PASSWORD", "VENUE_USER_PASSWORD"], "test-only"), "APP_BASE_URL": "http://localhost:8088", "VENUE_BIND_IP": "127.0.0.1", "VENUE_PORT": "8088"} http_config = json.loads(run(*compose, "config", "--format", "json", env=compose_env)) https = json.loads(run(*compose, "-f", str(workspace / "venue/docker-compose.https.yml"), "config", "--format", "json", env=compose_env)) assert http_config["services"]["evening_detective_server"]["environment"]["APP_BASE_URL"] == "http://localhost:8088" server = https["services"]["evening_detective_server"]["environment"] assert server["APP_MODE"] == "venue" and server["APP_BASE_URL"] == "https://" + ui assert server["FILE_PREFIX_DOMAIN"] == server["APP_BASE_URL"] + "/api/files/" assert {str(p["published"]) for p in https["services"]["proxy"]["ports"]} == {"80", "443", "8088"} configs = [v for v in https["services"]["proxy"]["volumes"] if v["target"] == "/etc/nginx/conf.d/default.conf"] assert len(configs) == 1 and configs[0]["source"].endswith("/nginx.https.conf") and configs[0]["read_only"] assert any(v["target"] == "/etc/nginx/certificates" and v["read_only"] for v in https["services"]["proxy"]["volumes"]) assert https["networks"]["default"]["internal"] and https["volumes"] == http_config["volumes"] dns = https["services"]["dns"] assert dns["image"] == "coredns/coredns:1.14.7" assert dns["environment"]["VENUE_DNS_IP"] == "127.0.0.1" assert set(dns["networks"]) == {"entrance"} assert {(p["protocol"], str(p["published"])) for p in dns["ports"]} == {("udp", "53"), ("tcp", "53")} assert all(p["host_ip"] == "127.0.0.1" for p in dns["ports"]) assert any(v["target"] == "/etc/coredns/Corefile" and v["read_only"] for v in dns["volumes"]) collector = https["services"]["cudy_collector"] backend = https["services"]["evening_detective_server"] assert set(collector["networks"]) == {"entrance"} and not collector.get("ports") assert set(backend["networks"]) == {"default"} assert "DATABASE_URL" not in collector["environment"] and "S3_PASSWORD" not in collector["environment"] assert any(v["target"] == "/run/secrets/cudy" and v["read_only"] for v in collector["volumes"]) assert any(v["target"] == "/run/cudy" and v["read_only"] for v in backend["volumes"]) assert not any(v["target"] == "/run/secrets/cudy" for v in backend["volumes"]) assert collector["environment"]["CUDY_SNAPSHOT_FILE"] == server["CUDY_SNAPSHOT_FILE"] print("PASS: Compose merge, HTTPS addresses, ports, read-only mounts and preserved data volumes") suffix = uuid.uuid4().hex[:10] network = "venue-https-test-" + suffix upstream = network + "-upstream" proxy = network + "-proxy" upstream_config = root / "upstream.conf" upstream_config.write_text('server { listen 8090; return 200 "api:$request_uri"; }\n' 'server { listen 8080; return 200 "spa:$request_uri"; }\n') # Test-only CA is explicit; public HTTPS verification is never disabled. context = ssl.create_default_context(cafile=str(root / "ca.pem")) def request(domain, path, tls=True, method="GET", body=None): port = int(run("docker", "port", proxy, "443/tcp" if tls else "80/tcp").split(":")[-1]) connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) if tls: connection.sock = context.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=10), server_hostname=domain) try: connection.request(method, path, body=body, headers={"Host": domain}) response = connection.getresponse() return response.status, response.getheader("Location"), response.read().decode() finally: connection.close() def serial(): port = int(run("docker", "port", proxy, "443/tcp").split(":")[-1]) with context.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=10), server_hostname=ui) as connection: return connection.getpeercert()["serialNumber"] try: run("docker", "network", "create", network) run("docker", "run", "-d", "--name", upstream, "--network", network, "--network-alias", "evening_detective_server", "--network-alias", "evening_detective_frontend", "-v", str(upstream_config) + ":/etc/nginx/conf.d/default.conf:ro", "nginx:alpine") run("docker", "run", "-d", "--name", proxy, "--network", network, "-p", "127.0.0.1::80", "-p", "127.0.0.1::443", "-v", str(workspace / "venue/nginx.https.conf") + ":/etc/nginx/conf.d/default.conf:ro", "-v", str(certificates) + ":/etc/nginx/certificates:ro", "nginx:alpine") run("docker", "exec", proxy, "nginx", "-t") for domain in [ui, api]: assert request(domain, "/api/test/ping?value=1", tls=False)[:2] == (308, "https://" + domain + "/api/test/ping?value=1") assert request(domain, "/api/test/ping?value=1") == (200, None, "api:/api/test/ping?value=1") assert request(domain, "/api/files/material.pdf") == (200, None, "api:/api/files/material.pdf") assert request(domain, "/api/games/import", method="POST", body=b"test ZIP") == (200, None, "api:/api/games/import") assert request(ui, "/team-story/1?password=test") == (200, None, "spa:/team-story/1?password=test") assert request(api, "/docs/") == (200, None, "api:/docs/") run("docker", "restart", proxy) run("docker", "exec", proxy, "nginx", "-t") assert request(ui, "/games") == (200, None, "spa:/games") previous_serial = serial() issue([ui, api]) run("docker", "exec", proxy, "nginx", "-t") run("docker", "exec", proxy, "nginx", "-s", "reload") for attempt in range(40): if serial() != previous_serial: break time.sleep(0.05) else: raise AssertionError("Nginx did not reload renewed certificate") assert request(ui, "/games") == (200, None, "spa:/games") print("PASS: real Nginx TLS/SNI, both domains, redirects, API, materials, SPA paths, restart and certificate renewal") finally: for container in [proxy, upstream]: subprocess.run(["docker", "rm", "-f", container], capture_output=True) subprocess.run(["docker", "network", "rm", network], capture_output=True)