Repository: libp2p/go-libp2p
Pull request #2798 (2024-05-17 00:11:33)

Add backwards compatibility option to libp2phttp for the well-known resource
We've bike shedded on the well-known resource so long that we have a number of users that are relying on the old well-known path being at `.well-known/libp2p`. This patch gives them a migration path. Enabling this option allows clients to request both the new and old path in parallel (so it doesn't cost one whole round trip). On the server side, it serves both well-known resources so that legacy clients don't break.

This change is meant to give our early adopters of libp2phttp a migration path. The goal is to get rid of this code after some time (2 releases? 3?) to allow users to upgrade.


Patch:
diff --git a/p2p/host/resource-manager/conn_limiter.go b/p2p/host/resource-manager/conn_limiter.go
index 2cea6b30..de201d12 100644
--- a/p2p/host/resource-manager/conn_limiter.go
+++ b/p2p/host/resource-manager/conn_limiter.go
@@ -85,7 +85,6 @@ func (cl *connLimiter) addConn(ip netip.Addr) bool {
 			return false
 		}
 		masked := prefix.String()
-
 		counts, ok := countsPerLimit[i][masked]
 		if !ok {
 			if countsPerLimit[i] == nil {
diff --git a/p2p/host/resource-manager/rcmgr.go b/p2p/host/resource-manager/rcmgr.go
index dceb18bd..739657c2 100644
--- a/p2p/host/resource-manager/rcmgr.go
+++ b/p2p/host/resource-manager/rcmgr.go
@@ -677,10 +677,15 @@ func (s *connectionScope) PeerScope() network.PeerScope {
 }
 
 func (s *connectionScope) Done() {
+	s.Lock()
+	defer s.Unlock()
+	if s.done {
+		return
+	}
 	if s.ip.IsValid() {
 		s.rcmgr.connLimiter.rmConn(s.ip)
 	}
-	s.resourceScope.Done()
+	s.resourceScope.doneUnlocked()
 }
 
 // transferAllowedToStandard transfers this connection scope from being part of
diff --git a/p2p/host/resource-manager/scope.go b/p2p/host/resource-manager/scope.go
index bb70c100..8a6bc230 100644
--- a/p2p/host/resource-manager/scope.go
+++ b/p2p/host/resource-manager/scope.go
@@ -743,10 +743,13 @@ func (s *resourceScope) Done() {
 	s.Lock()
 	defer s.Unlock()
 
+	s.doneUnlocked()
+}
+
+func (s *resourceScope) doneUnlocked() {
 	if s.done {
 		return
 	}
-
 	stat := s.rc.stat()
 	if s.owner != nil {
 		s.owner.ReleaseResources(stat)
diff --git a/p2p/http/libp2phttp.go b/p2p/http/libp2phttp.go
index c0c5a421..5ff025e3 100644
--- a/p2p/http/libp2phttp.go
+++ b/p2p/http/libp2phttp.go
@@ -4,6 +4,7 @@ package libp2phttp
 
 import (
 	"bufio"
+	"context"
 	"crypto/tls"
 	"encoding/json"
 	"errors"
@@ -15,6 +16,7 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"time"
 
 	lru "github.com/hashicorp/golang-lru/v2"
 	logging "github.com/ipfs/go-log/v2"
@@ -29,8 +31,16 @@ import (
 
 var log = logging.Logger("libp2phttp")
 
+var WellKnownRequestTimeout = 30 * time.Second
+
 const ProtocolIDForMultistreamSelect = "/http/1.1"
 const WellKnownProtocols = "/.well-known/libp2p/protocols"
+
+// LegacyWellKnownProtocols refer to a the well-known resource used in an early
+// draft of the libp2p+http spec. Some users have deployed this, and need backwards compatibility.
+// Hopefully we can phase this out in the future. Context: https://github.com/libp2p/go-libp2p/pull/2797
+const LegacyWellKnownProtocols = "/.well-known/libp2p"
+
 const peerMetadataLimit = 8 << 10 // 8KB
 const peerMetadataLRUSize = 256   // How many different peer's metadata to keep in our LRU cache
 
@@ -145,6 +155,17 @@ type Host struct {
 	// here when a user calls `SetHTTPHandler` or `SetHTTPHandlerAtPath`.
 	WellKnownHandler WellKnownHandler
 
+	// EnableCompatibilityWithLegacyWellKnownEndpoint allows compatibility with
+	// an older version of the spec that defined the well-known resource as:
+	// .well-known/libp2p.
+	// For servers, this means hosting the well-known resource at both the
+	// legacy and current paths.
+	// For clients it means making two parallel requests and picking the first one that succeeds.
+	//
+	// Long term this should be deprecated once enough users have upgraded to a
+	// newer go-libp2p version and we can remove all this code.
+	EnableCompatibilityWithLegacyWellKnownEndpoint bool
+
 	// peerMetadata is an LRU cache of a peer's well-known protocol map.
 	peerMetadata *lru.Cache[peer.ID, PeerMeta]
 	// createHTTPTransport is used to lazily create the httpTransport in a thread-safe way.
@@ -272,6 +293,9 @@ func (h *Host) Serve() error {
 
 	h.serveMuxInit()
 	h.ServeMux.Handle(WellKnownProtocols, &h.WellKnownHandler)
+	if h.EnableCompatibilityWithLegacyWellKnownEndpoint {
+		h.ServeMux.Handle(LegacyWellKnownProtocols, &h.WellKnownHandler)
+	}
 
 	h.httpTransportInit()
 
@@ -405,7 +429,10 @@ func (s *streamReadCloser) Close() error {
 }
 
 func (rt *streamRoundTripper) GetPeerMetadata() (PeerMeta, error) {
-	return rt.httpHost.getAndStorePeerMetadata(rt, rt.server)
+	ctx := context.Background()
+	ctx, cancel := context.WithDeadline(ctx, time.Now().Add(WellKnownRequestTimeout))
+	defer cancel()
+	return rt.httpHost.getAndStorePeerMetadata(ctx, rt, rt.server)
 }
 
 // RoundTrip implements http.RoundTripper.
@@ -476,7 +503,10 @@ func (rt *roundTripperForSpecificServer) GetPeerMetadata() (PeerMeta, error) {
 		}
 	}
 
-	wk, err := rt.httpHost.getAndStorePeerMetadata(rt, rt.server)
+	ctx := context.Background()
+	ctx, cancel := context.WithDeadline(ctx, time.Now().Add(WellKnownRequestTimeout))
+	defer cancel()
+	wk, err := rt.httpHost.getAndStorePeerMetadata(ctx, rt, rt.server)
 	if err == nil {
 		rt.cachedProtos = wk
 		return wk, nil
@@ -541,7 +571,10 @@ func (rt *namespacedRoundTripper) RoundTrip(r *http.Request) (*http.Response, er
 
 // NamespaceRoundTripper returns an http.RoundTripper that are scoped to the given protocol on the given server.
 func (h *Host) NamespaceRoundTripper(roundtripper http.RoundTripper, p protocol.ID, server peer.ID) (*namespacedRoundTripper, error) {
-	protos, err := h.getAndStorePeerMetadata(roundtripper, server)
+	ctx := context.Background()
+	ctx, cancel := context.WithDeadline(ctx, time.Now().Add(WellKnownRequestTimeout))
+	defer cancel()
+	protos, err := h.getAndStorePeerMetadata(ctx, roundtripper, server)
 	if err != nil {
 		return &namespacedRoundTripper{}, err
 	}
@@ -740,10 +773,10 @@ func normalizeHTTPMultiaddr(addr ma.Multiaddr) (ma.Multiaddr, bool) {
 	return ma.Join(beforeHTTPS, tlsComponent, httpComponent, afterHTTPS), isHTTPMultiaddr
 }
 
-// ProtocolPathPrefix looks up the protocol path in the well-known mapping and
+// getAndStorePeerMetadata looks up the protocol path in the well-known mapping and
 // returns it. Will only store the peer's protocol mapping if the server ID is
 // provided.
-func (h *Host) getAndStorePeerMetadata(roundtripper http.RoundTripper, server peer.ID) (PeerMeta, error) {
+func (h *Host) getAndStorePeerMetadata(ctx context.Context, roundtripper http.RoundTripper, server peer.ID) (PeerMeta, error) {
 	if h.peerMetadata == nil {
 		h.peerMetadata = newPeerMetadataCache()
 	}
@@ -751,7 +784,61 @@ func (h *Host) getAndStorePeerMetadata(roundtripper http.RoundTripper, server pe
 		return meta, nil
 	}
 
-	req, err := http.NewRequest("GET", WellKnownProtocols, nil)
+	var meta PeerMeta
+	var err error
+	if h.EnableCompatibilityWithLegacyWellKnownEndpoint {
+		type metaAndErr struct {
+			m   PeerMeta
+			err error
+		}
+		legacyRespCh := make(chan metaAndErr, 1)
+		wellKnownRespCh := make(chan metaAndErr, 1)
+		ctx, cancel := context.WithCancel(ctx)
+		go func() {
+			meta, err := requestPeerMeta(ctx, roundtripper, LegacyWellKnownProtocols)
+			legacyRespCh <- metaAndErr{meta, err}
+		}()
+		go func() {
+			meta, err := requestPeerMeta(ctx, roundtripper, WellKnownProtocols)
+			wellKnownRespCh <- metaAndErr{meta, err}
+		}()
+		select {
+		case resp := <-legacyRespCh:
+			if resp.err != nil {
+				resp = <-wellKnownRespCh
+			}
+			meta, err = resp.m, resp.err
+		case resp := <-wellKnownRespCh:
+			if resp.err != nil {
+				legacyResp := <-legacyRespCh
+				if legacyResp.err != nil {
+					// If both endpoints error, return the error from the well
+					// known resource (not the legacy well known resource)
+					meta, err = resp.m, resp.err
+				} else {
+					meta, err = legacyResp.m, legacyResp.err
+				}
+			} else {
+				meta, err = resp.m, resp.err
+			}
+		}
+		cancel()
+	} else {
+		meta, err = requestPeerMeta(ctx, roundtripper, WellKnownProtocols)
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	if server != "" {
+		h.peerMetadata.Add(server, meta)
+	}
+
+	return meta, nil
+}
+
+func requestPeerMeta(ctx context.Context, roundtripper http.RoundTripper, wellKnownResource string) (PeerMeta, error) {
+	req, err := http.NewRequest("GET", wellKnownResource, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -776,9 +863,6 @@ func (h *Host) getAndStorePeerMetadata(roundtripper http.RoundTripper, server pe
 	if err != nil {
 		return nil, err
 	}
-	if server != "" {
-		h.peerMetadata.Add(server, meta)
-	}
 
 	return meta, nil
 }


Test patch:
diff --git a/p2p/http/libp2phttp_test.go b/p2p/http/libp2phttp_test.go
index 5f7cfa7e..a444c6e2 100644
--- a/p2p/http/libp2phttp_test.go
+++ b/p2p/http/libp2phttp_test.go
@@ -15,6 +15,7 @@ import (
 	"math/big"
 	"net"
 	"net/http"
+	"net/netip"
 	"net/url"
 	"os"
 	"reflect"
@@ -621,3 +622,100 @@ func TestSetHandlerAtPath(t *testing.T) {
 		})
 	}
 }
+
+func TestServerLegacyWellKnownResource(t *testing.T) {
+	mkHTTPServer := func(wellKnown string) ma.Multiaddr {
+		mux := http.NewServeMux()
+		wk := libp2phttp.WellKnownHandler{}
+		mux.Handle(wellKnown, &wk)
+
+		mux.Handle("/ping/", httpping.Ping{})
+		wk.AddProtocolMeta(httpping.PingProtocolID, libp2phttp.ProtocolMeta{Path: "/ping/"})
+
+		server := &http.Server{Addr: "127.0.0.1:0", Handler: mux}
+
+		l, err := net.Listen("tcp", server.Addr)
+		require.NoError(t, err)
+
+		go server.Serve(l)
+		t.Cleanup(func() { server.Close() })
+		addrPort, err := netip.ParseAddrPort(l.Addr().String())
+		require.NoError(t, err)
+		return ma.StringCast(fmt.Sprintf("/ip4/%s/tcp/%d/http", addrPort.Addr().String(), addrPort.Port()))
+	}
+
+	mkServerlibp2phttp := func(enableLegacyWellKnown bool) ma.Multiaddr {
+		server := libp2phttp.Host{
+			EnableCompatibilityWithLegacyWellKnownEndpoint: enableLegacyWellKnown,
+			ListenAddrs:       []ma.Multiaddr{ma.StringCast("/ip4/127.0.0.1/tcp/0/http")},
+			InsecureAllowHTTP: true,
+		}
+		server.SetHTTPHandler(httpping.PingProtocolID, httpping.Ping{})
+		go server.Serve()
+		t.Cleanup(func() { server.Close() })
+		return server.Addrs()[0]
+	}
+
+	type testCase struct {
+		name       string
+		client     libp2phttp.Host
+		serverAddr ma.Multiaddr
+		expectErr  bool
+	}
+
+	var testCases = []testCase{
+		{
+			name:       "legacy server, client with compat",
+			client:     libp2phttp.Host{EnableCompatibilityWithLegacyWellKnownEndpoint: true},
+			serverAddr: mkHTTPServer(libp2phttp.LegacyWellKnownProtocols),
+		},
+		{
+			name:       "up-to-date http server, client with compat",
+			client:     libp2phttp.Host{EnableCompatibilityWithLegacyWellKnownEndpoint: true},
+			serverAddr: mkHTTPServer(libp2phttp.WellKnownProtocols),
+		},
+		{
+			name:       "up-to-date http server, client without compat",
+			client:     libp2phttp.Host{},
+			serverAddr: mkHTTPServer(libp2phttp.WellKnownProtocols),
+		},
+		{
+			name:       "libp2phttp server with compat, client with compat",
+			client:     libp2phttp.Host{EnableCompatibilityWithLegacyWellKnownEndpoint: true},
+			serverAddr: mkServerlibp2phttp(true),
+		},
+		{
+			name:       "libp2phttp server without compat, client with compat",
+			client:     libp2phttp.Host{EnableCompatibilityWithLegacyWellKnownEndpoint: true},
+			serverAddr: mkServerlibp2phttp(false),
+		},
+		{
+			name:       "libp2phttp server with compat, client without compat",
+			client:     libp2phttp.Host{},
+			serverAddr: mkServerlibp2phttp(true),
+		},
+		{
+			name:       "legacy server, client without compat",
+			client:     libp2phttp.Host{},
+			serverAddr: mkHTTPServer(libp2phttp.LegacyWellKnownProtocols),
+			expectErr:  true,
+		},
+	}
+
+	for i := range testCases {
+		tc := &testCases[i] // to not copy the lock in libp2phttp.Host
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.expectErr {
+				_, err := tc.client.NamespacedClient(httpping.PingProtocolID, peer.AddrInfo{Addrs: []ma.Multiaddr{tc.serverAddr}})
+				require.Error(t, err)
+				return
+			}
+			httpClient, err := tc.client.NamespacedClient(httpping.PingProtocolID, peer.AddrInfo{Addrs: []ma.Multiaddr{tc.serverAddr}})
+			require.NoError(t, err)
+
+			err = httpping.SendPing(httpClient)
+			require.NoError(t, err)
+		})
+	}
+
+}