This commit is contained in:
Marcin Rataj 2025-12-14 09:49:44 +01:00 committed by GitHub
commit 45f8b08117
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 400 additions and 10 deletions

View File

@ -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()

View File

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<meta name="description" content="Default landing page for Kubo, an IPFS node implementation.">
<meta property="og:title" content="Welcome to Kubo IPFS Node!">
<meta property="og:description" content="Default landing page for Kubo, an IPFS node implementation.">
<meta property="og:type" content="website">
<title>Welcome to Kubo IPFS Node!</title>
<style>
html { color-scheme: light dark; }
body {
max-width: 40em;
margin: 2em auto;
padding: 0 1em;
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
}
h1 {
border-bottom: 2px solid;
padding-bottom: 0.3em;
}
.note {
background: rgba(128, 128, 128, 0.1);
border-left: 4px solid rgba(128, 128, 128, 0.5);
padding: 0.5em 1em;
margin: 1.5em 0;
}
a { color: #0969da; }
@media (prefers-color-scheme: dark) {
a { color: #58a6ff; }
}
ul { padding-left: 1.5em; }
li { margin: 0.3em 0; }
code {
background: rgba(128, 128, 128, 0.15);
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.9em;
}
</style>
</head>
<body>
<h1>Welcome to Kubo!</h1>
<p>If you see this page, the <a href="https://github.com/ipfs/kubo" target="_blank" rel="noopener noreferrer">Kubo IPFS node</a> has been successfully installed and is working.</p>
<p>For configuration options, please refer to the <a href="https://github.com/ipfs/kubo/blob/master/docs/config.md" target="_blank" rel="noopener noreferrer">documentation</a>.</p>
<div class="note">
<strong>Note to operators:</strong> This is the default landing page.
Set <code>Gateway.RootRedirect</code> in the <a href="https://github.com/ipfs/kubo/blob/master/docs/config.md#gatewayrootredirect" target="_blank" rel="noopener noreferrer">configuration</a> to redirect to your own content.
</div>
<h2>Resources</h2>
<ul>
<li><a href="https://github.com/ipfs/kubo" target="_blank" rel="noopener noreferrer">Kubo on GitHub</a></li>
<li><a href="https://github.com/ipfs/kubo/blob/master/docs/config.md" target="_blank" rel="noopener noreferrer">Kubo Configuration Reference</a></li>
<li><a href="https://docs.ipfs.tech" target="_blank" rel="noopener noreferrer">IPFS Documentation</a></li>
<li><a href="https://docs.ipfs.tech/concepts/glossary/#gateway" target="_blank" rel="noopener noreferrer">IPFS Gateway Documentation</a></li>
<li><a href="https://specs.ipfs.tech/http-gateways/" target="_blank" rel="noopener noreferrer">IPFS HTTP Gateway Specifications</a></li>
</ul>
<div id="abuse-section">
<h2>Abuse Reports</h2>
<p>
This gateway is operated by a third party. To report abuse, contact the operator or owner of
<span id="gateway-host"></span>.
</p>
</div>
<script>
(function() {
var hostname = window.location.hostname;
var section = document.getElementById('abuse-section');
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
section.style.display = 'none';
return;
}
var host = document.getElementById('gateway-host');
var link = document.createElement('a');
link.href = 'https://whois.domaintools.com/' + hostname;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = hostname;
host.appendChild(link);
})();
</script>
<noscript>
<p>To report abuse, look up the domain owner using a WHOIS service.</p>
</noscript>
</body>
</html>

View File

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

View File

@ -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")

142
core/corehttp/landing.go Normal file
View File

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

View File

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

View File

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

View File

@ -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)

View File

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