diff --git a/.github/workflows/gateway-conformance.yml b/.github/workflows/gateway-conformance.yml index f45e9aedf..c9c3eb072 100644 --- a/.github/workflows/gateway-conformance.yml +++ b/.github/workflows/gateway-conformance.yml @@ -16,18 +16,18 @@ jobs: steps: # 1. Download the gateway-conformance fixtures - name: Download gateway-conformance fixtures - uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.4 + uses: ipfs/gateway-conformance/.github/actions/extract-fixtures@v0.5 with: output: fixtures merged: true # 2. Build the car-gateway - name: Setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v4 with: go-version: 1.21.x - name: Checkout boxo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: boxo - name: Build car-gateway @@ -40,7 +40,7 @@ jobs: # 4. Run the gateway-conformance tests - name: Run gateway-conformance tests - uses: ipfs/gateway-conformance/.github/actions/test@v0.4 + uses: ipfs/gateway-conformance/.github/actions/test@v0.5 with: gateway-url: http://127.0.0.1:8040 json: output.json diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3d34c3f..785b27904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,25 @@ The following emojis are used to highlight certain changes: ### Removed +### Fixed + ### Security +## [v0.18.0] + +### Added + +- `blockservice` now has `ContextWithSession` and `EmbedSessionInContext` functions, which allows to embed a session in a context. Future calls to `BlockGetter.GetBlock`, `BlockGetter.GetBlocks` and `NewSession` will use the session in the context. +- `blockservice.NewWritethrough` deprecated function has been removed, instead you can do `blockservice.New(..., ..., WriteThrough())` like previously. +- `gateway`: a new header configuration middleware has been added to replace the existing header configuration, which can be used more generically. +- `namesys` now has a `WithMaxCacheTTL` option, which allows you to define a maximum TTL that will be used for caching IPNS entries. + +### Fixed + +- ๐ `boxo/gateway`: when making a trustless CAR request with the "entity-bytes" parameter, using a negative index greater than the underlying entity length could trigger reading more data than intended +- ๐ `boxo/gateway`: the header configuration `Config.Headers` and `AddAccessControlHeaders` has been replaced by the new middleware provided by `NewHeaders`. +- ๐ `routing/http/client`: the default HTTP client is no longer a global singleton. Therefore, using `WithUserAgent` won't modify the user agent of existing routing clients. This will also prevent potential race conditions. In addition, incompatible options will now return errors instead of silently failing. + ## [v0.17.0] ### Added @@ -47,7 +64,7 @@ The following emojis are used to highlight certain changes: ### Fixed * `boxo/gateway` - * a panic (which is recovered) could sporadically be triggered inside a CAR request, if the right [conditions were met](https://github.com/ipfs/boxo/pull/511). + * a panic (which is recovered) could sporadically be triggered inside a CAR request, if the right [conditions were met](https://github.com/ipfs/boxo/pull/511). * no longer emits `http: superfluous response.WriteHeader` warnings when an error happens. ## [v0.15.0] @@ -151,7 +168,7 @@ The following emojis are used to highlight certain changes: * ๐ The `routing/http` package experienced following removals: * Server and client no longer support the experimental `Provide` method. - `ProvideBitswap` is still usable, but marked as deprecated. A protocol-agnostic + `ProvideBitswap` is still usable, but marked as deprecated. A protocol-agnostic provide mechanism is being worked on in [IPIP-378](https://github.com/ipfs/specs/pull/378). * Server no longer exports `FindProvidersPath` and `ProvidePath`. diff --git a/blockservice/blockservice.go b/blockservice/blockservice.go index 423697d87..353be00f8 100644 --- a/blockservice/blockservice.go +++ b/blockservice/blockservice.go @@ -71,6 +71,8 @@ type BoundedBlockService interface { Allowlist() verifcid.Allowlist } +var _ BoundedBlockService = (*blockService)(nil) + type blockService struct { allowlist verifcid.Allowlist blockstore blockstore.Blockstore @@ -117,14 +119,6 @@ func New(bs blockstore.Blockstore, exchange exchange.Interface, opts ...Option) return service } -// NewWriteThrough creates a BlockService that guarantees writes will go -// through to the blockstore and are not skipped by cache checks. -// -// Deprecated: Use [New] with the [WriteThrough] option. -func NewWriteThrough(bs blockstore.Blockstore, exchange exchange.Interface) BlockService { - return New(bs, exchange, WriteThrough()) -} - // Blockstore returns the blockstore behind this blockservice. func (s *blockService) Blockstore() blockstore.Blockstore { return s.blockstore @@ -144,29 +138,19 @@ func (s *blockService) Allowlist() verifcid.Allowlist { // If the current exchange is a SessionExchange, a new exchange // session will be created. Otherwise, the current exchange will be used // directly. +// Sessions are lazily setup, this is cheap. func NewSession(ctx context.Context, bs BlockService) *Session { - allowlist := verifcid.Allowlist(verifcid.DefaultAllowlist) - if bbs, ok := bs.(BoundedBlockService); ok { - allowlist = bbs.Allowlist() - } - exch := bs.Exchange() - if sessEx, ok := exch.(exchange.SessionExchange); ok { - return &Session{ - allowlist: allowlist, - sessCtx: ctx, - ses: nil, - sessEx: sessEx, - bs: bs.Blockstore(), - notifier: exch, - } - } - return &Session{ - allowlist: allowlist, - ses: exch, - sessCtx: ctx, - bs: bs.Blockstore(), - notifier: exch, + ses := grabSessionFromContext(ctx, bs) + if ses != nil { + return ses } + + return newSession(ctx, bs) +} + +// newSession is like [NewSession] but it does not attempt to reuse session from the existing context. +func newSession(ctx context.Context, bs BlockService) *Session { + return &Session{bs: bs, sesctx: ctx} } // AddBlock adds a particular block to the service, Putting it into the datastore. @@ -248,92 +232,100 @@ func (s *blockService) AddBlocks(ctx context.Context, bs []blocks.Block) error { // GetBlock retrieves a particular block from the service, // Getting it from the datastore using the key (hash). func (s *blockService) GetBlock(ctx context.Context, c cid.Cid) (blocks.Block, error) { + if ses := grabSessionFromContext(ctx, s); ses != nil { + return ses.GetBlock(ctx, c) + } + ctx, span := internal.StartSpan(ctx, "blockService.GetBlock", trace.WithAttributes(attribute.Stringer("CID", c))) defer span.End() - var f func() notifiableFetcher - if s.exchange != nil { - f = s.getExchange - } - - return getBlock(ctx, c, s.blockstore, s.allowlist, f) + return getBlock(ctx, c, s, s.getExchangeFetcher) } -func (s *blockService) getExchange() notifiableFetcher { +// Look at what I have to do, no interface covariance :'( +func (s *blockService) getExchangeFetcher() exchange.Fetcher { return s.exchange } -func getBlock(ctx context.Context, c cid.Cid, bs blockstore.Blockstore, allowlist verifcid.Allowlist, fget func() notifiableFetcher) (blocks.Block, error) { - err := verifcid.ValidateCid(allowlist, c) // hash security +func getBlock(ctx context.Context, c cid.Cid, bs BlockService, fetchFactory func() exchange.Fetcher) (blocks.Block, error) { + err := verifcid.ValidateCid(grabAllowlistFromBlockservice(bs), c) // hash security if err != nil { return nil, err } - block, err := bs.Get(ctx, c) - if err == nil { + blockstore := bs.Blockstore() + + block, err := blockstore.Get(ctx, c) + switch { + case err == nil: return block, nil + case ipld.IsNotFound(err): + break + default: + return nil, err } - if ipld.IsNotFound(err) && fget != nil { - f := fget() // Don't load the exchange until we have to + fetch := fetchFactory() // lazily create session if needed + if fetch == nil { + logger.Debug("BlockService GetBlock: Not found") + return nil, err + } - // TODO be careful checking ErrNotFound. If the underlying - // implementation changes, this will break. - logger.Debug("BlockService: Searching") - blk, err := f.GetBlock(ctx, c) - if err != nil { - return nil, err - } - // also write in the blockstore for caching, inform the exchange that the block is available - err = bs.Put(ctx, blk) - if err != nil { - return nil, err - } - err = f.NotifyNewBlocks(ctx, blk) + logger.Debug("BlockService: Searching") + blk, err := fetch.GetBlock(ctx, c) + if err != nil { + return nil, err + } + // also write in the blockstore for caching, inform the exchange that the block is available + err = blockstore.Put(ctx, blk) + if err != nil { + return nil, err + } + if ex := bs.Exchange(); ex != nil { + err = ex.NotifyNewBlocks(ctx, blk) if err != nil { return nil, err } - logger.Debugf("BlockService.BlockFetched %s", c) - return blk, nil } - - logger.Debug("BlockService GetBlock: Not found") - return nil, err + logger.Debugf("BlockService.BlockFetched %s", c) + return blk, nil } // GetBlocks gets a list of blocks asynchronously and returns through // the returned channel. // NB: No guarantees are made about order. func (s *blockService) GetBlocks(ctx context.Context, ks []cid.Cid) <-chan blocks.Block { + if ses := grabSessionFromContext(ctx, s); ses != nil { + return ses.GetBlocks(ctx, ks) + } + ctx, span := internal.StartSpan(ctx, "blockService.GetBlocks") defer span.End() - var f func() notifiableFetcher - if s.exchange != nil { - f = s.getExchange - } - - return getBlocks(ctx, ks, s.blockstore, s.allowlist, f) + return getBlocks(ctx, ks, s, s.getExchangeFetcher) } -func getBlocks(ctx context.Context, ks []cid.Cid, bs blockstore.Blockstore, allowlist verifcid.Allowlist, fget func() notifiableFetcher) <-chan blocks.Block { +func getBlocks(ctx context.Context, ks []cid.Cid, blockservice BlockService, fetchFactory func() exchange.Fetcher) <-chan blocks.Block { out := make(chan blocks.Block) go func() { defer close(out) - allValid := true - for _, c := range ks { + allowlist := grabAllowlistFromBlockservice(blockservice) + + var lastAllValidIndex int + var c cid.Cid + for lastAllValidIndex, c = range ks { if err := verifcid.ValidateCid(allowlist, c); err != nil { - allValid = false break } } - if !allValid { + if lastAllValidIndex != len(ks) { // can't shift in place because we don't want to clobber callers. - ks2 := make([]cid.Cid, 0, len(ks)) - for _, c := range ks { + ks2 := make([]cid.Cid, lastAllValidIndex, len(ks)) + copy(ks2, ks[:lastAllValidIndex]) // fast path for already filtered elements + for _, c := range ks[lastAllValidIndex:] { // don't rescan already scanned elements // hash security if err := verifcid.ValidateCid(allowlist, c); err == nil { ks2 = append(ks2, c) @@ -344,6 +336,8 @@ func getBlocks(ctx context.Context, ks []cid.Cid, bs blockstore.Blockstore, allo ks = ks2 } + bs := blockservice.Blockstore() + var misses []cid.Cid for _, c := range ks { hit, err := bs.Get(ctx, c) @@ -358,17 +352,18 @@ func getBlocks(ctx context.Context, ks []cid.Cid, bs blockstore.Blockstore, allo } } - if len(misses) == 0 || fget == nil { + fetch := fetchFactory() // don't load exchange unless we have to + if len(misses) == 0 || fetch == nil { return } - f := fget() // don't load exchange unless we have to - rblocks, err := f.GetBlocks(ctx, misses) + rblocks, err := fetch.GetBlocks(ctx, misses) if err != nil { logger.Debugf("Error with GetBlocks: %s", err) return } + ex := blockservice.Exchange() var cache [1]blocks.Block // preallocate once for all iterations for { var b blocks.Block @@ -389,14 +384,16 @@ func getBlocks(ctx context.Context, ks []cid.Cid, bs blockstore.Blockstore, allo return } - // inform the exchange that the blocks are available - cache[0] = b - err = f.NotifyNewBlocks(ctx, cache[:]...) - if err != nil { - logger.Errorf("could not tell the exchange about new blocks: %s", err) - return + if ex != nil { + // inform the exchange that the blocks are available + cache[0] = b + err = ex.NotifyNewBlocks(ctx, cache[:]...) + if err != nil { + logger.Errorf("could not tell the exchange about new blocks: %s", err) + return + } + cache[0] = nil // early gc } - cache[0] = nil // early gc select { case out <- b: @@ -428,54 +425,35 @@ func (s *blockService) Close() error { return s.exchange.Close() } -type notifier interface { - NotifyNewBlocks(context.Context, ...blocks.Block) error -} - // Session is a helper type to provide higher level access to bitswap sessions type Session struct { - allowlist verifcid.Allowlist - bs blockstore.Blockstore - ses exchange.Fetcher - sessEx exchange.SessionExchange - sessCtx context.Context - notifier notifier - lk sync.Mutex -} - -type notifiableFetcher interface { - exchange.Fetcher - notifier + createSession sync.Once + bs BlockService + ses exchange.Fetcher + sesctx context.Context } -type notifiableFetcherWrapper struct { - exchange.Fetcher - notifier -} +// grabSession is used to lazily create sessions. +func (s *Session) grabSession() exchange.Fetcher { + s.createSession.Do(func() { + defer func() { + s.sesctx = nil // early gc + }() -func (s *Session) getSession() notifiableFetcher { - s.lk.Lock() - defer s.lk.Unlock() - if s.ses == nil { - s.ses = s.sessEx.NewSession(s.sessCtx) - } + ex := s.bs.Exchange() + if ex == nil { + return + } + s.ses = ex // always fallback to non session fetches - return notifiableFetcherWrapper{s.ses, s.notifier} -} + sesEx, ok := ex.(exchange.SessionExchange) + if !ok { + return + } + s.ses = sesEx.NewSession(s.sesctx) + }) -func (s *Session) getExchange() notifiableFetcher { - return notifiableFetcherWrapper{s.ses, s.notifier} -} - -func (s *Session) getFetcherFactory() func() notifiableFetcher { - if s.sessEx != nil { - return s.getSession - } - if s.ses != nil { - // Our exchange isn't session compatible, let's fallback to non sessions fetches - return s.getExchange - } - return nil + return s.ses } // GetBlock gets a block in the context of a request session @@ -483,7 +461,7 @@ func (s *Session) GetBlock(ctx context.Context, c cid.Cid) (blocks.Block, error) ctx, span := internal.StartSpan(ctx, "Session.GetBlock", trace.WithAttributes(attribute.Stringer("CID", c))) defer span.End() - return getBlock(ctx, c, s.bs, s.allowlist, s.getFetcherFactory()) + return getBlock(ctx, c, s.bs, s.grabSession) } // GetBlocks gets blocks in the context of a request session @@ -491,7 +469,52 @@ func (s *Session) GetBlocks(ctx context.Context, ks []cid.Cid) <-chan blocks.Blo ctx, span := internal.StartSpan(ctx, "Session.GetBlocks") defer span.End() - return getBlocks(ctx, ks, s.bs, s.allowlist, s.getFetcherFactory()) + return getBlocks(ctx, ks, s.bs, s.grabSession) } var _ BlockGetter = (*Session)(nil) + +// ContextWithSession is a helper which creates a context with an embded session, +// future calls to [BlockGetter.GetBlock], [BlockGetter.GetBlocks] and [NewSession] with the same [BlockService] +// will be redirected to this same session instead. +// Sessions are lazily setup, this is cheap. +// It wont make a new session if one exists already in the context. +func ContextWithSession(ctx context.Context, bs BlockService) context.Context { + if grabSessionFromContext(ctx, bs) != nil { + return ctx + } + return EmbedSessionInContext(ctx, newSession(ctx, bs)) +} + +// EmbedSessionInContext is like [ContextWithSession] but it allows to embed an existing session. +func EmbedSessionInContext(ctx context.Context, ses *Session) context.Context { + // use ses.bs as a key, so if multiple blockservices use embeded sessions it gets dispatched to the matching blockservice. + return context.WithValue(ctx, ses.bs, ses) +} + +// grabSessionFromContext returns nil if the session was not found +// This is a private API on purposes, I dislike when consumers tradeoff compiletime typesafety with runtime typesafety, +// if this API is public it is too easy to forget to pass a [BlockService] or [Session] object around in your app. +// By having this private we allow consumers to follow the trace of where the blockservice is passed and used. +func grabSessionFromContext(ctx context.Context, bs BlockService) *Session { + s := ctx.Value(bs) + if s == nil { + return nil + } + + ss, ok := s.(*Session) + if !ok { + // idk what to do here, that kinda sucks, giveup + return nil + } + + return ss +} + +// grabAllowlistFromBlockservice never returns nil +func grabAllowlistFromBlockservice(bs BlockService) verifcid.Allowlist { + if bbs, ok := bs.(BoundedBlockService); ok { + return bbs.Allowlist() + } + return verifcid.DefaultAllowlist +} diff --git a/blockservice/blockservice_test.go b/blockservice/blockservice_test.go index e36058040..53fd725f3 100644 --- a/blockservice/blockservice_test.go +++ b/blockservice/blockservice_test.go @@ -27,7 +27,7 @@ func TestWriteThroughWorks(t *testing.T) { } exchbstore := blockstore.NewBlockstore(dssync.MutexWrap(ds.NewMapDatastore())) exch := offline.Exchange(exchbstore) - bserv := NewWriteThrough(bstore, exch) + bserv := New(bstore, exch, WriteThrough()) bgen := butil.NewBlockGenerator() block := bgen.Next() @@ -62,7 +62,7 @@ func TestExchangeWrite(t *testing.T) { offline.Exchange(exchbstore), 0, } - bserv := NewWriteThrough(bstore, exch) + bserv := New(bstore, exch, WriteThrough()) bgen := butil.NewBlockGenerator() for name, fetcher := range map[string]BlockGetter{ @@ -136,7 +136,7 @@ func TestLazySessionInitialization(t *testing.T) { session := offline.Exchange(bstore2) exch := offline.Exchange(bstore3) sessionExch := &fakeSessionExchange{Interface: exch, session: session} - bservSessEx := NewWriteThrough(bstore, sessionExch) + bservSessEx := New(bstore, sessionExch, WriteThrough()) bgen := butil.NewBlockGenerator() block := bgen.Next() @@ -234,7 +234,7 @@ func TestNilExchange(t *testing.T) { block := bgen.Next() bs := blockstore.NewBlockstore(dssync.MutexWrap(ds.NewMapDatastore())) - bserv := NewWriteThrough(bs, nil) + bserv := New(bs, nil, WriteThrough()) sess := NewSession(ctx, bserv) _, err := sess.GetBlock(ctx, block.Cid()) if !ipld.IsNotFound(err) { @@ -288,3 +288,68 @@ func TestAllowlist(t *testing.T) { check(blockservice.GetBlock) check(NewSession(ctx, blockservice).GetBlock) } + +type fakeIsNewSessionCreateExchange struct { + ses exchange.Fetcher + newSessionWasCalled bool +} + +var _ exchange.SessionExchange = (*fakeIsNewSessionCreateExchange)(nil) + +func (*fakeIsNewSessionCreateExchange) Close() error { + return nil +} + +func (*fakeIsNewSessionCreateExchange) GetBlock(context.Context, cid.Cid) (blocks.Block, error) { + panic("should call on the session") +} + +func (*fakeIsNewSessionCreateExchange) GetBlocks(context.Context, []cid.Cid) (<-chan blocks.Block, error) { + panic("should call on the session") +} + +func (f *fakeIsNewSessionCreateExchange) NewSession(context.Context) exchange.Fetcher { + f.newSessionWasCalled = true + return f.ses +} + +func (*fakeIsNewSessionCreateExchange) NotifyNewBlocks(context.Context, ...blocks.Block) error { + return nil +} + +func TestContextSession(t *testing.T) { + t.Parallel() + a := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + bgen := butil.NewBlockGenerator() + block1 := bgen.Next() + block2 := bgen.Next() + + bs := blockstore.NewBlockstore(ds.NewMapDatastore()) + a.NoError(bs.Put(ctx, block1)) + a.NoError(bs.Put(ctx, block2)) + sesEx := &fakeIsNewSessionCreateExchange{ses: offline.Exchange(bs)} + + service := New(blockstore.NewBlockstore(ds.NewMapDatastore()), sesEx) + + ctx = ContextWithSession(ctx, service) + + b, err := service.GetBlock(ctx, block1.Cid()) + a.NoError(err) + a.Equal(b.RawData(), block1.RawData()) + a.True(sesEx.newSessionWasCalled, "new session from context should be created") + sesEx.newSessionWasCalled = false + + bchan := service.GetBlocks(ctx, []cid.Cid{block2.Cid()}) + a.Equal((<-bchan).RawData(), block2.RawData()) + a.False(sesEx.newSessionWasCalled, "session should be reused in context") + + a.Equal( + NewSession(ctx, service), + NewSession(ContextWithSession(ctx, service), service), + "session must be deduped in all invocations on the same context", + ) +} diff --git a/chunker/buzhash_norace_test.go b/chunker/buzhash_norace_test.go deleted file mode 100644 index 50dc0e5ce..000000000 --- a/chunker/buzhash_norace_test.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !race - -package chunk - -import ( - "testing" -) - -func TestFuzzBuzhashChunking(t *testing.T) { - buf := make([]byte, 1024*1024*16) - for i := 0; i < 100; i++ { - testBuzhashChunking(t, buf) - } -} diff --git a/chunker/buzhash_test.go b/chunker/buzhash_test.go index fe6de4434..2eaf5ae32 100644 --- a/chunker/buzhash_test.go +++ b/chunker/buzhash_test.go @@ -9,6 +9,8 @@ import ( ) func testBuzhashChunking(t *testing.T, buf []byte) (chunkCount int) { + t.Parallel() + n, err := util.NewTimeSeededRand().Read(buf) if n < len(buf) { t.Fatalf("expected %d bytes, got %d", len(buf), n) @@ -89,3 +91,13 @@ func TestBuzhashBitsHashBias(t *testing.T) { } } } + +func FuzzBuzhashChunking(f *testing.F) { + f.Add(make([]byte, 1024*1024*16)) + f.Fuzz(func(t *testing.T, b []byte) { + if len(b) < buzMin { + return + } + testBuzhashChunking(t, b) + }) +} diff --git a/chunker/parse_test.go b/chunker/parse_test.go index 2a33d64de..6809476e9 100644 --- a/chunker/parse_test.go +++ b/chunker/parse_test.go @@ -11,6 +11,8 @@ const ( ) func TestParseRabin(t *testing.T) { + t.Parallel() + r := bytes.NewReader(randBuf(t, 1000)) _, err := FromString(r, "rabin-18-25-32") @@ -55,6 +57,8 @@ func TestParseRabin(t *testing.T) { } func TestParseSize(t *testing.T) { + t.Parallel() + r := bytes.NewReader(randBuf(t, 1000)) _, err := FromString(r, "size-0") diff --git a/chunker/rabin_test.go b/chunker/rabin_test.go index 79699e324..31f3464ee 100644 --- a/chunker/rabin_test.go +++ b/chunker/rabin_test.go @@ -11,6 +11,8 @@ import ( ) func TestRabinChunking(t *testing.T) { + t.Parallel() + data := make([]byte, 1024*1024*16) n, err := util.NewTimeSeededRand().Read(data) if n < len(data) { @@ -67,6 +69,8 @@ func chunkData(t *testing.T, newC newSplitter, data []byte) map[string]blocks.Bl } func testReuse(t *testing.T, cr newSplitter) { + t.Parallel() + data := make([]byte, 1024*1024*16) n, err := util.NewTimeSeededRand().Read(data) if n < len(data) { diff --git a/chunker/splitting_test.go b/chunker/splitting_test.go index 4a9f7f332..c6712446a 100644 --- a/chunker/splitting_test.go +++ b/chunker/splitting_test.go @@ -23,6 +23,8 @@ func copyBuf(buf []byte) []byte { } func TestSizeSplitterOverAllocate(t *testing.T) { + t.Parallel() + max := 1000 r := bytes.NewReader(randBuf(t, max)) chunksize := int64(1024 * 256) @@ -40,6 +42,7 @@ func TestSizeSplitterIsDeterministic(t *testing.T) { if testing.Short() { t.SkipNow() } + t.Parallel() test := func() { bufR := randBuf(t, 10000000) // crank this up to satisfy yourself. @@ -75,6 +78,7 @@ func TestSizeSplitterFillsChunks(t *testing.T) { if testing.Short() { t.SkipNow() } + t.Parallel() max := 10000000 b := randBuf(t, max) diff --git a/examples/gateway/common/handler.go b/examples/gateway/common/handler.go index d21f38b64..5c4469aba 100644 --- a/examples/gateway/common/handler.go +++ b/examples/gateway/common/handler.go @@ -12,10 +12,6 @@ import ( func NewHandler(gwAPI gateway.IPFSBackend) http.Handler { conf := gateway.Config{ - // Initialize the headers. For this example, we do not add any special headers, - // only the required ones via gateway.AddAccessControlHeaders. - Headers: map[string][]string{}, - // If you set DNSLink to point at the CID from CAR, you can load it! NoDNSLink: false, @@ -58,9 +54,6 @@ func NewHandler(gwAPI gateway.IPFSBackend) http.Handler { }, } - // Add required access control headers to the configuration. - gateway.AddAccessControlHeaders(conf.Headers) - // Creates a mux to serve the gateway paths. This is not strictly necessary // and gwHandler could be used directly. However, on the next step we also want // to add prometheus metrics, hence needing the mux. @@ -86,6 +79,10 @@ func NewHandler(gwAPI gateway.IPFSBackend) http.Handler { // http.ServeMux which does not support CONNECT by default. handler = withConnect(handler) + // Add headers middleware that applies any headers we define to all requests + // as well as a default CORS configuration. + handler = gateway.NewHeaders(nil).ApplyCors().Wrap(handler) + // Finally, wrap with the otelhttp handler. This will allow the tracing system // to work and for correct propagation of tracing headers. This step is optional // and only required if you want to use tracing. Note that OTel must be correctly diff --git a/gateway/README.md b/gateway/README.md index a434f9b36..60e2dbcfb 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -14,13 +14,7 @@ This example shows how you can start your own gateway, assuming you have an `IPF implementation. ```go -// Initialize your headers and apply the default headers. -headers := map[string][]string{} -gateway.AddAccessControlHeaders(headers) - -conf := gateway.Config{ - Headers: headers, -} +conf := gateway.Config{} // Initialize an IPFSBackend interface for both an online and offline versions. // The offline version should not make any network request for missing content. @@ -29,9 +23,11 @@ ipfsBackend := ... // Create http mux and setup path gateway handler. mux := http.NewServeMux() handler := gateway.NewHandler(conf, ipfsBackend) +handler = gateway.NewHeaders(nil).ApplyCors().Wrap(handler) mux.Handle("/ipfs/", handler) mux.Handle("/ipns/", handler) + // Start the server on :8080 and voilรก! You have a basic IPFS gateway running // in http://localhost:8080. _ = http.ListenAndServe(":8080", mux) diff --git a/gateway/blocks_backend.go b/gateway/blocks_backend.go index 4faadb206..d85c2846b 100644 --- a/gateway/blocks_backend.go +++ b/gateway/blocks_backend.go @@ -508,6 +508,9 @@ func walkGatewaySimpleSelector(ctx context.Context, p path.ImmutablePath, params return err } from = fileLength + entityRange.From + if from < 0 { + from = 0 + } foundFileLength = true } @@ -521,13 +524,15 @@ func walkGatewaySimpleSelector(ctx context.Context, p path.ImmutablePath, params } to := *entityRange.To - if (*entityRange.To) < 0 && !foundFileLength { - fileLength, err = f.Seek(0, io.SeekEnd) - if err != nil { - return err + if (*entityRange.To) < 0 { + if !foundFileLength { + fileLength, err = f.Seek(0, io.SeekEnd) + if err != nil { + return err + } + foundFileLength = true } to = fileLength + *entityRange.To - foundFileLength = true } numToRead := 1 + to - from @@ -684,6 +689,12 @@ func (bb *BlocksBackend) IsCached(ctx context.Context, p path.Path) bool { return has } +var _ WithContextHint = (*BlocksBackend)(nil) + +func (bb *BlocksBackend) WrapContextForRequest(ctx context.Context) context.Context { + return blockservice.ContextWithSession(ctx, bb.blockService) +} + func (bb *BlocksBackend) ResolvePath(ctx context.Context, path path.ImmutablePath) (ContentPathMetadata, error) { roots, lastSeg, remainder, err := bb.getPathRoots(ctx, path) if err != nil { diff --git a/gateway/errors_test.go b/gateway/errors_test.go index cad7ae061..ca41b759b 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -43,7 +43,7 @@ func TestWebError(t *testing.T) { t.Parallel() // Create a handler to be able to test `webError`. - config := &Config{Headers: map[string][]string{}} + config := &Config{} t.Run("429 Too Many Requests", func(t *testing.T) { t.Parallel() @@ -113,7 +113,7 @@ func TestWebError(t *testing.T) { t.Run("Error is sent as plain text when 'Accept' header contains 'text/html' and config.DisableHTMLErrors is true", func(t *testing.T) { t.Parallel() - config := &Config{Headers: map[string][]string{}, DisableHTMLErrors: true} + config := &Config{DisableHTMLErrors: true} w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/blah", nil) r.Header.Set("Accept", "something/else, text/html") diff --git a/gateway/gateway.go b/gateway/gateway.go index b2edbf20c..be9501281 100644 --- a/gateway/gateway.go +++ b/gateway/gateway.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "io" - "net/http" - "sort" "strconv" "strings" "time" @@ -20,11 +18,6 @@ import ( // Config is the configuration used when creating a new gateway handler. type Config struct { - // Headers is a map containing all the headers that should be sent by default - // in all requests. You can define custom headers, as well as add the recommended - // headers via AddAccessControlHeaders. - Headers map[string][]string - // DeserializedResponses configures this gateway to support returning data // in deserialized format. By default, the gateway will only support // trustless, verifiable [application/vnd.ipld.raw] and @@ -386,77 +379,12 @@ type IPFSBackend interface { GetDNSLinkRecord(context.Context, string) (path.Path, error) } -// cleanHeaderSet is an helper function that cleans a set of headers by -// (1) canonicalizing, (2) de-duplicating and (3) sorting. -func cleanHeaderSet(headers []string) []string { - // Deduplicate and canonicalize. - m := make(map[string]struct{}, len(headers)) - for _, h := range headers { - m[http.CanonicalHeaderKey(h)] = struct{}{} - } - result := make([]string, 0, len(m)) - for k := range m { - result = append(result, k) - } - - // Sort - sort.Strings(result) - return result -} - -// AddAccessControlHeaders ensures safe default HTTP headers are used for -// controlling cross-origin requests. This function adds several values to the -// [Access-Control-Allow-Headers] and [Access-Control-Expose-Headers] entries -// to be exposed on GET and OPTIONS responses, including [CORS Preflight]. -// -// If the Access-Control-Allow-Origin entry is missing, a default value of '*' is -// added, indicating that browsers should allow requesting code from any -// origin to access the resource. -// -// If the Access-Control-Allow-Methods entry is missing a value, 'GET, HEAD, -// OPTIONS' is added, indicating that browsers may use them when issuing cross -// origin requests. -// -// [Access-Control-Allow-Headers]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers -// [Access-Control-Expose-Headers]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers -// [CORS Preflight]: https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request -func AddAccessControlHeaders(headers map[string][]string) { - // Hard-coded headers. - const ACAHeadersName = "Access-Control-Allow-Headers" - const ACEHeadersName = "Access-Control-Expose-Headers" - const ACAOriginName = "Access-Control-Allow-Origin" - const ACAMethodsName = "Access-Control-Allow-Methods" - - if _, ok := headers[ACAOriginName]; !ok { - // Default to *all* - headers[ACAOriginName] = []string{"*"} - } - if _, ok := headers[ACAMethodsName]; !ok { - // Default to GET, HEAD, OPTIONS - headers[ACAMethodsName] = []string{ - http.MethodGet, - http.MethodHead, - http.MethodOptions, - } - } - - headers[ACAHeadersName] = cleanHeaderSet( - append([]string{ - "Content-Type", - "User-Agent", - "Range", - "X-Requested-With", - }, headers[ACAHeadersName]...)) - - headers[ACEHeadersName] = cleanHeaderSet( - append([]string{ - "Content-Length", - "Content-Range", - "X-Chunked-Output", - "X-Stream-Output", - "X-Ipfs-Path", - "X-Ipfs-Roots", - }, headers[ACEHeadersName]...)) +// WithContextHint allows an [IPFSBackend] to inject custom [context.Context] configurations. +// This should be considered optional, consumers might only make a best effort attempt at calling WrapContextForRequest on requests. +type WithContextHint interface { + // WrapContextForRequest allows the backend to add request scopped modifications to the context, like debug values or value caches. + // There are no promises on actual usage in consumers. + WrapContextForRequest(context.Context) context.Context } // RequestContextKey is a type representing a [context.Context] value key. diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index 53f19ca08..031a184a5 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -352,8 +352,7 @@ func TestHeaders(t *testing.T) { headers := map[string][]string{} headers[headerACAO] = []string{expectedACAO} - ts := newTestServerWithConfig(t, backend, Config{ - Headers: headers, + ts := newTestServerWithConfigAndHeaders(t, backend, Config{ PublicGateways: map[string]*PublicGateway{ "subgw.example.com": { Paths: []string{"/ipfs", "/ipns"}, @@ -362,7 +361,7 @@ func TestHeaders(t *testing.T) { }, }, DeserializedResponses: true, - }) + }, headers) t.Logf("test server url: %s", ts.URL) testCORSPreflightRequest := func(t *testing.T, path, hostHeader string, requestOriginHeader string, code int) { @@ -532,7 +531,6 @@ func TestRedirects(t *testing.T) { backend.namesys["/ipns/example.com"] = newMockNamesysItem(path.FromCid(root), 0) ts := newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, NoDNSLink: false, PublicGateways: map[string]*PublicGateway{ "example.com": { @@ -579,6 +577,92 @@ func TestRedirects(t *testing.T) { do(http.MethodGet) do(http.MethodHead) }) + + t.Run("Superfluous namespace", func(t *testing.T) { + t.Parallel() + + backend, root := newMockBackend(t, "fixtures.car") + backend.namesys["/ipns/dnslink-gateway.com"] = newMockNamesysItem(path.FromCid(root), 0) + backend.namesys["/ipns/dnslink-website.com"] = newMockNamesysItem(path.FromCid(root), 0) + + ts := newTestServerWithConfig(t, backend, Config{ + NoDNSLink: false, + PublicGateways: map[string]*PublicGateway{ + "dnslink-gateway.com": { + Paths: []string{"/ipfs", "/ipns"}, + NoDNSLink: false, + DeserializedResponses: true, + }, + "dnslink-website.com": { + Paths: []string{}, + NoDNSLink: false, + DeserializedResponses: true, + }, + "gateway.com": { + Paths: []string{"/ipfs"}, + UseSubdomains: false, + NoDNSLink: true, + DeserializedResponses: true, + }, + "subdomain-gateway.com": { + Paths: []string{"/ipfs", "/ipns"}, + UseSubdomains: true, + NoDNSLink: true, + DeserializedResponses: true, + }, + }, + DeserializedResponses: true, + }) + + for _, test := range []struct { + host string + path string + status int + location string + }{ + // Barebones gateway + {"", "/ipfs/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"", "/ipfs/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"", "/ipfs/ipns/dnslink.com", http.StatusMovedPermanently, "/ipns/dnslink.com"}, + + // DNSLink Gateway with /ipfs and /ipns enabled + {"dnslink-gateway.com", "/ipfs/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"dnslink-gateway.com", "/ipfs/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"dnslink-gateway.com", "/ipfs/ipns/dnslink.com", http.StatusMovedPermanently, "/ipns/dnslink.com"}, + + // DNSLink Gateway without /ipfs and /ipns + {"dnslink-website.com", "/ipfs/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusNotFound, ""}, + {"dnslink-website.com", "/ipfs/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusNotFound, ""}, + {"dnslink-website.com", "/ipfs/ipns/dnslink.com", http.StatusNotFound, ""}, + + // Public gateway + {"gateway.com", "/ipfs/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"gateway.com", "/ipfs/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"gateway.com", "/ipfs/ipns/dnslink.com", http.StatusMovedPermanently, "/ipns/dnslink.com"}, + + // Subdomain gateway + {"subdomain-gateway.com", "/ipfs/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"subdomain-gateway.com", "/ipfs/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", http.StatusMovedPermanently, "/ipns/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR"}, + {"subdomain-gateway.com", "/ipfs/ipns/dnslink.com", http.StatusMovedPermanently, "/ipns/dnslink.com"}, + } { + testName := ts.URL + test.path + if test.host != "" { + testName += " " + test.host + } + + t.Run(testName, func(t *testing.T) { + req := mustNewRequest(t, http.MethodGet, ts.URL+test.path, nil) + req.Header.Set("Accept", "text/html") + if test.host != "" { + req.Host = test.host + } + resp := mustDoWithoutRedirect(t, req) + defer resp.Body.Close() + require.Equal(t, test.status, resp.StatusCode) + require.Equal(t, test.location, resp.Header.Get("Location")) + }) + } + }) } func TestDeserializedResponses(t *testing.T) { @@ -590,7 +674,6 @@ func TestDeserializedResponses(t *testing.T) { backend, root := newMockBackend(t, "fixtures.car") ts := newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, NoDNSLink: false, PublicGateways: map[string]*PublicGateway{ "trustless.com": { @@ -670,7 +753,6 @@ func TestDeserializedResponses(t *testing.T) { backend.namesys["/ipns/trusted.com"] = newMockNamesysItem(path.FromCid(root), 0) ts := newTestServerWithConfig(t, backend, Config{ - Headers: map[string][]string{}, NoDNSLink: false, PublicGateways: map[string]*PublicGateway{ "trustless.com": { diff --git a/gateway/handler.go b/gateway/handler.go index 1299c7e59..6963bdebf 100644 --- a/gateway/handler.go +++ b/gateway/handler.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "html/template" "io" "mime" "net/http" @@ -43,26 +42,6 @@ var ( noModtime = time.Unix(0, 0) // disables Last-Modified header if passed as modtime ) -// HTML-based redirect for errors which can be recovered from, but we want -// to provide hint to people that they should fix things on their end. -var redirectTemplate = template.Must(template.New("redirect").Parse(` - -
- - - - - -{{.ErrorMsg}}
(if a redirect does not happen in 10 seconds, use "{{.SuggestedPath}}" instead)- -`)) - -type redirectTemplateData struct { - RedirectURL string - SuggestedPath string - ErrorMsg string -} - // handler is a HTTP handler that serves IPFS objects (accessible by default at /ipfs/