up
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""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 ("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"]
|
||||
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):
|
||||
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("GET", path, 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(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)
|
||||
Reference in New Issue
Block a user