Skip to content

Commit

Permalink
feat(routing/http): delegated IPNS server implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
hacdias committed Jun 5, 2023
1 parent e2fc7f2 commit b8a0ddb
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 5 deletions.
12 changes: 12 additions & 0 deletions routing/http/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/benbjohnson/clock"
ipns_pb "github.com/ipfs/boxo/ipns/pb"
"github.com/ipfs/boxo/routing/http/server"
"github.com/ipfs/boxo/routing/http/types"
"github.com/ipfs/boxo/routing/http/types/iter"
Expand All @@ -31,6 +32,7 @@ func (m *mockContentRouter) FindProviders(ctx context.Context, key cid.Cid, limi
args := m.Called(ctx, key, limit)
return args.Get(0).(iter.ResultIter[types.ProviderResponse]), args.Error(1)
}

func (m *mockContentRouter) ProvideBitswap(ctx context.Context, req *server.BitswapWriteProvideRequest) (time.Duration, error) {
args := m.Called(ctx, req)
return args.Get(0).(time.Duration), args.Error(1)
Expand All @@ -41,6 +43,16 @@ func (m *mockContentRouter) Provide(ctx context.Context, req *server.WriteProvid
return args.Get(0).(types.ProviderResponse), args.Error(1)
}

func (m *mockContentRouter) GetIPNSRecord(ctx context.Context, pid peer.ID) (*ipns_pb.IpnsEntry, error) {
args := m.Called(ctx, pid)
return args.Get(0).(*ipns_pb.IpnsEntry), args.Error(1)
}

func (m *mockContentRouter) PutIPNSRecord(ctx context.Context, pid peer.ID, record *ipns_pb.IpnsEntry) error {
args := m.Called(ctx, pid, record)
return args.Error(0)
}

type testDeps struct {
// recordingHandler records requests received on the server side
recordingHandler *recordingHandler
Expand Down
122 changes: 117 additions & 5 deletions routing/http/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import (
"io"
"mime"
"net/http"
"strconv"
"strings"
"time"

"github.com/cespare/xxhash/v2"
"github.com/gogo/protobuf/proto"
"github.com/gorilla/mux"
"github.com/ipfs/boxo/ipns"
ipns_pb "github.com/ipfs/boxo/ipns/pb"
"github.com/ipfs/boxo/routing/http/internal/drjson"
"github.com/ipfs/boxo/routing/http/types"
"github.com/ipfs/boxo/routing/http/types/iter"
Expand All @@ -25,18 +30,22 @@ 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
)

var logger = logging.Logger("service/server/delegatedrouting")

const ProvidePath = "/routing/v1/providers/"
const FindProvidersPath = "/routing/v1/providers/{cid}"
const (
ProvidePath = "/routing/v1/providers/"
FindProvidersPath = "/routing/v1/providers/{cid}"
IPNSPath = "/routing/v1/ipns/{cid}"
)

type FindProvidersAsyncResponse struct {
ProviderResponse types.ProviderResponse
Expand All @@ -49,6 +58,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)

// GetIPNSRecord searches for an IPNS Record for the given peer ID.
GetIPNSRecord(ctx context.Context, pid peer.ID) (*ipns_pb.IpnsEntry, error)

// PutIPNSRecord stores the provided IPNS Record for the given peer ID. It is
// guaranteed that the record matches the provided peer ID.
PutIPNSRecord(ctx context.Context, pid peer.ID, record *ipns_pb.IpnsEntry) error
}

type BitswapWriteProvideRequest struct {
Expand Down Expand Up @@ -105,6 +121,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
}

Expand Down Expand Up @@ -296,6 +315,99 @@ 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
}

pid, err := peer.FromCid(cid)
if err != nil {
writeErr(w, "GetIPNSRecord", http.StatusBadRequest, fmt.Errorf("peer ID CID is not valid: %w", err))
return
}

record, err := s.svc.GetIPNSRecord(r.Context(), pid)
if err != nil {
writeErr(w, "GetIPNSRecord", http.StatusInternalServerError, fmt.Errorf("delegate error: %w", err))
return
}

