Transparent mode¶
Proxy traffic that a firewall or gateway redirects to MicroProxy instead of sending it there: TLS
routed by its SNI, plain HTTP by its Host, and QUIC on a UDP port of its own, intercepted or
relayed. Use this page to set up a transparent proxy with iptables; the clients then need no proxy
settings, only the proxy's CA for intercepted hosts.
Transparent TLS and HTTP¶
--transparent (withTransparent(true), transparent=true or mode=transparent) is for
traffic redirected to the proxy rather than sent to it, by a firewall rule or a gateway. Besides
leaving out Via and keeping hop-by-hop headers, the listener then accepts:
- TLS without
CONNECT. A connection that starts with a TLS record is routed by its ClientHello's SNI, to that host's port 443 (--transparent-tls-port,transparent_tls_port), and treated like aCONNECTto it: filters see aCONNECTthe proxy made up (it gets no answer on the wire), and the host rules, hooks and interception apply. Without a MITM manager, such connections are tunnelled: an SNI proxy. - Plain HTTP, routed by its
Hostfield, when origin-form requests are allowed (--allow-requests-to-origin-server,allow_requests_to_origin_server=true), as before. - QUIC on a UDP port of its own, with
--transparent-quic-port <port>(withTransparentQuicPort,transparent_quic_port): routed by the SNI of the ClientHello in its first packets, then intercepted (HTTP/3, or other protocols stream by stream) or relayed by the same rules (see Transparent QUIC).
Java cannot read a redirected connection's original destination (Linux's SO_ORIGINAL_DST), so
the server is the one the SNI or the Host field names, not the address the client connected to.
The client choosing the server is by design here, as it is for any forward proxy: a transparent
proxy takes the client where it was going. Put it where only the clients it serves can reach it.
Inside an SNI-routed session, an absolute URI is a request for that session's server (as for a
proxy, In front of an HTTP or HTTPS proxy), never for the URI's authority.
A ClientHello without SNI (clients connecting to an IP address send none) cannot be routed: the
proxy answers with a TLS unrecognized_name alert, closes the connection and reports it to
ActivityTracker.connectionExceptionCaught. Such clients cannot send proxy credentials: with
proxy authentication, their connections are closed unless the authenticator accepts them without.
Redirecting a machine's outgoing web traffic to a proxy on port 8080, running as the user proxy
so that its own connections are not redirected again:
iptables -t nat -A OUTPUT -p tcp -m owner ! --uid-owner proxy --dport 80 -j REDIRECT --to-ports 8080
iptables -t nat -A OUTPUT -p tcp -m owner ! --uid-owner proxy --dport 443 -j REDIRECT --to-ports 8080
# On a gateway, for the clients behind it (eth1), use PREROUTING instead:
# iptables -t nat -A PREROUTING -i eth1 -p tcp -m multiport --dports 80,443 -j REDIRECT --to-ports 8080
sudo -u proxy java -jar microproxy.jar --server --port 8080 --transparent --mitm \
--allow-requests-to-origin-server --ignore-hosts '\.bank\.example:443$'
Clients must trust the proxy's CA (microproxy-ca.pem) for the hosts that are intercepted. QUIC
(UDP 443) is not redirected by these rules: redirect it to a transparent QUIC
port too, or block it so that browsers fall back to TCP.
Transparent QUIC¶
--transparent-quic-port <port> (withTransparentQuicPort(port), transparent_quic_port, with
--transparent) opens a UDP listener for QUIC that a firewall redirects to the proxy: the UDP
counterpart of transparent TLS. Java cannot read a redirected datagram's
original destination either, so each new client flow (a client address and the Destination
Connection ID of its first Initial packets) is routed by its SNI, to that host's
--transparent-tls-port (443).
- Reading the ClientHello. The proxy holds a new client's datagrams and decrypts its Initial
packets with the keys their Destination Connection ID gives (RFC 9001 section 5.2, QUIC v1 and
v2), without a handshake, as mitmproxy does. It puts the CRYPTO frames back together across
packets and datagrams, in any order (browsers' post-quantum ClientHellos take two packets, their
frames shuffled), and parses the same
ClientHellothat TLS over TCP gives. - Deciding, as for transparent TLS: a
CONNECT sni:443is made up for the flow (filters see it;clientToProxyRequestanswering it drops the flow;proxyToServerAllowMitm()andallow_mitmmay decline), then the ignore and allow rules, the client's ALPN (h3is served as HTTP/3; other protocols are intercepted stream by stream with aQuicInterceptoror for the--quic-hosts, and relayed otherwise),MitmManager.shouldIntercept(hello, flow), andproxyToServerAllowMitm(hello)/on_client_hello(the table below). A redirected client sends no proxy credentials: with an authenticator, it must accept clients without. - Intercepted (HTTP/3 with a MITM manager, which must be a
CertificateAuthorityMitmManagerto issue QUIC's certificates): the held datagrams go to the proxy's QUIC server, whose handshake presents a certificate the CA issues for the SNI, and the connection is served HTTP/3 like a reverse proxy's clients, with its requests going to the SNI's host: over HTTP/3 with--http3-upstream(the proxy takes the client's HTTP/3 as the origin's advertisement, and falls back to TCP if its QUIC handshake fails), else over HTTP/2 or HTTP/1.1. Filters, scripts (on_request,on_frame, ...), logging and trackers see the requests, withFlowContext.isTransparentQuic()(andctx.transparent_quic) true. - Intercepted stream by stream (another ALPN such as DNS over QUIC's
doqwith aQuicInterceptor, or a--quic-hostshost): the proxy connects to the server first, then completes the client's handshake with the server's choice of ALPN, and relays the two connections' streams and datagrams through the QUIC stream hooks. - Relayed (no MITM manager, an ignored or declined host, another ALPN without a
QuicInterceptor): the datagrams go to the server untouched from a UDP socket of the flow's own, and the server's back to the client from the listener's socket, until the flow is idle for--transparent-quic-relay-idle-timeout(60 s). The client completes its handshake with the real server. - Demultiplexing. One socket serves every client. Intercepted connections are routed by the connection IDs the proxy issued (so they survive the client moving to new ones); relayed flows by the client address and the connection IDs seen in their long headers, and otherwise to the client address's most recently active flow (servers issue more IDs in encrypted frames).
- Clients that move (QUIC connection migration, RFC 9000 section 9: a NAT that rebinds the
client to a new port, a phone that goes from Wi-Fi to cellular, a client that migrates on
purpose). An intercepted connection, HTTP/3 or stream by stream, follows
its client as any QUIC server does: its datagrams are routed by connection ID from any address,
the proxy validates the new path with PATH_CHALLENGE, sends there no more than three times what
it received until the client answers, keeps the congestion state for a new port and starts it
over for a new address, goes back to the old path if the new one does not answer, and answers
from the new path. The proxy's own connection to the server (stream by stream, and HTTP/3 to
origins) stays where it is. A relayed flow is harder: the proxy cannot read the client's packets,
nor the encrypted frames in which the server gives the client the connection IDs it moves to.
A short header packet from an address with no flow goes to the relayed flow that knows its
connection ID (from any address), else to the flow of the same client IP address whose known
server IDs share the longest prefix of at least 4 bytes with it (servers that encode routing in
their IDs), else to that IP address's most recently active relayed flow, which is what a NAT
rebinding needs. The flow then sends the server's datagrams to the new address, as a NAT sends
replies to its latest mapping, and learns the client's new ID when the server's IDs all have
one length. Limits: a relayed client that changes both IP address and connection ID is found
only by an ID prefix; with several relayed flows from one IP address the fallback may pick the
wrong one; and since the proxy can neither validate the new address nor tell a newer packet from
a replayed one, a sender that can spoof the client's IP address with one of the flow's
connection IDs can turn its replies elsewhere until the client sends again (the end-to-end
connection stays the client's: the server, whose path to the proxy does not change, validates
nothing). A server that sends
disable_active_migrationis not migrated away from by clients that honour it. - Versions. A new client offering a version the proxy cannot read gets Version Negotiation (v1, v2), since its destination is in a ClientHello the proxy cannot decrypt; on an existing relayed flow, other versions are relayed like anything else.
- Retry.
--http3-retry(Http3Options.retry) makes intercepted clients prove their address first. Relayed clients never get one: their server would refuse the proxy's token.
The decision, in order; the first row that applies decides:
| The flow | Becomes |
|---|---|
A clientToProxyRequest answers the made-up CONNECT, or the authenticator refuses a client without credentials |
dropped |
No MITM manager, or proxyToServerAllowMitm() / allow_mitm declines |
relayed |
| An ignore or allow rule leaves out the server or the SNI | relayed |
ALPN without h3, not a --quic-hosts host, and no QuicInterceptor (on_quic_*) |
relayed |
No SNI (only inside a CONNECT-UDP flow to an IP address), or no CertificateAuthorityMitmManager to issue the certificate |
relayed |
MitmManager.shouldIntercept(hello, flow) or proxyToServerAllowMitm(hello) / on_client_hello declines |
relayed |
A --quic-hosts host (whatever its ALPN, h3 included) |
intercepted stream by stream |
ALPN offering h3 |
intercepted as HTTP/3 |
Any other ALPN (doq, hq-interop, a protocol of its own), with a QuicInterceptor |
intercepted stream by stream |
ActivityTracker.transparentQuicFlowDecided reports each decision (intercepted is true for
both kinds of interception, the reason saying why for stream by stream).
- Bounds (Http3Options): new clients held at once (1024, and 32 per client IP address, each
holding 16 datagrams and 64 KiB at most, all together 8 MiB), how long one may take to send its
ClientHello and be decided on (5 s), relayed flows (4096, and 256 per client IP address).
Datagrams past a bound are dropped, as a full socket buffer would drop them.
MicroProxy.bootstrap()
.withTransparent(true)
.withTransparentQuicPort(8443) // --transparent-quic-port 8443
.withManInTheMiddle(new CertificateAuthorityMitmManager(ca))
.withHttp3Upstream(true) // HTTP/3 to origins too
.withIgnoreHosts("\\.bank\\.example:443$") // relayed untouched
.start();
Redirecting a gateway's clients' QUIC (eth1) to UDP port 8443, next to their TCP:
# REDIRECT (NAT): the proxy answers from its listener socket, and the kernel's connection
# tracking turns the replies' source back into the address the client sent to.
iptables -t nat -A PREROUTING -i eth1 -p udp --dport 443 -j REDIRECT --to-ports 8443
# On the proxy's own machine, its own QUIC (relays, HTTP/3 to origins) must not be redirected:
iptables -t nat -A OUTPUT -p udp -m owner ! --uid-owner proxy --dport 443 -j REDIRECT --to-ports 8443
# TPROXY keeps the original destination on the packet instead of rewriting it:
ip rule add fwmark 1 lookup 100
ip route add local 0.0.0.0/0 dev lo table 100
iptables -t mangle -A PREROUTING -i eth1 -p udp --dport 443 -j TPROXY --on-port 8443 --tproxy-mark 1
NAT¶
Use REDIRECT. With it, replies must leave from the socket the client's datagrams arrived
at, as the proxy's do, so that connection tracking maps them back; and the conntrack entry of a
UDP flow (nf_conntrack_udp_timeout_stream, 120 s by default) should outlive the relay's idle
timeout. TPROXY needs a listening socket with IP_TRANSPARENT, which the JDK cannot set, and
replies sent from the original destination address, which the JDK cannot do either: it does not
work with MicroProxy as it is. Relayed flows reach servers from a new UDP socket each, so a
firewall in front of the proxy sees each as a flow of its own. A client the NAT maps to a new port
is followed (see Clients that move above).