260 lines
15 KiB
Python
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)
|