Repository: libp2p/go-libp2p
Pull request #2262 (2023-04-26 11:43:33)

quic: prioritise listen connections for reuse
Currently there is a race in kubo between the first dial made by a node on startup and starting to listen on a quic address. 

If the dial happens first, this places an entry in the ```reuse.global``` map. This entry will stay in the map till we get a period of 30 seconds where we make no new dials. In my experiments this entry is removed after 15 minutes of starting the node. This dial happens before listening 5-10% of the times on my machine. 

The impact of this is on holepunching. While this entry is in the map, the function ```reuse.Dial``` in ```p2p/transport/quicreuse/reuse.go``` can return either this connection that we dialed out of but are not listening on, or it can return the correct connection the one on which we are listening.  


Patch:
diff --git a/.githooks/README.md b/.githooks/README.md
new file mode 100644
index 00000000..c2de4599
--- /dev/null
+++ b/.githooks/README.md
@@ -0,0 +1,8 @@
+# Git Hooks
+
+This directory contains useful Git hooks for working with go-libp2p.
+
+Install them by running
+```bash
+git config core.hooksPath .githooks
+```
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 00000000..27af2ba8
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+pushd ./test-plans > /dev/null
+go mod tidy
+if [[ -n $(git diff --name-only -- "go.mod" "go.sum") ]]; then
+  echo "go.mod / go.sum in test-plans not tidied"
+  errored=true
+fi
+popd > /dev/null
+
+if [ "$errored" = true ]; then
+	exit 1
+fi
diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md
index 5f77e72c..5326b706 100644
--- a/.github/ISSUE_TEMPLATE/release.md
+++ b/.github/ISSUE_TEMPLATE/release.md
@@ -30,4 +30,5 @@ about: 'Start a new libp2p release.'
     - [ ] [go-libp2p-kad-dht](https://github.com/libp2p/go-libp2p-kad-dht/)
     - [ ] [go-libp2p-pubsub](https://github.com/libp2p/go-libp2p-pubsub) (In case of breaking changes.)
     - [ ] [ipfs/kubo](https://github.com/ipfs/kubo)
+  - [ ] Add new release to interop tester in [test-plans](https://github.com/libp2p/test-plans/)
 - [ ] Make required changes to the release process.
diff --git a/p2p/host/basic/basic_host.go b/p2p/host/basic/basic_host.go
index ce82ad33..8ec62565 100644
--- a/p2p/host/basic/basic_host.go
+++ b/p2p/host/basic/basic_host.go
@@ -25,7 +25,6 @@ import (
 	"github.com/libp2p/go-libp2p/p2p/host/eventbus"
 	"github.com/libp2p/go-libp2p/p2p/host/pstoremanager"
 	"github.com/libp2p/go-libp2p/p2p/host/relaysvc"
-	inat "github.com/libp2p/go-libp2p/p2p/net/nat"
 	relayv2 "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay"
 	"github.com/libp2p/go-libp2p/p2p/protocol/holepunch"
 	"github.com/libp2p/go-libp2p/p2p/protocol/identify"
@@ -771,7 +770,7 @@ func (h *BasicHost) Addrs() []ma.Multiaddr {
 	}
 
 	type addCertHasher interface {
-		AddCertHashes(m ma.Multiaddr) ma.Multiaddr
+		AddCertHashes(m ma.Multiaddr) (ma.Multiaddr, bool)
 	}
 
 	addrs := h.AddrsFactory(h.AllAddrs())
@@ -793,7 +792,11 @@ func (h *BasicHost) Addrs() []ma.Multiaddr {
 			if !ok {
 				continue
 			}
-			addrs[i] = tpt.AddCertHashes(addr)
+			addrWithCerthash, added := tpt.AddCertHashes(addr)
+			addrs[i] = addrWithCerthash
+			if !added {
+				log.Debug("Couldn't add certhashes to webtransport multiaddr because we aren't listening on webtransport")
+			}
 		}
 	}
 	return addrs
@@ -858,99 +861,19 @@ func (h *BasicHost) AllAddrs() []ma.Multiaddr {
 
 	finalAddrs = dedupAddrs(finalAddrs)
 
-	var natMappings []inat.Mapping
-
 	// natmgr is nil if we do not use nat option;
-	// h.natmgr.NAT() is nil if not ready, or no nat is available.
-	if h.natmgr != nil && h.natmgr.NAT() != nil {
-		natMappings = h.natmgr.NAT().Mappings()
-	}
-
-	if len(natMappings) > 0 {
+	if h.natmgr != nil {
 		// We have successfully mapped ports on our NAT. Use those
 		// instead of observed addresses (mostly).
 
-		// First, generate a mapping table.
-		// protocol -> internal port -> external addr
-		ports := make(map[string]map[int]net.Addr)
-		for _, m := range natMappings {
-			addr, err := m.ExternalAddr()
-			if err != nil {
-				// mapping not ready yet.
-				continue
-			}
-			protoPorts, ok := ports[m.Protocol()]
-			if !ok {
-				protoPorts = make(map[int]net.Addr)
-				ports[m.Protocol()] = protoPorts
-			}
-			protoPorts[m.InternalPort()] = addr
-		}
-
 		// Next, apply this mapping to our addresses.
 		for _, listen := range listenAddrs {
-			found := false
-			transport, rest := ma.SplitFunc(listen, func(c ma.Component) bool {
-				if found {
-					return true
-				}
-				switch c.Protocol().Code {
-				case ma.P_TCP, ma.P_UDP:
-					found = true
-				}
-				return false
-			})
-			if !manet.IsThinWaist(transport) {
-				continue
-			}
-
-			naddr, err := manet.ToNetAddr(transport)
-			if err != nil {
-				log.Error("error parsing net multiaddr %q: %s", transport, err)
+			extMaddr := h.natmgr.GetMapping(listen)
+			if extMaddr == nil {
+				// not mapped
 				continue
 			}
 
-			var (
-				ip       net.IP
-				iport    int
-				protocol string
-			)
-			switch naddr := naddr.(type) {
-			case *net.TCPAddr:
-				ip = naddr.IP
-				iport = naddr.Port
-				protocol = "tcp"
-			case *net.UDPAddr:
-				ip = naddr.IP
-				iport = naddr.Port
-				protocol = "udp"
-			default:
-				continue
-			}
-
-			if !ip.IsGlobalUnicast() && !ip.IsUnspecified() {
-				// We only map global unicast & unspecified addresses ports.
-				// Not broadcast, multicast, etc.
-				continue
-			}
-
-			mappedAddr, ok := ports[protocol][iport]
-			if !ok {
-				// Not mapped.
-				continue
-			}
-
-			mappedMaddr, err := manet.FromNetAddr(mappedAddr)
-			if err != nil {
-				log.Errorf("mapped addr can't be turned into a multiaddr %q: %s", mappedAddr, err)
-				continue
-			}
-
-			extMaddr := mappedMaddr
-			if rest != nil {
-				extMaddr = ma.Join(extMaddr, rest)
-			}
-
 			// if the router reported a sane address
 			if !manet.IsIPUnspecified(extMaddr) {
 				// Add in the mapped addr.
diff --git a/p2p/host/basic/mocks.go b/p2p/host/basic/mocks.go
new file mode 100644
index 00000000..3ad4d4e9
--- /dev/null
+++ b/p2p/host/basic/mocks.go
@@ -0,0 +1,6 @@
+//go:build gomock || generate
+
+package basichost
+
+//go:generate sh -c "go run github.com/golang/mock/mockgen -build_flags=\"-tags=gomock\" -package basichost -destination mock_nat_test.go github.com/libp2p/go-libp2p/p2p/host/basic NAT"
+type NAT nat
diff --git a/p2p/host/basic/natmgr.go b/p2p/host/basic/natmgr.go
index 782c116d..6ebe37b9 100644
--- a/p2p/host/basic/natmgr.go
+++ b/p2p/host/basic/natmgr.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"io"
 	"net"
+	"net/netip"
 	"strconv"
 	"sync"
 	"time"
@@ -12,24 +13,37 @@ import (
 	inat "github.com/libp2p/go-libp2p/p2p/net/nat"
 
 	ma "github.com/multiformats/go-multiaddr"
+	manet "github.com/multiformats/go-multiaddr/net"
 )
 
 // NATManager is a simple interface to manage NAT devices.
+// It listens Listen and ListenClose notifications from the network.Network,
+// and tries to obtain port mappings for those.
 type NATManager interface {
-	// NAT gets the NAT device managed by the NAT manager.
-	NAT() *inat.NAT
-
-	// Ready receives a notification when the NAT device is ready for use.
-	Ready() <-chan struct{}
-
+	GetMapping(ma.Multiaddr) ma.Multiaddr
 	io.Closer
 }
 
 // NewNATManager creates a NAT manager.
 func NewNATManager(net network.Network) NATManager {
-	return newNatManager(net)
+	return newNATManager(net)
+}
+
+type entry struct {
+	protocol string
+	port     int
+}
+
+type nat interface {
+	AddMapping(protocol string, port int) error
+	RemoveMapping(protocol string, port int) error
+	GetMapping(protocol string, port int) (netip.AddrPort, bool)
+	io.Closer
 }
 
+// so we can mock it in tests
+var discoverNAT = func(ctx context.Context) (nat, error) { return inat.DiscoverNAT(ctx) }
+
 // natManager takes care of adding + removing port mappings to the nat.
 // Initialized with the host if it has a NATPortMap option enabled.
 // natManager receives signals from the network, and check on nat mappings:
@@ -39,22 +53,23 @@ func NewNATManager(net network.Network) NATManager {
 type natManager struct {
 	net   network.Network
 	natMx sync.RWMutex
-	nat   *inat.NAT
+	nat   nat
 
-	ready    chan struct{} // closed once the nat is ready to process port mappings
-	syncFlag chan struct{}
+	syncFlag chan struct{} // cap: 1
+
+	tracked map[entry]bool // the bool is only used in doSync and has no meaning outside of that function
 
 	refCount  sync.WaitGroup
 	ctxCancel context.CancelFunc
 }
 
-func newNatManager(net network.Network) *natManager {
+func newNATManager(net network.Network) *natManager {
 	ctx, cancel := context.WithCancel(context.Background())
 	nmgr := &natManager{
 		net:       net,
-		ready:     make(chan struct{}),
 		syncFlag:  make(chan struct{}, 1),
 		ctxCancel: cancel,
+		tracked:   make(map[entry]bool),
 	}
 	nmgr.refCount.Add(1)
 	go nmgr.background(ctx)
@@ -69,36 +84,29 @@ func (nmgr *natManager) Close() error {
 	return nil
 }
 
-// Ready returns a channel which will be closed when the NAT has been found
-// and is ready to be used, or the search process is done.
-func (nmgr *natManager) Ready() <-chan struct{} {
-	return nmgr.ready
-}
-
 func (nmgr *natManager) background(ctx context.Context) {
 	defer nmgr.refCount.Done()
 
 	defer func() {
 		nmgr.natMx.Lock()
+		defer nmgr.natMx.Unlock()
+
 		if nmgr.nat != nil {
 			nmgr.nat.Close()
 		}
-		nmgr.natMx.Unlock()
 	}()
 
 	discoverCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
 	defer cancel()
-	natInstance, err := inat.DiscoverNAT(discoverCtx)
+	natInstance, err := discoverNAT(discoverCtx)
 	if err != nil {
 		log.Info("DiscoverNAT error:", err)
-		close(nmgr.ready)
 		return
 	}
 
 	nmgr.natMx.Lock()
 	nmgr.nat = natInstance
 	nmgr.natMx.Unlock()
-	close(nmgr.ready)
 
 	// sign natManager up for network notifications
 	// we need to sign up here to avoid missing some notifs
@@ -127,10 +135,10 @@ func (nmgr *natManager) sync() {
 // doSync syncs the current NAT mappings, removing any outdated mappings and adding any
 // new mappings.
 func (nmgr *natManager) doSync() {
-	ports := map[string]map[int]bool{
-		"tcp": {},
-		"udp": {},
+	for e := range nmgr.tracked {
+		nmgr.tracked[e] = false
 	}
+	var newAddresses []entry
 	for _, maddr := range nmgr.net.ListenAddresses() {
 		// Strip the IP
 		maIP, rest := ma.SplitFirst(maddr)
@@ -144,10 +152,9 @@ func (nmgr *natManager) doSync() {
 			continue
 		}
 
-		// Only bother if we're listening on a
-		// unicast/unspecified IP.
+		// Only bother if we're listening on an unicast / unspecified IP.
 		ip := net.IP(maIP.RawValue())
-		if !(ip.IsGlobalUnicast() || ip.IsUnspecified()) {
+		if !ip.IsGlobalUnicast() && !ip.IsUnspecified() {
 			continue
 		}
 
@@ -166,74 +173,118 @@ func (nmgr *natManager) doSync() {
 		default:
 			continue
 		}
-
 		port, err := strconv.ParseUint(proto.Value(), 10, 16)
 		if err != nil {
 			// bug in multiaddr
 			panic(err)
 		}
-		ports[protocol][int(port)] = false
+		e := entry{protocol: protocol, port: int(port)}
+		if _, ok := nmgr.tracked[e]; ok {
+			nmgr.tracked[e] = true
+		} else {
+			newAddresses = append(newAddresses, e)
+		}
 	}
 
 	var wg sync.WaitGroup
 	defer wg.Wait()
 
 	// Close old mappings
-	for _, m := range nmgr.nat.Mappings() {
-		mappedPort := m.InternalPort()
-		if _, ok := ports[m.Protocol()][mappedPort]; !ok {
-			// No longer need this mapping.
-			wg.Add(1)
-			go func(m inat.Mapping) {
-				defer wg.Done()
-				m.Close()
-			}(m)
-		} else {
-			// already mapped
-			ports[m.Protocol()][mappedPort] = true
+	for e, v := range nmgr.tracked {
+		if !v {
+			nmgr.nat.RemoveMapping(e.protocol, e.port)
+			delete(nmgr.tracked, e)
 		}
 	}
 
 	// Create new mappings.
-	for proto, pports := range ports {
-		for port, mapped := range pports {
-			if mapped {
-				continue
-			}
-			wg.Add(1)
-			go func(proto string, port int) {
-				defer wg.Done()
-				_, err := nmgr.nat.NewMapping(proto, port)
-				if err != nil {
-					log.Errorf("failed to port-map %s port %d: %s", proto, port, err)
-				}
-			}(proto, port)
+	for _, e := range newAddresses {
+		if err := nmgr.nat.AddMapping(e.protocol, e.port); err != nil {
+			log.Errorf("failed to port-map %s port %d: %s", e.protocol, e.port, err)
 		}
+		nmgr.tracked[e] = false
 	}
 }
 
-// NAT returns the natManager's nat object. this may be nil, if
-// (a) the search process is still ongoing, or (b) the search process
-// found no nat. Clients must check whether the return value is nil.
-func (nmgr *natManager) NAT() *inat.NAT {
+func (nmgr *natManager) GetMapping(addr ma.Multiaddr) ma.Multiaddr {
 	nmgr.natMx.Lock()
 	defer nmgr.natMx.Unlock()
-	return nmgr.nat
-}
 
-type nmgrNetNotifiee natManager
+	if nmgr.nat == nil { // NAT not yet initialized
+		return nil
+	}
 
-func (nn *nmgrNetNotifiee) natManager() *natManager {
-	return (*natManager)(nn)
-}
+	var found bool
+	var proto int // ma.P_TCP or ma.P_UDP
+	transport, rest := ma.SplitFunc(addr, func(c ma.Component) bool {
+		if found {
+			return true
+		}
+		proto = c.Protocol().Code
+		found = proto == ma.P_TCP || proto == ma.P_UDP
+		return false
+	})
+	if !manet.IsThinWaist(transport) {
+		return nil
+	}
 
-func (nn *nmgrNetNotifiee) Listen(n network.Network, addr ma.Multiaddr) {
-	nn.natManager().sync()
-}
+	naddr, err := manet.ToNetAddr(transport)
+	if err != nil {
+		log.Error("error parsing net multiaddr %q: %s", transport, err)
+		return nil
+	}
 
-func (nn *nmgrNetNotifiee) ListenClose(n network.Network, addr ma.Multiaddr) {
-	nn.natManager().sync()
+	var (
+		ip       net.IP
+		port     int
+		protocol string
+	)
+	switch naddr := naddr.(type) {
+	case *net.TCPAddr:
+		ip = naddr.IP
+		port = naddr.Port
+		protocol = "tcp"
+	case *net.UDPAddr:
+		ip = naddr.IP
+		port = naddr.Port
+		protocol = "udp"
+	default:
+		return nil
+	}
+
+	if !ip.IsGlobalUnicast() && !ip.IsUnspecified() {
+		// We only map global unicast & unspecified addresses ports, not broadcast, multicast, etc.
+		return nil
+	}
+
+	extAddr, ok := nmgr.nat.GetMapping(protocol, port)
+	if !ok {
+		return nil
+	}
+
+	var mappedAddr net.Addr
+	switch naddr.(type) {
+	case *net.TCPAddr:
+		mappedAddr = net.TCPAddrFromAddrPort(extAddr)
+	case *net.UDPAddr:
+		mappedAddr = net.UDPAddrFromAddrPort(extAddr)
+	}
+	mappedMaddr, err := manet.FromNetAddr(mappedAddr)
+	if err != nil {
+		log.Errorf("mapped addr can't be turned into a multiaddr %q: %s", mappedAddr, err)
+		return nil
+	}
+	extMaddr := mappedMaddr
+	if rest != nil {
+		extMaddr = ma.Join(extMaddr, rest)
+	}
+	return extMaddr
 }
 
-func (nn *nmgrNetNotifiee) Connected(network.Network, network.Conn)    {}
-func (nn *nmgrNetNotifiee) Disconnected(network.Network, network.Conn) {}
+type nmgrNetNotifiee natManager
+
+func (nn *nmgrNetNotifiee) natManager() *natManager                          { return (*natManager)(nn) }
+func (nn *nmgrNetNotifiee) Listen(network.Network, ma.Multiaddr)             { nn.natManager().sync() }
+func (nn *nmgrNetNotifiee) ListenClose(n network.Network, addr ma.Multiaddr) { nn.natManager().sync() }
+func (nn *nmgrNetNotifiee) Connected(network.Network, network.Conn)          {}
+func (nn *nmgrNetNotifiee) Disconnected(network.Network, network.Conn)       {}
diff --git a/p2p/net/nat/mapping.go b/p2p/net/nat/mapping.go
deleted file mode 100644
index f9b508e4..00000000
--- a/p2p/net/nat/mapping.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package nat
-
-import (
-	"fmt"
-	"net"
-	"sync"
-	"time"
-)
-
-// Mapping represents a port mapping in a NAT.
-type Mapping interface {
-	// NAT returns the NAT object this Mapping belongs to.
-	NAT() *NAT
-
-	// Protocol returns the protocol of this port mapping. This is either
-	// "tcp" or "udp" as no other protocols are likely to be NAT-supported.
-	Protocol() string
-
-	// InternalPort returns the internal device port. Mapping will continue to
-	// try to map InternalPort() to an external facing port.
-	InternalPort() int
-
-	// ExternalPort returns the external facing port. If the mapping is not
-	// established, port will be 0
-	ExternalPort() int
-
-	// ExternalAddr returns the external facing address. If the mapping is not
-	// established, addr will be nil, and and ErrNoMapping will be returned.
-	ExternalAddr() (addr net.Addr, err error)
-
-	// Close closes the port mapping
-	Close() error
-}
-
-// keeps republishing
-type mapping struct {
-	sync.Mutex // guards all fields
-
-	nat     *NAT
-	proto   string
-	intport int
-	extport int
-
-	cached    net.IP
-	cacheTime time.Time
-	cacheLk   sync.Mutex
-}
-
-func (m *mapping) NAT() *NAT {
-	m.Lock()
-	defer m.Unlock()
-	return m.nat
-}
-
-func (m *mapping) Protocol() string {
-	m.Lock()
-	defer m.Unlock()
-	return m.proto
-}
-
-func (m *mapping) InternalPort() int {
-	m.Lock()
-	defer m.Unlock()
-	return m.intport
-}
-
-func (m *mapping) ExternalPort() int {
-	m.Lock()
-	defer m.Unlock()
-	return m.extport
-}
-
-func (m *mapping) setExternalPort(p int) {
-	m.Lock()
-	defer m.Unlock()
-	m.extport = p
-}
-
-func (m *mapping) ExternalAddr() (net.Addr, error) {
-	m.cacheLk.Lock()
-	defer m.cacheLk.Unlock()
-	oport := m.ExternalPort()
-	if oport == 0 {
-		// dont even try right now.
-		return nil, ErrNoMapping
-	}
-
-	if time.Since(m.cacheTime) >= CacheTime {
-		m.nat.natmu.Lock()
-		cval, err := m.nat.nat.GetExternalAddress()
-		m.nat.natmu.Unlock()
-
-		if err != nil {
-			return nil, err
-		}
-
-		m.cached = cval
-		m.cacheTime = time.Now()
-	}
-	switch m.Protocol() {
-	case "tcp":
-		return &net.TCPAddr{
-			IP:   m.cached,
-			Port: oport,
-		}, nil
-	case "udp":
-		return &net.UDPAddr{
-			IP:   m.cached,
-			Port: oport,
-		}, nil
-	default:
-		panic(fmt.Sprintf("invalid protocol %q", m.Protocol()))
-	}
-}
-
-func (m *mapping) Close() error {
-	m.nat.removeMapping(m)
-	return nil
-}
diff --git a/p2p/net/nat/nat.go b/p2p/net/nat/nat.go
index e2656f8b..68834ac8 100644
--- a/p2p/net/nat/nat.go
+++ b/p2p/net/nat/nat.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"net/netip"
 	"sync"
 	"time"
 
@@ -19,18 +20,30 @@ var log = logging.Logger("nat")
 
 // MappingDuration is a default port mapping duration.
 // Port mappings are renewed every (MappingDuration / 3)
-const MappingDuration = time.Second * 60
+const MappingDuration = time.Minute
 
 // CacheTime is the time a mapping will cache an external address for
-const CacheTime = time.Second * 15
+const CacheTime = 15 * time.Second
 
-// DiscoverNAT looks for a NAT device in the network and
-// returns an object that can manage port mappings.
+type entry struct {
+	protocol string
+	port     int
+}
+
+// so we can mock it in tests
+var discoverGateway = nat.DiscoverGateway
+
+// DiscoverNAT looks for a NAT device in the network and returns an object that can manage port mappings.
 func DiscoverNAT(ctx context.Context) (*NAT, error) {
-	natInstance, err := nat.DiscoverGateway(ctx)
+	natInstance, err := discoverGateway(ctx)
 	if err != nil {
 		return nil, err
 	}
+	var extAddr netip.Addr
+	extIP, err := natInstance.GetExternalAddress()
+	if err == nil {
+		extAddr, _ = netip.AddrFromSlice(extIP)
+	}
 
 	// Log the device addr.
 	addr, err := natInstance.GetDeviceAddress()
@@ -40,7 +53,20 @@ func DiscoverNAT(ctx context.Context) (*NAT, error) {
 		log.Debug("DiscoverGateway address:", addr)
 	}
 
-	return newNAT(natInstance), nil
+	ctx, cancel := context.WithCancel(context.Background())
+	nat := &NAT{
+		nat:       natInstance,
+		extAddr:   extAddr,
+		mappings:  make(map[entry]int),
+		ctx:       ctx,
+		ctxCancel: cancel,
+	}
+	nat.refCount.Add(1)
+	go func() {
+		defer nat.refCount.Done()
+		nat.background()
+	}()
+	return nat, nil
 }
 
 // NAT is an object that manages address port mappings in
@@ -50,6 +76,8 @@ func DiscoverNAT(ctx context.Context) (*NAT, error) {
 type NAT struct {
 	natmu sync.Mutex
 	nat   nat.NAT
+	// External IP of the NAT. Will be renewed periodically (every CacheTime).
+	extAddr netip.Addr
 
 	refCount  sync.WaitGroup
 	ctx       context.Context
@@ -57,17 +85,7 @@ type NAT struct {
 
 	mappingmu sync.RWMutex // guards mappings
 	closed    bool
-	mappings  map[*mapping]struct{}
-}
-
-func newNAT(realNAT nat.NAT) *NAT {
-	ctx, cancel := context.WithCancel(context.Background())
-	return &NAT{
-		nat:       realNAT,
-		mappings:  make(map[*mapping]struct{}),
-		ctx:       ctx,
-		ctxCancel: cancel,
-	}
+	mappings  map[entry]int
 }
 
 // Close shuts down all port mappings. NAT can no longer be used.
@@ -81,99 +99,139 @@ func (nat *NAT) Close() error {
 	return nil
 }
 
-// Mappings returns a slice of all NAT mappings
-func (nat *NAT) Mappings() []Mapping {
+func (nat *NAT) GetMapping(protocol string, port int) (addr netip.AddrPort, found bool) {
 	nat.mappingmu.Lock()
-	maps2 := make([]Mapping, 0, len(nat.mappings))
-	for m := range nat.mappings {
-		maps2 = append(maps2, m)
+	defer nat.mappingmu.Unlock()
+
+	if !nat.extAddr.IsValid() {
+		return netip.AddrPort{}, false
 	}
-	nat.mappingmu.Unlock()
-	return maps2
+	extPort, found := nat.mappings[entry{protocol: protocol, port: port}]
+	if !found {
+		return netip.AddrPort{}, false
+	}
+	return netip.AddrPortFrom(nat.extAddr, uint16(extPort)), true
 }
 
-// NewMapping attempts to construct a mapping on protocol and internal port
-// It will also periodically renew the mapping until the returned Mapping
-// -- or its parent NAT -- is Closed.
+// AddMapping attempts to construct a mapping on protocol and internal port.
+// It blocks until a mapping was established. Once added, it periodically renews the mapping.
 //
 // May not succeed, and mappings may change over time;
 // NAT devices may not respect our port requests, and even lie.
-// Clients should not store the mapped results, but rather always
-// poll our object for the latest mappings.
-func (nat *NAT) NewMapping(protocol string, port int) (Mapping, error) {
-	if nat == nil {
-		return nil, fmt.Errorf("no nat available")
-	}
-
+func (nat *NAT) AddMapping(protocol string, port int) error {
 	switch protocol {
 	case "tcp", "udp":
 	default:
-		return nil, fmt.Errorf("invalid protocol: %s", protocol)
-	}
-
-	m := &mapping{
-		intport: port,
-		nat:     nat,
-		proto:   protocol,
+		return fmt.Errorf("invalid protocol: %s", protocol)
 	}
 
 	nat.mappingmu.Lock()
+	defer nat.mappingmu.Unlock()
+
 	if nat.closed {
-		nat.mappingmu.Unlock()
-		return nil, errors.New("closed")
+		return errors.New("closed")
 	}
-	nat.mappings[m] = struct{}{}
-	nat.refCount.Add(1)
-	nat.mappingmu.Unlock()
-	go nat.refreshMappings(m)
 
 	// do it once synchronously, so first mapping is done right away, and before exiting,
 	// allowing users -- in the optimistic case -- to use results right after.
-	nat.establishMapping(m)
-	return m, nil
+	extPort := nat.establishMapping(protocol, port)
+	nat.mappings[entry{protocol: protocol, port: port}] = extPort
+	return nil
 }
 
-func (nat *NAT) removeMapping(m *mapping) {
+// RemoveMapping removes a port mapping.
+// It blocks until the NAT has removed the mapping.
+func (nat *NAT) RemoveMapping(protocol string, port int) error {
 	nat.mappingmu.Lock()
-	delete(nat.mappings, m)
-	nat.mappingmu.Unlock()
-	nat.natmu.Lock()
-	nat.nat.DeletePortMapping(m.Protocol(), m.InternalPort())
-	nat.natmu.Unlock()
+	defer nat.mappingmu.Unlock()
+
+	switch protocol {
+	case "tcp", "udp":
+		e := entry{protocol: protocol, port: port}
+		if _, ok := nat.mappings[e]; ok {
+			delete(nat.mappings, e)
+			return nat.nat.DeletePortMapping(protocol, port)
+		}
+		return errors.New("unknown mapping")
+	default:
+		return fmt.Errorf("invalid protocol: %s", protocol)
+	}
 }
 
-func (nat *NAT) refreshMappings(m *mapping) {
-	defer nat.refCount.Done()
-	t := time.NewTicker(MappingDuration / 3)
+func (nat *NAT) background() {
+	const mappingUpdate = MappingDuration / 3
+
+	now := time.Now()
+	nextMappingUpdate := now.Add(mappingUpdate)
+	nextAddrUpdate := now.Add(CacheTime)
+
+	t := time.NewTimer(minTime(nextMappingUpdate, nextAddrUpdate).Sub(now)) // don't use a ticker here. We don't know how long establishing the mappings takes.
 	defer t.Stop()
 
+	var in []entry
+	var out []int // port numbers
 	for {
 		select {
-		case <-t.C:
-			nat.establishMapping(m)
+		case now := <-t.C:
+			if now.After(nextMappingUpdate) {
+				in = in[:0]
+				out = out[:0]
+				nat.mappingmu.Lock()
+				for e := range nat.mappings {
+					in = append(in, e)
+				}
+				nat.mappingmu.Unlock()
+				// Establishing the mapping involves network requests.
+				// Don't hold the mutex, just save the ports.
+				for _, e := range in {
+					out = append(out, nat.establishMapping(e.protocol, e.port))
+				}
+				nat.mappingmu.Lock()
+				for i, p := range in {
+					if _, ok := nat.mappings[p]; !ok {
+						continue // entry might have been deleted
+					}
+					nat.mappings[p] = out[i]
+				}
+				nat.mappingmu.Unlock()
+				nextMappingUpdate = time.Now().Add(mappingUpdate)
+			}
+			if now.After(nextAddrUpdate) {
+				var extAddr netip.Addr
+				extIP, err := nat.nat.GetExternalAddress()
+				if err == nil {
+					extAddr, _ = netip.AddrFromSlice(extIP)
+				}
+				nat.extAddr = extAddr
+				nextAddrUpdate = time.Now().Add(CacheTime)
+			}
+			t.Reset(time.Until(minTime(nextAddrUpdate, nextMappingUpdate)))
 		case <-nat.ctx.Done():
-			m.Close()
+			nat.mappingmu.Lock()
+			for e := range nat.mappings {
+				delete(nat.mappings, e)
+				nat.nat.DeletePortMapping(e.protocol, e.port)
+			}
+			nat.mappingmu.Unlock()
 			return
 		}
 	}
 }
 
-func (nat *NAT) establishMapping(m *mapping) {
-	oldport := m.ExternalPort()
-
-	log.Debugf("Attempting port map: %s/%d", m.Protocol(), m.InternalPort())
+func (nat *NAT) establishMapping(protocol string, internalPort int) (externalPort int) {
+	log.Debugf("Attempting port map: %s/%d", protocol, internalPort)
 	const comment = "libp2p"
 
 	nat.natmu.Lock()
-	newport, err := nat.nat.AddPortMapping(m.Protocol(), m.InternalPort(), comment, MappingDuration)
+	var err error
+	externalPort, err = nat.nat.AddPortMapping(protocol, internalPort, comment, MappingDuration)
 	if err != nil {
 		// Some hardware does not support mappings with timeout, so try that
-		newport, err = nat.nat.AddPortMapping(m.Protocol(), m.InternalPort(), comment, 0)
+		externalPort, err = nat.nat.AddPortMapping(protocol, internalPort, comment, 0)
 	}
 	nat.natmu.Unlock()
 
-	if err != nil || newport == 0 {
-		m.setExternalPort(0) // clear mapping
+	if err != nil || externalPort == 0 {
 		// TODO: log.Event
 		if err != nil {
 			log.Warnf("failed to establish port mapping: %s", err)
@@ -182,12 +240,16 @@ func (nat *NAT) establishMapping(m *mapping) {
 		}
 		// we do not close if the mapping failed,
 		// because it may work again next time.
-		return
+		return 0
 	}
 
-	m.setExternalPort(newport)
-	log.Debugf("NAT Mapping: %d --> %d (%s)", m.ExternalPort(), m.InternalPort(), m.Protocol())
-	if oldport != 0 && newport != oldport {
-		log.Debugf("failed to renew same port mapping: ch %d -> %d", oldport, newport)
+	log.Debugf("NAT Mapping: %d --> %d (%s)", externalPort, internalPort, protocol)
+	return externalPort
+}
+
+func minTime(a, b time.Time) time.Time {
+	if a.Before(b) {
+		return a
 	}
+	return b
 }
diff --git a/p2p/transport/quic/virtuallistener.go b/p2p/transport/quic/virtuallistener.go
index fc6373f8..36c11c66 100644
--- a/p2p/transport/quic/virtuallistener.go
+++ b/p2p/transport/quic/virtuallistener.go
@@ -46,8 +46,9 @@ type acceptVal struct {
 type acceptLoopRunner struct {
 	acceptSem chan struct{}
 
-	muxerMu sync.Mutex
-	muxer   map[quic.VersionNumber]chan acceptVal
+	muxerMu     sync.Mutex
+	muxer       map[quic.VersionNumber]chan acceptVal
+	muxerClosed bool
 }
 
 func (r *acceptLoopRunner) AcceptForVersion(v quic.VersionNumber) chan acceptVal {
@@ -68,6 +69,11 @@ func (r *acceptLoopRunner) RmAcceptForVersion(v quic.VersionNumber) {
 	r.muxerMu.Lock()
 	defer r.muxerMu.Unlock()
 
+	if r.muxerClosed {
+		// Already closed, all versions are removed
+		return
+	}
+
 	ch, ok := r.muxer[v]
 	if !ok {
 		panic("expected chan in accept muxer")
@@ -79,6 +85,7 @@ func (r *acceptLoopRunner) RmAcceptForVersion(v quic.VersionNumber) {
 func (r *acceptLoopRunner) sendErrAndClose(err error) {
 	r.muxerMu.Lock()
 	defer r.muxerMu.Unlock()
+	r.muxerClosed = true
 	for k, ch := range r.muxer {
 		select {
 		case ch <- acceptVal{err: err}:
diff --git a/p2p/transport/quicreuse/reuse.go b/p2p/transport/quicreuse/reuse.go
index 684a7e0b..cc90038e 100644
--- a/p2p/transport/quicreuse/reuse.go
+++ b/p2p/transport/quicreuse/reuse.go
@@ -74,16 +74,21 @@ type reuse struct {
 
 	routes  routing.Router
 	unicast map[string] /* IP.String() */ map[int] /* port */ *reuseConn
-	// global contains connections that are listening on 0.0.0.0 / ::
-	global map[int]*reuseConn
+	// globalListeners contains connections that are listening on 0.0.0.0 / ::
+	globalListeners map[int]*reuseConn
+	// globalDialers contains connections that we've dialed out from. These connections are listening on 0.0.0.0 / ::
+	// On Dial, connections are reused from this map if no connection is available in the globalListeners
+	// On Listen, connections are reused from this map if the requested port is 0, and then moved to globalListeners
+	globalDialers map[int]*reuseConn
 }
 
 func newReuse() *reuse {
 	r := &reuse{
-		unicast:    make(map[string]map[int]*reuseConn),
-		global:     make(map[int]*reuseConn),
-		closeChan:  make(chan struct{}),
-		gcStopChan: make(chan struct{}),
+		unicast:         make(map[string]map[int]*reuseConn),
+		globalListeners: make(map[int]*reuseConn),
+		globalDialers:   make(map[int]*reuseConn),
+		closeChan:       make(chan struct{}),
+		gcStopChan:      make(chan struct{}),
 	}
 	go r.gc()
 	return r
@@ -92,7 +97,10 @@ func newReuse() *reuse {
 func (r *reuse) gc() {
 	defer func() {
 		r.mutex.Lock()
-		for _, conn := range r.global {
+		for _, conn := range r.globalListeners {
+			conn.Close()
+		}
+		for _, conn := range r.globalDialers {
 			conn.Close()
 		}
 		for _, conns := range r.unicast {
@@ -113,10 +121,16 @@ func (r *reuse) gc() {
 		case <-ticker.C:
 			now := time.Now()
 			r.mutex.Lock()
-			for key, conn := range r.global {
+			for key, conn := range r.globalListeners {
+				if conn.ShouldGarbageCollect(now) {
+					conn.Close()
+					delete(r.globalListeners, key)
+				}
+			}
+			for key, conn := range r.globalDialers {
 				if conn.ShouldGarbageCollect(now) {
 					conn.Close()
-					delete(r.global, key)
+					delete(r.globalDialers, key)
 				}
 			}
 			for ukey, conns := range r.unicast {
@@ -185,7 +199,12 @@ func (r *reuse) dialLocked(network string, source *net.IP) (*reuseConn, error) {
 
 	// Use a connection listening on 0.0.0.0 (or ::).
 	// Again, we don't care about the port number.
-	for _, conn := range r.global {
+	for _, conn := range r.globalListeners {
+		return conn, nil
+	}
+
+	// Use a connection we've previously dialed from
+	for _, conn := range r.globalDialers {
 		return conn, nil
 	}
 
@@ -203,29 +222,59 @@ func (r *reuse) dialLocked(network string, source *net.IP) (*reuseConn, error) {
 		return nil, err
 	}
 	rconn := newReuseConn(conn)
-	r.global[conn.LocalAddr().(*net.UDPAddr).Port] = rconn
+	r.globalDialers[conn.LocalAddr().(*net.UDPAddr).Port] = rconn
 	return rconn, nil
 }
 
 func (r *reuse) Listen(network string, laddr *net.UDPAddr) (*reuseConn, error) {
+	r.mutex.Lock()
+	defer r.mutex.Unlock()
+
+	// Check if we can reuse a connection we have already dialed out from.
+	// We reuse a connection from globalDialers when the requested port is 0 or the requested
+	// port is already in the globalDialers.
+	// If we are reusing a connection from globalDialers, we move the globalDialers entry to
+	// globalListeners
+	if laddr.IP.IsUnspecified() {
+		var rconn *reuseConn
+		var localAddr *net.UDPAddr
+
+		if laddr.Port == 0 {
+			// the requested port is 0, we can reuse any connection
+			for _, conn := range r.globalDialers {
+				rconn = conn
+				localAddr = rconn.UDPConn.LocalAddr().(*net.UDPAddr)
+				delete(r.globalDialers, localAddr.Port)
+				break
+			}
+		} else if _, ok := r.globalDialers[laddr.Port]; ok {
+			rconn = r.globalDialers[laddr.Port]
+			localAddr = rconn.UDPConn.LocalAddr().(*net.UDPAddr)
+			delete(r.globalDialers, localAddr.Port)
+		}
+		// found a match
+		if rconn != nil {
+			rconn.IncreaseCount()
+			r.globalListeners[localAddr.Port] = rconn
+			return rconn, nil
+		}
+	}
+
 	conn, err := net.ListenUDP(network, laddr)
 	if err != nil {
 		return nil, err
 	}
 	localAddr := conn.LocalAddr().(*net.UDPAddr)
-
 	rconn := newReuseConn(conn)
-	rconn.IncreaseCount()
 
-	r.mutex.Lock()
-	defer r.mutex.Unlock()
+	rconn.IncreaseCount()
 
 	// Deal with listen on a global address
 	if localAddr.IP.IsUnspecified() {
 		// The kernel already checked that the laddr is not already listen
 		// so we need not check here (when we create ListenUDP).
-		r.global[localAddr.Port] = rconn
-		return rconn, err
+		r.globalListeners[localAddr.Port] = rconn
+		return rconn, nil
 	}
 
 	// Deal with listen on a unicast address
@@ -239,7 +288,7 @@ func (r *reuse) Listen(network string, laddr *net.UDPAddr) (*reuseConn, error) {
 	// The kernel already checked that the laddr is not already listen
 	// so we need not check here (when we create ListenUDP).
 	r.unicast[localAddr.IP.String()][localAddr.Port] = rconn
-	return rconn, err
+	return rconn, nil
 }
 
 func (r *reuse) Close() error {
diff --git a/p2p/transport/webtransport/transport.go b/p2p/transport/webtransport/transport.go
index e44ac332..f9c68ddf 100644
--- a/p2p/transport/webtransport/transport.go
+++ b/p2p/transport/webtransport/transport.go
@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"io"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/libp2p/go-libp2p/core/connmgr"
@@ -68,12 +69,12 @@ type transport struct {
 	rcmgr       network.ResourceManager
 	gater       connmgr.ConnectionGater
 
-	listenOnce       sync.Once
-	listenOnceErr    error
-	certManager      *certManager
-	certManagerReady chan struct{} // Closed when the certManager has been instantiated.
-	staticTLSConf    *tls.Config
-	tlsClientConf    *tls.Config
+	listenOnce     sync.Once
+	listenOnceErr  error
+	certManager    *certManager
+	hasCertManager atomic.Bool // set to true once the certManager is initialized
+	staticTLSConf  *tls.Config
+	tlsClientConf  *tls.Config
 
 	noise *noise.Transport
 
@@ -98,14 +99,13 @@ func New(key ic.PrivKey, psk pnet.PSK, connManager *quicreuse.ConnManager, gater
 		return nil, err
 	}
 	t := &transport{
-		pid:              id,
-		privKey:          key,
-		rcmgr:            rcmgr,
-		gater:            gater,
-		clock:            clock.New(),
-		connManager:      connManager,
-		conns:            map[uint64]*conn{},
-		certManagerReady: make(chan struct{}),
+		pid:         id,
+		privKey:     key,
+		rcmgr:       rcmgr,
+		gater:       gater,
+		clock:       clock.New(),
+		connManager: connManager,
+		conns:       map[uint64]*conn{},
 	}
 	for _, opt := range opts {
 		if err := opt(t); err != nil {
@@ -300,13 +300,12 @@ func (t *transport) Listen(laddr ma.Multiaddr) (tpt.Listener, error) {
 	if t.staticTLSConf == nil {
 		t.listenOnce.Do(func() {
 			t.certManager, t.listenOnceErr = newCertManager(t.privKey, t.clock)
-			close(t.certManagerReady)
+			t.hasCertManager.Store(true)
 		})
 		if t.listenOnceErr != nil {
 			return nil, t.listenOnceErr
 		}
 	} else {
-		close(t.certManagerReady)
 		return nil, errors.New("static TLS config not supported on WebTransport")
 	}
 	tlsConf := t.staticTLSConf.Clone()
@@ -405,10 +404,11 @@ func (t *transport) Resolve(_ context.Context, maddr ma.Multiaddr) ([]ma.Multiad
 	return []ma.Multiaddr{beforeQuicMA.Encapsulate(quicComponent).Encapsulate(sniComponent).Encapsulate(afterQuicMA)}, nil
 }
 
-func (t *transport) AddCertHashes(m ma.Multiaddr) ma.Multiaddr {
-	<-t.certManagerReady
-	if t.certManager == nil {
-		return m
+// AddCertHashes adds the current certificate hashes to a multiaddress.
+// If called before Listen, it's a no-op.
+func (t *transport) AddCertHashes(m ma.Multiaddr) (ma.Multiaddr, bool) {
+	if !t.hasCertManager.Load() {
+		return m, false
 	}
-	return m.Encapsulate(t.certManager.AddrComponent())
+	return m.Encapsulate(t.certManager.AddrComponent()), true
 }


Test patch:
diff --git a/.github/workflows/interop-test.yml b/.github/workflows/interop-test.yml
index e96b7b03..c5f2a5e5 100644
--- a/.github/workflows/interop-test.yml
+++ b/.github/workflows/interop-test.yml
@@ -8,7 +8,7 @@ on:
 jobs:
   run-multidim-interop:
     name: Run multidimensional interoperability tests
-    runs-on: ubuntu-22.04
+    runs-on: ${{ fromJSON(vars['INTEROP_TEST_RUNNER_UBUNTU'] || '"ubuntu-22.04"') }}
     steps:
       - uses: actions/checkout@v3
       - name: Build image
diff --git a/docs/flaky-tests.md b/docs/flaky-tests.md
new file mode 100644
index 00000000..e65800ac
--- /dev/null
+++ b/docs/flaky-tests.md
@@ -0,0 +1,43 @@
+# Debugging Flaky Tests
+
+If a test is flaky in CI it's probably because there's some timing issue. The
+test probably depends on some Go routine making progress in the background and
+polling to see if the expected outcome is achieved.
+
+This will pretty much always work locally because your local machine is likely
+pretty capable and there isn't too many concurrent processes running. In CI, we
+are susceptible to both slower hardware and noisier neighbors. However we can
+mimic this environment locally with
+[cgroups](https://man7.org/linux/man-pages/man7/cgroups.7.html).
+
+# Replicating noisy neighbors
+
+We can limit the amount of CPU time relative to real time a process gets with
+cgroups. This lets us replicate the environment where many other neighboring
+processes are vying for CPU time.
+
+```bash
+  # Compile some test we want to run. We do this outside the cgroup so this is
+  # fast
+  go test -c ./p2p/host/autorelay
+
+  # Create the group
+  sudo cgcreate -g cpu:/cpulimit
+
+  # Limit the time to 10,000 microseconds for every 1s
+  sudo cgset -r cpu.cfs_quota_us=10000 cpulimit
+  sudo cgset -r cpu.cfs_period_us=1000000 cpulimit
+
+  # Run a shell with in our limited environemnt
+  sudo cgexec -g cpu:cpulimit bash
+
+  # In the shell, run the test
+  ./autorelay.test -test.v
+```
+
+# Flakiness with coverage profile
+
+Sometimes adding the `-coverprofile=module-coverage.txt` introduces flaky
+behavior since it adds another goroutine to the mix. If you're having trouble
+reproducing a flaky test, try enabling this flag.
+
diff --git a/libp2p_test.go b/libp2p_test.go
index 0b81e33b..7d947afb 100644
--- a/libp2p_test.go
+++ b/libp2p_test.go
@@ -329,3 +329,30 @@ func TestTransportCustomAddressWebTransport(t *testing.T) {
 	require.Equal(t, secondToLastComp.Protocol().Code, ma.P_CERTHASH)
 	require.True(t, restOfAddr.Equal(customAddr))
 }
+
+// TestTransportCustomAddressWebTransportDoesNotStall tests that if the user
+// manually returns a webtransport address from AddrsFactory, but we aren't
+// listening on a webtranport address, we don't stall.
+func TestTransportCustomAddressWebTransportDoesNotStall(t *testing.T) {
+	customAddr, err := ma.NewMultiaddr("/ip4/127.0.0.1/udp/0/quic-v1/webtransport")
+	if err != nil {
+		t.Fatal(err)
+	}
+	h, err := New(
+		Transport(webtransport.New),
+		// Purposely not listening on the custom address so that we make sure the node doesn't stall if it fails to add a certhash to the multiaddr
+		// ListenAddrs(customAddr),
+		DisableRelay(),
+		AddrsFactory(func(multiaddrs []ma.Multiaddr) []ma.Multiaddr {
+			return []ma.Multiaddr{customAddr}
+		}),
+	)
+	require.NoError(t, err)
+	defer h.Close()
+	addrs := h.Addrs()
+	require.Len(t, addrs, 1)
+	_, lastComp := ma.SplitLast(addrs[0])
+	require.NotEqual(t, lastComp.Protocol().Code, ma.P_CERTHASH)
+	// We did not add the certhash to the multiaddr
+	require.Equal(t, addrs[0], customAddr)
+}
diff --git a/p2p/host/basic/mock_nat_test.go b/p2p/host/basic/mock_nat_test.go
new file mode 100644
index 00000000..7714b258
--- /dev/null
+++ b/p2p/host/basic/mock_nat_test.go
@@ -0,0 +1,92 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/libp2p/go-libp2p/p2p/host/basic (interfaces: NAT)
+
+// Package basichost is a generated GoMock package.
+package basichost
+
+import (
+	netip "net/netip"
+	reflect "reflect"
+
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockNAT is a mock of NAT interface.
+type MockNAT struct {
+	ctrl     *gomock.Controller
+	recorder *MockNATMockRecorder
+}
+
+// MockNATMockRecorder is the mock recorder for MockNAT.
+type MockNATMockRecorder struct {
+	mock *MockNAT
+}
+
+// NewMockNAT creates a new mock instance.
+func NewMockNAT(ctrl *gomock.Controller) *MockNAT {
+	mock := &MockNAT{ctrl: ctrl}
+	mock.recorder = &MockNATMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockNAT) EXPECT() *MockNATMockRecorder {
+	return m.recorder
+}
+
+// AddMapping mocks base method.
+func (m *MockNAT) AddMapping(arg0 string, arg1 int) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AddMapping", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// AddMapping indicates an expected call of AddMapping.
+func (mr *MockNATMockRecorder) AddMapping(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMapping", reflect.TypeOf((*MockNAT)(nil).AddMapping), arg0, arg1)
+}
+
+// Close mocks base method.
+func (m *MockNAT) Close() error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Close")
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Close indicates an expected call of Close.
+func (mr *MockNATMockRecorder) Close() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockNAT)(nil).Close))
+}
+
+// GetMapping mocks base method.
+func (m *MockNAT) GetMapping(arg0 string, arg1 int) (netip.AddrPort, bool) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMapping", arg0, arg1)
+	ret0, _ := ret[0].(netip.AddrPort)
+	ret1, _ := ret[1].(bool)
+	return ret0, ret1
+}
+
+// GetMapping indicates an expected call of GetMapping.
+func (mr *MockNATMockRecorder) GetMapping(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMapping", reflect.TypeOf((*MockNAT)(nil).GetMapping), arg0, arg1)
+}
+
+// RemoveMapping mocks base method.
+func (m *MockNAT) RemoveMapping(arg0 string, arg1 int) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "RemoveMapping", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// RemoveMapping indicates an expected call of RemoveMapping.
+func (mr *MockNATMockRecorder) RemoveMapping(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveMapping", reflect.TypeOf((*MockNAT)(nil).RemoveMapping), arg0, arg1)
+}
diff --git a/p2p/host/basic/natmgr_test.go b/p2p/host/basic/natmgr_test.go
new file mode 100644
index 00000000..e507b45c
--- /dev/null
+++ b/p2p/host/basic/natmgr_test.go
@@ -0,0 +1,108 @@
+package basichost
+
+import (
+	"context"
+	"net/netip"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	ma "github.com/multiformats/go-multiaddr"
+
+	swarmt "github.com/libp2p/go-libp2p/p2p/net/swarm/testing"
+
+	"github.com/golang/mock/gomock"
+)
+
+func setupMockNAT(t *testing.T) (mockNAT *MockNAT, reset func()) {
+	t.Helper()
+	ctrl := gomock.NewController(t)
+	mockNAT = NewMockNAT(ctrl)
+	origDiscoverNAT := discoverNAT
+	discoverNAT = func(ctx context.Context) (nat, error) { return mockNAT, nil }
+	return mockNAT, func() {
+		discoverNAT = origDiscoverNAT
+		ctrl.Finish()
+	}
+}
+
+func TestMapping(t *testing.T) {
+	mockNAT, reset := setupMockNAT(t)
+	defer reset()
+
+	sw := swarmt.GenSwarm(t)
+	defer sw.Close()
+	m := newNATManager(sw)
+	require.Eventually(t, func() bool {
+		m.natMx.Lock()
+		defer m.natMx.Unlock()
+		return m.nat != nil
+	}, time.Second, time.Millisecond)
+	externalAddr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, 3, 4}), 4321)
+	// pretend that we have a TCP mapping
+	mockNAT.EXPECT().GetMapping("tcp", 1234).Return(externalAddr, true)
+	require.Equal(t, ma.StringCast("/ip4/1.2.3.4/tcp/4321"), m.GetMapping(ma.StringCast("/ip4/0.0.0.0/tcp/1234")))
+
+	// pretend that we have a QUIC mapping
+	mockNAT.EXPECT().GetMapping("udp", 1234).Return(externalAddr, true)
+	require.Equal(t, ma.StringCast("/ip4/1.2.3.4/udp/4321/quic-v1"), m.GetMapping(ma.StringCast("/ip4/0.0.0.0/udp/1234/quic-v1")))
+
+	// pretend that there's no mapping
+	mockNAT.EXPECT().GetMapping("tcp", 1234).Return(netip.AddrPort{}, false)
+	require.Nil(t, m.GetMapping(ma.StringCast("/ip4/0.0.0.0/tcp/1234")))
+
+	// make sure this works for WebSocket addresses as well
+	mockNAT.EXPECT().GetMapping("tcp", 1234).Return(externalAddr, true)
+	require.Equal(t, ma.StringCast("/ip4/1.2.3.4/tcp/4321/ws"), m.GetMapping(ma.StringCast("/ip4/0.0.0.0/tcp/1234/ws")))
+
+	// make sure this works for WebTransport addresses as well
+	mockNAT.EXPECT().GetMapping("udp", 1234).Return(externalAddr, true)
+	require.Equal(t, ma.StringCast("/ip4/1.2.3.4/udp/4321/quic-v1/webtransport"), m.GetMapping(ma.StringCast("/ip4/0.0.0.0/udp/1234/quic-v1/webtransport")))
+}
+
+func TestAddAndRemoveListeners(t *testing.T) {
+	mockNAT, reset := setupMockNAT(t)
+	defer reset()
+
+	sw := swarmt.GenSwarm(t)
+	defer sw.Close()
+	m := newNATManager(sw)
+	require.Eventually(t, func() bool {
+		m.natMx.Lock()
+		defer m.natMx.Unlock()
+		return m.nat != nil
+	}, time.Second, time.Millisecond)
+
+	added := make(chan struct{}, 1)
+	// add a TCP listener
+	mockNAT.EXPECT().AddMapping("tcp", 1234).Do(func(string, int) { added <- struct{}{} })
+	require.NoError(t, sw.Listen(ma.StringCast("/ip4/0.0.0.0/tcp/1234")))
+	select {
+	case <-added:
+	case <-time.After(time.Second):
+		t.Fatal("didn't receive call to AddMapping")
+	}
+
+	// add a QUIC listener
+	mockNAT.EXPECT().AddMapping("udp", 1234).Do(func(string, int) { added <- struct{}{} })
+	require.NoError(t, sw.Listen(ma.StringCast("/ip4/0.0.0.0/udp/1234/quic-v1")))
+	select {
+	case <-added:
+	case <-time.After(time.Second):
+		t.Fatal("didn't receive call to AddMapping")
+	}
+
+	// remove the QUIC listener
+	mockNAT.EXPECT().RemoveMapping("udp", 1234).Do(func(string, int) { added <- struct{}{} })
+	sw.ListenClose(ma.StringCast("/ip4/0.0.0.0/udp/1234/quic-v1"))
+	select {
+	case <-added:
+	case <-time.After(time.Second):
+		t.Fatal("didn't receive call to RemoveMapping")
+	}
+
+	// test shutdown
+	mockNAT.EXPECT().RemoveMapping("tcp", 1234).MaxTimes(1)
+	mockNAT.EXPECT().Close().MaxTimes(1)
+}
diff --git a/p2p/net/nat/mock_nat_test.go b/p2p/net/nat/mock_nat_test.go
new file mode 100644
index 00000000..bb91bac2
--- /dev/null
+++ b/p2p/net/nat/mock_nat_test.go
@@ -0,0 +1,124 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/libp2p/go-nat (interfaces: NAT)
+
+// Package nat is a generated GoMock package.
+package nat
+
+import (
+	net "net"
+	reflect "reflect"
+	time "time"
+
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockNAT is a mock of NAT interface.
+type MockNAT struct {
+	ctrl     *gomock.Controller
+	recorder *MockNATMockRecorder
+}
+
+// MockNATMockRecorder is the mock recorder for MockNAT.
+type MockNATMockRecorder struct {
+	mock *MockNAT
+}
+
+// NewMockNAT creates a new mock instance.
+func NewMockNAT(ctrl *gomock.Controller) *MockNAT {
+	mock := &MockNAT{ctrl: ctrl}
+	mock.recorder = &MockNATMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockNAT) EXPECT() *MockNATMockRecorder {
+	return m.recorder
+}
+
+// AddPortMapping mocks base method.
+func (m *MockNAT) AddPortMapping(arg0 string, arg1 int, arg2 string, arg3 time.Duration) (int, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "AddPortMapping", arg0, arg1, arg2, arg3)
+	ret0, _ := ret[0].(int)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// AddPortMapping indicates an expected call of AddPortMapping.
+func (mr *MockNATMockRecorder) AddPortMapping(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPortMapping", reflect.TypeOf((*MockNAT)(nil).AddPortMapping), arg0, arg1, arg2, arg3)
+}
+
+// DeletePortMapping mocks base method.
+func (m *MockNAT) DeletePortMapping(arg0 string, arg1 int) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeletePortMapping", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeletePortMapping indicates an expected call of DeletePortMapping.
+func (mr *MockNATMockRecorder) DeletePortMapping(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePortMapping", reflect.TypeOf((*MockNAT)(nil).DeletePortMapping), arg0, arg1)
+}
+
+// GetDeviceAddress mocks base method.
+func (m *MockNAT) GetDeviceAddress() (net.IP, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetDeviceAddress")
+	ret0, _ := ret[0].(net.IP)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetDeviceAddress indicates an expected call of GetDeviceAddress.
+func (mr *MockNATMockRecorder) GetDeviceAddress() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeviceAddress", reflect.TypeOf((*MockNAT)(nil).GetDeviceAddress))
+}
+
+// GetExternalAddress mocks base method.
+func (m *MockNAT) GetExternalAddress() (net.IP, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetExternalAddress")
+	ret0, _ := ret[0].(net.IP)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetExternalAddress indicates an expected call of GetExternalAddress.
+func (mr *MockNATMockRecorder) GetExternalAddress() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExternalAddress", reflect.TypeOf((*MockNAT)(nil).GetExternalAddress))
+}
+
+// GetInternalAddress mocks base method.
+func (m *MockNAT) GetInternalAddress() (net.IP, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetInternalAddress")
+	ret0, _ := ret[0].(net.IP)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetInternalAddress indicates an expected call of GetInternalAddress.
+func (mr *MockNATMockRecorder) GetInternalAddress() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInternalAddress", reflect.TypeOf((*MockNAT)(nil).GetInternalAddress))
+}
+
+// Type mocks base method.
+func (m *MockNAT) Type() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Type")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// Type indicates an expected call of Type.
+func (mr *MockNATMockRecorder) Type() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockNAT)(nil).Type))
+}
diff --git a/p2p/net/nat/nat_test.go b/p2p/net/nat/nat_test.go
new file mode 100644
index 00000000..8fffb512
--- /dev/null
+++ b/p2p/net/nat/nat_test.go
@@ -0,0 +1,69 @@
+package nat
+
+import (
+	"context"
+	"errors"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/libp2p/go-nat"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+)
+
+//go:generate sh -c "go run github.com/golang/mock/mockgen -package nat -destination mock_nat_test.go github.com/libp2p/go-nat NAT"
+
+func setupMockNAT(t *testing.T) (mockNAT *MockNAT, reset func()) {
+	t.Helper()
+	ctrl := gomock.NewController(t)
+	mockNAT = NewMockNAT(ctrl)
+	mockNAT.EXPECT().GetDeviceAddress().Return(nil, errors.New("nope")) // is only used for logging
+	origDiscoverGateway := discoverGateway
+	discoverGateway = func(ctx context.Context) (nat.NAT, error) { return mockNAT, nil }
+	return mockNAT, func() {
+		discoverGateway = origDiscoverGateway
+		ctrl.Finish()
+	}
+}
+
+func TestAddMapping(t *testing.T) {
+	mockNAT, reset := setupMockNAT(t)
+	defer reset()
+
+	mockNAT.EXPECT().GetExternalAddress().Return(net.IPv4(1, 2, 3, 4), nil)
+	nat, err := DiscoverNAT(context.Background())
+	require.NoError(t, err)
+
+	mockNAT.EXPECT().AddPortMapping("tcp", 10000, gomock.Any(), MappingDuration).Return(1234, nil)
+	require.NoError(t, nat.AddMapping("tcp", 10000))
+
+	_, found := nat.GetMapping("tcp", 9999)
+	require.False(t, found, "didn't expect a port mapping for unmapped port")
+	_, found = nat.GetMapping("udp", 10000)
+	require.False(t, found, "didn't expect a port mapping for unmapped protocol")
+	mapped, found := nat.GetMapping("tcp", 10000)
+	require.True(t, found, "expected port mapping")
+	require.Equal(t, netip.AddrPortFrom(netip.AddrFrom4([4]byte{1, 2, 3, 4}), 1234), mapped)
+}
+
+func TestRemoveMapping(t *testing.T) {
+	mockNAT, reset := setupMockNAT(t)
+	defer reset()
+
+	mockNAT.EXPECT().GetExternalAddress().Return(net.IPv4(1, 2, 3, 4), nil)
+	nat, err := DiscoverNAT(context.Background())
+	require.NoError(t, err)
+	mockNAT.EXPECT().AddPortMapping("tcp", 10000, gomock.Any(), MappingDuration).Return(1234, nil)
+	require.NoError(t, nat.AddMapping("tcp", 10000))
+	_, found := nat.GetMapping("tcp", 10000)
+	require.True(t, found, "expected port mapping")
+
+	require.Error(t, nat.RemoveMapping("tcp", 9999), "expected error for unknown mapping")
+	mockNAT.EXPECT().DeletePortMapping("tcp", 10000)
+	require.NoError(t, nat.RemoveMapping("tcp", 10000))
+
+	_, found = nat.GetMapping("tcp", 10000)
+	require.False(t, found, "didn't expect port mapping for deleted mapping")
+}
diff --git a/p2p/transport/quicreuse/connmgr_test.go b/p2p/transport/quicreuse/connmgr_test.go
index 16677c50..b5399e83 100644
--- a/p2p/transport/quicreuse/connmgr_test.go
+++ b/p2p/transport/quicreuse/connmgr_test.go
@@ -25,7 +25,7 @@ func checkClosed(t *testing.T, cm *ConnManager) {
 			continue
 		}
 		r.mutex.Lock()
-		for _, conn := range r.global {
+		for _, conn := range r.globalListeners {
 			require.Zero(t, conn.GetCount())
 		}
 		for _, conns := range r.unicast {
diff --git a/p2p/transport/quicreuse/reuse_test.go b/p2p/transport/quicreuse/reuse_test.go
index 36a109c8..0cd62f0d 100644
--- a/p2p/transport/quicreuse/reuse_test.go
+++ b/p2p/transport/quicreuse/reuse_test.go
@@ -21,7 +21,12 @@ func (c *reuseConn) GetCount() int {
 
 func closeAllConns(reuse *reuse) {
 	reuse.mutex.Lock()
-	for _, conn := range reuse.global {
+	for _, conn := range reuse.globalListeners {
+		for conn.GetCount() > 0 {
+			conn.DecreaseCount()
+		}
+	}
+	for _, conn := range reuse.globalDialers {
 		for conn.GetCount() > 0 {
 			conn.DecreaseCount()
 		}
@@ -110,6 +115,52 @@ func TestReuseConnectionWhenDialing(t *testing.T) {
 	require.Equal(t, conn.GetCount(), 2)
 }
 
+func TestReuseConnectionWhenListening(t *testing.T) {
+	reuse := newReuse()
+	cleanup(t, reuse)
+
+	raddr, err := net.ResolveUDPAddr("udp4", "1.1.1.1:1234")
+	require.NoError(t, err)
+	conn, err := reuse.Dial("udp4", raddr)
+	require.NoError(t, err)
+	laddr := &net.UDPAddr{IP: net.IPv4zero, Port: conn.UDPConn.LocalAddr().(*net.UDPAddr).Port}
+	lconn, err := reuse.Listen("udp4", laddr)
+	require.NoError(t, err)
+	require.Equal(t, lconn.GetCount(), 2)
+	require.Equal(t, conn.GetCount(), 2)
+}
+
+func TestReuseConnectionWhenDialBeforeListen(t *testing.T) {
+	reuse := newReuse()
+	cleanup(t, reuse)
+
+	// dial any address
+	raddr, err := net.ResolveUDPAddr("udp4", "1.1.1.1:1234")
+	require.NoError(t, err)
+	rconn, err := reuse.Dial("udp4", raddr)
+	require.NoError(t, err)
+
+	// open a listener
+	laddr := &net.UDPAddr{IP: net.IPv4zero, Port: 1234}
+	lconn, err := reuse.Listen("udp4", laddr)
+	require.NoError(t, err)
+
+	// new dials should go via the listener connection
+	raddr, err = net.ResolveUDPAddr("udp4", "1.1.1.1:1235")
+	require.NoError(t, err)
+	conn, err := reuse.Dial("udp4", raddr)
+	require.NoError(t, err)
+	require.Equal(t, conn, lconn)
+	require.Equal(t, conn.GetCount(), 2)
+
+	// a listener on an unspecified port should reuse the dialer
+	laddr2 := &net.UDPAddr{IP: net.IPv4zero, Port: 0}
+	lconn2, err := reuse.Listen("udp4", laddr2)
+	require.NoError(t, err)
+	require.Equal(t, lconn2, rconn)
+	require.Equal(t, lconn2.GetCount(), 2)
+}
+
 func TestReuseListenOnSpecificInterface(t *testing.T) {
 	if platformHasRoutingTables() {
 		t.Skip("this test only works on platforms that support routing tables")
@@ -157,10 +208,16 @@ func TestReuseGarbageCollect(t *testing.T) {
 	numGlobals := func() int {
 		reuse.mutex.Lock()
 		defer reuse.mutex.Unlock()
-		return len(reuse.global)
+		return len(reuse.globalListeners) + len(reuse.globalDialers)
 	}
 
-	addr, err := net.ResolveUDPAddr("udp4", "0.0.0.0:0")
+	raddr, err := net.ResolveUDPAddr("udp4", "1.2.3.4:1234")
+	require.NoError(t, err)
+	dconn, err := reuse.Dial("udp4", raddr)
+	require.NoError(t, err)
+	require.Equal(t, dconn.GetCount(), 1)
+
+	addr, err := net.ResolveUDPAddr("udp4", "0.0.0.0:1234")
 	require.NoError(t, err)
 	lconn, err := reuse.Listen("udp4", addr)
 	require.NoError(t, err)
@@ -168,13 +225,14 @@ func TestReuseGarbageCollect(t *testing.T) {
 
 	closeTime := time.Now()
 	lconn.DecreaseCount()
+	dconn.DecreaseCount()
 
 	for {
 		num := numGlobals()
 		if closeTime.Add(maxUnusedDuration).Before(time.Now()) {
 			break
 		}
-		require.Equal(t, num, 1)
+		require.Equal(t, num, 2)
 		time.Sleep(2 * time.Millisecond)
 	}
 	require.Eventually(t, func() bool { return numGlobals() == 0 }, 4*garbageCollectInterval, 10*time.Millisecond)