diff --git a/config/routing.go b/config/routing.go index bd234e8a3..d68016e4e 100644 --- a/config/routing.go +++ b/config/routing.go @@ -214,3 +214,57 @@ func getEnvOrDefault(key string, defaultValue []string) []string { } return defaultValue } + +// HasHTTPProviderConfigured checks if the node is configured to use HTTP routers +// for providing content announcements. This is used when determining if the node +// can provide content even when not connected to libp2p peers. +// +// Note: Right now we only support delegated HTTP content providing if Routing.Type=custom +// and Routing.Routers are configured according to: +// https://github.com/ipfs/kubo/blob/master/docs/delegated-routing.md#configuration-file-example +// +// This uses the `ProvideBitswap` request type that is not documented anywhere, +// because we hoped something like IPIP-378 (https://github.com/ipfs/specs/pull/378) +// would get finalized and we'd switch to that. It never happened due to politics, +// and now we are stuck with ProvideBitswap being the only API that works. +// Some people have reverse engineered it (example: +// https://discuss.ipfs.tech/t/only-peers-found-from-dht-seem-to-be-getting-used-as-relays-so-cant-use-http-routers/19545/9) +// and use it, so what we do here is the bare minimum to ensure their use case works +// using this old API until something better is available. +func (c *Config) HasHTTPProviderConfigured() bool { + if len(c.Routing.Routers) == 0 { + // No "custom" routers + return false + } + method, ok := c.Routing.Methods[MethodNameProvide] + if !ok { + // No provide method configured + return false + } + return c.routerSupportsHTTPProviding(method.RouterName) +} + +// routerSupportsHTTPProviding checks if the supplied custom router is or +// includes an HTTP-based router. +func (c *Config) routerSupportsHTTPProviding(routerName string) bool { + rp, ok := c.Routing.Routers[routerName] + if !ok { + // Router configured for providing doesn't exist + return false + } + + switch rp.Type { + case RouterTypeHTTP: + return true + case RouterTypeParallel, RouterTypeSequential: + // Check if any child router supports HTTP + if children, ok := rp.Parameters.(*ComposableRouterParams); ok { + for _, childRouter := range children.Routers { + if c.routerSupportsHTTPProviding(childRouter.RouterName) { + return true + } + } + } + } + return false +} diff --git a/core/commands/routing.go b/core/commands/routing.go index e88b207d8..c772e2045 100644 --- a/core/commands/routing.go +++ b/core/commands/routing.go @@ -170,10 +170,15 @@ var provideRefRoutingCmd = &cmds.Command{ return errors.New("invalid configuration: Provide.Enabled is set to 'false'") } - if len(nd.PeerHost.Network().Conns()) == 0 { + if len(nd.PeerHost.Network().Conns()) == 0 && !cfg.HasHTTPProviderConfigured() { + // Node is depending on DHT for providing (no custom HTTP provider + // configured) and currently has no connected peers. return errors.New("cannot provide, no connected peers") } + // If we reach here with no connections but HTTP provider configured, + // we proceed with the provide operation via HTTP + // Needed to parse stdin args. // TODO: Lazy Load err = req.ParseBodyArgs() diff --git a/core/node/provider.go b/core/node/provider.go index b692fa8cd..3aff6e53c 100644 --- a/core/node/provider.go +++ b/core/node/provider.go @@ -355,6 +355,12 @@ func SweepingProviderOpt(cfg *config.Config) fx.Option { } } if impl == nil { + // No DHT available, check if HTTP provider is configured + cfg, err := in.Repo.Config() + if err == nil && cfg.HasHTTPProviderConfigured() { + // HTTP provider is configured, return NoopProvider to allow HTTP-based providing + return &NoopProvider{}, keyStore, nil + } return &NoopProvider{}, nil, errors.New("provider: no valid DHT available for providing") } diff --git a/test/cli/provider_test.go b/test/cli/provider_test.go index 6617811f8..debeddcd0 100644 --- a/test/cli/provider_test.go +++ b/test/cli/provider_test.go @@ -3,6 +3,9 @@ package cli import ( "bytes" "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" @@ -139,6 +142,77 @@ func runProviderSuite(t *testing.T, reprovide bool, apply cfgApplier) { expectNoProviders(t, cid, nodes[1:]...) }) + t.Run("manual provide fails when no libp2p peers and no custom HTTP router", func(t *testing.T) { + t.Parallel() + + h := harness.NewT(t) + node := h.NewNode().Init() + apply(node) + node.SetIPFSConfig("Provide.Enabled", true) + node.StartDaemon() + defer node.StopDaemon() + + cid := node.IPFSAddStr(time.Now().String()) + res := node.RunIPFS("routing", "provide", cid) + assert.Contains(t, res.Stderr.Trimmed(), "cannot provide, no connected peers") + assert.Equal(t, 1, res.ExitCode()) + }) + + t.Run("manual provide succeeds via custom HTTP router when no libp2p peers", func(t *testing.T) { + t.Parallel() + + // Create a mock HTTP server that accepts provide requests. + // This simulates the undocumented API behavior described in + // https://discuss.ipfs.tech/t/only-peers-found-from-dht-seem-to-be-getting-used-as-relays-so-cant-use-http-routers/19545/9 + // Note: This is NOT IPIP-378, which was not implemented. + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Accept both PUT and POST requests to /routing/v1/providers and /routing/v1/ipns + if (r.Method == http.MethodPut || r.Method == http.MethodPost) && + (strings.HasPrefix(r.URL.Path, "/routing/v1/providers") || strings.HasPrefix(r.URL.Path, "/routing/v1/ipns")) { + // Return HTTP 200 to indicate successful publishing + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + h := harness.NewT(t) + node := h.NewNode().Init() + apply(node) + node.SetIPFSConfig("Provide.Enabled", true) + // Configure a custom HTTP router for providing. + // Using our mock server that will accept the provide requests. + routingConf := map[string]any{ + "Type": "custom", // https://github.com/ipfs/kubo/blob/master/docs/delegated-routing.md#configuration-file-example + "Methods": map[string]any{ + "provide": map[string]any{"RouterName": "MyCustomRouter"}, + "get-ipns": map[string]any{"RouterName": "MyCustomRouter"}, + "put-ipns": map[string]any{"RouterName": "MyCustomRouter"}, + "find-peers": map[string]any{"RouterName": "MyCustomRouter"}, + "find-providers": map[string]any{"RouterName": "MyCustomRouter"}, + }, + "Routers": map[string]any{ + "MyCustomRouter": map[string]any{ + "Type": "http", + "Parameters": map[string]any{ + // Use the mock server URL + "Endpoint": mockServer.URL, + }, + }, + }, + } + node.SetIPFSConfig("Routing", routingConf) + node.StartDaemon() + defer node.StopDaemon() + + cid := node.IPFSAddStr(time.Now().String()) + // The command should successfully provide via HTTP even without libp2p peers + res := node.RunIPFS("routing", "provide", cid) + assert.Empty(t, res.Stderr.String(), "Should have no errors when providing via HTTP router") + assert.Equal(t, 0, res.ExitCode(), "Should succeed with exit code 0") + }) + // Right now Provide and Reprovide are tied together t.Run("Reprovide.Interval=0 disables announcement of new CID too", func(t *testing.T) { t.Parallel()