From 9767a42264fa70b674c643d0c87ee95c309a4553 Mon Sep 17 00:00:00 2001 From: "Nicholas S. Husin" Date: Fri, 27 Feb 2026 14:49:59 -0500 Subject: [PATCH] internal/http3: add support for plugging into net/http This CL adds RegisterServer and RegisterTransport, which are used to plug our HTTP/3 implementation into net/http. For now, (inaccurately) assume that RegisterTransport do not take any configs for simplicity. Various exported symbols have also been changed to be unexported, since HTTP/3 will be used only via net/http and will not support direct usage. For golang/go#77440 Change-Id: I7b9528c193c18f5049da7f497d4259153b8770eb Reviewed-on: https://go-review.googlesource.com/c/net/+/739881 Reviewed-by: Damien Neil LUCI-TryBot-Result: Go LUCI Reviewed-by: Nicholas Husin --- internal/http3/nethttp_test.go | 98 ++++++++++++++++++++++++++ internal/http3/roundtrip.go | 10 +-- internal/http3/server.go | 115 ++++++++++++++++++++++++------- internal/http3/server_test.go | 12 ++-- internal/http3/transport.go | 94 ++++++++++++++++--------- internal/http3/transport_test.go | 14 ++-- 6 files changed, 271 insertions(+), 72 deletions(-) create mode 100644 internal/http3/nethttp_test.go diff --git a/internal/http3/nethttp_test.go b/internal/http3/nethttp_test.go new file mode 100644 index 00000000..8fc0c655 --- /dev/null +++ b/internal/http3/nethttp_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.27 + +package http3_test + +import ( + "crypto/tls" + "io" + "net/http" + "slices" + "testing" + "time" + + _ "unsafe" // for linkname + + "golang.org/x/net/internal/http3" + "golang.org/x/net/internal/testcert" + "golang.org/x/net/quic" +) + +//go:linkname protocolSetHTTP3 +func protocolSetHTTP3(p *http.Protocols) + +func newTestTLSConfig() *tls.Config { + testCert := func() tls.Certificate { + cert, err := tls.X509KeyPair(testcert.LocalhostCert, testcert.LocalhostKey) + if err != nil { + panic(err) + } + return cert + }() + config := &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{testCert}, + } + return config +} + +func TestNetHTTPIntegration(t *testing.T) { + body := []byte("some body") + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(body) + }) + + srv := &http.Server{ + Addr: "127.0.0.1:0", + Handler: handler, + TLSConfig: newTestTLSConfig(), + } + srv.Protocols = &http.Protocols{} + protocolSetHTTP3(srv.Protocols) + + var listenAddr string + listenAddrSet := make(chan any) + http3.RegisterServer(srv, http3.ServerOpts{ + ListenQUIC: func(addr string, config *quic.Config) (*quic.Endpoint, error) { + e, err := quic.Listen("udp", addr, config) + listenAddr = e.LocalAddr().String() + listenAddrSet <- struct{}{} + return e, err + }, + }) + go func() { + if err := srv.ListenAndServeTLS("", ""); err != nil { + panic(err) + } + }() + + tr := &http.Transport{TLSClientConfig: newTestTLSConfig()} + tr.Protocols = &http.Protocols{} + protocolSetHTTP3(tr.Protocols) + http3.RegisterTransport(tr) + + client := &http.Client{ + Transport: tr, + Timeout: time.Second, + } + <-listenAddrSet + req, err := http.NewRequest("GET", "https://"+listenAddr, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Errorf("got %v err, want nil", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(b, body) { + t.Errorf("got %v, want %v", string(b), string(body)) + } +} diff --git a/internal/http3/roundtrip.go b/internal/http3/roundtrip.go index 5cf988cb..2ea584b7 100644 --- a/internal/http3/roundtrip.go +++ b/internal/http3/roundtrip.go @@ -18,7 +18,7 @@ import ( ) type roundTripState struct { - cc *ClientConn + cc *clientConn st *stream // Request body, provided by the caller. @@ -87,7 +87,7 @@ func (rt *roundTripState) maybeCallWait100Continue() { } // RoundTrip sends a request on the connection. -func (cc *ClientConn) RoundTrip(req *http.Request) (_ *http.Response, err error) { +func (cc *clientConn) RoundTrip(req *http.Request) (_ *http.Response, err error) { // Each request gets its own QUIC stream. st, err := newConnStream(req.Context(), cc.qconn, streamTypeRequest) if err != nil { @@ -239,7 +239,7 @@ func actualContentLength(req *http.Request) int64 { // writeBodyAndTrailer handles writing the body and trailer for a given // request, if any. This function will close the write direction of the stream. -func (cc *ClientConn) writeBodyAndTrailer(rt *roundTripState, req *http.Request) { +func (cc *clientConn) writeBodyAndTrailer(rt *roundTripState, req *http.Request) { defer rt.closeReqBody() declaredTrailer := req.Trailer.Clone() @@ -339,7 +339,7 @@ func parseResponseContentLength(method string, statusCode int, h http.Header) (i return int64(contentLen), nil } -func (cc *ClientConn) handleHeaders(st *stream) (statusCode int, h http.Header, err error) { +func (cc *clientConn) handleHeaders(st *stream) (statusCode int, h http.Header, err error) { haveStatus := false cookie := "" // Issue #71374: Consider tracking the never-indexed status of headers @@ -406,7 +406,7 @@ func (cc *ClientConn) handleHeaders(st *stream) (statusCode int, h http.Header, return statusCode, h, err } -func (cc *ClientConn) handlePushPromise(st *stream) error { +func (cc *clientConn) handlePushPromise(st *stream) error { // "A client MUST treat receipt of a PUSH_PROMISE frame that contains a // larger push ID than the client has advertised as a connection error of H3_ID_ERROR." // https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.5-5 diff --git a/internal/http3/server.go b/internal/http3/server.go index 8c960809..c0e6ba47 100644 --- a/internal/http3/server.go +++ b/internal/http3/server.go @@ -6,6 +6,7 @@ package http3 import ( "context" + "crypto/tls" "io" "maps" "net/http" @@ -20,52 +21,120 @@ import ( "golang.org/x/net/quic" ) -// A Server is an HTTP/3 server. -// The zero value for Server is a valid server. -type Server struct { - // Handler to invoke for requests, http.DefaultServeMux if nil. - Handler http.Handler +// A server is an HTTP/3 server. +// The zero value for server is a valid server. +type server struct { + // handler to invoke for requests, http.DefaultServeMux if nil. + handler http.Handler - // Config is the QUIC configuration used by the server. - // The Config may be nil. - // - // ListenAndServe may clone and modify the Config. - // The Config must not be modified after calling ListenAndServe. - Config *quic.Config + config *quic.Config + + listenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error) initOnce sync.Once + + serveCtx context.Context } -func (s *Server) init() { +// netHTTPHandler is an interface that is implemented by +// net/http.http3ServerHandler in std. +// +// It provides a way for information to be passed between x/net and net/http +// that would otherwise be inaccessible, such as the TLS configs that users +// have supplied to net/http servers. +// +// This allows us to integrate our HTTP/3 server implementation with the +// net/http server when RegisterServer is called. +type netHTTPHandler interface { + http.Handler + TLSConfig() *tls.Config + BaseContext() context.Context + Addr() string + ListenErrHook(err error) +} + +type ServerOpts struct { + // ListenQUIC determines how the server will open a QUIC endpoint. + // By default, quic.Listen("udp", addr, config) is used. + ListenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error) + + // QUICConfig is the QUIC configuration used by the server. + // QUICConfig may be nil and should not be modified after calling + // RegisterServer. + // If QUICConfig.TLSConfig is nil, the TLSConfig of the net/http Server + // given to RegisterServer will be used. + QUICConfig *quic.Config +} + +// RegisterServer adds HTTP/3 support to a net/http Server. +// +// RegisterServer must be called before s begins serving, and only affects +// s.ListenAndServeTLS. +func RegisterServer(s *http.Server, opts ServerOpts) { + if s.TLSNextProto == nil { + s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) + } + s.TLSNextProto["http/3"] = func(s *http.Server, c *tls.Conn, h http.Handler) { + stdHandler, ok := h.(netHTTPHandler) + if !ok { + panic("RegisterServer was given a server that does not implement netHTTPHandler") + } + if opts.QUICConfig == nil { + opts.QUICConfig = &quic.Config{} + } + if opts.QUICConfig.TLSConfig == nil { + opts.QUICConfig.TLSConfig = stdHandler.TLSConfig() + } + s3 := &server{ + config: opts.QUICConfig, + listenQUIC: opts.ListenQUIC, + handler: stdHandler, + serveCtx: stdHandler.BaseContext(), + } + stdHandler.ListenErrHook(s3.listenAndServe(stdHandler.Addr())) + } +} + +func (s *server) init() { s.initOnce.Do(func() { - s.Config = initConfig(s.Config) - if s.Handler == nil { - s.Handler = http.DefaultServeMux + s.config = initConfig(s.config) + if s.handler == nil { + s.handler = http.DefaultServeMux + } + if s.serveCtx == nil { + s.serveCtx = context.Background() + } + if s.listenQUIC == nil { + s.listenQUIC = func(addr string, config *quic.Config) (*quic.Endpoint, error) { + return quic.Listen("udp", addr, config) + } } }) } -// ListenAndServe listens on the UDP network address addr +// listenAndServe listens on the UDP network address addr // and then calls Serve to handle requests on incoming connections. -func (s *Server) ListenAndServe(addr string) error { +func (s *server) listenAndServe(addr string) error { s.init() - e, err := quic.Listen("udp", addr, s.Config) + e, err := s.listenQUIC(addr, s.config) if err != nil { return err } - return s.Serve(e) + go s.serve(e) + return nil } -// Serve accepts incoming connections on the QUIC endpoint e, +// serve accepts incoming connections on the QUIC endpoint e, // and handles requests from those connections. -func (s *Server) Serve(e *quic.Endpoint) error { +func (s *server) serve(e *quic.Endpoint) error { s.init() + defer e.Close(s.serveCtx) for { - qconn, err := e.Accept(context.Background()) + qconn, err := e.Accept(s.serveCtx) if err != nil { return err } - go newServerConn(qconn, s.Handler) + go newServerConn(qconn, s.handler) } } diff --git a/internal/http3/server_test.go b/internal/http3/server_test.go index f5216968..91887a98 100644 --- a/internal/http3/server_test.go +++ b/internal/http3/server_test.go @@ -934,7 +934,7 @@ func TestServer304NotModified(t *testing.T) { type testServer struct { t testing.TB - s *Server + s *server tn testNet *testQUICEndpoint @@ -957,16 +957,16 @@ func newTestServer(t testing.TB, handler http.Handler) *testServer { t.Helper() ts := &testServer{ t: t, - s: &Server{ - Config: &quic.Config{ + s: &server{ + config: &quic.Config{ TLSConfig: testTLSConfig, }, - Handler: handler, + handler: handler, }, } - e := ts.tn.newQUICEndpoint(t, ts.s.Config) + e := ts.tn.newQUICEndpoint(t, ts.s.config) ts.addr = e.LocalAddr() - go ts.s.Serve(e) + go ts.s.serve(e) return ts } diff --git a/internal/http3/transport.go b/internal/http3/transport.go index 48aaadd8..4ac1aa01 100644 --- a/internal/http3/transport.go +++ b/internal/http3/transport.go @@ -7,60 +7,92 @@ package http3 import ( "context" "fmt" + "net/http" + "net/url" "sync" "golang.org/x/net/quic" ) -// A Transport is an HTTP/3 transport. +// A transport is an HTTP/3 transport. // // It does not manage a pool of connections, // and therefore does not implement net/http.RoundTripper. // -// TODO: Provide a way to register an HTTP/3 transport with a net/http.Transport's +// TODO: Provide a way to register an HTTP/3 transport with a net/http.transport's // connection pool. -type Transport struct { - // Endpoint is the QUIC endpoint used by connections created by the transport. +type transport struct { + // endpoint is the QUIC endpoint used by connections created by the transport. // If unset, it is initialized by the first call to Dial. - Endpoint *quic.Endpoint + endpoint *quic.Endpoint - // Config is the QUIC configuration used for client connections. - // The Config may be nil. + // config is the QUIC configuration used for client connections. + // The config may be nil. // - // Dial may clone and modify the Config. - // The Config must not be modified after calling Dial. - Config *quic.Config + // Dial may clone and modify the config. + // The config must not be modified after calling Dial. + config *quic.Config initOnce sync.Once initErr error } -func (tr *Transport) init() error { +// netHTTPTransport implements the net/http.dialClientConner interface, +// allowing our HTTP/3 transport to integrate with net/http. +type netHTTPTransport struct { + *transport +} + +// RoundTrip is defined since Transport.RegisterProtocol takes in a +// RoundTripper. However, this method will never be used as net/http's +// dialClientConner interface does not have a RoundTrip method and will only +// use DialClientConn to create a new RoundTripper. +func (t netHTTPTransport) RoundTrip(*http.Request) (*http.Response, error) { + panic("netHTTPTransport.RoundTrip should never be called") +} + +func (t netHTTPTransport) DialClientConn(ctx context.Context, addr string, _ *url.URL, _ func()) (http.RoundTripper, error) { + return t.transport.dial(ctx, addr) +} + +// RegisterTransport configures a net/http HTTP/1 Transport to use HTTP/3. +// +// TODO: most likely, add another arg for transport configuration. +func RegisterTransport(tr *http.Transport) { + tr3 := &transport{ + config: &quic.Config{ + TLSConfig: tr.TLSClientConfig.Clone(), + }, + } + tr.RegisterProtocol("http/3", netHTTPTransport{tr3}) +} + +func (tr *transport) init() error { tr.initOnce.Do(func() { - tr.Config = initConfig(tr.Config) - if tr.Endpoint == nil { - tr.Endpoint, tr.initErr = quic.Listen("udp", ":0", nil) + tr.config = initConfig(tr.config) + if tr.endpoint == nil { + tr.endpoint, tr.initErr = quic.Listen("udp", ":0", nil) } }) return tr.initErr } -// Dial creates a new HTTP/3 client connection. -func (tr *Transport) Dial(ctx context.Context, target string) (*ClientConn, error) { +// dial creates a new HTTP/3 client connection. +func (tr *transport) dial(ctx context.Context, target string) (*clientConn, error) { if err := tr.init(); err != nil { return nil, err } - qconn, err := tr.Endpoint.Dial(ctx, "udp", target, tr.Config) + qconn, err := tr.endpoint.Dial(ctx, "udp", target, tr.config) if err != nil { return nil, err } return newClientConn(ctx, qconn) } -// A ClientConn is a client HTTP/3 connection. +// A clientConn is a client HTTP/3 connection. // -// Multiple goroutines may invoke methods on a ClientConn simultaneously. -type ClientConn struct { +// Multiple goroutines may invoke methods on a clientConn simultaneously. +type clientConn struct { qconn *quic.Conn genericConn @@ -68,8 +100,8 @@ type ClientConn struct { dec qpackDecoder } -func newClientConn(ctx context.Context, qconn *quic.Conn) (*ClientConn, error) { - cc := &ClientConn{ +func newClientConn(ctx context.Context, qconn *quic.Conn) (*clientConn, error) { + cc := &clientConn{ qconn: qconn, } cc.enc.init() @@ -86,10 +118,10 @@ func newClientConn(ctx context.Context, qconn *quic.Conn) (*ClientConn, error) { return cc, nil } -// Close closes the connection. +// close closes the connection. // Any in-flight requests are canceled. -// Close does not wait for the peer to acknowledge the connection closing. -func (cc *ClientConn) Close() error { +// close does not wait for the peer to acknowledge the connection closing. +func (cc *clientConn) close() error { // Close the QUIC connection immediately with a status of NO_ERROR. cc.qconn.Abort(nil) @@ -99,7 +131,7 @@ func (cc *ClientConn) Close() error { return cc.qconn.Wait(ctx) } -func (cc *ClientConn) handleControlStream(st *stream) error { +func (cc *clientConn) handleControlStream(st *stream) error { // "A SETTINGS frame MUST be sent as the first frame of each control stream [...]" // https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.4-2 if err := st.readSettings(func(settingsType, settingsValue int64) error { @@ -145,17 +177,17 @@ func (cc *ClientConn) handleControlStream(st *stream) error { } } -func (cc *ClientConn) handleEncoderStream(*stream) error { +func (cc *clientConn) handleEncoderStream(*stream) error { // TODO return nil } -func (cc *ClientConn) handleDecoderStream(*stream) error { +func (cc *clientConn) handleDecoderStream(*stream) error { // TODO return nil } -func (cc *ClientConn) handlePushStream(*stream) error { +func (cc *clientConn) handlePushStream(*stream) error { // "A client MUST treat receipt of a push stream as a connection error // of type H3_ID_ERROR when no MAX_PUSH_ID frame has been sent [...]" // https://www.rfc-editor.org/rfc/rfc9114.html#section-4.6-3 @@ -165,7 +197,7 @@ func (cc *ClientConn) handlePushStream(*stream) error { } } -func (cc *ClientConn) handleRequestStream(st *stream) error { +func (cc *clientConn) handleRequestStream(st *stream) error { // "Clients MUST treat receipt of a server-initiated bidirectional // stream as a connection error of type H3_STREAM_CREATION_ERROR [...]" // https://www.rfc-editor.org/rfc/rfc9114.html#section-6.1-3 @@ -176,7 +208,7 @@ func (cc *ClientConn) handleRequestStream(st *stream) error { } // abort closes the connection with an error. -func (cc *ClientConn) abort(err error) { +func (cc *clientConn) abort(err error) { if e, ok := err.(*connectionError); ok { cc.qconn.Abort(&quic.ApplicationError{ Code: uint64(e.code), diff --git a/internal/http3/transport_test.go b/internal/http3/transport_test.go index 71c5aeca..7cf44cdb 100644 --- a/internal/http3/transport_test.go +++ b/internal/http3/transport_test.go @@ -370,8 +370,8 @@ func (ts *testQUICStream) Flush() error { // A testClientConn is a ClientConn on a test network. type testClientConn struct { - tr *Transport - cc *ClientConn + tr *transport + cc *clientConn // *testQUICConn is the server half of the connection. *testQUICConn @@ -380,19 +380,19 @@ type testClientConn struct { func newTestClientConn(t testing.TB) *testClientConn { e1, e2 := newQUICEndpointPair(t) - tr := &Transport{ - Endpoint: e1, - Config: &quic.Config{ + tr := &transport{ + endpoint: e1, + config: &quic.Config{ TLSConfig: testTLSConfig, }, } - cc, err := tr.Dial(t.Context(), e2.LocalAddr().String()) + cc, err := tr.dial(t.Context(), e2.LocalAddr().String()) if err != nil { t.Fatal(err) } t.Cleanup(func() { - cc.Close() + cc.close() }) srvConn, err := e2.Accept(t.Context()) if err != nil {