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!")
+ })
+}