/ core / corehttp / gateway.go
gateway.go
  1  package corehttp
  2  
  3  import (
  4  	"context"
  5  	"errors"
  6  	"fmt"
  7  	"io"
  8  	"maps"
  9  	"net"
 10  	"net/http"
 11  	"slices"
 12  	"strings"
 13  	"time"
 14  
 15  	"github.com/ipfs/boxo/blockservice"
 16  	"github.com/ipfs/boxo/exchange/offline"
 17  	"github.com/ipfs/boxo/files"
 18  	"github.com/ipfs/boxo/gateway"
 19  	"github.com/ipfs/boxo/namesys"
 20  	"github.com/ipfs/boxo/path"
 21  	offlineroute "github.com/ipfs/boxo/routing/offline"
 22  	"github.com/ipfs/go-cid"
 23  	version "github.com/ipfs/kubo"
 24  	"github.com/ipfs/kubo/config"
 25  	"github.com/ipfs/kubo/core"
 26  	iface "github.com/ipfs/kubo/core/coreiface"
 27  	"github.com/ipfs/kubo/core/node"
 28  	"github.com/libp2p/go-libp2p/core/routing"
 29  	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
 30  	"go.opentelemetry.io/otel/attribute"
 31  )
 32  
 33  func GatewayOption(paths ...string) ServeOption {
 34  	return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) {
 35  		config, headers, err := getGatewayConfig(n)
 36  		if err != nil {
 37  			return nil, err
 38  		}
 39  
 40  		backend, err := newGatewayBackend(n)
 41  		if err != nil {
 42  			return nil, err
 43  		}
 44  
 45  		handler := gateway.NewHandler(config, backend)
 46  		handler = gateway.NewHeaders(headers).ApplyCors().Wrap(handler)
 47  		var otelOpts []otelhttp.Option
 48  		if fn := newServerDomainAttrFn(n); fn != nil {
 49  			otelOpts = append(otelOpts, otelhttp.WithMetricAttributesFn(fn))
 50  		}
 51  		handler = otelhttp.NewHandler(handler, "Gateway", otelOpts...)
 52  
 53  		for _, p := range paths {
 54  			mux.Handle(p+"/", handler)
 55  		}
 56  
 57  		return mux, nil
 58  	}
 59  }
 60  
 61  func HostnameOption() ServeOption {
 62  	return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) {
 63  		config, headers, err := getGatewayConfig(n)
 64  		if err != nil {
 65  			return nil, err
 66  		}
 67  
 68  		backend, err := newGatewayBackend(n)
 69  		if err != nil {
 70  			return nil, err
 71  		}
 72  
 73  		childMux := http.NewServeMux()
 74  
 75  		var handler http.Handler
 76  		handler = gateway.NewHostnameHandler(config, backend, childMux)
 77  		handler = gateway.NewHeaders(headers).ApplyCors().Wrap(handler)
 78  		var otelOpts []otelhttp.Option
 79  		if fn := newServerDomainAttrFn(n); fn != nil {
 80  			otelOpts = append(otelOpts, otelhttp.WithMetricAttributesFn(fn))
 81  		}
 82  		handler = otelhttp.NewHandler(handler, "HostnameGateway", otelOpts...)
 83  
 84  		mux.Handle("/", handler)
 85  		return childMux, nil
 86  	}
 87  }
 88  
 89  func VersionOption() ServeOption {
 90  	return func(_ *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) {
 91  		mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
 92  			fmt.Fprintf(w, "Commit: %s\n", version.CurrentCommit)
 93  			fmt.Fprintf(w, "Client Version: %s\n", version.GetUserAgentVersion())
 94  		})
 95  		return mux, nil
 96  	}
 97  }
 98  
 99  func Libp2pGatewayOption() ServeOption {
100  	return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) {
101  		bserv := blockservice.New(n.Blocks.Blockstore(), offline.Exchange(n.Blocks.Blockstore()))
102  
103  		backend, err := gateway.NewBlocksBackend(bserv,
104  			// GatewayOverLibp2p only returns things that are in local blockstore
105  			// (same as Gateway.NoFetch=true), we have to pass offline path resolver
106  			gateway.WithResolver(n.OfflineUnixFSPathResolver),
107  		)
108  		if err != nil {
109  			return nil, err
110  		}
111  
112  		// Get gateway configuration from the node's config
113  		cfg, err := n.Repo.Config()
114  		if err != nil {
115  			return nil, err
116  		}
117  
118  		gwConfig := gateway.Config{
119  			// Keep these constraints for security
120  			DeserializedResponses: false, // Trustless-only
121  			NoDNSLink:             true,  // No DNS resolution
122  			DisableHTMLErrors:     true,  // Plain text errors only
123  			PublicGateways:        nil,
124  			Menu:                  nil,
125  			// Apply timeout and concurrency limits from user config
126  			RetrievalTimeout:        cfg.Gateway.RetrievalTimeout.WithDefault(config.DefaultRetrievalTimeout),
127  			MaxRequestDuration:      cfg.Gateway.MaxRequestDuration.WithDefault(config.DefaultMaxRequestDuration),
128  			MaxConcurrentRequests:   int(cfg.Gateway.MaxConcurrentRequests.WithDefault(int64(config.DefaultMaxConcurrentRequests))),
129  			MaxRangeRequestFileSize: int64(cfg.Gateway.MaxRangeRequestFileSize.WithDefault(uint64(config.DefaultMaxRangeRequestFileSize))),
130  			DiagnosticServiceURL:    "", // Not used since DisableHTMLErrors=true
131  		}
132  
133  		handler := gateway.NewHandler(gwConfig, &offlineGatewayErrWrapper{gwimpl: backend})
134  		handler = otelhttp.NewHandler(handler, "Libp2p-Gateway",
135  			otelhttp.WithMetricAttributesFn(staticServerDomainAttrFn("libp2p")),
136  		)
137  
138  		mux.Handle("/ipfs/", handler)
139  
140  		return mux, nil
141  	}
142  }
143  
144  func newGatewayBackend(n *core.IpfsNode) (gateway.IPFSBackend, error) {
145  	cfg, err := n.Repo.Config()
146  	if err != nil {
147  		return nil, err
148  	}
149  
150  	bserv := n.Blocks
151  	var vsRouting routing.ValueStore = n.Routing
152  	nsys := n.Namesys
153  	pathResolver := n.UnixFSPathResolver
154  
155  	if cfg.Gateway.NoFetch {
156  		bserv = blockservice.New(bserv.Blockstore(), offline.Exchange(bserv.Blockstore()))
157  
158  		cs := cfg.Ipns.ResolveCacheSize
159  		if cs == 0 {
160  			cs = node.DefaultIpnsCacheSize
161  		}
162  		if cs < 0 {
163  			return nil, fmt.Errorf("cannot specify negative resolve cache size")
164  		}
165  
166  		nsOptions := []namesys.Option{
167  			namesys.WithDatastore(n.Repo.Datastore()),
168  			namesys.WithDNSResolver(n.DNSResolver),
169  			namesys.WithCache(cs),
170  			namesys.WithMaxCacheTTL(cfg.Ipns.MaxCacheTTL.WithDefault(config.DefaultIpnsMaxCacheTTL)),
171  		}
172  
173  		vsRouting = offlineroute.NewOfflineRouter(n.Repo.Datastore(), n.RecordValidator)
174  		nsys, err = namesys.NewNameSystem(vsRouting, nsOptions...)
175  		if err != nil {
176  			return nil, fmt.Errorf("error constructing namesys: %w", err)
177  		}
178  
179  		// Gateway.NoFetch=true requires offline path resolver
180  		// to avoid fetching missing blocks during path traversal
181  		pathResolver = n.OfflineUnixFSPathResolver
182  	}
183  
184  	backend, err := gateway.NewBlocksBackend(bserv,
185  		gateway.WithValueStore(vsRouting),
186  		gateway.WithNameSystem(nsys),
187  		gateway.WithResolver(pathResolver),
188  	)
189  	if err != nil {
190  		return nil, err
191  	}
192  	return &offlineGatewayErrWrapper{gwimpl: backend}, nil
193  }
194  
195  type offlineGatewayErrWrapper struct {
196  	gwimpl gateway.IPFSBackend
197  }
198  
199  func offlineErrWrap(err error) error {
200  	if errors.Is(err, iface.ErrOffline) {
201  		return fmt.Errorf("%s : %w", err.Error(), gateway.ErrServiceUnavailable)
202  	}
203  	return err
204  }
205  
206  func (o *offlineGatewayErrWrapper) Get(ctx context.Context, path path.ImmutablePath, ranges ...gateway.ByteRange) (gateway.ContentPathMetadata, *gateway.GetResponse, error) {
207  	md, n, err := o.gwimpl.Get(ctx, path, ranges...)
208  	err = offlineErrWrap(err)
209  	return md, n, err
210  }
211  
212  func (o *offlineGatewayErrWrapper) GetAll(ctx context.Context, path path.ImmutablePath) (gateway.ContentPathMetadata, files.Node, error) {
213  	md, n, err := o.gwimpl.GetAll(ctx, path)
214  	err = offlineErrWrap(err)
215  	return md, n, err
216  }
217  
218  func (o *offlineGatewayErrWrapper) GetBlock(ctx context.Context, path path.ImmutablePath) (gateway.ContentPathMetadata, files.File, error) {
219  	md, n, err := o.gwimpl.GetBlock(ctx, path)
220  	err = offlineErrWrap(err)
221  	return md, n, err
222  }
223  
224  func (o *offlineGatewayErrWrapper) Head(ctx context.Context, path path.ImmutablePath) (gateway.ContentPathMetadata, *gateway.HeadResponse, error) {
225  	md, n, err := o.gwimpl.Head(ctx, path)
226  	err = offlineErrWrap(err)
227  	return md, n, err
228  }
229  
230  func (o *offlineGatewayErrWrapper) ResolvePath(ctx context.Context, path path.ImmutablePath) (gateway.ContentPathMetadata, error) {
231  	md, err := o.gwimpl.ResolvePath(ctx, path)
232  	err = offlineErrWrap(err)
233  	return md, err
234  }
235  
236  func (o *offlineGatewayErrWrapper) GetCAR(ctx context.Context, path path.ImmutablePath, params gateway.CarParams) (gateway.ContentPathMetadata, io.ReadCloser, error) {
237  	md, data, err := o.gwimpl.GetCAR(ctx, path, params)
238  	err = offlineErrWrap(err)
239  	return md, data, err
240  }
241  
242  func (o *offlineGatewayErrWrapper) IsCached(ctx context.Context, path path.Path) bool {
243  	return o.gwimpl.IsCached(ctx, path)
244  }
245  
246  func (o *offlineGatewayErrWrapper) GetIPNSRecord(ctx context.Context, c cid.Cid) ([]byte, error) {
247  	rec, err := o.gwimpl.GetIPNSRecord(ctx, c)
248  	err = offlineErrWrap(err)
249  	return rec, err
250  }
251  
252  func (o *offlineGatewayErrWrapper) ResolveMutable(ctx context.Context, path path.Path) (path.ImmutablePath, time.Duration, time.Time, error) {
253  	imPath, ttl, lastMod, err := o.gwimpl.ResolveMutable(ctx, path)
254  	err = offlineErrWrap(err)
255  	return imPath, ttl, lastMod, err
256  }
257  
258  func (o *offlineGatewayErrWrapper) GetDNSLinkRecord(ctx context.Context, s string) (path.Path, error) {
259  	p, err := o.gwimpl.GetDNSLinkRecord(ctx, s)
260  	err = offlineErrWrap(err)
261  	return p, err
262  }
263  
264  var _ gateway.IPFSBackend = (*offlineGatewayErrWrapper)(nil)
265  
266  var defaultPaths = []string{"/ipfs/", "/ipns/", "/p2p/"}
267  
268  // serverDomainAttrKey is the OTel attribute key for the logical server domain.
269  // It replaces the high-cardinality server.address attribute (dropped by the
270  // View in cmd/ipfs/kubo/daemon.go) with a bounded set of values: configured
271  // Gateway.PublicGateways suffixes, "localhost", "loopback", "api", "libp2p",
272  // or "other".
273  var serverDomainAttrKey = attribute.Key("server.domain")
274  
275  // staticServerDomainAttrFn returns a MetricAttributesFn that always returns
276  // a fixed server.domain value. Use for handlers where the domain is known
277  // statically (e.g. "api", "libp2p") to keep the label set consistent across
278  // all http_server_* metrics.
279  func staticServerDomainAttrFn(domain string) func(*http.Request) []attribute.KeyValue {
280  	attrs := []attribute.KeyValue{serverDomainAttrKey.String(domain)}
281  	return func(*http.Request) []attribute.KeyValue { return attrs }
282  }
283  
284  // newServerDomainAttrFn returns an otelhttp.WithMetricAttributesFn callback
285  // that adds a server.domain attribute grouping requests by their matching
286  // Gateway.PublicGateways hostname suffix (e.g. "dweb.link", "ipfs.io").
287  // Requests that don't match any configured gateway get "other".
288  //
289  // All return values are pre-allocated at setup time so the per-request
290  // closure is zero-allocation.
291  func newServerDomainAttrFn(n *core.IpfsNode) func(*http.Request) []attribute.KeyValue {
292  	cfg, err := n.Repo.Config()
293  	if err != nil {
294  		return nil
295  	}
296  
297  	// Collect non-nil gateway domain suffixes, sorted longest-first
298  	// so more-specific suffixes match before shorter ones.
299  	// Strip ports from keys to match boxo's fallback behavior
300  	// (boxo tries exact match with port, then strips port and retries).
301  	seen := make(map[string]struct{}, len(cfg.Gateway.PublicGateways))
302  	suffixes := make([]string, 0, len(cfg.Gateway.PublicGateways))
303  	for hostname, gw := range cfg.Gateway.PublicGateways {
304  		if gw == nil {
305  			continue
306  		}
307  		if h, _, err := net.SplitHostPort(hostname); err == nil {
308  			hostname = h
309  		}
310  		if _, ok := seen[hostname]; ok {
311  			continue
312  		}
313  		seen[hostname] = struct{}{}
314  		suffixes = append(suffixes, hostname)
315  	}
316  	slices.SortFunc(suffixes, func(a, b string) int {
317  		return len(b) - len(a)
318  	})
319  
320  	// Pre-allocate attribute slices so the per-request closure only returns
321  	// existing slices and does not allocate.
322  	suffixAttrs := make([][]attribute.KeyValue, len(suffixes))
323  	for i, s := range suffixes {
324  		suffixAttrs[i] = []attribute.KeyValue{serverDomainAttrKey.String(s)}
325  	}
326  	localhostAttr := []attribute.KeyValue{serverDomainAttrKey.String("localhost")}
327  	loopbackAttr := []attribute.KeyValue{serverDomainAttrKey.String("loopback")}
328  	otherAttr := []attribute.KeyValue{serverDomainAttrKey.String("other")}
329  
330  	return func(r *http.Request) []attribute.KeyValue {
331  		host := r.Host
332  		if h, _, err := net.SplitHostPort(host); err == nil {
333  			host = h
334  		}
335  
336  		// Check localhost/loopback before iterating suffixes.
337  		// "localhost" is an implicit default gateway (defaultKnownGateways)
338  		// not present in cfg.Gateway.PublicGateways, so it won't appear
339  		// in suffixes.
340  		if host == "localhost" || strings.HasSuffix(host, ".localhost") {
341  			return localhostAttr
342  		}
343  		if strings.HasPrefix(host, "127.") || host == "::1" {
344  			return loopbackAttr
345  		}
346  
347  		for i, suffix := range suffixes {
348  			if strings.HasSuffix(host, suffix) {
349  				return suffixAttrs[i]
350  			}
351  		}
352  
353  		return otherAttr
354  	}
355  }
356  
357  var subdomainGatewaySpec = &gateway.PublicGateway{
358  	Paths:         defaultPaths,
359  	UseSubdomains: true,
360  }
361  
362  var defaultKnownGateways = map[string]*gateway.PublicGateway{
363  	"localhost": subdomainGatewaySpec,
364  }
365  
366  func getGatewayConfig(n *core.IpfsNode) (gateway.Config, map[string][]string, error) {
367  	cfg, err := n.Repo.Config()
368  	if err != nil {
369  		return gateway.Config{}, nil, err
370  	}
371  
372  	// Initialize gateway configuration, with empty PublicGateways, handled after.
373  	gwCfg := gateway.Config{
374  		DeserializedResponses:   cfg.Gateway.DeserializedResponses.WithDefault(config.DefaultDeserializedResponses),
375  		AllowCodecConversion:    cfg.Gateway.AllowCodecConversion.WithDefault(config.DefaultAllowCodecConversion),
376  		DisableHTMLErrors:       cfg.Gateway.DisableHTMLErrors.WithDefault(config.DefaultDisableHTMLErrors),
377  		NoDNSLink:               cfg.Gateway.NoDNSLink,
378  		PublicGateways:          map[string]*gateway.PublicGateway{},
379  		RetrievalTimeout:        cfg.Gateway.RetrievalTimeout.WithDefault(config.DefaultRetrievalTimeout),
380  		MaxRequestDuration:      cfg.Gateway.MaxRequestDuration.WithDefault(config.DefaultMaxRequestDuration),
381  		MaxConcurrentRequests:   int(cfg.Gateway.MaxConcurrentRequests.WithDefault(int64(config.DefaultMaxConcurrentRequests))),
382  		MaxRangeRequestFileSize: int64(cfg.Gateway.MaxRangeRequestFileSize.WithDefault(uint64(config.DefaultMaxRangeRequestFileSize))),
383  		DiagnosticServiceURL:    cfg.Gateway.DiagnosticServiceURL.WithDefault(config.DefaultDiagnosticServiceURL),
384  	}
385  
386  	// Add default implicit known gateways, such as subdomain gateway on localhost.
387  	maps.Copy(gwCfg.PublicGateways, defaultKnownGateways)
388  
389  	// Apply values from cfg.Gateway.PublicGateways if they exist.
390  	for hostname, gw := range cfg.Gateway.PublicGateways {
391  		if gw == nil {
392  			// Remove any implicit defaults, if present. This is useful when one
393  			// wants to disable subdomain gateway on localhost, etc.
394  			delete(gwCfg.PublicGateways, hostname)
395  			continue
396  		}
397  
398  		gwCfg.PublicGateways[hostname] = &gateway.PublicGateway{
399  			Paths:                 gw.Paths,
400  			NoDNSLink:             gw.NoDNSLink,
401  			UseSubdomains:         gw.UseSubdomains,
402  			InlineDNSLink:         gw.InlineDNSLink.WithDefault(config.DefaultInlineDNSLink),
403  			DeserializedResponses: gw.DeserializedResponses.WithDefault(gwCfg.DeserializedResponses),
404  		}
405  	}
406  
407  	return gwCfg, cfg.Gateway.HTTPHeaders, nil
408  }