Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Performance

on:
workflow_dispatch:
schedule:
- cron: "10 4 * * 1"

permissions:
contents: read

jobs:
benchmark:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.lock

- name: Start local origin
run: |
python -m http.server 18080 >/tmp/origin.log 2>&1 &
echo $! > /tmp/origin.pid

- name: Start EvilWAF
run: |
python evilwaf.py -t http://127.0.0.1:18080 --no-tui --listen-host 127.0.0.1 --listen-port 8080 >/tmp/evilwaf.log 2>&1 &
echo $! > /tmp/evilwaf.pid
sleep 5

- name: Run benchmark
run: |
python benchmarks/proxy_benchmark.py \
--proxy http://127.0.0.1:8080 \
--target http://127.0.0.1:18080 \
--requests 200 \
--concurrency 20 > perf.txt
cat perf.txt

- name: Check budgets
run: |
python benchmarks/check_budgets.py perf.txt benchmarks/perf_budgets.json

- name: Upload benchmark artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: perf-results
path: |
perf.txt
/tmp/origin.log
/tmp/evilwaf.log

- name: Cleanup background services
if: always()
run: |
kill "$(cat /tmp/evilwaf.pid)" || true
kill "$(cat /tmp/origin.pid)" || true
47 changes: 47 additions & 0 deletions benchmarks/check_budgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
from __future__ import annotations

import json
import sys
from pathlib import Path


def _parse_result(path: Path) -> dict:
out = {}
for line in path.read_text().splitlines():
if "=" not in line:
continue
k, v = line.strip().split("=", 1)
try:
out[k] = float(v)
except ValueError:
pass
return out


def main() -> int:
if len(sys.argv) != 3:
print("usage: check_budgets.py <result-file> <budget-file>")
return 2
result = _parse_result(Path(sys.argv[1]))
budget = json.loads(Path(sys.argv[2]).read_text())

failures = []
if result.get("latency_p95_ms", 0.0) > budget["latency_p95_ms_max"]:
failures.append("latency_p95_ms")
if result.get("success_rate", 0.0) < budget["success_rate_min"]:
failures.append("success_rate")
if result.get("rps", 0.0) < budget["rps_min"]:
failures.append("rps")

if failures:
print("budget check failed:", ", ".join(failures))
print(result)
return 1
print("budget check passed")
print(result)
return 0


if __name__ == "__main__":
raise SystemExit(main())
5 changes: 5 additions & 0 deletions benchmarks/perf_budgets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"latency_p95_ms_max": 1200.0,
"success_rate_min": 0.9,
"rps_min": 5.0
}
108 changes: 108 additions & 0 deletions benchmarks/proxy_benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import statistics
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Dict, List, Tuple
from urllib.parse import urlparse

import requests


@dataclass
class Sample:
status_code: int
latency_ms: float
ok: bool


def _single_request(proxy_url: str, target_url: str, timeout: float) -> Sample:
start = time.perf_counter()
try:
resp = requests.get(
target_url,
timeout=timeout,
proxies={"http": proxy_url, "https": proxy_url},
verify=False,
)
latency_ms = (time.perf_counter() - start) * 1000.0
return Sample(status_code=resp.status_code, latency_ms=latency_ms, ok=True)
except Exception:
latency_ms = (time.perf_counter() - start) * 1000.0
return Sample(status_code=0, latency_ms=latency_ms, ok=False)


def percentile(values: List[float], pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
idx = int((pct / 100.0) * (len(ordered) - 1))
return ordered[idx]


def run_benchmark(
proxy_url: str,
target_url: str,
requests_count: int,
concurrency: int,
timeout: float,
) -> Dict[str, float]:
latencies: List[float] = []
success = 0
started = time.perf_counter()
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [
pool.submit(_single_request, proxy_url, target_url, timeout)
for _ in range(requests_count)
]
for f in as_completed(futures):
sample = f.result()
latencies.append(sample.latency_ms)
if sample.ok:
success += 1
elapsed = time.perf_counter() - started
rps = (requests_count / elapsed) if elapsed > 0 else 0.0
return {
"requests": float(requests_count),
"success_rate": (success / requests_count) if requests_count else 0.0,
"rps": rps,
"latency_p50_ms": percentile(latencies, 50.0),
"latency_p95_ms": percentile(latencies, 95.0),
"latency_mean_ms": statistics.mean(latencies) if latencies else 0.0,
"duration_s": elapsed,
}


def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Proxy benchmark harness")
parser.add_argument("--proxy", default="http://127.0.0.1:8080")
parser.add_argument("--target", default="https://example.com")
parser.add_argument("--requests", type=int, default=200)
parser.add_argument("--concurrency", type=int, default=20)
parser.add_argument("--timeout", type=float, default=8.0)
return parser.parse_args()


def main() -> int:
args = _args()
parsed = urlparse(args.proxy)
if not parsed.scheme or not parsed.netloc:
raise SystemExit(f"Invalid proxy URL: {args.proxy}")
result = run_benchmark(
proxy_url=args.proxy,
target_url=args.target,
requests_count=args.requests,
concurrency=args.concurrency,
timeout=args.timeout,
)
print("benchmark_result")
for k, v in result.items():
print(f"{k}={v}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading