http3: rework registration to allow using a fake network

This is the x/net half of a paired set of changes which modify
the mechanism by which x/net/http3 integrates with net/http.
The other half of the change is CL 803380.

x/net/http3 exports two functions via linkname for use by
net/http's tests. These are now:

	registerServer(*http.Server, opts any) error
	registerTransport(*http.Transport, opts any) error

These are now thin wrappers around the interal/http3 registration
functions, with a bit of reflection-based struct copying to permit
passing in options without exporting the options types.

(The end state is for these functions to be exported and take
exported options types.)

Server registration no longer uses TLSNextProto.
Instead, the HTTP/3 server registers itself via net/http.Server.Serve,
which is the same approach used by x/net/http2.
This gives us a simpler link between the two packages.
For example, net/http can just call a Shutdown method on the HTTP/3 server
and pass it a Context, rather than the prior complex dance.

The HTTP/3 server no longer calls net.Listen.
It always accepts a net.PacketConn from net/http.

TransportOpts now has a ListenPacket field, in addition to ListenQUIC.

Server and Transport now both accept their *tls.Configs from net/http.
net/http is responsible for correctly configuring NextProtos.
This makes TLS config construction more consistent overall,
since net/http already sets up the HTTP/1 and HTTP/2 configs.

These changes simplify the registration machinery in some places,
but mainly permit us to now pass a fake net.PacketConn from
net/http's tests into the http package.

As part of this change, the HTTP/3 server's handler and base context
are now per-endpoint rather than per-server.

net/http Servers permit setting a per-net.Listener base context
for request handlers. Treating the HTTP/3 base context as per-endpoint
maintains the equivalent ability. Making the handler per-endpoint
isn't strictly necessary, but is consistent with the context and
simplifies the connection between net/http and x/net/http3.

For golang/go#80480

Change-Id: I10d6e998c11cade33e517d1d2f03a2d86a6a6964
Reviewed-on: https://go-review.googlesource.com/c/net/+/801940
Reviewed-by: Nicholas Husin <husin@google.com>
Reviewed-by: Nicholas Husin <nsh@golang.org>
Auto-Submit: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/http3/http3.go b/http3/http3.go
index 3592668..ff5ef01 100644
--- a/http3/http3.go
+++ b/http3/http3.go
@@ -5,42 +5,57 @@
 package http3
 
 import (
+	"fmt"
 	"net/http"
-	"time"
+	"reflect"
 	_ "unsafe" // for linkname
 
 	. "golang.org/x/net/internal/http3"
-	"golang.org/x/net/quic"
 )
 
