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 }