| 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 | } |