-// Be extra generous with the handshake timeout. On some builders, the default
-// handshake timeout seems to be insufficient, causing rare test flakes.
-const handshakeTimeout = 1 * time.Minute
-
-//go:linkname registerHTTP3Server net/http_test.registerHTTP3Server
-func registerHTTP3Server(s *http.Server) <-chan *quic.Endpoint {
-	endpointCh := make(chan *quic.Endpoint)
-	RegisterServer(s, ServerOpts{
-		ListenQUIC: func(addr string, config *quic.Config) (*quic.Endpoint, error) {
-			e, err := quic.Listen("udp", addr, config)
-			endpointCh <- e
-			return e, err
-		},
-		QUICConfig: &quic.Config{HandshakeTimeout: handshakeTimeout},
-	})
-	return endpointCh
+//go:linkname registerServer net/http_test.registerHTTP3Server
+func registerServer(s *http.Server, opts any) error {
+	var o ServerOpts
+	if err := shallowStructCopy(&o, opts); err != nil {
+		return err
+	}
+	return RegisterServer(s, o)
 }
 
-//go:linkname registerHTTP3Transport net/http_test.registerHTTP3Transport
-func registerHTTP3Transport(tr *http.Transport) <-chan *quic.Endpoint {
-	endpointCh := make(chan *quic.Endpoint)
-	RegisterTransport(tr, TransportOpts{
-		ListenQUIC: func(addr string, config *quic.Config) (*quic.Endpoint, error) {
-			e, err := quic.Listen("udp", addr, config)
-			endpointCh <- e
-			return e, err
-		},
-		QUICConfig: &quic.Config{HandshakeTimeout: handshakeTimeout},
-	})
-	return endpointCh
+//go:linkname registerTransport net/http_test.registerHTTP3Transport
+func registerTransport(tr *http.Transport, opts any) error {
+	var o TransportOpts
+	if err := shallowStructCopy(&o, opts); err != nil {
+		return err
+	}
+	return RegisterTransport(tr, o)
+}
+
+// shallowStructCopy copies every field in src to *dst.
+// src must be a struct, and dst must be a pointer to a struct.
+//
+// We use this to let net/http tests pass their own version of an options struct to
+// registerHTTP3{Server,Transport}.
+//
+// This is a temporary measure pending this package having a public API,
+// at which time net/http tests can use the ServerOpts and TransportOpts directly.
+func shallowStructCopy(dst, src any) error {
+	dv := reflect.ValueOf(dst).Elem()
+	sv := reflect.ValueOf(src)
+	for i := range sv.Type().NumField() {
+		stype := sv.Type().Field(i)
+		if !stype.IsExported() {
+			return fmt.Errorf("%T contains unexported fields", src)
+		}
+		sf := sv.Field(i)
+		df := dv.FieldByName(stype.Name)
+		if !df.CanSet() {
+			return fmt.Errorf("%T.%v: field does not exist or is unassignable", dst, stype.Name)
+		}
+		if !sf.Type().AssignableTo(df.Type()) {
+			return fmt.Errorf("%T.%v: %v is not assignable to %v", dst, stype.Name, sf.Type(), df.Type())
+		}
+		df.Set(sf)
+	}
+	return nil
 }
diff --git a/internal/http3/nethttp_test.go b/internal/http3/nethttp_test.go
index 1944fd6..fa6e3de 100644
--- a/internal/http3/nethttp_test.go
+++ b/internal/http3/nethttp_test.go
@@ -10,6 +10,7 @@
 	"context"
 	"crypto/tls"
 	"io"
+	"net"
 	"net/http"
 	"slices"
 	"testing"
@@ -19,7 +20,6 @@
 
 	"golang.org/x/net/internal/http3"
 	"golang.org/x/net/internal/testcert"
-	"golang.org/x/net/quic"
 )
 
 //go:linkname protocolSetHTTP3
@@ -51,29 +51,30 @@
 		Handler:   handler,
 		TLSConfig: newTestTLSConfig(),
 	}
+	if err := http3.RegisterServer(srv, http3.ServerOpts{}); err != nil {
+		t.Skipf("cannot register server: %v", err)
+	}
 	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)
-		}
-	}()
+	// We do not yet have a public API for serving on a system-chosen port
+	// that lets us find out what that port is. (ListenAndServeTLS will listen on
+	// a system-chosen port, but we can't find out what port it picked.)
+	// So use ServeTLS with an pro tem mechanism for passing in a PacketConn.
+	nc, err := net.ListenPacket("udp", srv.Addr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer nc.Close()
+	go srv.ServeTLS(http3ServeConn{nc}, "", "")
 
 	tr := &http.Transport{TLSClientConfig: newTestTLSConfig()}
 	tr.Protocols = &http.Protocols{}
 	protocolSetHTTP3(tr.Protocols)
-	http3.RegisterTransport(tr, http3.TransportOpts{})
+	if err := http3.RegisterTransport(tr, http3.TransportOpts{}); err != nil {
+		// If RegisterServer above succeeded, this should as well.
+		t.Fatalf("cannot register transport: %v", err)
+	}
 
 	client := &http.Client{
 		Transport: tr,
@@ -81,10 +82,9 @@
 		// that we use for e.g. plan9.
 		Timeout: 5 * time.Second,
 	}
-	<-listenAddrSet
 
 	for range 5 {
-		req, err := http.NewRequest("GET", "https://"+listenAddr, nil)
+		req, err := http.NewRequest("GET", "https://"+nc.LocalAddr().String(), nil)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -118,3 +118,15 @@
 		t.Fatal(err)
 	}
 }
