HTTP3, 2, 1

HTTP1 is simple and easy. With enough care you can open a TCP connection and hand-write an HTTP request to a server and get a response. Good fun.

HTTP2 is more complex. Multiple bidirectional requests can be multiplexed over a single connection. You might use it with something like GRPC, or to get web pages to load faster.

HTTP3 is wild stuff. Implemented over UDP instead of TCP. You can open a connection, open streams on that connection, send data with different types of ordering and deliverability guarantees.

I’ve mostly known HTTP3 as a thing that big tech companies use to eek out more efficiency. Recently I was experimenting with HTTP3+Go to do network tunnelling and got exposed to more of its features.

Let’s use what I’ve learned and have a bit of weird fun. If HTTP3 can do ordered streams then surely we can stream HTTP2 within an HTTP3 connection? And if HTTP2 can do bidirectional streaming over a single connection then surely you can implement HTTP1 over it? Right?

The code is here, and each of these sections has a corresponding test in http321_test.go if you’d like to follow along with greater detail

HTTP3

We can start a listener, connect to it, and open a stream:

listener := NewHTTP3Listener(t) // listen
conn := DialListener(t, listener) // dial the listener
serverConn, _ := listener.Accept(ctx) // accept the dialed connection
stream, _ := conn.OpenStream() // open a stream
serverStream, _ := serverConn.AcceptStream(ctx) // accept the stream
_, _ = stream.Write([]byte("hello")) // write bytes
buf := make([]byte, 5)
_, _ = io.ReadFull(serverStream, buf) // read them
fmt.Println(string(buf)) // => "hello"

The stream we’re opening is reliable, and ordered. Each connection can open many streams. Straightforward enough.

HTTP3+2

In order to make an HTTP2 connection we’re going to need to “dial” the other server. Normally this would be a net.Dial call and new TCP connection. Here, we’re going to call conn.OpenStream() and wrap the stream up into a net.Conn.

func QuicConnDial(conn quic.Connection) (net.Conn, error) {
	stream, err := conn.OpenStreamSync(context.Background())
	if err != nil {
		return nil, err
	}
	return &ReadWriteConn{Reader: stream, Writer: stream, Closer: stream}, nil
}

ReadWriteConn takes our stream and wraps it up wth some dummy methods to behave like a net.Conn.

On the other end we’ll need to implement a net.Listener. When the listener calls conn, err := listener.Accept(), instead of accepting a new TCP stream we’re going to call serverConn.AcceptStream and wrap the returned stream up as a connection.

func (l *QuicNetListener) Accept() (net.Conn, error) {
	stream, err := l.Connection.AcceptStream(context.Background())
	if err != nil {
		return nil, err
	}
	return &ReadWriteConn{Reader: stream, Writer: stream, Closer: stream}, nil
}

With those bits, we can string it all together:

listener := NewHTTP3Listener(t)
conn := DialListener(t, listener)
serverConn, _ := listener.Accept(ctx) // Connect HTTP3

netListener := QuicNetListener{Connection: serverConn} // Make the net.Listener
http2Server := &http.Server{Handler: h2c.NewHandler(
	http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
	}), &http2.Server{},
)} // Configure our http2 handler
go func() { _ = http2Server.Serve(&netListener)}() // run the server over our listener

client := &http.Client{
	Transport: &http2.Transport{
		AllowHTTP: true, // Allow unencrypted http2
		DialTLSContext: func(ctx context.Context,
			network, addr string, cfg *tls.Config) (net.Conn, error) {
			return QuicConnDial(conn)
		},
	},
} // Create an HTTP2 client that uses our QuicConnDial

resp, err := client.Get("http://any.domain")
fmt.Println(resp) // => OK 200 HTTP/2.0

There we have it. HTTP2 within HTTP3. Here’s a test that fires of 10 requests to an http2 server that runs time.Sleep(time.Millisecond * 100). Each request is made over the same connection and all the requests return in ~100ms total.

HTTP3+2+1

In order to get HTTP1 working, we need to do the same task over again. We need to implement a dial function and a net.Listener, but this time over a streaming HTTP2 request. It was tricky to get this working, and my final version fails in certain situations. This issue was helpful in confirming the basic patterns that needed to be set up.

Here’s the net.Listener implementation

func (l *HTTP2OverQuicListener) Accept() (net.Conn, error) {
	l.once.Do(func() {
		l.conns = make(chan net.Conn, 1)
		handler := h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(200)
			pReader, pWriter := io.Pipe()
			l.conns <- &ReadWriteConn{Reader: r.Body, Writer: pWriter, Closer: r.Body}
			_, _ = io.Copy(flushWriter{w}, pReader)
		}), &http2.Server{})
		http2Server := &http.Server{Handler: handler}
		go func() { _ = http2Server.Serve(l.listener) }()
	})
	return <-l.conns, nil
}

The first time we call Accept we use a sync.Once to start an http server. When a new request comes in we turn the request body and response in to a net.Conn. We use a pipe, and an io.Copy for this so that the request is held open until the connection is closed. Note the use of flushWriter to make sure we’re flushing the response bytes back over the connection.

func HTTP2OverQuicDial(conn quic.Connection) (net.Conn, error) {
	client := &http.Client{
		Transport: &http2.Transport{
			AllowHTTP: true,
			DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
				return QuicConnDial(conn)
			},
		},
	}
	inReader, inWriter := io.Pipe()
	outReader, outWriter := io.Pipe()
	req, err := http.NewRequest(http.MethodPost, "http://any.domain", io.NopCloser(inReader))
	if err != nil {
		return nil, err
	}
	go func() {
		resp, _ := client.Do(req)
		_, _ = io.Copy(outWriter, resp.Body)
	}()
	time.Sleep(1 * time.Millisecond) // :(
	return &ReadWriteConn{Reader: outReader, Writer: inWriter, Closer: outReader}, nil
}

Dial is similar. Open an http2 request and start copying the bytes into a connection. This ended up being a bit unreliable and I’m still not sure why. That time.Sleep is sadly working to prevent a deadlock that popped up from time to time.

With those complete, we can string together our network request.

listener := NewHTTP3Listener(t)
conn := DialListener(t, listener)
serverConn, _ := listener.Accept(ctx) // Connect HTTP3

netListener := QuicNetListener{Connection: serverConn} // Listener
http2Listener := HTTP2OverQuicListener{listener: &netListener} // Listener over HTTP2

go func() {
	_ = http.Serve(&http2Listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "I'm http1! 🐢🐢🐢")
	}))
}() // Run our HTTP server.

client := &http.Client{
	Transport: &http.Transport{
		Dial: func(network, addr string) (net.Conn, error) {
			return HTTP2OverQuicDial(conn)
		},
	},
}
resp, err := client.Get("http://any.domain")
fmt.Println(resp) // => OK 200 HTTP/1.1

Woo! 🎉


Fun? Useful? Maybe.

I did try to get websockets working over the HTTP1 handler, but it was unhappy with my very fake net.Conn. Until next time, happy tunneling!