Interface QuicInterceptor

Functional Interface:
This is a functional interface and can therefore be used as the assignment target for a lambda expression or method reference.

@FunctionalInterface public interface QuicInterceptor
Sees, rewrites, drops and adds the stream data and datagrams of QUIC connections the proxy terminates without speaking their protocol: the QUIC counterpart of TcpInterceptor. Configure one per proxy with HttpProxyServerBootstrap.withQuicInterceptor(QuicInterceptor).

 bootstrap.withQuicInterceptor((message, stream) -> message.fromClient()
         ? message.data()                                              // pass on
         : message.text().replace("secret", "******").getBytes(UTF_8));
 

Which connections

QUIC whose ClientHello offers no HTTP/3 (doq, hq-interop, a protocol of its own) that the proxy decides to intercept: redirected to the transparent QUIC listener, or inside a CONNECT-UDP flow with Http3Options.Builder.connectUdpInterceptQuic. With an interceptor, such clients are intercepted (the host rules, shouldIntercept and the filters still decide first); without one they are relayed untouched. Hosts matched by HttpProxyServerBootstrap.withQuicHosts(java.lang.String...) come here even when they offer HTTP/3, with or without an interceptor.

The proxy finishes a handshake with the server first, offering the client's ALPN protocols and server name, then one with the client, presenting a certificate its CA issues for the server name and choosing the protocol the server chose. Each stream one side opens is opened on the other connection, of the same kind (bidirectional or unidirectional), and its bytes, its end (FIN), a reset (RESET_STREAM) and a request to stop sending (STOP_SENDING) are passed on, with their error codes; datagrams (RFC 9221) go across one for one, when both sides take them. When one connection closes, the other is closed with the same application error code and reason.

Stream data

A stream is a byte stream, as TCP is: a QuicStreamMessage is whatever one read returned (at most 16 KiB), so a peer's write may be seen in pieces or joined with the next one, differently from one run to the next. A hook that looks for a message of the protocol keeps the start of it until its end arrives.

Flow control is end to end: each direction of each stream is read only as fast as the other side takes its data, so a slow reader slows its writer down through QUIC's own flow control, and the proxy holds at most one read per direction (with framing, a message's bytes and one read). Stream limits are the peers' too: a stream one side opens waits until the other side's limit allows it.

Messages

An interceptor that overrides streamMessage(org.microproxy.quic.QuicMessage, org.microproxy.quic.QuicStreamContext) sees whole messages instead, on the connections that QuicFraming (configured with HttpProxyServerBootstrap.withQuicFraming(org.microproxy.quic.QuicFraming)) gives a MessageFramer: by default DNS over QUIC (doq), whose messages have a two-byte length prefix. The proxy keeps each direction's bytes until a message is whole, shows its payload to streamMessage, and frames what that returns again, computing a length prefix afresh for an edited payload. On such a stream streamData(org.microproxy.quic.QuicStreamMessage, org.microproxy.quic.QuicStreamContext) is not called: framing replaces it. A message larger than QuicFraming.maxMessageSize(), or bytes the framer cannot read, reset the stream in both directions on both connections with QuicFraming.errorCode(). Bytes left over when a side finishes its stream, not a whole message, are passed on as they are. Streams of connections without a framer, and every stream when the interceptor does not override streamMessage, go through streamData as before, and nothing is kept.


 bootstrap.withQuicInterceptor(new QuicInterceptor() {
     public byte[] streamData(QuicStreamMessage message, QuicStreamContext stream) {
         return message.data();                                  // other protocols' streams
     }

     public List<QuicMessage> streamMessage(QuicMessage message, QuicStreamContext stream) {
         DnsMessage dns = message.dns();                          // a DNS over QUIC message
         log(dns.questions());
         return List.of(message);
     }
 });
 

Threads

Each direction of each stream is read by a thread of its own, and so are the datagrams of each direction, so a connection's hooks are called from many threads at once: the interceptor must be thread-safe. Within one direction of one stream, calls come in order, and the direction waits for each one.