+
+type http3ServeConn struct {
+	conn net.PacketConn
+}
+
+func (c http3ServeConn) Accept() (net.Conn, error) { return nil, net.ErrClosed }
+func (c http3ServeConn) Close() error              { return nil }
+func (c http3ServeConn) Addr() net.Addr            { return nil }
+
+func (c http3ServeConn) HTTP3PacketConn() net.PacketConn {
+	return c.conn
+}
diff --git a/internal/http3/quic.go b/internal/http3/quic.go
index 4f1cca1..68f2d5f 100644
--- a/internal/http3/quic.go
+++ b/internal/http3/quic.go
@@ -6,35 +6,23 @@
 
 import (
 	"crypto/tls"
+	"slices"
 
 	"golang.org/x/net/quic"
 )
 
-func initConfig(config *quic.Config) *quic.Config {
+func newQUICConfig(config *quic.Config, tlsConfig *tls.Config) *quic.Config {
+	config = config.Clone()
 	if config == nil {
 		config = &quic.Config{}
 	}
-
-	// maybeCloneTLSConfig clones the user-provided tls.Config (but only once)
-	// prior to us modifying it.
-	needCloneTLSConfig := true
-	maybeCloneTLSConfig := func() *tls.Config {
-		if needCloneTLSConfig {
-			config.TLSConfig = config.TLSConfig.Clone()
-			needCloneTLSConfig = false
+	if !slices.Equal(tlsConfig.NextProtos, []string{"h3"}) {
+		tlsConfig = tlsConfig.Clone()
+		if tlsConfig == nil {
+			tlsConfig = &tls.Config{}
 		}
-		return config.TLSConfig
+		tlsConfig.NextProtos = []string{"h3"}
 	}
-
-	if config.TLSConfig == nil {
-		config.TLSConfig = &tls.Config{}
-		needCloneTLSConfig = false
-	}
-	if config.TLSConfig.MinVersion == 0 {
-		maybeCloneTLSConfig().MinVersion = tls.VersionTLS13
-	}
-	if config.TLSConfig.NextProtos == nil {
-		maybeCloneTLSConfig().NextProtos = []string{"h3"}
-	}
+	config.TLSConfig = tlsConfig
 	return config
 }
diff --git a/internal/http3/server.go b/internal/http3/server.go
index ef7d9a4..43ab277 100644
--- a/internal/http3/server.go
+++ b/internal/http3/server.go
@@ -10,6 +10,7 @@
 	"errors"
 	"fmt"
 	"maps"
+	"net"
 	"net/http"
 	"net/textproto"
 	"os"
@@ -27,17 +28,11 @@
 // 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     *quic.Config
-	srv1       *http.Server
-	listenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error)
+	srv1 *http.Server
+	opts ServerOpts
 
 	initOnce sync.Once
 
-	serveCtx       context.Context
-	serveCtxCancel context.CancelFunc
-
 	// connClosed is used to signal that a connection has been unregistered
 	// from activeConns. That way, when shutting down gracefully, the server
 	// can avoid busy-waiting for activeConns to be empty.
@@ -46,29 +41,37 @@
 	activeConns map[*serverConn]struct{}
 }
 
-// netHTTPHandler is an interface that is implemented by
-// net/http.http3ServerHandler in std.
+// netHTTPServer implements the net/http.http3Server interface,
+// allowing our HTTP/3 server to integrate with net/http.
+type netHTTPServer struct {
+	*server
+}
+
+// Implement net.Listener, so we can pass a netHTTPServer to net/http.Server.Serve.
+func (netHTTPServer) Accept() (net.Conn, error) { return nil, net.ErrClosed }
+func (netHTTPServer) Close() error              { return nil }
+func (netHTTPServer) Addr() net.Addr            { return nil }
+
+// ServeHTTP3 starts serving HTTP/3 on a UDP port.
 //
-// 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)
-	ShutdownContext() context.Context
+// The ctx parameter is used as the base context for request handlers
+// for requests receieved via this port.
+func (s netHTTPServer) ServeHTTP3(ctx context.Context, conn net.PacketConn, tlsConfig *tls.Config, h http.Handler) error {
+	s.init()
+	e, err := quic.NewEndpoint(conn, newQUICConfig(s.opts.QUICConfig, tlsConfig))
+	if err != nil {
+		return err
+	}
+	return s.serve(ctx, e, h)
+}
+
+// Shutdown shuts down the server.
+func (s netHTTPServer) Shutdown(ctx context.Context) error {
+	s.shutdown(ctx)
+	return nil
 }
 
 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.
@@ -81,79 +84,34 @@
 //
 // 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))
+func RegisterServer(s *http.Server, opts ServerOpts) error {
+	if err := s.Serve(netHTTPServer{&server{
+		opts: opts,
+		srv1: s,
+	}}); err != nil {
+		return errors.New("http3: net/http does not support HTTP/3")
 	}
-	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,
-			srv1:       s,
-			handler:    stdHandler,
-			serveCtx:   stdHandler.BaseContext(),
-		}
-		s3.init()
-		s.RegisterOnShutdown(func() {
-			s3.shutdown(stdHandler.ShutdownContext())
-		})
-		stdHandler.ListenErrHook(s3.listenAndServe(stdHandler.Addr()))
-	}
+	return nil
 }
 
 func (s *server) init() {
 	s.initOnce.Do(func() {
-		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)
-			}
-		}
-		s.serveCtx, s.serveCtxCancel = context.WithCancel(s.serveCtx)
 		s.activeConns = make(map[*serverConn]struct{})
 		s.connClosed = make(chan any, 1)
 	})
 }
 