rawRecord, err := proto.Marshal(record)
if err != nil {
writeErr(w, "GetIPNSRecord", http.StatusInternalServerError, err)
return
}

if record.Ttl != nil {
seconds := int(time.Duration(*record.Ttl).Seconds())
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", 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
}

pid, err := peer.FromCid(cid)
if err != nil {
writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("peer ID CID is not valid: %w", err))
return
}

// The record is at most 10 KiB.
rawRecord, err := io.ReadAll(io.LimitReader(r.Body, 10240))
if err != nil {
writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("provided record is too long: %w", err))
return
}

record, err := ipns.UnmarshalIpnsEntry(rawRecord)
if err != nil {
writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("provided record is invalid: %w", err))
return
}

err = ipns.ValidateWithPeerID(pid, record)
if err != nil {
writeErr(w, "PutIPNSRecord", http.StatusBadRequest, fmt.Errorf("provided record is invalid: %w", err))
return
}

err = s.svc.PutIPNSRecord(r.Context(), pid, 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)

Expand Down
135 changes: 135 additions & 0 deletions routing/http/server/server_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package server

import (
"bytes"
"context"
"crypto/rand"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gogo/protobuf/proto"
"github.com/ipfs/boxo/coreiface/path"
"github.com/ipfs/boxo/ipns"
ipns_pb "github.com/ipfs/boxo/ipns/pb"
"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"
Expand Down Expand Up @@ -117,6 +124,124 @@ func TestResponse(t *testing.T) {
})
}

func makePeerID(t *testing.T) (crypto.PrivKey, peer.ID) {
sk, _, err := crypto.GenerateEd25519Key(rand.Reader)
require.NoError(t, err)

pid, err := peer.IDFromPrivateKey(sk)
require.NoError(t, err)

return sk, pid
}

func makeIPNSRecord(t *testing.T, cid cid.Cid, sk crypto.PrivKey) (*ipns_pb.IpnsEntry, []byte) {
path := path.IpfsPath(cid)
eol := time.Now().Add(time.Hour * 48)
ttl := time.Second * 20

record, err := ipns.Create(sk, []byte(path.String()), 1, eol, ttl)
require.NoError(t, err)

rawRecord, err := proto.Marshal(record)
require.NoError(t, err)

return record, rawRecord
}

func TestIPNS(t *testing.T) {
cid1, err := cid.Decode("bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4")
require.NoError(t, err)
require.NoError(t, err)

sk, pid1 := makePeerID(t)
record1, rawRecord1 := makeIPNSRecord(t, cid1, sk)

_, pid2 := makePeerID(t)

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
}

t.Run("GET /routing/v1/ipns/{cid-peer-id} returns 200", func(t *testing.T) {
t.Parallel()

router := &mockContentRouter{}
router.On("GetIPNSRecord", mock.Anything, pid1).Return(record1, nil)

resp := makeRequest(t, router, "/routing/v1/ipns/"+peer.ToCid(pid1).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/"+pid1.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("PutIPNSRecord", mock.Anything, pid1, record1).Return(nil)

server := httptest.NewServer(Handler(router))
t.Cleanup(server.Close)
serverAddr := "http://" + server.Listener.Addr().String()
urlStr := serverAddr + "/routing/v1/ipns/" + peer.ToCid(pid1).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/" + peer.ToCid(pid2).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)
})
}

type mockContentRouter struct{ mock.Mock }

func (m *mockContentRouter) FindProviders(ctx context.Context, key cid.Cid, limit int) (iter.ResultIter[types.ProviderResponse], error) {
Expand All @@ -132,3 +257,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) GetIPNSRecord(ctx context.Context, pid peer.ID) (*ipns_pb.IpnsEntry, error) {
args := m.Called(ctx, pid)
return args.Get(0).(*ipns_pb.IpnsEntry), args.Error(1)
}

func (m *mockContentRouter) PutIPNSRecord(ctx context.Context, pid peer.ID, record *ipns_pb.IpnsEntry) error {
args := m.Called(ctx, pid, record)
return args.Error(0)
}

0 comments on commit b8a0ddb

Please sign in to comment.