mirror of
https://github.com/ipfs/kubo.git
synced 2026-03-05 00:08:06 +08:00
Merge 052e8233a2 into ab44726177
This commit is contained in:
commit
45f8b08117
@ -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()
|
||||
|
||||
94
core/corehttp/assets/landing.html
Normal file
94
core/corehttp/assets/landing.html
Normal 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>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
142
core/corehttp/landing.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
128
test/cli/gateway_landing_test.go
Normal file
128
test/cli/gateway_landing_test.go
Normal 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!")
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user