-// 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 {
-	s.init()
-	e, err := s.listenQUIC(addr, s.config)
-	if err != nil {
-		return err
-	}
-	go s.serve(e)
-	return nil
-}
-
 // 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(ctx context.Context, e *quic.Endpoint, h http.Handler) error {
 	s.init()
 	defer e.Close(canceledCtx)
 	for {
-		qconn, err := e.Accept(s.serveCtx)
+		qconn, err := e.Accept(ctx)
 		if err != nil {
 			return err
 		}
-		go s.newServerConn(qconn)
+		go s.newServerConn(ctx, qconn, h)
 	}
 }
 
@@ -181,7 +139,6 @@
 	defer func() {
 		s.mu.Lock()
 		defer s.mu.Unlock()
-		s.serveCtxCancel()
 		for sc := range s.activeConns {
 			sc.abort(&connectionError{
 				code:    errH3NoError,
@@ -254,8 +211,10 @@
 }
 
 type serverConn struct {
-	qconn *quic.Conn
-	srv   *server
+	qconn   *quic.Conn
+	srv     *server
+	baseCtx context.Context
+	handler http.Handler
 
 	genericConn // for handleUnidirectionalStream
 	enc         qpackEncoder
@@ -268,10 +227,14 @@
 	goawaySent         bool
 }
 
-func (s *server) newServerConn(qconn *quic.Conn) {
+// newServerConn handles a new connection.
+// The baseCtx parameter is the base context for request handlers on this connection.
+func (s *server) newServerConn(baseCtx context.Context, qconn *quic.Conn, h http.Handler) {
 	sc := &serverConn{
-		qconn: qconn,
-		srv:   s,
+		qconn:   qconn,
+		srv:     s,
+		baseCtx: baseCtx,
+		handler: h,
 	}
 	s.registerConn(sc)
 	defer s.unregisterConn(sc)
@@ -548,7 +511,7 @@
 		contentLength = int64(n)
 	}
 
-	req := &http.Request{
+	req := (&http.Request{
 		Proto:         "HTTP/3.0",
 		Method:        pHeader.method,
 		Host:          pHeader.authority,
@@ -559,7 +522,7 @@
 		RemoteAddr:    sc.qconn.RemoteAddr().String(),
 		Header:        header,
 		ContentLength: contentLength,
-	}
+	}).WithContext(sc.baseCtx)
 
 	rw := &responseWriter{
 		st:             st,
@@ -596,7 +559,7 @@
 	if t := sc.srv.writeTimeout(); t > 0 {
 		st.writeDeadline.set(time.Now().Add(t))
 	}
-	sc.srv.handler.ServeHTTP(rw, req)
+	sc.handler.ServeHTTP(rw, req)
 	return rw.close()
 }
 
diff --git a/internal/http3/server_test.go b/internal/http3/server_test.go
index 0e9c973..47b7173 100644
--- a/internal/http3/server_test.go
+++ b/internal/http3/server_test.go
@@ -1667,9 +1667,10 @@
 }
 
 type testServer struct {
-	t  testing.TB
-	s  *server
-	tn testNet
+	t           testing.TB
+	s           *server
+	tn          testNet
+	testHandler *testServerHandler
 	*testQUICEndpoint
 
 	addr netip.AddrPort
@@ -1708,21 +1709,20 @@
 		t: t,
 	}
 	if handler == nil {
-		handler = &testServerHandler{
+		ts.testHandler = &testServerHandler{
 			ts:    ts,
 			calls: []*serverHandlerCall{},
 		}
+		handler = ts.testHandler
 	}
 	ts.s = &server{
-		config: &quic.Config{
-			TLSConfig: testTLSConfig,
-		},
-		srv1:    &http.Server{},
-		handler: handler,
+		srv1: &http.Server{},
 	}
-	e := ts.tn.newQUICEndpoint(t, ts.s.config)
+	e := ts.tn.newQUICEndpoint(t, &quic.Config{
+		TLSConfig: testTLSConfig,
+	})
 	ts.addr = e.LocalAddr()
-	go ts.s.serve(e)
+	go ts.s.serve(t.Context(), e, handler)
 	return ts
 }
 
@@ -1756,8 +1756,8 @@
 // nextHandlerCall returns the next handler call that has been initiated by tc.
 // If there is no handler call, nil is returned.
 func (tc *testServerConn) nextHandlerCall() *serverHandlerCall {
-	h, ok := tc.ts.s.handler.(*testServerHandler)
-	if !ok {
+	h := tc.ts.testHandler
+	if h == nil {
 		tc.t.Fatal("nextHandlerCall is called for a testServer with non-nil handler")
 	}
 	tc.t.Helper()
diff --git a/internal/http3/transport.go b/internal/http3/transport.go
index 16daa24..b1c56c8 100644
--- a/internal/http3/transport.go
+++ b/internal/http3/transport.go
@@ -6,9 +6,11 @@
 
 import (
 	"context"
+	"crypto/tls"
 	"errors"
 	"fmt"
 	"math"
+	"net"
 	"net/http"
 	"net/url"
 	"sync"
@@ -24,11 +26,8 @@
 // TODO: Provide a way to register an HTTP/3 transport with a net/http.transport's
 // connection pool.
 type transport struct {
-	// config is the QUIC configuration used for client connections.
-	config *quic.Config
-	tr1    *http.Transport
-
-	listenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error)
+	tr1  *http.Transport
+	opts TransportOpts
 
 	mu sync.Mutex // Guards fields below.
 	// endpoint is the QUIC endpoint used by connections created by the
@@ -46,6 +45,11 @@
 	*transport
 }
 
+// Registered is called to record successful registration with a net/http Transport.
+func (t netHTTPTransport) Registered(tr1 *http.Transport) {
+	t.transport.tr1 = tr1
+}
+
 // 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
@@ -54,8 +58,8 @@
 	panic("netHTTPTransport.RoundTrip should never be called")
 }
 
-func (t netHTTPTransport) DialClientConn(ctx context.Context, addr string, _ *url.URL, stateHook func()) (http.RoundTripper, error) {
-	return t.transport.dial(ctx, addr, stateHook)
+func (t netHTTPTransport) DialClientConn(ctx context.Context, addr string, _ *url.URL, tlsConfig *tls.Config, stateHook func()) (http.RoundTripper, error) {
+	return t.transport.dial(ctx, addr, tlsConfig, stateHook)
 }
 
 type TransportOpts struct {
@@ -64,35 +68,33 @@
 	// ListenQUIC might be called multiple times.
 	ListenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error)
 
+	// ListenPacket specifies the function for creating a UDP listener.
+	// If ListenPacket is nil, then the transport listens using net.ListenPacket.
+	//
+	// If ListenQUIC and ListenPacket are both set, ListenQUIC takes priority.
+	ListenPacket func(network, addr string) (net.PacketConn, error)
+
 	// QUICConfig is the QUIC configuration used by the transport.
 	// QUICConfig may be nil and should not be modified after calling
 	// RegisterTransport.
-	// If QUICConfig.TLSConfig is nil, the TLSConfig of the net/http Transport
-	// given to RegisterTransport will be used.
+	//
+	// The QUICConfig's TLSConfig is not used.
+	// Set the TLSConfig on the net/http Transport instead.
 	QUICConfig *quic.Config
 }
 
 // RegisterTransport configures a net/http HTTP/1 Transport to use HTTP/3.
-func RegisterTransport(tr *http.Transport, opts TransportOpts) {
-	if opts.QUICConfig == nil {
-		opts.QUICConfig = &quic.Config{}
-	}
-	if opts.QUICConfig.TLSConfig == nil {
-		opts.QUICConfig.TLSConfig = tr.TLSClientConfig
-	}
-	if opts.ListenQUIC == nil {
-		opts.ListenQUIC = func(addr string, config *quic.Config) (*quic.Endpoint, error) {
-			return quic.Listen("udp", addr, config)
-		}
-	}
+func RegisterTransport(tr *http.Transport, opts TransportOpts) error {
 	tr3 := &transport{
-		// initConfig will clone the tr.TLSClientConfig.
-		config:      initConfig(opts.QUICConfig),
-		tr1:         tr,
-		listenQUIC:  opts.ListenQUIC,
+		opts:        opts,
 		activeConns: make(map[*clientConn]struct{}),
 	}
+	// RegisterProtocol will set tr3.tr1.
 	tr.RegisterProtocol("http/3", netHTTPTransport{tr3})
+	if tr3.tr1 != tr {
+		return errors.New("http3: net/http does not support HTTP/3")
+	}
+	return nil
 }
 
 func (tr *transport) incInFlightDials() {
@@ -136,20 +138,34 @@
 	// probably uncommon for regular use cases. However, finding a workaround
 	// for this eventually would be ideal.
 	if tr.endpoint == nil {
-		tr.endpoint, err = tr.listenQUIC(":0", tr.config)
+		quicConfig := newQUICConfig(tr.opts.QUICConfig, tr.tr1.TLSClientConfig)
+		if tr.opts.ListenQUIC != nil {
+			tr.endpoint, err = tr.opts.ListenQUIC(":0", quicConfig)
+		} else if tr.opts.ListenPacket != nil {
+			conn, err := tr.opts.ListenPacket("udp", ":0")
+			if err != nil {
+				return err
+			}
+			tr.endpoint, err = quic.NewEndpoint(conn, quicConfig)
+			if err != nil {
+				conn.Close()
+			}
+		} else {
+			tr.endpoint, err = quic.Listen("udp", ":0", quicConfig)
+		}
 	}
 	return err
 }
 
 // dial creates a new HTTP/3 client connection.
-func (tr *transport) dial(ctx context.Context, target string, stateHook func()) (*clientConn, error) {
+func (tr *transport) dial(ctx context.Context, target string, tlsConfig *tls.Config, stateHook func()) (*clientConn, error) {
 	tr.incInFlightDials()
 	defer tr.decInFlightDials()
 
 	if err := tr.initEndpoint(); err != nil {
 		return nil, err
 	}
-	qconn, err := tr.endpoint.Dial(ctx, "udp", target, tr.config)
+	qconn, err := tr.endpoint.Dial(ctx, "udp", target, newQUICConfig(tr.opts.QUICConfig, tlsConfig))
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/http3/transport_test.go b/internal/http3/transport_test.go
index 7d35525..071f936 100644
--- a/internal/http3/transport_test.go
+++ b/internal/http3/transport_test.go
@@ -549,15 +549,12 @@
 func newTestClientConnWithHook(t testing.TB, stateHook func()) *testClientConn {
 	e1, e2 := newQUICEndpointPair(t)
 	tr := &transport{
-		endpoint: e1,
-		config: &quic.Config{
-			TLSConfig: testTLSConfig,
-		},
+		endpoint:    e1,
 		tr1:         new(http.Transport),
 		activeConns: make(map[*clientConn]struct{}),
 	}
 
-	cc, err := tr.dial(t.Context(), e2.LocalAddr().String(), stateHook)
+	cc, err := tr.dial(t.Context(), e2.LocalAddr().String(), testTLSConfig, stateHook)
 	if err != nil {
 		t.Fatal(err)
 	}