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