diff --git a/cmd/ipfs/kubo/daemon.go b/cmd/ipfs/kubo/daemon.go index 97d46c7cf0a..325ee9c0ebe 100644 --- a/cmd/ipfs/kubo/daemon.go +++ b/cmd/ipfs/kubo/daemon.go @@ -876,6 +876,8 @@ func serveHTTPApi(req *cmds.Request, cctx *oldcmds.Context) (<-chan error, error if len(cfg.Gateway.RootRedirect) > 0 { opts = append(opts, corehttp.RedirectOption("", cfg.Gateway.RootRedirect)) + } else { + opts = append(opts, corehttp.LandingPageOption()) } node, err := cctx.ConstructNode() diff --git a/core/corehttp/assets/landing.html b/core/corehttp/assets/landing.html new file mode 100644 index 00000000000..366b8321793 --- /dev/null +++ b/core/corehttp/assets/landing.html @@ -0,0 +1,94 @@ + + + + + + + + + + + Welcome to Kubo IPFS Node! + + + +

Welcome to Kubo!

+ +

If you see this page, the Kubo IPFS node has been successfully installed and is working.

+ +

For configuration options, please refer to the documentation.

+ +
+ Note to operators: This is the default landing page. + Set Gateway.RootRedirect in the configuration to redirect to your own content. +
+ +

Resources

+ + +
+

Abuse Reports

+

+ This gateway is operated by a third party. To report abuse, contact the operator or owner of + . +

+
+ + + + diff --git a/core/corehttp/gateway.go b/core/corehttp/gateway.go index 393a668bfe1..ac4766e79ec 100644 --- a/core/corehttp/gateway.go +++ b/core/corehttp/gateway.go @@ -28,7 +28,7 @@ import ( func GatewayOption(paths ...string) ServeOption { return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) { - config, headers, err := getGatewayConfig(n) + config, headers, _, err := getGatewayConfig(n) if err != nil { return nil, err } @@ -50,9 +50,13 @@ func GatewayOption(paths ...string) ServeOption { } } +// HostnameOption returns a ServeOption that wraps the gateway with hostname-based +// routing (subdomain gateways, DNSLink). When Gateway.RootRedirect is not configured, +// requests to "/" that would return 404 (e.g., on known gateways like localhost) +// will show a landing page instead. func HostnameOption() ServeOption { return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) { - config, headers, err := getGatewayConfig(n) + cfg, headers, rootRedirect, err := getGatewayConfig(n) if err != nil { return nil, err } @@ -65,8 +69,16 @@ func HostnameOption() ServeOption { childMux := http.NewServeMux() var handler http.Handler - handler = gateway.NewHostnameHandler(config, backend, childMux) + handler = gateway.NewHostnameHandler(cfg, backend, childMux) handler = gateway.NewHeaders(headers).ApplyCors().Wrap(handler) + + // When RootRedirect is not configured, wrap with landing page fallback. + // This intercepts 404 responses for "/" on loopback addresses (like localhost) + // and serves a kubo-specific landing page instead. + if rootRedirect == "" { + handler = withLandingPageFallback(handler, headers) + } + handler = otelhttp.NewHandler(handler, "HostnameGateway") mux.Handle("/", handler) @@ -259,10 +271,11 @@ var defaultKnownGateways = map[string]*gateway.PublicGateway{ "localhost": subdomainGatewaySpec, } -func getGatewayConfig(n *core.IpfsNode) (gateway.Config, map[string][]string, error) { +// getGatewayConfig returns gateway configuration, HTTP headers, and root redirect URL. +func getGatewayConfig(n *core.IpfsNode) (gateway.Config, map[string][]string, string, error) { cfg, err := n.Repo.Config() if err != nil { - return gateway.Config{}, nil, err + return gateway.Config{}, nil, "", err } // Initialize gateway configuration, with empty PublicGateways, handled after. @@ -300,5 +313,5 @@ func getGatewayConfig(n *core.IpfsNode) (gateway.Config, map[string][]string, er } } - return gwCfg, cfg.Gateway.HTTPHeaders, nil + return gwCfg, cfg.Gateway.HTTPHeaders, cfg.Gateway.RootRedirect, nil } diff --git a/core/corehttp/gateway_test.go b/core/corehttp/gateway_test.go index df307ef7311..2003a8ee28d 100644 --- a/core/corehttp/gateway_test.go +++ b/core/corehttp/gateway_test.go @@ -206,7 +206,7 @@ func TestDeserializedResponsesInheritance(t *testing.T) { n, err := core.NewNode(context.Background(), &core.BuildCfg{Repo: r}) assert.NoError(t, err) - gwCfg, _, err := getGatewayConfig(n) + gwCfg, _, _, err := getGatewayConfig(n) assert.NoError(t, err) assert.Contains(t, gwCfg.PublicGateways, "example.com") diff --git a/core/corehttp/landing.go b/core/corehttp/landing.go new file mode 100644 index 00000000000..47aeb335033 --- /dev/null +++ b/core/corehttp/landing.go @@ -0,0 +1,142 @@ +package corehttp + +import ( + "bufio" + _ "embed" + "net" + "net/http" + + core "github.com/ipfs/kubo/core" +) + +//go:embed assets/landing.html +var landingPageHTML []byte + +// LandingPageOption returns a ServeOption that serves a default landing page +// for the gateway root ("/") when Gateway.RootRedirect is not configured. +// This helps third-party gateway operators by clearly indicating that the +// gateway software is working but needs configuration, and provides guidance +// for abuse reporting. +func LandingPageOption() ServeOption { + return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) { + cfg, err := n.Repo.Config() + if err != nil { + return nil, err + } + headers := cfg.Gateway.HTTPHeaders + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + serveLandingPage(w, headers) + })) + return mux, nil + } +} + +// serveLandingPage writes the landing page HTML with appropriate headers. +func serveLandingPage(w http.ResponseWriter, headers map[string][]string) { + for k, v := range headers { + w.Header()[http.CanonicalHeaderKey(k)] = v + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(landingPageHTML) +} + +// withLandingPageFallback wraps an http.Handler to intercept 404 responses for +// the root path "/" on loopback addresses and serve a landing page instead. +// +// This is needed because boxo's HostnameHandler returns 404 for bare gateway +// hostnames (like "localhost") that don't have content configured. Without this +// fallback, users would see a confusing 404 instead of a helpful landing page. +// +// The middleware only intercepts requests to loopback addresses (127.0.0.1, +// localhost, ::1) because these cannot have DNSLink configured, so any 404 on +// "/" is guaranteed to be "no content configured" rather than "content not +// found". This avoids false positives where a real 404 (e.g., from DNSLink +// pointing to missing content) would incorrectly show the landing page. +func withLandingPageFallback(next http.Handler, headers map[string][]string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only intercept requests to exactly "/" + if r.URL.Path != "/" { + next.ServeHTTP(w, r) + return + } + + // Only intercept for loopback addresses. These cannot have DNSLink + // configured, so any 404 is genuinely "no content configured". + // For other hosts, pass through to avoid intercepting real 404s + // from DNSLink or other content resolution. + host := r.Host + if h, _, err := net.SplitHostPort(r.Host); err == nil { + host = h + } + switch host { + case "localhost", "127.0.0.1", "::1", "[::1]": + // Continue to intercept + default: + next.ServeHTTP(w, r) + return + } + + // Wrap ResponseWriter to intercept 404 responses + lw := &landingResponseWriter{ResponseWriter: w} + next.ServeHTTP(lw, r) + + // If 404 was suppressed, serve the landing page + if lw.suppressed404 { + serveLandingPage(w, headers) + } + }) +} + +// landingResponseWriter wraps http.ResponseWriter to intercept 404 responses. +// It suppresses the 404 status and body so we can serve a landing page instead. +type landingResponseWriter struct { + http.ResponseWriter + wroteHeader bool + suppressed404 bool +} + +func (w *landingResponseWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.wroteHeader = true + if code == http.StatusNotFound { + w.suppressed404 = true + return // Suppress 404 - we'll serve landing page instead + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *landingResponseWriter) Write(b []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + if w.suppressed404 { + return len(b), nil // Discard 404 body + } + return w.ResponseWriter.Write(b) +} + +// Flush implements http.Flusher for streaming responses. +func (w *landingResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Hijack implements http.Hijacker for websocket support. +func (w *landingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if h, ok := w.ResponseWriter.(http.Hijacker); ok { + return h.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// Unwrap returns the underlying ResponseWriter for http.ResponseController. +func (w *landingResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/core/corehttp/routing.go b/core/corehttp/routing.go index 239f8737bec..1b4719e57a8 100644 --- a/core/corehttp/routing.go +++ b/core/corehttp/routing.go @@ -24,7 +24,7 @@ import ( func RoutingOption() ServeOption { return func(n *core.IpfsNode, _ net.Listener, mux *http.ServeMux) (*http.ServeMux, error) { - _, headers, err := getGatewayConfig(n) + _, headers, _, err := getGatewayConfig(n) if err != nil { return nil, err } diff --git a/docs/changelogs/v0.40.md b/docs/changelogs/v0.40.md index 29780937f4b..9c70f76dc59 100644 --- a/docs/changelogs/v0.40.md +++ b/docs/changelogs/v0.40.md @@ -11,7 +11,8 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team. - [Overview](#overview) - [๐Ÿ”ฆ Highlights](#-highlights) - [Routing V1 HTTP API now exposed by default](#routing-v1-http-api-now-exposed-by-default) - - [Track total size when adding pins](#track-total-size-when-adding-pins] + - [Track total size when adding pins](#track-total-size-when-adding-pins) + - [Friendlier default landing page](#friendlier-default-landing-page) - [๐Ÿ“ Changelog](#-changelog) - [๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors](#-contributors) @@ -32,6 +33,12 @@ Example output: Fetched/Processed 336 nodes (83 MB) ``` +#### Friendlier default landing page + +Visiting the gateway root `/` now displays a landing page instead of returning a 404 error. The page confirms Kubo is running and provides links to documentation and configuration resources. + +Gateway operators can customize this by setting [`Gateway.RootRedirect`](https://github.com/ipfs/kubo/blob/master/docs/config.md#gatewayrootredirect) to redirect visitors to their own documentation page. + ### ๐Ÿ“ Changelog ### ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Contributors diff --git a/docs/config.md b/docs/config.md index 23386f7e64b..317c56726dd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1218,7 +1218,11 @@ Type: `object[string -> array[string]]` A URL to redirect requests for `/` to. -Default: `""` +When not set, a default landing page is displayed instead. The landing page +indicates that the gateway software is working and provides links to +documentation and resources. + +Default: `""` (landing page) Type: `string` (url) diff --git a/test/cli/gateway_landing_test.go b/test/cli/gateway_landing_test.go new file mode 100644 index 00000000000..0b7b3429af4 --- /dev/null +++ b/test/cli/gateway_landing_test.go @@ -0,0 +1,128 @@ +package cli + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/ipfs/kubo/config" + "github.com/ipfs/kubo/test/cli/harness" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatewayLandingPage(t *testing.T) { + t.Parallel() + + t.Run("default landing page is served when RootRedirect is not set", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") + client := node.GatewayClient() + + resp := client.Get("/") + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/html; charset=utf-8", resp.Headers.Get("Content-Type")) + assert.Contains(t, resp.Body, "Welcome to Kubo!") + assert.Contains(t, resp.Body, `name="robots" content="noindex"`) + assert.Contains(t, resp.Body, "Gateway.RootRedirect") + assert.Contains(t, resp.Body, "github.com/ipfs/kubo") + }) + + t.Run("landing page returns 404 for non-root paths", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") + client := node.GatewayClient() + + resp := client.Get("/nonexistent-path") + assert.Equal(t, 404, resp.StatusCode) + }) + + t.Run("RootRedirect takes precedence over landing page", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + node.UpdateConfig(func(cfg *config.Config) { + cfg.Gateway.RootRedirect = "/ipfs/bafkqaaa" + }) + node.StartDaemon("--offline") + client := node.GatewayClient().DisableRedirects() + + resp := client.Get("/") + assert.Equal(t, 302, resp.StatusCode) + assert.Equal(t, "/ipfs/bafkqaaa", resp.Headers.Get("Location")) + }) + + t.Run("landing page is also served on RPC API port", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") + client := node.APIClient() + + resp := client.Get("/") + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, resp.Body, "Welcome to Kubo!") + }) + + t.Run("landing page includes abuse reporting section", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") + client := node.GatewayClient() + + resp := client.Get("/") + require.Equal(t, 200, resp.StatusCode) + assert.Contains(t, resp.Body, "Abuse Reports") + assert.Contains(t, resp.Body, "whois.domaintools.com") + }) + + t.Run("landing page respects Gateway.HTTPHeaders", func(t *testing.T) { + t.Parallel() + h := harness.NewT(t) + node := h.NewNode().Init() + node.UpdateConfig(func(cfg *config.Config) { + cfg.Gateway.HTTPHeaders = map[string][]string{ + "X-Custom-Header": {"test-value"}, + } + }) + node.StartDaemon("--offline") + client := node.GatewayClient() + + resp := client.Get("/") + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "test-value", resp.Headers.Get("X-Custom-Header")) + }) + + t.Run("gateway paths still work with landing page enabled", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") + cid := node.IPFSAddStr("test content") + client := node.GatewayClient() + + // /ipfs/ path should work + resp := client.Get("/ipfs/" + cid) + assert.Equal(t, 200, resp.StatusCode) + assert.True(t, strings.Contains(resp.Body, "test content")) + }) + + t.Run("landing page works on localhost (implicitly enabled subdomain gateway)", func(t *testing.T) { + t.Parallel() + node := harness.NewT(t).NewNode().Init().StartDaemon("--offline") + + // Get the gateway URL and replace 127.0.0.1 with localhost. + // localhost is an implicitly enabled subdomain gateway (see defaultKnownGateways + // in gateway.go). The landing page must work as a fallback even when the + // hostname handler intercepts requests for known gateways. + gwURL := node.GatewayURL() + u, err := url.Parse(gwURL) + require.NoError(t, err) + u.Host = "localhost:" + u.Port() + + client := &harness.HTTPClient{ + Client: http.DefaultClient, + BaseURL: u.String(), + } + + resp := client.Get("/") + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, resp.Body, "Welcome to Kubo!") + }) +}