From 0b9bcbc58c4cf127d5a42989d08cc0236faa71cc Mon Sep 17 00:00:00 2001 From: "Nicholas S. Husin" Date: Mon, 26 Jan 2026 18:15:57 -0500 Subject: [PATCH] net/http: add basic unexported pluggable HTTP/3 support Following #77440, this CL adds a basic support for plugging in an HTTP/3 implementation to net/http. As the proposal is not accepted yet, this CL does not add any exported symbols. Access to plug HTTP/3 support is locked behind net/http.protocolSetHTTP3, which can only be used via linkname by golang.org/x/net/internal/http3_test.protocolSetHTTP3. This will allow us to run our HTTP/3 implementation in x/net againts various tests in net/http to support development, without expanding the API surface for any users. Support for closeIdleConnectionser will be added separately in the future. For #77440 Change-Id: I6e3a0c2e9b329cef43e4682463ed5e2093d04256 Reviewed-on: https://go-review.googlesource.com/c/go/+/740120 Reviewed-by: Nicholas Husin Reviewed-by: Damien Neil LUCI-TryBot-Result: Go LUCI --- src/net/http/http.go | 16 ++++++ src/net/http/server.go | 112 +++++++++++++++++++++++++++++++++----- src/net/http/transport.go | 65 +++++++++++++++++++++- 3 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/net/http/http.go b/src/net/http/http.go index 100d4fe4a1..dc4e3ba14d 100644 --- a/src/net/http/http.go +++ b/src/net/http/http.go @@ -12,6 +12,7 @@ import ( "strings" "time" "unicode/utf8" + _ "unsafe" "golang.org/x/net/http/httpguts" ) @@ -35,6 +36,7 @@ const ( protoHTTP1 = 1 << iota protoHTTP2 protoUnencryptedHTTP2 + protoHTTP3 ) // HTTP1 reports whether p includes HTTP/1. @@ -55,6 +57,15 @@ func (p Protocols) UnencryptedHTTP2() bool { return p.bits&protoUnencryptedHTTP2 // SetUnencryptedHTTP2 adds or removes unencrypted HTTP/2 from p. func (p *Protocols) SetUnencryptedHTTP2(ok bool) { p.setBit(protoUnencryptedHTTP2, ok) } +// http3 reports whether p includes HTTP/3. +func (p Protocols) http3() bool { return p.bits&protoHTTP3 != 0 } + +// setHTTP3 adds or removes HTTP/3 from p. +func (p *Protocols) setHTTP3(ok bool) { p.setBit(protoHTTP3, ok) } + +//go:linkname protocolSetHTTP3 golang.org/x/net/internal/http3_test.protocolSetHTTP3 +func protocolSetHTTP3(p *Protocols) { p.setHTTP3(true) } + func (p *Protocols) setBit(bit uint8, ok bool) { if ok { p.bits |= bit @@ -63,6 +74,11 @@ func (p *Protocols) setBit(bit uint8, ok bool) { } } +// empty returns true if p has no protocol set at all. +func (p Protocols) empty() bool { + return p.bits == 0 +} + func (p Protocols) String() string { var s []string if p.HTTP1() { diff --git a/src/net/http/server.go b/src/net/http/server.go index a388777d3a..fb167ac7a1 100644 --- a/src/net/http/server.go +++ b/src/net/http/server.go @@ -3476,6 +3476,22 @@ func (s *Server) Serve(l net.Listener) error { } } +func (s *Server) setupTLSConfig(certFile, keyFile string, nextProtos []string) (*tls.Config, error) { + config := cloneTLSConfig(s.TLSConfig) + config.NextProtos = nextProtos + + configHasCert := len(config.Certificates) > 0 || config.GetCertificate != nil || config.GetConfigForClient != nil + if !configHasCert || certFile != "" || keyFile != "" { + var err error + config.Certificates = make([]tls.Certificate, 1) + config.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + } + return config, nil +} + // ServeTLS accepts incoming connections on the Listener l, creating a // new service goroutine for each. The service goroutines perform TLS // setup and then read requests, calling s.Handler to reply to them. @@ -3497,17 +3513,9 @@ func (s *Server) ServeTLS(l net.Listener, certFile, keyFile string) error { return err } - config := cloneTLSConfig(s.TLSConfig) - config.NextProtos = adjustNextProtos(config.NextProtos, s.protocols()) - - configHasCert := len(config.Certificates) > 0 || config.GetCertificate != nil || config.GetConfigForClient != nil - if !configHasCert || certFile != "" || keyFile != "" { - var err error - config.Certificates = make([]tls.Certificate, 1) - config.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return err - } + config, err := s.setupTLSConfig(certFile, keyFile, adjustNextProtos(s.TLSConfig.NextProtos, s.protocols())) + if err != nil { + return err } tlsListener := tls.NewListener(l, config) @@ -3516,6 +3524,15 @@ func (s *Server) ServeTLS(l net.Listener, certFile, keyFile string) error { func (s *Server) protocols() Protocols { if s.Protocols != nil { + // Historically, even when Protocols for a Server was set to be empty, + // the Server can still run normally with just HTTP/1. + // To keep backward-compatibility, the zero value of Protocols is + // defined as having only HTTP/1 enabled. + if s.Protocols.empty() { + var p Protocols + p.SetHTTP1(true) + return p + } return *s.Protocols // user-configured set } @@ -3696,6 +3713,51 @@ func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error { return server.ListenAndServeTLS(certFile, keyFile) } +// http3ServerHandler implements an interface in an external library that +// supports HTTP/3, allowing an external implementation of HTTP/3 to be used +// via net/http. See https://go.dev/issue/77440 for details. +// +// This is currently only used with golang.org/x/net/internal/http3, to allow +// us to test our HTTP/3 implementation againts tests in net/http. HTTP/3 is +// not yet accessible to end-users. +type http3ServerHandler struct { + handler serverHandler + tlsConfig *tls.Config + baseCtx context.Context + errc chan error +} + +// ServeHTTP ensures that http3ServerHandler implements the Handler interface, +// and gives an HTTP/3 server implementation access to the net/http handler. +func (h http3ServerHandler) ServeHTTP(w ResponseWriter, r *Request) { + h.handler.ServeHTTP(w, r) +} + +// Addr gives an HTTP/3 server implementation the address that it should listen +// on. +func (h http3ServerHandler) Addr() string { + return h.handler.srv.Addr +} + +// TLSConfig gives an HTTP/3 server implementation the *tls.Config that it +// should use. +func (h http3ServerHandler) TLSConfig() *tls.Config { + return h.tlsConfig +} + +// BaseContext gives an HTTP/3 server implementation the base context to use +// for server requests. +func (h http3ServerHandler) BaseContext() context.Context { + return h.baseCtx +} + +// ListenErrHook should be called by an HTTP/3 server implementation to +// propagate any error it encounters when trying to listen, if any, to +// net/http. +func (h http3ServerHandler) ListenErrHook(err error) { + h.errc <- err +} + // ListenAndServeTLS listens on the TCP network address s.Addr and // then calls [ServeTLS] to handle requests on incoming TLS connections. // Accepted connections are configured to enable TCP keep-alives. @@ -3720,13 +3782,37 @@ func (s *Server) ListenAndServeTLS(certFile, keyFile string) error { addr = ":https" } + p := s.protocols() + if p.http3() { + fn, ok := s.TLSNextProto["http/3"] + if !ok { + return errors.New("http: Server.Protocols contains HTTP3, but Server does not support HTTP/3") + } + config, err := s.setupTLSConfig(certFile, keyFile, []string{"h3"}) + if err != nil { + return err + } + errc := make(chan error, 1) + go fn(s, nil, http3ServerHandler{ + handler: serverHandler{s}, + tlsConfig: config, + baseCtx: context.WithValue(context.Background(), ServerContextKey, s), + errc: errc, + }) + if err := <-errc; err != nil { + return err + } + } + + // Only start a TCP listener if HTTP/1 or HTTP/2 is used. + if !p.HTTP1() && !p.HTTP2() && !p.UnencryptedHTTP2() { + return nil + } ln, err := net.Listen("tcp", addr) if err != nil { return err } - defer ln.Close() - return s.ServeTLS(ln, certFile, keyFile) } diff --git a/src/net/http/transport.go b/src/net/http/transport.go index 49f9096e3f..efc3838154 100644 --- a/src/net/http/transport.go +++ b/src/net/http/transport.go @@ -288,8 +288,9 @@ type Transport struct { // nextProtoOnce guards initialization of TLSNextProto and // h2transport (via onceSetNextProtoDefaults) nextProtoOnce sync.Once - h2transport h2Transport // non-nil if http2 wired up - tlsNextProtoWasNil bool // whether TLSNextProto was nil when the Once fired + h2transport h2Transport // non-nil if http2 wired up + h3transport dialClientConner // non-nil if http3 wired up + tlsNextProtoWasNil bool // whether TLSNextProto was nil when the Once fired // ForceAttemptHTTP2 controls whether HTTP/2 is enabled when a non-zero // Dial, DialTLS, or DialContext func or TLSClientConfig is provided. @@ -380,6 +381,36 @@ func (t *Transport) Clone() *Transport { return t2 } +type dialClientConner interface { + // DialClientConn creates a new client connection to address. + // + // If proxy is non-nil, the connection should use the provided proxy. + // If HTTP/3 proxies are not supported, DialClientConn should return + // an error wrapping [errors.ErrUnsupported]. + // + // The RoundTripper returned by DialClientConn may implement + // any of the following methods to support the [ClientConn] + // method of the same name: + // Close() error + // Err() error + // Reserve() error + // Release() error + // Available() int + // InFlight() int + // + // The client connection should arrange to call internalStateHook + // when the connection closes, when requests complete, and when the + // connection concurrency limit changes. + // + // The client connection must call the internal state hook when + // the connection state changes asynchronously, such as when a request completes. + // + // The internal state hook need not be called after synchronous changes + // to the state: Close, Reserve, Release, and RoundTrip calls + // which don't start a request do not need to call the hook. + DialClientConn(ctx context.Context, address string, proxy *url.URL, internalStateHook func()) (RoundTripper, error) +} + // h2Transport is the interface we expect to be able to call from // net/http against an *http2.Transport that's either bundled into // h2_bundle.go or supplied by the user via x/net/http2. @@ -878,6 +909,14 @@ var ErrSkipAltProtocol = errors.New("net/http: skip alternate protocol") func (t *Transport) RegisterProtocol(scheme string, rt RoundTripper) { t.altMu.Lock() defer t.altMu.Unlock() + + if scheme == "http/3" { + var ok bool + if t.h3transport, ok = rt.(dialClientConner); !ok { + panic("http: HTTP/3 RoundTripper does not implement DialClientConn") + } + } + oldMap, _ := t.altProto.Load().(map[string]RoundTripper) if _, exists := oldMap[scheme]; exists { panic("protocol " + scheme + " already registered") @@ -1767,6 +1806,28 @@ type erringRoundTripper interface { var testHookProxyConnectTimeout = context.WithTimeout func (t *Transport) dialConn(ctx context.Context, cm connectMethod, isClientConn bool, internalStateHook func()) (pconn *persistConn, err error) { + // TODO: actually support HTTP/3. Among other things: + // - make HTTP/3 play well with proxy. + // - implement happy eyeball between HTTP/3 and HTTP/1 & HTTP/2. + // - clean up the connection pooling logic. + if p := t.protocols(); p.http3() { + if p.HTTP1() || p.HTTP2() || p.UnencryptedHTTP2() { + return nil, errors.New("http: when using HTTP3, Transport.Protocols must contain only HTTP3") + } + if t.h3transport == nil { + return nil, errors.New("http: Transport.Protocols contains HTTP3, but Transport does not support HTTP/3") + } + rt, err := t.h3transport.DialClientConn(ctx, cm.addr(), cm.proxyURL, internalStateHook) + if err != nil { + return nil, err + } + return &persistConn{ + t: t, + cacheKey: cm.key(), + alt: rt, + }, nil + } + pconn = &persistConn{ t: t, cacheKey: cm.key(),