From 024293c77e17794b0dd9dacec3032b4c5a535f64 Mon Sep 17 00:00:00 2001 From: Marco Munizaga Date: Fri, 17 May 2024 08:22:37 -0700 Subject: [PATCH] Add backwards compatibility with old well-known resource (#2798) --- p2p/http/libp2phttp.go | 102 ++++++++++++++++++++++++++++++++---- p2p/http/libp2phttp_test.go | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 9 deletions(-) diff --git a/p2p/http/libp2phttp.go b/p2p/http/libp2phttp.go index c0c5a421d3..5ff025e3ab 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 } diff --git a/p2p/http/libp2phttp_test.go b/p2p/http/libp2phttp_test.go index 5f7cfa7e52..a444c6e209 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) + }) + } + +}