449 lines
22 KiB
Python
Executable File
449 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Disposable, loopback-only fixtures for testing actual RustProxy processes.
|
|
|
|
Runtime databases and credentials live in TemporaryDirectory, never in results.
|
|
No pre-existing SQLite database, fixed port, or historical /mnt/data path is used.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import gzip
|
|
import hashlib
|
|
from http.client import HTTPConnection, HTTPException
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pwd
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
result = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
result.update(block)
|
|
return result.hexdigest()
|
|
|
|
|
|
def write_json(path: Path, value) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
|
|
|
|
|
|
def seal_results(directory: Path) -> None:
|
|
"""Checksums cover every regular evidence file, except the checksum manifest."""
|
|
paths = sorted(p for p in directory.rglob("*") if p.is_file()
|
|
and p.name != "SHA256SUMS")
|
|
(directory / "SHA256SUMS").write_text("".join(
|
|
f"{sha256(path)} {path.relative_to(directory)}\n" for path in paths))
|
|
|
|
|
|
def http(port: int, path="/small.html", method="GET", headers=None, body=None,
|
|
timeout=10, source_address=None):
|
|
connection = HTTPConnection("127.0.0.1", port, timeout=timeout,
|
|
source_address=source_address)
|
|
try:
|
|
connection.request(method, path, body, headers or {})
|
|
response = connection.getresponse()
|
|
payload = response.read()
|
|
return response.status, {k.lower(): v for k, v in response.getheaders()}, payload
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def wait_ready(port: int, process: subprocess.Popen, timeout=30):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if process.poll() is not None:
|
|
raise RuntimeError(f"process {process.pid} exited with {process.returncode}")
|
|
try:
|
|
http(port, "/", timeout=.5)
|
|
return
|
|
except (OSError, HTTPException):
|
|
time.sleep(.05)
|
|
raise RuntimeError(f"loopback listener {port} did not start")
|
|
|
|
|
|
class Fixture:
|
|
def __init__(self, results: Path, workers=4, temp_parent: Path | None = None,
|
|
server_cpus=None, origin_cpus=None, origin_workers=None):
|
|
self.results = results.resolve()
|
|
self.results.mkdir(parents=True, exist_ok=True)
|
|
# Keep live state outside the evidence tree. Even an interrupted run or
|
|
# workspace synchronization must never put databases/keys in results.
|
|
self.temp = tempfile.TemporaryDirectory(prefix="rustproxy-perf-", dir=temp_parent)
|
|
self.root = Path(self.temp.name)
|
|
self.root.chmod(0o755) # nginx workers must traverse the public fixture root
|
|
self.site = self.root / "site"
|
|
self.site.mkdir(mode=0o755)
|
|
self.workers = workers
|
|
self.server_cpus = server_cpus
|
|
self.origin_cpus = origin_cpus
|
|
self.origin_workers = origin_workers or workers
|
|
self.children = []
|
|
self.handles = []
|
|
self.ports = set()
|
|
self.origin_port = self.port()
|
|
self.make_site()
|
|
|
|
def port(self):
|
|
while True:
|
|
port = free_port()
|
|
if port not in self.ports:
|
|
self.ports.add(port)
|
|
return port
|
|
|
|
def make_site(self):
|
|
# Exact, reproducible payload sizes match the original report's cases.
|
|
small = b"<!doctype html><html><body>RustProxy benchmark "
|
|
small += b"x" * (1040 - len(small) - len(b"</body></html>"))
|
|
small += b"</body></html>"
|
|
files = {"small.html": small,
|
|
"large.bin": bytes(range(256)) * 256,
|
|
"huge.bin": bytes(range(256)) * 4096,
|
|
"empty.txt": b"",
|
|
"app.json": (b'{\n "message": "stable fixture",\n "values": [1, 2, 3]\n}\n'),
|
|
"app.css": b"/* fixture comment */\nbody { color: red; padding: 0px; }\n",
|
|
"compress.txt": (b"compressible RustProxy text fixture. " * 4096),
|
|
"download.txt": b"download fixture\n"}
|
|
for name, body in files.items():
|
|
(self.site / name).write_bytes(body)
|
|
(self.site / "small.html.gz").write_bytes(gzip.compress(small, mtime=0))
|
|
(self.site / "directory").mkdir()
|
|
(self.site / "directory" / "index.html").write_bytes(b"directory index\n")
|
|
(self.site / "listing").mkdir()
|
|
(self.site / "listing" / "visible.txt").write_bytes(b"listing fixture\n")
|
|
(self.site / "inside-link.txt").symlink_to(self.site / "download.txt")
|
|
self.outside = self.root / "outside-secret.txt"
|
|
self.outside.write_bytes(b"MUST-NOT-BE-SERVED-OUTSIDE-ROOT\n")
|
|
(self.site / "escape-link.txt").symlink_to(self.outside)
|
|
write_json(self.results / "payloads.json", {
|
|
name: {"bytes": len(body), "sha256": hashlib.sha256(body).hexdigest()}
|
|
for name, body in files.items()})
|
|
|
|
def spawn(self, argv, name, env=None, affinity=None):
|
|
log = (self.results / f"{name}.log").open("w")
|
|
self.handles.append(log)
|
|
if affinity:
|
|
argv = ["taskset", "-c", affinity, *argv]
|
|
child = subprocess.Popen([str(x) for x in argv], stdout=log,
|
|
stderr=subprocess.STDOUT, env=env, start_new_session=True)
|
|
self.children.append(child)
|
|
return child
|
|
|
|
@staticmethod
|
|
def stop(process):
|
|
if process.poll() is None:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
try:
|
|
process.wait(timeout=12)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
process.wait(timeout=3)
|
|
|
|
def close(self):
|
|
for process in reversed(self.children):
|
|
self.stop(process)
|
|
for handle in self.handles:
|
|
handle.close()
|
|
self.temp.cleanup()
|
|
write_json(self.results / "fixture-cleanup.json", {
|
|
"temporary_state_removed": not self.root.exists(),
|
|
"state_was_outside_results": not self.root.is_relative_to(self.results)})
|
|
if self.root.exists():
|
|
raise RuntimeError("temporary runtime state was not removed; do not package the fixture directory")
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
self.close()
|
|
|
|
def base_config(self, reverse_port, static_port):
|
|
endpoints = []
|
|
for label, port in [("reverse", reverse_port), ("static", static_port)]:
|
|
endpoints.append({"id": label, "name": label, "scope": "internal",
|
|
"enabled": True, "binding": {
|
|
"address": f"127.0.0.1:{port}", "tls": False,
|
|
"http2": False, "http3": None}})
|
|
return {
|
|
"cache": {"enabled": False},
|
|
"geoip_updates": {"enabled": False},
|
|
"listeners": {"endpoints": endpoints},
|
|
"web_services": [{"id": "fixture-static", "name": "Fixture static",
|
|
"document_root": str(self.site), "index": ["index.html", "small.html"],
|
|
"directory_listing": False}],
|
|
"vhosts": [{"id": "reverse", "frontend": {
|
|
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
|
|
"listener_ids": ["reverse"]},
|
|
"backends": [{"id": "origin", "url": f"http://127.0.0.1:{self.origin_port}",
|
|
"allow_private": True, "preserve_host": False}],
|
|
"load_balancing": {"health_check": {"mode": "off"}}, "routes": []},
|
|
{"id": "static", "frontend": {
|
|
"hosts": ["127.0.0.1", "localhost", "fixture.test"],
|
|
"listener_ids": ["static"]},
|
|
"source": {"type": "web_server", "web_service_id": "fixture-static"},
|
|
"routes": []}]
|
|
}
|
|
|
|
def rust(self, binary: Path, label: str, max_idle=None):
|
|
return RustInstance(self, binary.resolve(), label, max_idle=max_idle)
|
|
|
|
def nginx(self, executable: Path, frontend=True):
|
|
"""Use a separate nginx master for the shared origin and comparator."""
|
|
executable = executable.resolve()
|
|
result = subprocess.run([str(executable), "-V"], capture_output=True, text=True, timeout=10)
|
|
(self.results / "nginx-version.txt").write_text(result.stdout + result.stderr)
|
|
origin_conf = self.nginx_config("nginx-origin", f"""
|
|
server {{ listen 127.0.0.1:{self.origin_port}; server_name _;
|
|
root "{self.site}"; location / {{ add_header Cache-Control "public, max-age=60"; }}
|
|
}}
|
|
""", workers=self.origin_workers)
|
|
origin = self.spawn([executable, "-p", self.root, "-c", origin_conf], "nginx-origin",
|
|
affinity=self.origin_cpus)
|
|
wait_ready(self.origin_port, origin)
|
|
result = {"origin": origin, "origin_port": self.origin_port}
|
|
if frontend:
|
|
reverse_port, static_port = self.port(), self.port()
|
|
conf = self.nginx_config("nginx-frontend", f"""
|
|
upstream fixture_origin {{ server 127.0.0.1:{self.origin_port}; keepalive 64; }}
|
|
server {{ listen 127.0.0.1:{reverse_port}; server_name _;
|
|
location / {{ proxy_pass http://fixture_origin; proxy_http_version 1.1;
|
|
proxy_set_header Connection ""; proxy_buffering off; }}
|
|
}}
|
|
server {{ listen 127.0.0.1:{static_port}; server_name _; root "{self.site}"; }}
|
|
""")
|
|
process = self.spawn([executable, "-p", self.root, "-c", conf], "nginx-frontend",
|
|
affinity=self.server_cpus)
|
|
wait_ready(reverse_port, process)
|
|
wait_ready(static_port, process)
|
|
result.update({"process": process, "reverse_port": reverse_port,
|
|
"static_port": static_port})
|
|
return result
|
|
|
|
def nginx_config(self, name, servers, workers=None):
|
|
path = self.root / f"{name}.conf"
|
|
text = f"""daemon off;
|
|
master_process on;
|
|
user {pwd.getpwuid(os.geteuid()).pw_name};
|
|
worker_processes {workers or self.workers};
|
|
pid {self.root}/{name}.pid;
|
|
error_log stderr warn;
|
|
events {{ worker_connections 8192; }}
|
|
http {{
|
|
access_log off;
|
|
sendfile on;
|
|
tcp_nopush on;
|
|
keepalive_timeout 65;
|
|
keepalive_requests 1000000;
|
|
default_type application/octet-stream;
|
|
types {{ text/html html; text/css css; application/json json; text/plain txt; }}
|
|
client_body_temp_path {self.root}/{name}-body;
|
|
proxy_temp_path {self.root}/{name}-proxy;
|
|
{servers}
|
|
}}
|
|
"""
|
|
path.write_text(text)
|
|
(self.results / path.name).write_text(text)
|
|
return path
|
|
|
|
|
|
class RustInstance:
|
|
def __init__(self, fixture, binary, label, max_idle=None):
|
|
self.fixture, self.binary, self.label = fixture, binary, label
|
|
if not os.access(binary, os.X_OK):
|
|
target = fixture.root / f"{label}-rust-proxy"
|
|
shutil.copyfile(binary, target)
|
|
target.chmod(0o700)
|
|
self.binary = target
|
|
self.admin_port, self.reverse_port, self.static_port = (fixture.port() for _ in range(3))
|
|
self.data = fixture.root / f"{label}-data"
|
|
self.base = fixture.base_config(self.reverse_port, self.static_port)
|
|
if max_idle is not None:
|
|
self.base["vhosts"][0]["load_balancing"]["keepalive"] = {"max_idle_per_host": max_idle}
|
|
self.password = "Fixture!" + secrets.token_hex(20)
|
|
self.username = "performance"
|
|
self.env = os.environ.copy()
|
|
self.env["TOKIO_WORKER_THREADS"] = str(fixture.workers)
|
|
self.env["RUST_PROXY_PROXY_LISTEN"] = f"127.0.0.1:{self.reverse_port}"
|
|
self.env["RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS"] = "0"
|
|
self.env.pop("RUST_PROXY_TLS_LISTEN", None)
|
|
self.applies = []
|
|
self.cli("admin", "init", "--username", self.username, "--password-stdin",
|
|
input=self.password + "\n")
|
|
self.start("bootstrap")
|
|
self.apply(self.base, "base")
|
|
fixture.stop(self.process)
|
|
self.start("runtime")
|
|
wait_ready(self.static_port, self.process)
|
|
version = self.cli("--version").stdout.strip()
|
|
write_json(fixture.results / f"{label}-binary.json", {
|
|
"label": label, "path": str(binary), "sha256": sha256(binary),
|
|
"version": version, "worker_threads": fixture.workers,
|
|
"pid": self.process.pid, "protocol": "HTTP/1.1 plaintext loopback",
|
|
"cpu_affinity": fixture.server_cpus,
|
|
"max_idle_per_host_override": max_idle})
|
|
|
|
def cli(self, *args, input=None, check=True):
|
|
result = subprocess.run([str(self.binary), "--data-dir", str(self.data),
|
|
"--admin-listen", f"127.0.0.1:{self.admin_port}", *args],
|
|
input=input, text=True, capture_output=True,
|
|
env=self.env, timeout=40)
|
|
if check and result.returncode:
|
|
raise RuntimeError(f"{self.label} CLI {' '.join(args[:2])}: "
|
|
f"{result.stdout[-2000:]} {result.stderr[-3000:]}")
|
|
return result
|
|
|
|
def start(self, phase):
|
|
self.process = self.fixture.spawn([
|
|
self.binary, "--data-dir", self.data, "--admin-listen", f"127.0.0.1:{self.admin_port}",
|
|
"--update-check-interval-secs", "0", "serve", "--proxy-listen",
|
|
f"127.0.0.1:{self.reverse_port}", "--max-connections", "4096", "--max-inflight", "4096"],
|
|
f"{self.label}-{phase}", env=self.env, affinity=self.fixture.server_cpus)
|
|
wait_ready(self.admin_port, self.process)
|
|
wait_ready(self.reverse_port, self.process)
|
|
|
|
def apply(self, config, name, expected_success=True):
|
|
config_path = self.fixture.root / f"{self.label}-{name}.json"
|
|
write_json(config_path, config)
|
|
result = self.cli("config", "apply", str(config_path), check=False)
|
|
# Configurations with ephemeral test passwords/hashes are intentionally not copied.
|
|
safe = copy.deepcopy(config)
|
|
for vhost in safe.get("vhosts", []):
|
|
if "basic_auth" in vhost:
|
|
vhost["basic_auth"]["users"] = "REDACTED fixture users"
|
|
write_json(self.fixture.results / "configs" / f"{self.label}-{name}.json", safe)
|
|
event = {"name": name, "sha256": sha256(config_path), "returncode": result.returncode,
|
|
"expected_success": expected_success, "stdout": result.stdout[-2000:],
|
|
"stderr": result.stderr[-3000:]}
|
|
self.applies.append(event)
|
|
write_json(self.fixture.results / f"{self.label}-config-applies.json", self.applies)
|
|
if (result.returncode == 0) != expected_success:
|
|
raise RuntimeError(f"config {name}: {result.stdout} {result.stderr}")
|
|
return result
|
|
|
|
def reset(self):
|
|
return self.apply(self.base, "restored-base")
|
|
|
|
def login(self):
|
|
status, headers, body = http(self.admin_port, "/api/auth/login", "POST",
|
|
{"Origin": f"http://127.0.0.1:{self.admin_port}", "Content-Type": "application/json",
|
|
"X-RustProxy-Request": "1"},
|
|
json.dumps({"username": self.username, "password": self.password}))
|
|
if status != 200:
|
|
raise RuntimeError(f"fixture login failed: HTTP {status}: {body[:200]!r}")
|
|
self.cookie = headers["set-cookie"].split(";", 1)[0]
|
|
self.csrf = json.loads(body)["csrf_token"]
|
|
return status
|
|
|
|
def wait_quiescent(self, timeout=180, quiet_seconds=5, poll_seconds=.5):
|
|
"""Wait for the real, one-time ACME bootstrap before any timed work.
|
|
|
|
This does not disable ACME or change the process environment. The
|
|
private fixture has no issuers/jobs; a terminal unavailable state is
|
|
valid for HTTP tests, but does not establish ACME issuance support.
|
|
"""
|
|
from resources import descendants, process_stats
|
|
|
|
if timeout <= 0 or quiet_seconds <= 0 or poll_seconds <= 0 or timeout <= quiet_seconds:
|
|
raise ValueError("quiescence timeout must exceed the positive quiet period")
|
|
started = time.monotonic()
|
|
deadline = started + timeout
|
|
output = self.fixture.results / f"{self.label}-quiescence.json"
|
|
report = {"completed": False, "label": self.label, "pid": self.process.pid,
|
|
"timeout_seconds": timeout, "required_quiet_seconds": quiet_seconds,
|
|
"poll_seconds": poll_seconds, "observations": [],
|
|
"acme_issuance_tested": False}
|
|
|
|
def read_admin(path):
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
|
|
if self.process.poll() is not None:
|
|
raise RuntimeError(f"{self.label} exited during quiescence")
|
|
status, _, body = self.admin(path, timeout=min(1, remaining))
|
|
if status != 200:
|
|
raise RuntimeError(f"{self.label} quiescence {path}: HTTP {status}")
|
|
return json.loads(body)
|
|
|
|
try:
|
|
root = process_stats(self.process.pid)
|
|
if root is None:
|
|
raise RuntimeError(f"{self.label} has no live root process")
|
|
identity = (root["pid"], root["start_ticks"])
|
|
report["start_ticks"] = root["start_ticks"]
|
|
envelope = read_admin("/api/config")
|
|
if not isinstance(envelope, dict) or not isinstance(envelope.get("config"), dict):
|
|
raise RuntimeError("quiescence requires the actual configuration envelope")
|
|
certificates = envelope["config"].get("tls", {}).get("certificates", [])
|
|
if not isinstance(certificates, list) or any(not isinstance(cert, dict) for cert in certificates):
|
|
raise RuntimeError("unexpected certificate configuration during quiescence")
|
|
report["configured_issuers"] = sum(cert.get("issuer") is not None for cert in certificates)
|
|
if report["configured_issuers"]:
|
|
raise RuntimeError("performance fixture must have no configured ACME issuers")
|
|
quiet_started, previous_terminal = None, None
|
|
while True:
|
|
payload = read_admin("/api/acme")
|
|
if not isinstance(payload, dict) or not {"runtime", "job", "renewals"} <= payload.keys():
|
|
raise RuntimeError("quiescence requires explicit ACME runtime/job/renewal state")
|
|
if payload["job"] is not None or payload["renewals"] != {}:
|
|
raise RuntimeError("performance fixture has an unexpected ACME job or renewal")
|
|
runtime = payload["runtime"]
|
|
if not isinstance(runtime, dict) or runtime.get("status") not in {
|
|
"checking", "initializing", "ready", "unavailable"}:
|
|
raise RuntimeError("unexpected ACME runtime state during quiescence")
|
|
pids = descendants(self.process.pid)
|
|
root = process_stats(self.process.pid)
|
|
if root is None or (root["pid"], root["start_ticks"]) != identity:
|
|
raise RuntimeError(f"{self.label} root process identity changed during quiescence")
|
|
now = time.monotonic()
|
|
if now >= deadline:
|
|
raise TimeoutError(f"{self.label} did not become quiescent within {timeout}s")
|
|
terminal = tuple(runtime.get(key) for key in ("status", "mode", "message"))
|
|
quiet = runtime["status"] in {"ready", "unavailable"} and pids == [self.process.pid]
|
|
if not quiet:
|
|
quiet_started, previous_terminal = None, None
|
|
elif quiet_started is None or terminal != previous_terminal:
|
|
quiet_started, previous_terminal = now, terminal
|
|
elapsed_quiet = now - quiet_started if quiet_started is not None else 0
|
|
report["observations"].append({
|
|
"elapsed_seconds": now - started, "process_count": len(pids),
|
|
"acme_status": runtime["status"], "acme_mode": runtime.get("mode"),
|
|
"acme_message": runtime.get("message"), "quiet_seconds": elapsed_quiet})
|
|
if elapsed_quiet >= quiet_seconds:
|
|
report.update({"completed": True, "elapsed_seconds": now - started,
|
|
"terminal_status": runtime["status"], "process_count": len(pids)})
|
|
write_json(output, report)
|
|
return report
|
|
write_json(output, report)
|
|
time.sleep(min(poll_seconds, max(0, deadline - time.monotonic())))
|
|
except (Exception, KeyboardInterrupt) as error:
|
|
report.update({"elapsed_seconds": time.monotonic() - started,
|
|
"error_type": type(error).__name__, "error": str(error)})
|
|
write_json(output, report)
|
|
raise
|
|
|
|
def admin(self, path, method="GET", body=None, csrf=True, cookie=True, origin=None,
|
|
timeout=10):
|
|
headers = {"Origin": origin or f"http://127.0.0.1:{self.admin_port}", "X-RustProxy-Request": "1"}
|
|
if cookie:
|
|
headers["Cookie"] = self.cookie
|
|
if csrf:
|
|
headers["X-CSRF-Token"] = self.csrf
|
|
if body is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
body = json.dumps(body)
|
|
return http(self.admin_port, path, method, headers, body, timeout=timeout)
|