Files
2026-10-13 14:16:35 +07:00

260 lines
15 KiB
Python

"""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/")
assert request("192.0.2.10", "/api/test/ping?value=1", tls=False) == (200, None, "api:/api/test/ping?value=1")
assert request("192.0.2.10", "/team-story/1?password=test", tls=False) == (200, None, "spa:/team-story/1?password=test")
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)