Repository navigation
Architecture Internals
How mitm-proxy works inside: for contributors, reviewers and AI assistants. User-facing behaviour belongs on the other pages.
One asyncio process runs two listeners: the proxy (proxy.port) and the read-only admin API (listen.port, FastAPI under uvicorn). A client connection enters through an interception source, which yields the target the client wants. One decision function says whether that tunnel is intercepted or relayed raw. Intercepted traffic is decrypted with a leaf certificate from the CA, parsed by the HTTP/1.1 (h11) or HTTP/2 (h2) layer into protocol-neutral flows, run through the middleware pipeline, and sent on through a pool of verified upstream connections.
flowchart LR
subgraph process
L[proxy listener] --> SRC[interception/explicit.py]
SRC -->|CONNECT| CL[classify.py]
SRC -->|http:// request| EX
CL -->|relay| RL[relay.py]
CL -->|intercept| TLS[tls/terminate.py + tls/ca.py]
TLS --> H[http/h1.py · http/h2.py]
H --> EX[exchange.py]
EX --> PL[pipeline.py]
EX --> POOL[upstream/pool.py]
API[api.py] -.reads.-> SRV[server.py]
end
POOL --> O[(origins)]
RL --> O
| Module | Responsibility |
|---|---|
mitm_proxy/__init__.py |
Package version |
mitm_proxy/config.py |
The settings, their checks and the rules that span settings, on interpose's engine (_FILE > env > file > default, strict types, Secret; design 0008). load_config reads each value through interpose's typed accessors (r.str, r.int, r.bool, r.list, r.secret), so a secret setting can only be read as a Secret, and one_of gives log_level its declared upper-case spelling |
mitm_proxy/cli.py |
The mitm-proxy command (Typer) |
mitm_proxy/api.py |
The HTTP API (FastAPI): health, readiness, version |
| mitm_proxy/service.py | The process: proxy listener and admin API on one loop; ordered shutdown on SIGTERM |
| mitm_proxy/server.py | ProxyServer: the configuration snapshot, the listener, tunnel handling, status |
| mitm_proxy/interception/base.py | InterceptedConnection and the InterceptionSource seam (Phase 2 adds a transparent source) |
| mitm_proxy/interception/explicit.py | The explicit-proxy source: CONNECT and absolute-form requests, proxy authentication |
| mitm_proxy/interception/reverse.py | The reverse-proxy source: a plain HTTP/1.1 listener, every request sent to one fixed upstream (design 0012) |
| mitm_proxy/classify.py | The one intercept-or-relay decision, and peeking the ClientHello |
| mitm_proxy/scope.py | The allowlist: host normalization and *.suffix matching |
| mitm_proxy/relay.py | Raw bidirectional relay with half-close and an idle timeout |
| mitm_proxy/streams.py | ByteStream: the one stream shape every layer uses |
| mitm_proxy/tls/clienthello.py | Parses SNI and ALPN from a ClientHello (pure function) |
| mitm_proxy/tls/ca.py | Root CA load/generate; leaf minting with one shared key; the LRU of server contexts |
| mitm_proxy/tls/terminate.py | TLS over memory BIOs, so peeked bytes can be replayed into the handshake |
| mitm_proxy/tls/upstream.py | The always-verifying upstream SSL contexts |
| mitm_proxy/http/flow.py | Flow, Request, Response, ClientConn, ServerConn, FlowType |
| mitm_proxy/http/headers.py | Headers: ordered, case-insensitive multimap |
| mitm_proxy/http/body.py | BodyStream, BufferedBodyStream, PassthroughBody, StreamingBody |
| mitm_proxy/http/normalize.py | Header normalization; h2 pseudo-headers ⇄ h1 request line and Host |
| mitm_proxy/http/framing.py | Content-Length vs chunked, and the HEAD/1xx/204/304 rule |
| mitm_proxy/http/messages.py | What protocol layers hand the exchange: IncomingRequest, Responder, UpstreamResponse |
| mitm_proxy/http/h1.py | HTTP/1.1 on h11: client-facing server (incl. proxy mode) and upstream client |
| mitm_proxy/http/h2.py | HTTP/2 on h2: client-facing server and upstream client, flow control |
| mitm_proxy/exchange.py | One request: flow, middleware, framing, upstream, response; WebSocket hand-off |
| mitm_proxy/pipeline.py | The middleware API (Middleware, Action), mitm-proxy's pipeline policy, and FlowPipeline: thin wrappers over interpose's pipeline core, which guards every hook (timing, timeout, failure; design 0010) |
| mitm_proxy/middleware/ | The built-in middleware, by name (BUILTIN), which load_middleware resolves when middleware.available has no entry; capture.py: the capture middleware, which selects in applies_to, builds, redacts and truncates its record in on_done, and appends it in a worker thread (asyncio.to_thread) under a per-file lock (design 0012) |
| mitm_proxy/upstream/pool.py | The connection pool and the TCP/TLS connector |
| mitm_proxy/auth.py | Basic proxy authentication |
| mitm_proxy/metrics.py | Which metrics exist, their buckets and their labels' declared values, on interpose's metric types and text format (design 0009) |
| mitm_proxy/errors.py | Exception types |
Following curl --proxy … https://example.org/ with example.org listed:
-
Accept.
service.pyaccepted the TCP connection;ProxyServerhands it toExplicitProxySource, which reads HTTP/1.1 in proxy mode and checksProxy-Authorizationifproxy.authis set (407 otherwise). -
CONNECT. The source builds an
InterceptedConnection(stream, target,accept/reject) and callsProxyServer.handle_tunnel. It reads the configuration snapshot current now and keeps it for the whole connection. -
Decide.
classify.decidechecks scope (unlisted: connect to the origin, answer 200 or 502/504, relay raw). Listed: answer 200, peek the ClientHello (proxy.peek_timeout_ms), decide again on the bytes and the SNI. -
TLS.
CertificateAuthority.server_context(host)returns a cached context, or mints a leaf (one signature: the leaf key is shared).TLSStreamreplays the peeked bytes into anSSLObjectand completes the handshake; ALPN picksH2ServerorH1Server. -
Flow. The protocol layer turns each request into an
IncomingRequest(normalized headers, a body iterator) and aResponder.exchange.handlecreates theFlow, starts the pipeline (applies_todecided once), buffers bodies only if middleware applies (up tobody.max_buffer_bytes, else passthrough) and not all of it is observe-only (then the bodies stream; below), runson_requestand the chunk hooks. -
Upstream. The pool returns a connection to
(host, port, SNI): an HTTP/2 one is shared by concurrent flows, an HTTP/1.1 one is leased to one request at a time. A reused HTTP/1.1 connection that fails before any response byte is retried once on a new connection, for idempotent requests with a replayable body. -
Back. The response head becomes
flow.response;on_responseand the response chunk hooks run;framing.planpicks the framing; the responder writes it.
The streaming path (design 0012). FlowPipeline.streams is true when every middleware that applies to the flow is observe-only (Middleware.observe_only; pipeline.Entry keeps the flag, and is_mutation_capable is false for them, so framing.plan keeps the declared length). Then exchange._exchange neither buffers the request body nor calls the chunk hooks itself: request.body becomes a StreamingBody over _observed(incoming.body, FlowPipeline.observe_request_chunk), and on_request runs on the head. _observed is an async generator: it yields each chunk to whoever writes it onward (the upstream layer's send loop), and only when that loop asks for the next chunk, so after this one was written, awaits the observers' chunk hook; when the source ends it makes one last call, (b"", True). The hooks are awaited in the consumer's own loop, never a task, so a slow hook stops the reading: TCP's window and HTTP/2's flow control (received DATA is acknowledged as it is consumed) carry the backpressure to the peer. _respond does the same for the response: on_response runs on the head (a DENY is still a 502: nothing has been sent), then the head goes out and the body is written through _observed(…, observe_response_chunk). A StreamingBody refuses read_all(). The request's trailers and the response's are put on the flow once their body has ended, for on_done. If an on_response hook replaces the body by assignment, that body is sent as on a buffered flow and the observers get it as one chunk. On a buffered (mixed) flow, FlowPipeline._chunk folds the body through the middleware that aren't observe-only, then shows the result to the observe-only ones (_observe), so they see the body as sent. An observe-only hook that returns bytes fails with _NotObserving (an InvalidResult): skipped for the rest of the flow, the chunk untouched.
Reverse listeners (reverse, design 0012) are the other arrival path. ProxyServer binds one listener per entry at startup (a bind failure is a StartupError, and /readyz waits for all of them), and hands each connection to a ReverseProxySource. The source runs an H1Server with no proxy mode (origin-form requests, no CONNECT, no proxy auth), rewrites each request to the fixed upstream — the authority becomes the upstream's, X-Forwarded-Host (if absent) and X-Forwarded-For are set — and calls the same exchange.handle with an ExchangeContext whose target is the upstream and whose direction is "inbound". So middleware, the access log, Server-Timing, metrics and upstream verification are exactly as above. A change to reverse applies on restart; a reload keeps the running listeners.
Timings: Flow.timings is stamped along the way on the time.monotonic() clock: by exchange.py at each stage (the request body read, the pool lease, the request sent upstream through a wrapper on the body iterator, the response head, the end), and by interpose's guard around every hook FlowPipeline awaits (per middleware, on time.perf_counter(); only the differences are used). That's a few clock reads per flow.
Access log: exchange.handle wraps the client's request body in a byte counter and the client's Responder in a _Recorder (status sent, body bytes, finished or not). The exchange marks the path it took (origin, local, denied, error), and the finally that stamps complete writes the line: a synchronous log call, so it's also written when the task is cancelled. An unfinished response is aborted. Relayed tunnels are logged from ProxyServer._relay, using the counts relay() returns.
End of a flow: the same finally sets flow.outcome, writes the access-log line, then awaits FlowPipeline.run_done() (on_done for each middleware that applied). It runs when the task is cancelled too: an HTTP/2 stream reset, or shutdown after the grace period. That's why H2Server awaits its cancelled stream tasks before returning, so shutdown waits for their on_done (each bounded by the hook timeout). Last, Metrics.observe_flow records the flow in the duration histograms, after on_done so the middleware histogram includes it. The text format (cumulative buckets, _sum, _count, and the counters and gauges) is interpose's Registry.render(). Every label value is declared before use: the fixed sets in metrics.py, and the middleware names from middleware.available and the capture rule names, declared by ProxyServer._build before each snapshot's pipeline exists (_build also hands each Capture the records counter). An undeclared value is a ValueError at the call, so a hostname can't become a label. With proxy.server_timing on, exchange.server_timing turns them into a Server-Timing field, added after the response hooks and the framing headers, just before responder.start (and in send_simple for denies and proxy errors).
State is in memory only: the snapshot, the CA's LRU of server contexts, the pool. The root CA is the only thing on disk (<state_dir>/ca/); the shared leaf key sits encrypted in a private temporary directory while the process runs.
Failure: middleware errors are contained per flow (the middleware is skipped, logged and counted); upstream errors become 502/504 responses; a broken client connection ends only that connection. HTTP/2 stream errors reset one stream, not the connection.
Backpressure: every write awaits drain(); HTTP/2 DATA is acknowledged only as it's consumed; middleware hooks are awaited in line, never as background tasks.
Why things are the way they are is recorded in docs/design/. Rejected ideas and the reasons are in TODO.md under "Decided against".
From 0001: Initial design, unless noted:
- Allowlist, fail closed: only listed hosts are intercepted; scope is checked before peeking, and an unlisted SNI is relayed.
-
The
InterceptionSourceseam: nothing after it knows how a connection arrived, so transparent interception is one new source, and so is the reverse-proxy source (design 0012): it reuses the whole exchange, only fixing the target and the flow direction. -
Flowper request/stream, protocol-neutral: both legs negotiate their own protocol; middleware never sees frames. - Chunk-capable hooks from the start, buffered implementation for now; observe-only middleware stream (design 0012), mutating ones still buffer.
- Framing from registration, not from what a hook did; HEAD/1xx/204/304 never framed.
-
Middleware failure fails open, with the middleware's name in the log; a middleware may opt in to failing closed (
on_error = "fail"), which ends the flow with502 middleware <name> failedin either phase (Outcome.failed; designs 0010, 0011). Hooks are checked forasync defwhen a middleware is registered (interpose'srequire_async), so a sync hook is a startup or apply error, never a per-flow failure. The hook timeout bounds each hook call and cancels it at its currentawait; a result that comes back after the limit anyway (sync code that overran, or a swallowed cancellation) is a timeout failure (interpose v0.1.4,Failure.late). - Upstream TLS is always verified; extra CAs are added, never substituted.
-
Middleware by name from a local registry, an immutable config snapshot,
/v1/statusand/metrics: from 0002: Control-plane management (Proposed; only these parts are built).
mitm-proxy | Async Python MITM proxy with a self-signed CA — HTTP/1.1 & HTTP/2, pluggable middleware for inspecting and rewriting traffic in flight.
GitHub • Releases • Issues • Changelog • Contributing
This wiki is generated from docs/wiki/ on every push to main: edits made here are overwritten. Change it with a pull request.
- 🔍 Intercept only the hosts you list; tunnel the rest untouched
- 🔀 HTTP/1.1 and HTTP/2 on both sides, bridged
- 🧩 Python middleware: inspect, rewrite, answer or deny
- 🔐 Its own root CA; origins always verified
- 📈 Prometheus metrics and a read-only status API