QUIC stream hooks¶
Intercept QUIC that does not carry HTTP/3 (DNS over QUIC, hq-interop, protocols of your own) and
see, rewrite, drop and add its stream bytes, whole framed messages and datagrams. Use this page for
non-HTTP QUIC arriving through transparent QUIC or
CONNECT-UDP flows; scripts use the on_quic_*
hooks.
Intercepting QUIC stream by stream¶
QUIC that does not carry HTTP/3 can be intercepted too, stream by stream:
withQuicInterceptor(QuicInterceptor) (or a script's on_quic_* hooks) makes the
proxy intercept the transparent QUIC flows, and the QUIC inside
CONNECT-UDP flows with --masque-intercept-quic, whose ALPN
offers no h3 (doq, hq-interop, a protocol of its own), once the other rules allow it, as the
TCP hooks do for TLS that carries no HTTP. withQuicHosts(...) (--quic-hosts,
quic_hosts) names hosts, by regular expressions searched for in the server's host name and the
SNI, whose QUIC is relayed this way whatever its ALPN: their HTTP/3 too, which filters and frame
hooks then do not see, and their other QUIC even without an interceptor.
MicroProxy.bootstrap()
.withTransparent(true)
.withTransparentQuicPort(8443)
.withManInTheMiddle(new CertificateAuthorityMitmManager(ca))
.withQuicHosts("^quic\\.example\\.com$") // --quic-hosts, quic_hosts
.withQuicInterceptor(new QuicInterceptor() {
@Override
public void quicStart(QuicContext ctx) {
log("QUIC " + ctx.alpn() + " to " + ctx.host() + ":" + ctx.port() + " (" + ctx.sni() + ")");
}
@Override
public byte[] streamData(QuicStreamMessage msg, QuicStreamContext stream) {
return stream.clientInitiated() && !msg.fromClient()
? msg.text().replace("secret", "******").getBytes(UTF_8) : msg.data();
}
@Override
public byte[] datagram(QuicDatagram datagram, QuicContext ctx) {
return datagram.length() > 1000 ? null : datagram.data(); // drop large ones
}
})
.start();
- Pairing the connections. The proxy first connects to the server, from a UDP socket of the flow's own, with the client's SNI and ALPN protocols, validating the server as it validates HTTP/3 servers (the MITM manager's server trust). Then it completes the client's handshake with a certificate its CA issues for the SNI, choosing the protocol the server chose, and taking DATAGRAM frames only when the server does. A server that cannot be reached drops the flow, as a relay that cannot be made does.
- Streams. Each stream one side opens, bidirectional or unidirectional, is opened on the
other connection when the peers' stream limits allow it, in order, so the stream IDs on both
connections are usually the same (
QuicStreamContext.streamId()is the client's,serverStreamId()the server's). The hooks:quicStart;streamOpened;streamDatafor the bytes of each read, which returns the bytes to pass on (msg.data()as it is or changed in place, others, ornullor an empty array to drop them);streamFinishedbefore a FIN is passed on;streamReset(RESET_STREAM, passed on with its code) andstreamStopped(STOP_SENDING: the other side is asked the same, and the stream reset towards the side that asked);datagramfor each datagram, which returns the payload ornull; thenquicEnd, orquicErrorwhen a connection timed out, was reset or failed with a transport error, never both. When one side closes its connection, the other is closed with the same application error code and reason. - Adding data.
QuicStreamContext.send(fromClient, bytes)writes bytes to a stream as if that side had sent them, after the message being handled when called from astreamDataof the same direction;QuicContext.sendDatagram(fromClient, bytes)sends a datagram. Neither passes through the hooks. - Messages. A stream is a byte stream, as TCP is: a
QuicStreamMessageis whatever one read returned (at most 16 KiB), so a peer's write may show up in pieces, or joined with the next one. Hooks that look for a protocol's messages keep the start of one until its end arrives, or let the proxy cut the stream into messages forstreamMessage(QUIC message framing). - Flow control. End to end: each direction of each stream is read only as fast as the other
side takes the bytes, so QUIC's flow control holds a fast sender back while a slow receiver
reads, and the proxy keeps at most one read per direction. The proxy's receive windows are
HTTP/3's (
Http3Options.streamReceiveWindow,connectionReceiveWindow). - Threads. Each direction of each stream, and of datagrams, has a virtual thread of its own:
the interceptor must be thread-safe; within one direction of one stream, calls come in order.
The context names the client connection (
connectionId,clientAddress,flowContext), the server (host,port,serverAddress),sni,alpnandmasque().
QUIC message framing¶
A stream's bytes come to streamData in reads, without the protocol's message boundaries. Many
protocols over QUIC frame their messages simply, and the proxy can cut their streams into whole
messages for a hook: QuicInterceptor.streamMessage(QuicMessage msg, QuicStreamContext stream)
(or a script's on_quic_message) sees each message's payload, without its length
prefix or delimiter, and returns the messages to send in its place. What it returns is framed
again, so an edited payload gets a length prefix of its new length.
--quic-framing (quic_framing, withQuicFraming(QuicFraming)) chooses a framer by ALPN or by
host: comma-separated key=framer rules, where a key is an ALPN protocol (doq), a host name
matched against the server's host and the SNI (dns.example.com, or *.example.com for it and
the names below it), or * for any connection. A host rule wins over an ALPN rule, which wins
over *. DNS over QUIC (doq) is framed with u16 by default; doq=none turns that off.
| Framer | Messages |
|---|---|
u16 |
a two-byte big-endian length, then the payload: DNS over QUIC (RFC 9250), DNS over TCP |
u32 |
a four-byte big-endian length, then the payload |
varint |
a QUIC variable-length integer (RFC 9000 section 16) as the length; written in its shortest form |
lf |
a line ending with a line feed, which the payload leaves out (a carriage return before it stays) |
crlf |
a line ending with a carriage return and a line feed |
none |
not framed: streamData sees the reads |
script |
a script's quic_message_length(data) (see QUIC) |
a MessageFramer |
from Java: framing.withRule("myproto", framer) or withFramer(name, framer) |
java -jar microproxy-starlark/target/microproxy-starlark-${MICROPROXY_VERSION}-all.jar --port 8080 \
--transparent --transparent-quic-port 8443 --mitm --script doq.star \
--quic-framing doq=u16,myproto=varint,*=none --quic-max-message-size 65537
- Which streams. Every stream of a connection whose framing rule names a framer, when the
interceptor overrides
streamMessage(a script, when it defineson_quic_message). Framing replaces the chunk hook there:streamData(on_quic_stream_data) is not called on those streams, and is called as before on the streams of other connections. An interceptor that does not overridestreamMessageis never framed, whatever the rules say: without framing, or without a message hook, nothing is kept, and each read goes on as it came. - What a hook returns.
List.of(msg)passes the message on (changed in place or not); other messages replace it, in order;nullor an empty list drops it. A message whosefromClient()is the other direction's (msg.withFromClient(false)) goes to the side that sentmsg, on the same stream, as if the other side had answered.QuicStreamContext.sendstill writes bytes as they are, not framed. A hook that throws, or returns a payload its framer cannot frame (more than 65535 bytes foru16), has the message passed on unchanged. - Limits. A message larger than
--quic-max-message-size(quic_max_message_size,QuicFraming.withMaxMessageSize; 1 MiB, the prefix or delimiter included), known from a length prefix as soon as it arrives or from a delimiter that does not come in time, and bytes a framer cannot read (a custom framer that throws), reset the stream in both directions on both connections, and ask both sides to stop sending, with--quic-message-error-code(quic_message_error_code,withErrorCode; 2, DNS over QUIC'sDOQ_PROTOCOL_ERROR), as a WebSocket message too big closes its connection with 1009. The connection and its other streams go on. Bytes left over when a side finishes its stream, not a whole message, are passed on as they are, before the FIN. - Flow control stays end to end: a direction keeps at most one message's bytes and a read.
- Datagrams are whole messages already:
datagram(on_quic_datagram) sees them as before. - Decoding.
QuicMessagehasdata(),text(),fromClient(),streamId(),alpn(),timestamp(), the copieswithData,withText,withFromClientandwithDns, and decoders:dns()reads the payload as a DNS message (org.microproxy.dns.DnsMessage: the header, the questions, and records with typed data for A, AAAA, NS, CNAME, PTR, DNAME, MX, SRV, TXT and SOA; it encodes back, names compressed);protobuf()andprotobuf(schema, type)decode protobuf as the content views do;render()renders the payload with the best content view, asdigshows it for DNS over QUIC (thednsview), for logging.
DNS over QUIC, logging each query and answering a blocked name with 0.0.0.0 by rewriting the
server's answer:
Set<String> blocked = Set.of("ads.example", "tracker.example");
bootstrap.withQuicInterceptor(new QuicInterceptor() {
@Override
public byte[] streamData(QuicStreamMessage msg, QuicStreamContext stream) {
return msg.data(); // streams that are not framed
}
@Override
public List<QuicMessage> streamMessage(QuicMessage msg, QuicStreamContext stream) {
DnsMessage dns = msg.dns();
DnsMessage.Question q = dns.questions().getFirst();
if (msg.fromClient()) {
log(stream.clientAddress() + " asks for " + q.name() + " " + q.typeName());
return List.of(msg);
}
if (!blocked.contains(q.name().toLowerCase(Locale.ROOT))) return List.of(msg);
List<DnsMessage.ResourceRecord> answers = dns.answers().stream()
.map(r -> r.type() == DnsMessage.A ? r.withData(DnsMessage.Address.of("0.0.0.0")) : r)
.toList();
return List.of(msg.withDns(dns.withAnswers(answers))); // framed again, its length recomputed
}
});