Skip to content

bench: five-proxy egress benchmark + upstream connection pooling - #7

Draft
yourbuddyconner wants to merge 21 commits into
mainfrom
bench/egress-proxy-comparison
Draft

bench: five-proxy egress benchmark + upstream connection pooling#7
yourbuddyconner wants to merge 21 commits into
mainfrom
bench/egress-proxy-comparison

Conversation

@yourbuddyconner

Copy link
Copy Markdown
Collaborator

What this is

A reproducible benchmark harness (tests/bench/) comparing hematite against four other egress proxies — iron-proxy 0.49.0, Squid 6 (ssl-bump), mitmproxy 11.1.3, and smokescreen v0.0.4 — plus the two production fixes the benchmark immediately surfaced. Results and tradeoffs are written up in tests/bench/COMPARISON.md.

Harness (one command: cd tests/bench && ./run.sh)

Four suites over a docker-compose fixture (shared CA, semantically equivalent configs printed verbatim in the report, pinned versions):

  • perf — vegeta over each proxy's CONNECT tunnel: fixed-rate latency percentiles + max throughput vs a no-proxy baseline, with docker-stats CPU/RSS sampling.
  • agent — realistic AI-agent sessions via a new standalone Rust crate (tests/bench/agentbench, deliberately outside the workspace): open-loop Poisson arrivals of 8–32KB chat POSTs (secret swap where supported) with SSE responses consumed chunk-by-chunk, 6-way parallel tool-call bursts, 10% denied-host requests; steady phase then 10× burst. Reports TTFT, worst inter-chunk stall, per-class p50/p99, phase-windowed CPU/RSS.
  • footprint — image/binary size, cold start (container start → first proxied 200), idle RSS.
  • conformance — 8 proxy-neutral security scenarios (allowlist, secret swap, log containment, IMDS/rebinding guard, header stripping, SSE, WebSocket); missing features score N/A via a capability map, FAILs are recorded as findings.

Production changes (hematite crates)

  1. Upstream connection pooling (hematite-proxy/src/pool.rs, Part 07 §4 QoI): the bench's first sustained-load run exposed ephemeral-port exhaustion (fresh TCP+TLS per request → mass 502s at ~660 rps). The pool keeps idle HTTP/1.1 senders keyed by (host, port, scheme), 8/key, 100 total, 30s idle. INV-2 stays type-enforced: pool::acquire is the only path to an upstream sender and consumes the AllowProof; the guard is re-checked against the pooled peer on every checkout; the pool dies with its Runtime on reload. After: 100% success at fixture-ceiling throughput (was ~30% success at 1/8th the rate).
  2. Stale-reuse retry (Go-equivalent semantics): a send failure on a reused connection redials once and replays, only when the body is buffered and the method is GET/HEAD/OPTIONS/TRACE. The reused sender retains the request's proof and pool::redial redeems it for exactly one fresh dial, so a fresh connection is unretryable by type.
  3. h2→h1.1 Host-header fix (http.rs): h2-negotiated requests through the CONNECT tunnel carried the host only in :authority, producing Host-less (RFC-invalid) upstream requests that nginx 400s. Found by the bench; invisible to the acceptance suite (nothing there negotiates h2).

Headline results (M-series laptop, relative numbers — see COMPARISON.md for caveats)

  • hematite is the only proxy with a clean conformance row (no FAIL, no N/A). Findings on others, reproduced across runs: Squid ssl-bump cannot proxy WebSocket upgrades (502); mitmproxy buffers SSE by default (TTFT = full stream duration).
  • Fresh-connection (tool-call) latency: hematite ≈ no-proxy floor (~44ms incl. fixture floor); smokescreen +2ms; Squid +17ms; mitmproxy +22ms; iron-proxy +45ms.
  • Uniform-GET p99: Squid 0.77ms < hematite 0.94 < iron 0.97 < mitmproxy 2.0 (baseline 0.51); hematite/Squid/smokescreen all saturate the fixture (~5.2k rps); iron caps at 2.6k, mitmproxy 1.7k.
  • Memory under agent load: hematite 11 MiB; smokescreen 32; mitmproxy 98; iron 110; Squid 243.

Tests

  • 2 new e2e suites for pooling (e2e_pool.rs): sequential requests reuse one upstream connection; guard-denied peers unreachable through the pool; stale-connection retry survives an upstream that kills each kept-alive connection ~5ms after responding.
  • Full workspace: fmt clean, clippy -D warnings clean, all tests green. agentbench has its own unit tests.
  • QUICK=1 ./run.sh (~10 min) is the harness's self-test; the full run (~35 min) produced the committed COMPARISON.md numbers.

Known follow-ups (not in this PR)

  • hematite's worst-case SSE inter-chunk stall (~41ms p99) is a few ms above iron-proxy's — write/flush path investigation.
  • MITM leaf advertises h2 ALPN unconditionally (tls.rs) — root cause the Host fix works around; consider h1-only or config.
  • Commits on this branch are unsigned (hardware-token unavailable in the authoring environment); commit.gpgsign needs re-enabling locally.

When the tunnel listener MITMs a TLS connection and the client negotiates
HTTP/2 (hematite generated cert advertises h2 via ALPN), req.headers() does
not include a Host header entry (carried only in the :authority pseudo-header).
The upstream send path uses hyper http1::Builder which requires a Host header
in the HeaderMap (the path-form URI has no authority).  Without Host, nginx
rejects the request with 400 Bad Request.

