diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1f16447..9ade2c65c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The following emojis are used to highlight certain changes: ### Added +* The `routing/http` client and server now support Delegated IPNS at `/routing/v1` + as per [IPIP-379](https://specs.ipfs.tech/ipips/ipip-0379/). * The `verifycid` package has been updated with the new Allowlist interface as part of reducing globals efforts. Still, existing global accessor funcs are kept for backwards-compatibility. diff --git a/examples/go.mod b/examples/go.mod index c03a48670..e68a08ebd 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -8,7 +8,7 @@ require ( github.com/ipfs/go-cid v0.4.1 github.com/ipfs/go-datastore v0.6.0 github.com/ipld/go-car/v2 v2.10.2-0.20230622090957-499d0c909d33 - github.com/ipld/go-ipld-prime v0.20.0 + github.com/ipld/go-ipld-prime v0.21.0 github.com/libp2p/go-libp2p v0.26.3 github.com/libp2p/go-libp2p-routing-helpers v0.7.0 github.com/multiformats/go-multiaddr v0.8.0 diff --git a/examples/go.sum b/examples/go.sum index 82c8b4c63..0c23da44d 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -122,7 +122,7 @@ github.com/flynn/noise v1.0.0 h1:DlTHqmzmvcEiKj+4RYo/imoswx/4r6iBlCMfVtrMXpQ= github.com/flynn/noise v1.0.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= @@ -332,8 +332,8 @@ github.com/ipld/go-car/v2 v2.10.2-0.20230622090957-499d0c909d33 h1:0OZwzSYWIuiKE github.com/ipld/go-car/v2 v2.10.2-0.20230622090957-499d0c909d33/go.mod h1:sQEkXVM3csejlb1kCCb+vQ/pWBKX9QtvsrysMQjOgOg= github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc= github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= -github.com/ipld/go-ipld-prime v0.20.0 h1:Ud3VwE9ClxpO2LkCYP7vWPc0Fo+dYdYzgxUJZ3uRG4g= -github.com/ipld/go-ipld-prime v0.20.0/go.mod h1:PzqZ/ZR981eKbgdr3y2DJYeD/8bgMawdGVlJDE8kK+M= +github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= +github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= @@ -625,7 +625,7 @@ github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtX github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= -github.com/warpfork/go-testmark v0.11.0 h1:J6LnV8KpceDvo7spaNU4+DauH2n1x+6RaO2rJrmpQ9U= +github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s= github.com/warpfork/go-wish v0.0.0-20180510122957-5ad1f5abf436/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= diff --git a/go.mod b/go.mod index baa78701e..0fdb8067e 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/ipfs/go-unixfsnode v1.7.1 github.com/ipld/go-car/v2 v2.10.2-0.20230622090957-499d0c909d33 github.com/ipld/go-codec-dagpb v1.6.0 - github.com/ipld/go-ipld-prime v0.20.0 + github.com/ipld/go-ipld-prime v0.21.0 github.com/jbenet/goprocess v0.1.4 github.com/libp2p/go-buffer-pool v0.1.0 github.com/libp2p/go-doh-resolver v0.4.0 diff --git a/go.sum b/go.sum index fa2a3628e..d6583aa5a 100644 --- a/go.sum +++ b/go.sum @@ -120,7 +120,7 @@ github.com/flynn/noise v1.0.0 h1:DlTHqmzmvcEiKj+4RYo/imoswx/4r6iBlCMfVtrMXpQ= github.com/flynn/noise v1.0.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= @@ -338,8 +338,8 @@ github.com/ipld/go-car/v2 v2.10.2-0.20230622090957-499d0c909d33 h1:0OZwzSYWIuiKE github.com/ipld/go-car/v2 v2.10.2-0.20230622090957-499d0c909d33/go.mod h1:sQEkXVM3csejlb1kCCb+vQ/pWBKX9QtvsrysMQjOgOg= github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc= github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= -github.com/ipld/go-ipld-prime v0.20.0 h1:Ud3VwE9ClxpO2LkCYP7vWPc0Fo+dYdYzgxUJZ3uRG4g= -github.com/ipld/go-ipld-prime v0.20.0/go.mod h1:PzqZ/ZR981eKbgdr3y2DJYeD/8bgMawdGVlJDE8kK+M= +github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= +github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= @@ -635,7 +635,7 @@ github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtX github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= -github.com/warpfork/go-testmark v0.11.0 h1:J6LnV8KpceDvo7spaNU4+DauH2n1x+6RaO2rJrmpQ9U= +github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s= github.com/warpfork/go-wish v0.0.0-20180510122957-5ad1f5abf436/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= diff --git a/routing/http/client/client.go b/routing/http/client/client.go index b3a74150c..c504a0315 100644 --- a/routing/http/client/client.go +++ b/routing/http/client/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "mime" "net/http" "strings" @@ -41,8 +42,9 @@ var ( ) const ( - mediaTypeJSON = "application/json" - mediaTypeNDJSON = "application/x-ndjson" + mediaTypeJSON = "application/json" + mediaTypeNDJSON = "application/x-ndjson" + mediaTypeIPNSRecord = "application/vnd.ipfs.ipns-record" ) type client struct { @@ -324,3 +326,68 @@ func (c *client) provideSignedBitswapRecord(ctx context.Context, bswp *types.Wri return 0, nil } + +func (c *client) FindIPNSRecord(ctx context.Context, name ipns.Name) (*ipns.Record, error) { + url := c.baseURL + "/routing/v1/ipns/" + name.String() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Accept", mediaTypeIPNSRecord) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("making HTTP req to get IPNS record: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, httpError(resp.StatusCode, resp.Body) + } + + // Limit the reader to the maximum record size. + rawRecord, err := io.ReadAll(io.LimitReader(resp.Body, int64(ipns.MaxRecordSize))) + if err != nil { + return nil, fmt.Errorf("making HTTP req to get IPNS record: %w", err) + } + + record, err := ipns.UnmarshalRecord(rawRecord) + if err != nil { + return nil, fmt.Errorf("IPNS record from remote endpoint is not valid: %w", err) + } + + err = ipns.ValidateWithName(record, name) + if err != nil { + return nil, fmt.Errorf("IPNS record from remote endpoint is not valid: %w", err) + } + + return record, nil +} + +func (c *client) ProvideIPNSRecord(ctx context.Context, name ipns.Name, record *ipns.Record) error { + url := c.baseURL + "/routing/v1/ipns/" + name.String() + + rawRecord, err := ipns.MarshalRecord(record) + if err != nil { + return err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(rawRecord)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", mediaTypeIPNSRecord) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("making HTTP req to get IPNS record: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return httpError(resp.StatusCode, resp.Body) + } + + return nil +} diff --git a/routing/http/client/client_test.go b/routing/http/client/client_test.go index 06ad1d6b4..c1690b3f2 100644 --- a/routing/http/client/client_test.go +++ b/routing/http/client/client_test.go @@ -3,6 +3,7 @@ package client import ( "context" "crypto/rand" + "errors" "net/http" "net/http/httptest" "runtime" @@ -10,6 +11,9 @@ import ( "time" "github.com/benbjohnson/clock" + "github.com/ipfs/boxo/coreiface/path" + ipns "github.com/ipfs/boxo/ipns" + ipfspath "github.com/ipfs/boxo/path" "github.com/ipfs/boxo/routing/http/server" "github.com/ipfs/boxo/routing/http/types" "github.com/ipfs/boxo/routing/http/types/iter" @@ -42,6 +46,16 @@ func (m *mockContentRouter) Provide(ctx context.Context, req *server.WriteProvid return args.Get(0).(types.ProviderResponse), args.Error(1) } +func (m *mockContentRouter) FindIPNSRecord(ctx context.Context, name ipns.Name) (*ipns.Record, error) { + args := m.Called(ctx, name) + return args.Get(0).(*ipns.Record), args.Error(1) +} + +func (m *mockContentRouter) ProvideIPNSRecord(ctx context.Context, name ipns.Name, record *ipns.Record) error { + args := m.Called(ctx, name, record) + return args.Error(0) +} + type testDeps struct { // recordingHandler records requests received on the server side recordingHandler *recordingHandler @@ -442,3 +456,101 @@ func TestClient_Provide(t *testing.T) { }) } } + +func makeName(t *testing.T) (crypto.PrivKey, ipns.Name) { + sk, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + pid, err := peer.IDFromPrivateKey(sk) + require.NoError(t, err) + + return sk, ipns.NameFromPeer(pid) +} + +func makeIPNSRecord(t *testing.T, sk crypto.PrivKey, opts ...ipns.Option) (*ipns.Record, []byte) { + cid, err := cid.Decode("bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4") + require.NoError(t, err) + + path := path.IpfsPath(cid) + eol := time.Now().Add(time.Hour * 48) + ttl := time.Second * 20 + + record, err := ipns.NewRecord(sk, ipfspath.FromString(path.String()), 1, eol, ttl, opts...) + require.NoError(t, err) + + rawRecord, err := ipns.MarshalRecord(record) + require.NoError(t, err) + + return record, rawRecord +} + +func TestClient_IPNS(t *testing.T) { + t.Run("Find IPNS Record returns error if server errors", func(t *testing.T) { + _, name := makeName(t) + + deps := makeTestDeps(t, nil, nil) + client := deps.client + router := deps.router + + router.On("FindIPNSRecord", mock.Anything, name).Return(nil, errors.New("something wrong happened")) + + receivedRecord, err := client.FindIPNSRecord(context.Background(), name) + require.Error(t, err) + require.Nil(t, receivedRecord) + }) + + runWithRecordOptions := func(t *testing.T, opts ...ipns.Option) { + t.Run("Find IPNS Record", func(t *testing.T) { + sk, name := makeName(t) + record, _ := makeIPNSRecord(t, sk, opts...) + + deps := makeTestDeps(t, nil, nil) + client := deps.client + router := deps.router + + router.On("FindIPNSRecord", mock.Anything, name).Return(record, nil) + + receivedRecord, err := client.FindIPNSRecord(context.Background(), name) + require.NoError(t, err) + require.Equal(t, record, receivedRecord) + }) + + t.Run("Find IPNS Record returns error if server sends bad data", func(t *testing.T) { + sk, _ := makeName(t) + record, _ := makeIPNSRecord(t, sk, opts...) + _, name2 := makeName(t) + + deps := makeTestDeps(t, nil, nil) + client := deps.client + router := deps.router + + router.On("FindIPNSRecord", mock.Anything, name2).Return(record, nil) + + receivedRecord, err := client.FindIPNSRecord(context.Background(), name2) + require.Error(t, err) + require.Nil(t, receivedRecord) + }) + + t.Run("Provide IPNS Record", func(t *testing.T) { + sk, name := makeName(t) + record, _ := makeIPNSRecord(t, sk, opts...) + + deps := makeTestDeps(t, nil, nil) + client := deps.client + router := deps.router + + router.On("ProvideIPNSRecord", mock.Anything, name, record).Return(nil) + + err := client.ProvideIPNSRecord(context.Background(), name, record) + require.NoError(t, err) + }) + } + + t.Run("V1+V2 IPNS Records", func(t *testing.T) { + runWithRecordOptions(t, ipns.WithV1Compatibility(true)) + }) + + t.Run("V2 IPNS Records", func(t *testing.T) { + runWithRecordOptions(t, ipns.WithV1Compatibility(false)) + }) +} diff --git a/routing/http/server/server.go b/routing/http/server/server.go index dc0026fbf..835262990 100644 --- a/routing/http/server/server.go +++ b/routing/http/server/server.go @@ -9,10 +9,13 @@ import ( "io" "mime" "net/http" + "strconv" "strings" "time" + "github.com/cespare/xxhash/v2" "github.com/gorilla/mux" + "github.com/ipfs/boxo/ipns" "github.com/ipfs/boxo/routing/http/internal/drjson" "github.com/ipfs/boxo/routing/http/types" "github.com/ipfs/boxo/routing/http/types/iter" @@ -25,9 +28,10 @@ import ( ) const ( - mediaTypeJSON = "application/json" - mediaTypeNDJSON = "application/x-ndjson" - mediaTypeWildcard = "*/*" + mediaTypeJSON = "application/json" + mediaTypeNDJSON = "application/x-ndjson" + mediaTypeWildcard = "*/*" + mediaTypeIPNSRecord = "application/vnd.ipfs.ipns-record" DefaultRecordsLimit = 20 DefaultStreamingRecordsLimit = 0 @@ -38,6 +42,7 @@ var logger = logging.Logger("service/server/delegatedrouting") const ( ProvidePath = "/routing/v1/providers/" FindProvidersPath = "/routing/v1/providers/{cid}" + IPNSPath = "/routing/v1/ipns/{cid}" ) type FindProvidersAsyncResponse struct { @@ -51,6 +56,13 @@ type ContentRouter interface { FindProviders(ctx context.Context, key cid.Cid, limit int) (iter.ResultIter[types.ProviderResponse], error) ProvideBitswap(ctx context.Context, req *BitswapWriteProvideRequest) (time.Duration, error) Provide(ctx context.Context, req *WriteProvideRequest) (types.ProviderResponse, error) + + // FindIPNSRecord searches for an [ipns.Record] for the given [ipns.Name]. + FindIPNSRecord(ctx context.Context, name ipns.Name) (*ipns.Record, error) + + // ProvideIPNSRecord stores the provided [ipns.Record] for the given [ipns.Name]. It is + // guaranteed that the record matches the provided name. + ProvideIPNSRecord(ctx context.Context, name ipns.Name, record *ipns.Record) error } type BitswapWriteProvideRequest struct { @@ -107,6 +119,9 @@ func Handler(svc ContentRouter, opts ...Option) http.Handler { r.HandleFunc(ProvidePath, server.provide).Methods(http.MethodPut) r.HandleFunc(FindProvidersPath, server.findProviders).Methods(http.MethodGet) + r.HandleFunc(IPNSPath, server.getIPNSRecord).Methods(http.MethodGet) + r.HandleFunc(IPNSPath, server.putIPNSRecord).Methods(http.MethodPut) + return r } @@ -297,6 +312,98 @@ func (s *server) findProvidersNDJSON(w http.ResponseWriter, provIter iter.Result } } +func (s *server) getIPNSRecord(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept"), mediaTypeIPNSRecord) { + writeErr(w, "GetIPNSRecord", http.StatusNotAcceptable, errors.New("content type in 'Accept' header is missing or not supported")) + return + } + + vars := mux.Vars(r) + cidStr := vars["cid"] + cid, err := cid.Decode(cidStr) + if err != nil { + writeErr(w, "GetIPNSRecord", http.StatusBadRequest, fmt.Errorf("unable to parse CID: %w", err)) + return + } + + name, err := ipns.NameFromCid(cid) + if err != nil { + writeErr(w, "GetIPNSRecord", http.StatusBadRequest, fmt.Errorf("peer ID CID is not valid: %w", err)) + return + } + + record, err := s.svc.FindIPNSRecord(r.Context(), name) + if err != nil { + writeErr(w, "GetIPNSRecord", http.StatusInternalServerError, fmt.Errorf("delegate error: %w", err)) + return + } + + rawRecord, err := ipns.MarshalRecord(record) + if err != nil { + writeErr(w, "GetIPNSRecord", http.StatusInternalServerError, err) + return + } + + if ttl, err := record.TTL(); err == nil { + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", int(ttl.Seconds()))) + } else { + w.Header().Set("Cache-Control", "max-age=60") + } + + recordEtag := strconv.FormatUint(xxhash.Sum64(rawRecord), 32) + w.Header().Set("Etag", recordEtag) + w.Header().Set("Content-Type", mediaTypeIPNSRecord) + w.Write(rawRecord) +} + +func (s *server) putIPNSRecord(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Content-Type"), mediaTypeIPNSRecord) { + writeErr(w, "PutIPNSRecord", http.StatusNotAcceptable, errors.New("content type in 'Content-Type' header is missing or not supported")) + return + } + + vars := mux.Vars(r) + cidStr := vars["cid"] + cid, err := cid.Decode(cidStr) + if err != nil { + writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("unable to parse CID: %w", err)) + return + } + + name, err := ipns.NameFromCid(cid) + if err != nil { + writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("peer ID CID is not valid: %w", err)) + return + } + + // Limit the reader to the maximum record size. + rawRecord, err := io.ReadAll(io.LimitReader(r.Body, int64(ipns.MaxRecordSize))) + if err != nil { + writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("provided record is too long: %w", err)) + return + } + + record, err := ipns.UnmarshalRecord(rawRecord) + if err != nil { + writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("provided record is invalid: %w", err)) + return + } + + err = ipns.ValidateWithName(record, name) + if err != nil { + writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("provided record is invalid: %w", err)) + return + } + + err = s.svc.ProvideIPNSRecord(r.Context(), name, record) + if err != nil { + writeErr(w, "PutIPNSRecord", http.StatusInternalServerError, fmt.Errorf("delegate error: %w", err)) + return + } + + w.WriteHeader(http.StatusOK) +} + func writeJSONResult(w http.ResponseWriter, method string, val any) { w.Header().Add("Content-Type", mediaTypeJSON) diff --git a/routing/http/server/server_test.go b/routing/http/server/server_test.go index db607aba6..dfe38f0da 100644 --- a/routing/http/server/server_test.go +++ b/routing/http/server/server_test.go @@ -1,16 +1,22 @@ package server import ( + "bytes" "context" + "crypto/rand" "io" "net/http" "net/http/httptest" "testing" "time" + "github.com/ipfs/boxo/coreiface/path" + "github.com/ipfs/boxo/ipns" + ipfspath "github.com/ipfs/boxo/path" "github.com/ipfs/boxo/routing/http/types" "github.com/ipfs/boxo/routing/http/types/iter" "github.com/ipfs/go-cid" + "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -119,6 +125,138 @@ func TestResponse(t *testing.T) { }) } +func makeName(t *testing.T) (crypto.PrivKey, ipns.Name) { + sk, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + pid, err := peer.IDFromPrivateKey(sk) + require.NoError(t, err) + + return sk, ipns.NameFromPeer(pid) +} + +func makeIPNSRecord(t *testing.T, cid cid.Cid, sk crypto.PrivKey, opts ...ipns.Option) (*ipns.Record, []byte) { + path := path.IpfsPath(cid) + eol := time.Now().Add(time.Hour * 48) + ttl := time.Second * 20 + + record, err := ipns.NewRecord(sk, ipfspath.FromString(path.String()), 1, eol, ttl, opts...) + require.NoError(t, err) + + rawRecord, err := ipns.MarshalRecord(record) + require.NoError(t, err) + + return record, rawRecord +} + +func TestIPNS(t *testing.T) { + cid1, err := cid.Decode("bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4") + require.NoError(t, err) + + makeRequest := func(t *testing.T, router *mockContentRouter, path string) *http.Response { + server := httptest.NewServer(Handler(router)) + t.Cleanup(server.Close) + serverAddr := "http://" + server.Listener.Addr().String() + urlStr := serverAddr + path + req, err := http.NewRequest(http.MethodGet, urlStr, nil) + require.NoError(t, err) + req.Header.Set("Accept", mediaTypeIPNSRecord) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp + } + + runWithRecordOptions := func(t *testing.T, opts ...ipns.Option) { + sk, name1 := makeName(t) + record1, rawRecord1 := makeIPNSRecord(t, cid1, sk) + + _, name2 := makeName(t) + + t.Run("GET /routing/v1/ipns/{cid-peer-id} returns 200", func(t *testing.T) { + t.Parallel() + + rec, err := ipns.UnmarshalRecord(rawRecord1) + require.NoError(t, err) + + router := &mockContentRouter{} + router.On("FindIPNSRecord", mock.Anything, name1).Return(rec, nil) + + resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.String()) + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, mediaTypeIPNSRecord, resp.Header.Get("Content-Type")) + require.NotEmpty(t, resp.Header.Get("Etag")) + require.Equal(t, "max-age=20", resp.Header.Get("Cache-Control")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, body, rawRecord1) + }) + + t.Run("GET /routing/v1/ipns/{non-peer-cid} returns 400", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + resp := makeRequest(t, router, "/routing/v1/ipns/"+cid1.String()) + require.Equal(t, 400, resp.StatusCode) + }) + + t.Run("GET /routing/v1/ipns/{peer-id} returns 400", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + resp := makeRequest(t, router, "/routing/v1/ipns/"+name1.Peer().String()) + require.Equal(t, 400, resp.StatusCode) + }) + + t.Run("PUT /routing/v1/ipns/{cid-peer-id} returns 200", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + router.On("ProvideIPNSRecord", mock.Anything, name1, record1).Return(nil) + + server := httptest.NewServer(Handler(router)) + t.Cleanup(server.Close) + serverAddr := "http://" + server.Listener.Addr().String() + urlStr := serverAddr + "/routing/v1/ipns/" + name1.String() + + req, err := http.NewRequest(http.MethodPut, urlStr, bytes.NewReader(rawRecord1)) + require.NoError(t, err) + req.Header.Set("Content-Type", mediaTypeIPNSRecord) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("PUT /routing/v1/ipns/{cid-peer-id} returns 400 for wrong record", func(t *testing.T) { + t.Parallel() + + router := &mockContentRouter{} + + server := httptest.NewServer(Handler(router)) + t.Cleanup(server.Close) + serverAddr := "http://" + server.Listener.Addr().String() + urlStr := serverAddr + "/routing/v1/ipns/" + name2.String() + + req, err := http.NewRequest(http.MethodPut, urlStr, bytes.NewReader(rawRecord1)) + require.NoError(t, err) + req.Header.Set("Content-Type", mediaTypeIPNSRecord) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, 400, resp.StatusCode) + }) + } + + t.Run("V1+V2 IPNS Records", func(t *testing.T) { + runWithRecordOptions(t, ipns.WithV1Compatibility(true)) + }) + + t.Run("V2 IPNS Records", func(t *testing.T) { + runWithRecordOptions(t, ipns.WithV1Compatibility(false)) + }) +} + type mockContentRouter struct{ mock.Mock } func (m *mockContentRouter) FindProviders(ctx context.Context, key cid.Cid, limit int) (iter.ResultIter[types.ProviderResponse], error) { @@ -135,3 +273,13 @@ func (m *mockContentRouter) Provide(ctx context.Context, req *WriteProvideReques args := m.Called(ctx, req) return args.Get(0).(types.ProviderResponse), args.Error(1) } + +func (m *mockContentRouter) FindIPNSRecord(ctx context.Context, name ipns.Name) (*ipns.Record, error) { + args := m.Called(ctx, name) + return args.Get(0).(*ipns.Record), args.Error(1) +} + +func (m *mockContentRouter) ProvideIPNSRecord(ctx context.Context, name ipns.Name, record *ipns.Record) error { + args := m.Called(ctx, name, record) + return args.Error(0) +}