test: add reverse proxy deployment check
This commit is contained in:
227
scripts/verifyProxy.py
Normal file
227
scripts/verifyProxy.py
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify an isolated Nginx -> native TLS reverse-proxy deployment on Linux."""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
def wait_for_native(process: subprocess.Popen[str], timeout: float) -> int:
|
||||
selector = selectors.DefaultSelector()
|
||||
assert process.stdout is not None
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"native server exited with code {process.returncode}")
|
||||
for _, _ in selector.select(timeout=0.5):
|
||||
line = process.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
address = value.get("address", "")
|
||||
if address:
|
||||
return int(address.rsplit(":", 1)[1])
|
||||
raise RuntimeError("native TLS server did not announce a listener")
|
||||
finally:
|
||||
selector.close()
|
||||
|
||||
|
||||
def write_edge_certificate(directory: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(directory / "key.pem"),
|
||||
"-out", str(directory / "cert.pem"),
|
||||
"-days", "1", "-subj", "/CN=localhost",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def write_nginx_config(path: Path, backend_port: int, edge_port: int) -> None:
|
||||
path.write_text(
|
||||
f"""events {{
|
||||
worker_connections 512;
|
||||
}}
|
||||
|
||||
http {{
|
||||
upstream go_omni_native {{
|
||||
server 127.0.0.1:{backend_port};
|
||||
keepalive 64;
|
||||
}}
|
||||
|
||||
server {{
|
||||
listen 127.0.0.1:{edge_port} ssl;
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Request-ID $http_x_request_id;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 1s;
|
||||
proxy_read_timeout 300ms;
|
||||
proxy_send_timeout 1s;
|
||||
|
||||
location / {{
|
||||
proxy_pass https://go_omni_native;
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name off;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def request(connection: http.client.HTTPSConnection, path: str) -> tuple[int, dict[str, str], bytes]:
|
||||
connection.request("GET", path, headers={"X-Request-ID": "proxyCheck"})
|
||||
response = connection.getresponse()
|
||||
body = response.read()
|
||||
return response.status, dict(response.headers), body
|
||||
|
||||
|
||||
def verify_keep_alive(edge_port: int) -> None:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=3)
|
||||
expected = b'{"code":"OK","message":"success","data":{"status":"ok"},"requestId":"proxyCheck"}'
|
||||
try:
|
||||
for attempt in range(5):
|
||||
status, headers, body = request(connection, "/health")
|
||||
if status != 200 or headers.get("X-Request-ID") != "proxyCheck" or body != expected:
|
||||
raise AssertionError(f"unexpected proxied response: status={status} body={body!r}")
|
||||
if attempt < 4 and getattr(connection, "sock", None) is None:
|
||||
raise AssertionError("Nginx did not keep the TLS client connection alive")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def verify_timeout(edge_port: int) -> None:
|
||||
context = ssl._create_unverified_context()
|
||||
connection = http.client.HTTPSConnection("127.0.0.1", edge_port, context=context, timeout=3)
|
||||
try:
|
||||
status, _, _ = request(connection, "/slow")
|
||||
if status != 504:
|
||||
raise AssertionError(f"expected Nginx upstream timeout 504, got {status}")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--image", default="golang:1.25")
|
||||
parser.add_argument("--nginx-image", default="nginx:1.27-alpine")
|
||||
args = parser.parse_args()
|
||||
if shutil.which("docker") is None:
|
||||
raise SystemExit("docker is required for proxy verification")
|
||||
if shutil.which("openssl") is None:
|
||||
raise SystemExit("openssl is required for proxy verification")
|
||||
if not Path("/proc").exists():
|
||||
raise SystemExit("proxy verification must run on Linux")
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
backend_name = "go-omni-proxy-native"
|
||||
nginx_name = "go-omni-proxy-nginx"
|
||||
backend = None
|
||||
nginx = None
|
||||
with tempfile.TemporaryDirectory(prefix="go-omni-proxy-") as temporary:
|
||||
directory = Path(temporary)
|
||||
edge_port = free_port()
|
||||
write_edge_certificate(directory)
|
||||
backend = subprocess.Popen(
|
||||
[
|
||||
"docker", "run", "--rm", "--pull=missing", "--name", backend_name,
|
||||
"--network", "host", "--cpus=1", "--memory=512m", "--pids-limit=512",
|
||||
"-v", f"{root}:/workspace", "-w", "/workspace", "-e", "GOWORK=off",
|
||||
args.image, "sh", "-c", "go run ./internal/perfcheck --server native --tls",
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
try:
|
||||
backend_port = wait_for_native(backend, 120)
|
||||
write_nginx_config(directory / "nginx.conf", backend_port, edge_port)
|
||||
nginx = subprocess.Popen(
|
||||
[
|
||||
"docker", "run", "--rm", "--pull=missing", "--name", nginx_name,
|
||||
"--network", "host",
|
||||
"-v", f"{directory / 'nginx.conf'}:/etc/nginx/nginx.conf:ro",
|
||||
"-v", f"{directory}:/etc/nginx/certs:ro",
|
||||
args.nginx_image, "nginx", "-g", "daemon off;",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
if nginx.poll() is not None:
|
||||
raise RuntimeError(f"Nginx exited with code {nginx.returncode}")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", edge_port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise RuntimeError("Nginx did not start listening")
|
||||
|
||||
verify_keep_alive(edge_port)
|
||||
verify_timeout(edge_port)
|
||||
print("PASS Nginx TLS proxy, Keep-Alive and upstream timeout", flush=True)
|
||||
|
||||
assert backend.stdin is not None
|
||||
backend.stdin.write('"stop"\n')
|
||||
backend.stdin.flush()
|
||||
if backend.wait(timeout=5) != 0:
|
||||
raise AssertionError("native server did not shut down cleanly behind Nginx")
|
||||
print("PASS native graceful shutdown behind proxy", flush=True)
|
||||
finally:
|
||||
if nginx is not None and nginx.poll() is None:
|
||||
nginx.terminate()
|
||||
try:
|
||||
nginx.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
nginx.kill()
|
||||
nginx.wait(timeout=5)
|
||||
if backend is not None and backend.poll() is None:
|
||||
if backend.stdin is not None:
|
||||
try:
|
||||
backend.stdin.write('"stop"\n')
|
||||
backend.stdin.flush()
|
||||
except OSError:
|
||||
pass
|
||||
backend.terminate()
|
||||
try:
|
||||
backend.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
backend.kill()
|
||||
backend.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user