Inject a Host header from summary.host when one is absent, so h2 to h1.1
translation produces a well-formed HTTP/1.1 request.
Adds the final three files for the bench-vs-iron-proxy harness:
- run.sh: orchestrator (all|perf|footprint|conformance; QUICK=1;
  runsuite writes suite-status.txt inline to avoid bash subshell issue;
  exit non-zero if any suite ERRORED)
- render-report.sh: renders results/*.json into results/REPORT.md;
  null-safe fmt_num guards printf against missing data when a suite
  errored; all four sections (env, perf, footprint, conformance) with
  verbatim configs and relative-comparison caveat
- README.md: usage, knobs, layout, interpretation notes

Hardware signing token unavailable; committed with -c commit.gpgsign=false.
Fixes the sustained-load failure found by tests/bench: dialing a fresh
TCP+TLS upstream connection per request exhausts ephemeral ports
(EADDRNOTAVAIL -> mass 502s) at a few hundred rps. A new pool module
keeps idle HTTP/1.1 senders keyed by (host, port, scheme), capped at
8/key and 100 total with a 30s idle timeout (below common upstream
keep-alive timeouts to shrink the stale-reuse window; the spec's Part
07 §4 quality-of-implementation allowance).

Invariants preserved: pool::acquire is the only path to an upstream
sender and consumes the pipeline AllowProof (INV-2); the guard is
re-checked against the pooled connection's peer address on every
checkout, and the pool dies with its Runtime on config reload, so a
new deny CIDR can never be bypassed by a pooled connection. Upgraded
(101) connections and non-forwarded responses are never checked in.
No post-send retry in v1: a stale reuse surfaces as a rare 502,
bounded by the checkout ready/closed gate and the 30s idle cap.

New e2e tests: three sequential proxied requests reach the upstream
on one connection; a guard-denied peer is unreachable through the
pool. dial::connect_upstream now returns the dialed peer address, and
dials report a 'reused' metric label.
Matches Go http.Transport semantics: when a send on a REUSED pooled
connection fails, redial and replay the identical request exactly once —
but only when the replay is provably safe: the body is fully buffered
(under max_request_body_bytes) and the method is GET/HEAD/OPTIONS/TRACE.
A failed POST or streamed body is never replayed; the upstream may have
executed or consumed it.

INV-2 stays type-enforced: a reused PooledSender retains the request's
AllowProof (it was not spent on a dial), and pool::redial redeems it for
exactly one fresh connection; a fresh sender carries no proof, so a
fresh dial can never be retried. Guard denial on the redial audits as a
guard rejection, same as the first dial.

New e2e test: an upstream that kills each kept-alive connection ~5ms
after responding cannot surface a 502 to idempotent clients.
New fourth suite modeling what these proxies actually front, instead of
uniform 1KB GETs. tests/bench/agentbench (standalone Rust crate, outside
the hematite workspace) is one binary with two modes: 'serve' is a mock
LLM API on llm.test (SSE chat streaming at a fixed token cadence + tool
endpoint), 'attack' replays agent sessions with open-loop Poisson
arrivals — an 8-32KB chat POST carrying the proxy token (secret swap on
every call) whose SSE response is consumed chunk by chunk, a burst of 6
parallel tool GETs, and a 10% chance of a denied-host request — through
baseline/hematite/iron, steady phase then 10x burst.

Reported per phase and class: p50/p99, TTFT, worst inter-chunk stall,
denied-request refusal latency, error counts, and phase-windowed proxy
CPU/RSS (docker stats samples now timestamped). run.sh gains the 'agent'
suite; both proxies' allowlists and secret rules gain llm.test; the
shared leaf gains the llm.test SAN (gen-certs regenerates when missing).
End-of-stream is detected by chunk count, not EOF, since proxies may
keep the client leg alive after a chunked response.
De-singles iron-proxy: the harness now compares five egress proxies.
Targets and capability flags live in targets.sh; all four suites loop
over the list. New services: squid 6 (ubuntu 24.04 squid-openssl,
ssl-bump with the shared CA, allowlist ACL), mitmproxy 11.1.3 (regular
mode + published 12-line allowlist addon), smokescreen v0.0.4 (built
from source; non-MITM CONNECT class with its own private-range guard —
fixture subnet explicitly allowed, link-local stays denied).

Conformance scenarios a proxy has no feature for score N/A via the
capability map (secret swap / containment: hematite+iron only; header
strip: hematite; resolved-IP guard: hematite+iron+smokescreen — an
in-fixture IMDS result for squid/mitmproxy would be a network artifact,
not policy). Scenario FAILs are now recorded as scorecard findings
instead of failing the harness run: the first shakedown immediately
caught two real ones (squid ssl-bump cannot proxy a bumped WebSocket
upgrade — 502 ERR_INVALID_RESP; mitmproxy buffers SSE by default —
TTFT equals the whole stream duration).

agentbench: denied-class probe now recognizes bump-style refusal
(CONNECT 200 to peek SNI, then inner-request 403) as a refusal; only
an inner 2xx counts as reachable. Report renderer generalized to
dynamic target columns; footprint handles binaryless targets
(mitmproxy).
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedrustls-pemfile@​2.2.010010093100100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant