Chained proxies¶
How MicroProxy forwards traffic through other proxies: upstream HTTP, HTTPS and SOCKS proxies
chosen from URLs, NO_PROXY rules or the environment, failover and backoff between them, and what
happens when a client's own proxy sits behind an intercepting MicroProxy. Use this page to put
MicroProxy in front of (or behind) a corporate proxy.
Chained proxies are HTTP (with Basic credentials, optionally over TLS, spoken to in HTTP/1.1 or
HTTP/2), SOCKS4a and SOCKS5 (with username/password). A ChainedProxyManager chooses them per request, as a list of candidates: the
proxy falls back to the next proxy or a direct connection when one fails, optionally with
exponential backoff between attempts. UpstreamProxyManager configures them from proxy URLs,
NO_PROXY rules or the environment; a Starlark script's upstream hook
can choose them too.
Upstream proxies and NO_PROXY¶
UpstreamProxyManager is a ChainedProxyManager configured the way command-line clients are:
- Upstreams: one proxy URL for plain HTTP and optionally another for HTTPS/CONNECT:
http://user:pw@proxy:3128,https://...(TLS to the proxy),h2://...andh2c://...(HTTP/2 to the proxy),socks4://,socks5://. - Bypass:
NoProxyRulestakes curl'sNO_PROXYsyntax:*, domain suffixes (example.com,.example.com,*.example.com), IP literals, CIDR ranges and:portqualifiers. Addresses are only compared when the request names an IP literal, so no DNS lookups happen. - Environment:
UpstreamProxyManager.fromEnvironment(System.getenv()),--env-proxyoruse_env_proxy=truereadhttp_proxy,https_proxy,all_proxyandno_proxylike curl does. Upper-caseHTTP_PROXYis ignored, as curl ignores it, because CGI servers set it from a request header.
java -jar microproxy.jar --upstream-proxy http://proxy.corp:3128 --no-proxy "localhost,.corp,10.0.0.0/8"
For servers or proxies signed by a private CA, SslContexts.systemDefaultPlus(caCert) trusts the
JDK's roots plus extra anchors and keeps host-name checks. Use it as the MITM manager's upstream
context, or as a chained proxy's.
Retries and backoff¶
When a connection through one chained proxy fails (refused, timed out, its name does not resolve,
its TLS handshake or CONNECT fails, or, for an intercepted CONNECT, the TLS handshake with the
server through it fails, before or after the CONNECT is
answered), the proxy tries the next candidate the
ChainedProxyManager offered, which may be a direct connection. By default it moves on at once,
as LittleProxy does. withChainedProxyRetryBackoff(RetryBackoff) waits in between, so a flapping
upstream is not hammered. RetryBackoff has these policies:
| Policy | Waits before attempts 2, 3, 4, ... |
|---|---|
RetryBackoff.none() |
nothing (the default) |
RetryBackoff.exponentialWithJitter(initial, max) |
a uniformly random time between zero and initial, 2 × initial, 4 × initial, ... capped at max ("full jitter", so clients that failed together do not retry together) |
RetryBackoff.exponential(initial, max) |
exactly initial, 2 × initial, 4 × initial, ... capped at max, without jitter: deterministic doubling, as LittleProxy forks that added a backoff used, for tests that depend on exact timings |
RetryBackoff.fixed(delay) |
exactly delay every time |
MicroProxy.bootstrap()
.withChainProxyManager(upstreams)
.withChainedProxyRetryBackoff(RetryBackoff.exponentialWithJitter(Duration.ofMillis(100), Duration.ofSeconds(2)))
.start();
withChainedProxyRetryBackoff(initial, max) is short for the jittered policy. In a properties file,
chained_proxy_backoff_initial_ms and chained_proxy_backoff_max_ms choose it, and
chained_proxy_backoff_jitter=false the deterministic one; on the command line,
--chained-proxy-backoff <initial-ms>[:<max-ms>] and --chained-proxy-backoff-deterministic
<initial-ms>[:<max-ms>]:
java -jar microproxy.jar --upstream-proxy http://proxy.corp:3128 --chained-proxy-backoff 100:2000
java -jar microproxy.jar --upstream-proxy http://proxy.corp:3128 --chained-proxy-backoff-deterministic 100:800
java -jar microproxy.jar --config proxy.properties --chained-proxy-backoff 0 # turn off the file's backoff
Without a maximum, the properties and the flags cap the wait at 8 × the initial one; 0 on the
command line turns waiting off, overriding a properties file. A request's filters can choose
another policy for it, including none(), with HttpFilters.chainedProxyRetryBackoff() (see
Filters).
- It never waits before the first attempt or after the last one.
- It stops waiting, and gives up on the request, if the client disconnects meanwhile. Once the client has sent more bytes (a request body, a pipelined request) a disconnect can no longer be seen, and the rest of the wait is a plain sleep.
- By default the waits of one request add up to at most 30 seconds or the connect timeout,
whichever is less, so a long candidate list cannot hold a request for minutes. Once that budget
is spent, the remaining candidates are tried without waiting (the wait that would overrun it is
shortened to what is left). The budget is part of the policy:
withBudget(Duration)sets another, andunbudgeted()lifts it, so that every wait the policy names is waited, as LittleProxy forks that added a backoff did. In a properties file,chained_proxy_backoff_budget_mstakes a number of milliseconds orunlimited:
// 100, 200, 400, 800, 1600 ms between six candidates, whatever the connect timeout
RetryBackoff.exponential(Duration.ofMillis(100), Duration.ofSeconds(5)).unbudgeted()
FlowContext.timings(); it fires no callbacks,
only a DEBUG log line.
HTTP/2 to chained proxies¶
A chained HTTP proxy is spoken to in HTTP/1.1 by default: a connection of its own for each
tunnel, and plain-HTTP requests on (pooled) connections one at a time. A chained proxy that speaks
HTTP/2 can carry all of them as streams of a few shared connections instead (RFC 9113 section
8.5), as forward proxies such as MicroProxy itself (on its TLS listener, where HTTP/2 is on by default,
or with --http2-cleartext) do. It is chosen per proxy: the h2:// and h2c:// schemes in a proxy URL,
or ChainedProxy.http2(), which is false by default.
| Proxy URL | Means | Bootstrap |
|---|---|---|
h2://[user:pw@]host[:port] |
TLS to the proxy (port 443 by default), offering h2 and http/1.1 with ALPN |
http2() and requiresEncryption() return true |
h2c://[user:pw@]host[:port] |
HTTP/2 with prior knowledge over plain TCP (port 80 by default) | http2() returns true |
Proxy URLs are taken by --upstream-proxy and --upstream-https-proxy, the upstream_proxy and
upstream_https_proxy properties, http_proxy, https_proxy and all_proxy (--env-proxy),
UpstreamProxyManager.proxy(url) and a script's upstream().
java -jar microproxy.jar --upstream-proxy h2://alice:s3cret@proxy.corp:443 --mitm
java -jar microproxy.jar --upstream-proxy h2c://127.0.0.1:3128
- What takes a stream. Every
CONNECTthe proxy sends the chained proxy is a stream: a client'sCONNECTit relays, the tunnel of an intercepted one (its TLS with the server, and HTTP/2 with the server if both choose it, then run inside the stream), the tunnel it opens to originate TLS for anhttps://request, and the tunnels to a client's own proxy reached through it. A plain-HTTP request is a stream too, as a proxy request::scheme http,:authoritythe server's, and:path. A WebSocket upgrade over plain HTTP still goes over HTTP/1.1. - Shared connections. A connection to the chained proxy is keyed by the proxy alone (its
address, TLS,
SSLContextinstance, credentials and local address), never by the server, and carries the streams of every client connection; it is never coalesced. The proxy reads the chained proxy's SETTINGS first and opens no more streams than itsSETTINGS_MAX_CONCURRENT_STREAMS; when every connection is full it opens another. A burst of exchanges waits for the connection one of them is making. Connections close after the idle timeout without streams. - Negotiation. Over TLS,
h2andhttp/1.1are offered with ALPN (before the proxy'sconfigure, which may change that); a chained proxy that chooseshttp/1.1, or no protocol, is spoken to in HTTP/1.1, as without HTTP/2. Anh2c://proxy must speak HTTP/2 with prior knowledge: one that does not fails as an unreachable proxy does. - Credentials. The proxy's credentials go on every stream, as
proxy-authorization(Basic). As over HTTP/1.1, a407to a plain-HTTP request is relayed to the client (without its challenge, which is not the client's to answer), and aCONNECTthe chained proxy refuses is a502. - Tunnels. A tunnel's bytes are DATA frames, flow controlled both ways: a client that stops
reading stops the chained proxy's stream, never the connection. One side's end is END_STREAM
(and the chained proxy's END_STREAM ends the client's side), and a reset of the stream fails
the tunnel as a reset socket does. The PROXY protocol header (
--send-proxy-protocol) goes into the tunnel, as through an HTTP/1.1 chained proxy. - Failover. Failing to reach the chained proxy (refused, timed out, TLS failed, its preface
not HTTP/2) or a refused
CONNECTmoves on to the route's next proxy (or a direct connection), with the backoff and thefailoverandbackoff_*stats, as over HTTP/1.1. A GOAWAY or a connection failure affects only the streams of that connection; others go on. A stream the chained proxy never processed (above GOAWAY's last stream id, or answeredREFUSED_STREAM) is sent again on another connection, and after two such streams an exchange moves on to the next proxy. A stream that started (a tunnel that opened, a request whose response was under way) is not sent again: it fails as with any server connection, with the response flags that say so (UCwhen the connection went away). - Stats and logs. The chained proxy's cluster (
cluster.<host_port>.*) counts its HTTP/2 connections inupstream_cx_totalandupstream_cx_http2_total(not each tunnel), the requests on them inupstream_rq_*, and their bytes, frames included, inupstream_cx_rx_bytes_totaland_tx_bytes_total;http2.upstream.*counts their streams with those to servers. In the access log,%UPSTREAM_CLUSTER%names the chained proxy and%MP_UPSTREAM_PROTOCOL%saysHTTP/2(FlowRecord.upstreamProtocol(), andmicroproxy.upstream_protocolin OpenTelemetry). - Needs the
http2-codecjar (bundled in the-alljar): anh2://orh2c://URL without it fails at startup; aChainedProxywhosehttp2()saystrueis spoken to in HTTP/1.1.
In front of an HTTP or HTTPS proxy¶
A client may be set up to use an HTTPS proxy, such as a corporate egress proxy corp.example:443
that speaks TLS (and often HTTP/2) on its listener, with MicroProxy in front of it intercepting
the client's CONNECT corp.example:443. Inside that session the client asks what it takes for its
proxy for a tunnel per site: a CONNECT stream over HTTP/2 (:authority github.com:443, no
:protocol), or CONNECT github.com:443 HTTP/1.1 over HTTP/1.1, and plain-HTTP URIs as proxy
requests: GET http://example.com/ HTTP/1.1 (absolute-form), or a stream with :scheme http and
:authority example.com.
A client may use a plain-HTTP forward proxy the same way, such as a Squid at corp.example:3128:
it sends CONNECT corp.example:3128, then speaks plain HTTP in the tunnel, which MicroProxy (with
a MITM manager) serves as a plain session (Deciding what to
intercept). Inside it the client sends what it would
send that proxy: absolute-form requests, and CONNECT github.com:443 HTTP/1.1.
In both cases MicroProxy forwards these proxy requests to the session's server, as the client
intended, rather than to github.com or example.com itself, which would bypass the proxy the
client chose. (Without a MITM manager, the CONNECT to the proxy is a tunnel, and everything in it
reaches the proxy untouched.)
MicroProxy.bootstrap()
.withManInTheMiddle(new CertificateAuthorityMitmManager(ca))
// HTTP/2 to servers (on by default) puts CONNECT streams on the HTTP/2 connection to the proxy
.withFiltersSource((request, ctx) -> new HttpFiltersAdapter(request, ctx) {
@Override
public boolean proxyToServerAllowMitm() {
// Intercept the session with the proxy, but not the tunnels through it.
return ctx.forwardedVia().isEmpty();
}
})
.start();
- Upstream. The session's server is a chained proxy for the tunnel. When MicroProxy's
connection to an HTTPS proxy negotiated HTTP/2 (
withHttp2Upstream, on by default), theCONNECTis a stream on that connection, multiplexed with the session's requests. Otherwise it goes on an HTTP/1.1 connection to it, over TLS to an HTTPS proxy and plain TCP to an HTTP one: an idle one of the session's, or one made as the session's are (the same routes, the MITM manager's trust andconfigureServerSocket, the same isolation; through an HTTP chained proxy, a tunnel to it). Connections that carry a client's credentials for the proxy are never pooled or shared with other clients. - Proxy requests. An absolute-form request goes to the proxy as it was sent: absolute-form,
with
Hostfrom its URI; over HTTP/2 to an HTTPS proxy, as:scheme httpand the target's:authority. It goes on the session's own connections to the proxy, never pooled ones (nor ones that hand an HTTP chained proxy its requests), by the routes to the proxy. - Fields, both ways. The client's fields go with the
CONNECTor the proxy request,Proxy-Authorizationincluded (those credentials are for the proxy; MicroProxy's own authentication covered the outerCONNECT), without hop-by-hop fields and withVia. The proxy's answer reaches the client as it was sent: a407with itsProxy-Authenticatechallenge, a403, the response it fetched, or the2xxthat opens the tunnel. An HTTP/1.1 client can retry on the same session after a refusal. - Filters. A proxy request is a request of the session:
clientToProxyRequest,proxyToServerRequest(which may rewrite aCONNECT's target), thenserverToProxyResponseandproxyToClientResponsefor the proxy's answer; a short-circuit response (a403, say) rejects it.FlowContext.forwardedVia()names the proxy (host:port) for it and for the requests inside the tunnel aCONNECTopens; scripts seectx.forwarded_via. - The client's URIs, and the filters'. Only an absolute URI the client sent itself makes a
proxy request. A filter or script that gives a request an absolute URI of its own (
MapRemote,req.uri = "https://...") sends it straight to that URI's server, as in any intercepted session, andforwardedVia()is then empty; one that leaves the client's URI as it was keeps it a proxy request. A client's absolute-form request to a server that is not a proxy is legal, if rare, and goes there as sent too: the client chose that server. - The tunnel. Once the proxy has opened it, the tunnel goes on as a
CONNECTto MicroProxy would (Deciding what to intercept), with the proxy's tunnel as the way to the target. Without interception for the target (proxyToServerAllowMitm,allow_mitmin scripts, an ignore rule for the target, or a MITM manager that declines), its bytes are relayed as they are. Otherwise the client's first bytes decide: a ClientHello goes through the host rules (with its SNI),shouldInterceptandproxyToServerAllowMitm(clientHello), and when intercepted, MicroProxy's own TLS to the target runs inside the tunnel, offering the client's ALPN protocols, and the client gets the target's choice; plain HTTP is served as HTTP, its requests going through the filters; anything else, a declined ClientHello, a TCP host or a server that speaks first (noticed through the tunnel) is relayed as it is, through the TCP hooks, as is intercepted TLS that does not carry HTTP. Relayed bytes go under flow control both ways; END_STREAM and half-closes go through (over an HTTP/1.1 TLS connection to the proxy, where TLS 1.3 allows one). Every other connection to the target, for the requests of an intercepted or plain session in the tunnel, is a tunnel through the same proxy, opened with the sameCONNECTfields, never pooled;FullFlowContext.getChainedProxy()names the proxy. A target that does not speak TLS to a client that does gets a plain tunnel, a new one. - Resets. A client's reset of the stream resets the stream to the proxy (or closes the connection to it), and once the tunnel is open it is not a rapid reset. A reset from the proxy, or its connection closing, resets the client's stream.
Over HTTP/2, :scheme https requests for another authority still get 421 (connection
coalescing), and so does a WebSocket's extended CONNECT, which is not forwarded.