Every connection gets quicStart(org.microproxy.quic.QuicContext) first, then its streams' and datagrams' hooks (each stream's streamOpened(org.microproxy.quic.QuicStreamContext) before its data), then either quicEnd(org.microproxy.quic.QuicContext) or quicError(org.microproxy.quic.QuicContext, java.lang.Throwable), never both. A hook that throws is logged; a throwing streamData(org.microproxy.quic.QuicStreamMessage, org.microproxy.quic.QuicStreamContext), streamMessage(org.microproxy.quic.QuicMessage, org.microproxy.quic.QuicStreamContext) or datagram(org.microproxy.quic.QuicDatagram, org.microproxy.quic.QuicContext) passes the bytes on unchanged.

  • Method Details

    • quicStart

      default void quicStart(QuicContext context)
      A connection was intercepted: the handshakes with both sides are complete, nothing has been relayed yet. QuicContext.sendDatagram(boolean, byte[]) may already send datagrams.
      Parameters:
      context - the connection
    • streamOpened

      default void streamOpened(QuicStreamContext stream)
      A side opened a stream, which the proxy has opened on the other connection: called before any of its data. QuicStreamContext.send(boolean, byte[]) may already add bytes.
      Parameters:
      stream - the stream
    • streamData

      byte[] streamData(QuicStreamMessage message, QuicStreamContext stream)
      Bytes read from one side of a stream, before they are passed on.
      Parameters:
      message - the bytes, which side sent them and when
      stream - the stream, and QuicStreamContext.send(boolean, byte[]) to add bytes after these
      Returns:
      the bytes to pass on: message.data() as it is or changed in place, other bytes, or null or an empty array to drop them
    • streamMessage

      default List<QuicMessage> streamMessage(QuicMessage message, QuicStreamContext stream)
      A whole message from one side of a stream, on a connection that QuicFraming gives a framer, before it is passed on; on such streams this replaces streamData(org.microproxy.quic.QuicStreamMessage, org.microproxy.quic.QuicStreamContext). Only interceptors that override this method see messages: the proxy frames streams for no other.

      Each message returned is framed again (MessageFramer.frame(byte[])) and sent in the direction its QuicMessage.fromClient() says, on the same stream: those of the same direction follow each other in place of message, and those of the other direction (made with QuicMessage.withFromClient(boolean)) are sent to the side that sent message, as if the other side had answered. A hook that throws, or returns a payload the framer cannot frame, has the message passed on unchanged.

      Parameters:
      message - the message's payload, which side sent it, on which stream and when
      stream - the stream, and QuicStreamContext.send(boolean, byte[]) to add bytes as they are (not framed) after these
      Returns:
      the messages to send: List.of(message) (the default) to pass it on with any change made in place, others, or null or an empty list to drop it
    • streamFinished

      default void streamFinished(QuicStreamContext stream, boolean fromClient)
      One side finished sending on a stream (its FIN): called before the end is passed on, so QuicStreamContext.send(boolean, byte[]) with the same fromClient can still add bytes.
      Parameters:
      stream - the stream
      fromClient - whether the client finished sending, rather than the server
    • streamReset

      default void streamReset(QuicStreamContext stream, boolean fromClient, long errorCode)
      One side reset its sending part of a stream (RESET_STREAM): the proxy resets the other side's with the same code, after this call.
      Parameters:
      stream - the stream
      fromClient - whether the client reset it, rather than the server
      errorCode - the application error code
    • streamStopped

      default void streamStopped(QuicStreamContext stream, boolean fromClient, long errorCode)
      One side asked the other to stop sending on a stream (STOP_SENDING): the proxy asks the other side the same, with the same code, and resets what it sends to the side that asked.
      Parameters:
      stream - the stream
      fromClient - whether the client asked the server to stop, rather than the server the client
      errorCode - the application error code
    • datagram

      default byte[] datagram(QuicDatagram datagram, QuicContext context)
      A datagram (RFC 9221) from one side, before it is passed on. Datagrams are unreliable: the proxy drops one that the other side does not take, or that is too large for it.
      Parameters:
      datagram - the payload, which side sent it and when
      context - the connection, and QuicContext.sendDatagram(boolean, byte[]) to add datagrams
      Returns:
      the payload to send on as one datagram (datagram.data() as it is or changed in place, or other bytes), or null to drop it
    • quicEnd

      default void quicEnd(QuicContext context)
      The connection ended: one side closed its connection (the other was closed the same way) without an error, or the proxy closed both.
      Parameters:
      context - the connection
    • quicError

      default void quicError(QuicContext context, Throwable error)
      The connection failed: one side's connection timed out, was reset (a stateless reset) or closed with a transport error. Called instead of quicEnd(org.microproxy.quic.QuicContext).
      Parameters:
      context - the connection
      error - what failed