feat: support GetClosesPeers (IPIP-476) and ExposeRoutingAPI by default (#10954)
Some checks are pending
CodeQL / codeql (push) Waiting to run
Docker Check / lint (push) Waiting to run
Docker Check / build (push) Waiting to run
Gateway Conformance / gateway-conformance (push) Waiting to run
Gateway Conformance / gateway-conformance-libp2p-experiment (push) Waiting to run
Go Build / go-build (push) Waiting to run
Go Check / go-check (push) Waiting to run
Go Lint / go-lint (push) Waiting to run
Go Test / go-test (push) Waiting to run
Interop / interop-prep (push) Waiting to run
Interop / helia-interop (push) Blocked by required conditions
Interop / ipfs-webui (push) Blocked by required conditions
Sharness / sharness-test (push) Waiting to run
Spell Check / spellcheck (push) Waiting to run

This allows Kubo to respond to the GetClosestPeers() http routing v1 endpoint
as spec'ed here: https://github.com/ipfs/specs/pull/476

It is based on work from https://github.com/ipfs/boxo/pull/1021

We let IpfsNode implmement the contentRouter.Client interface with the new
method.  We use our WAN-DHT to get the closest peers. 

Additionally, Routing V1 HTTP API is exposed by default which enables light clients in browsers to use Kubo Gateway as delegated routing backend

Co-authored-by: Marcin Rataj <lidel@lidel.org>
This commit is contained in:
Hector Sanjuan 2025-11-19 10:51:56 +00:00 committed by GitHub
parent 030d64f8ba
commit 73ab037d1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 247 additions and 18 deletions

View File

@ -8,7 +8,7 @@ const (
DefaultInlineDNSLink = false
DefaultDeserializedResponses = true
DefaultDisableHTMLErrors = false
DefaultExposeRoutingAPI = false
DefaultExposeRoutingAPI = true
DefaultDiagnosticServiceURL = "https://check.ipfs.network"
// Gateway limit defaults from boxo

View File

@ -2,6 +2,8 @@ package corehttp
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
@ -13,6 +15,9 @@ import (
"github.com/ipfs/boxo/routing/http/types/iter"
cid "github.com/ipfs/go-cid"
core "github.com/ipfs/kubo/core"
dht "github.com/libp2p/go-libp2p-kad-dht"
"github.com/libp2p/go-libp2p-kad-dht/dual"
"github.com/libp2p/go-libp2p-kad-dht/fullrt"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/libp2p/go-libp2p/core/routing"
)
@ -96,6 +101,60 @@ func (r *contentRouter) PutIPNS(ctx context.Context, name ipns.Name, record *ipn
return r.n.Routing.PutValue(ctx, string(name.RoutingKey()), raw)
}
func (r *contentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) {
// Per the spec, if the peer ID is empty, we should use self.
if key == cid.Undef {
return nil, errors.New("GetClosestPeers key is undefined")
}
keyStr := string(key.Hash())
var peers []peer.ID
var err error
if r.n.DHTClient == nil {
return nil, fmt.Errorf("GetClosestPeers not supported: DHT is not available")
}
switch dhtClient := r.n.DHTClient.(type) {
case *dual.DHT:
// Only use WAN DHT for public HTTP Routing API.
// LAN DHT contains private network peers that should not be exposed publicly.
if dhtClient.WAN == nil {
return nil, fmt.Errorf("GetClosestPeers not supported: WAN DHT is not available")
}
peers, err = dhtClient.WAN.GetClosestPeers(ctx, keyStr)
case *fullrt.FullRT:
peers, err = dhtClient.GetClosestPeers(ctx, keyStr)
case *dht.IpfsDHT:
peers, err = dhtClient.GetClosestPeers(ctx, keyStr)
default:
return nil, fmt.Errorf("GetClosestPeers not supported for DHT type %T", r.n.DHTClient)
}
if err != nil {
return nil, err
}
// We have some DHT-closest peers. Find addresses for them.
// The addresses should be in the peerstore.
records := make([]*types.PeerRecord, 0, len(peers))
for _, p := range peers {
addrs := r.n.Peerstore.Addrs(p)
rAddrs := make([]types.Multiaddr, len(addrs))
for i, addr := range addrs {
rAddrs[i] = types.Multiaddr{Multiaddr: addr}
}
record := types.PeerRecord{
ID: &p,
Schema: types.SchemaPeer,
Addrs: rAddrs,
}
records = append(records, &record)
}
return iter.ToResultIter(iter.FromSlice(records)), nil
}
type peerChanIter struct {
ch <-chan peer.AddrInfo
cancel context.CancelFunc

View File

@ -168,6 +168,10 @@ The `go-ipfs` name was deprecated in 2022 and renamed to `kubo`. Starting with t
All users should migrate to the `kubo` name in their scripts and configurations.
#### Routing V1 HTTP API now exposed by default
The [Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/) is now exposed by default at `http://127.0.0.1:8080/routing/v1`. This allows light clients in browsers to use Kubo Gateway as a delegated routing backend instead of running a full DHT client. Support for [IPIP-476: Delegated Routing DHT Closest Peers API](https://github.com/ipfs/specs/pull/476) is included. Can be disabled via [`Gateway.ExposeRoutingAPI`](https://github.com/ipfs/kubo/blob/master/docs/config.md#gatewayexposeroutingapi).
### 📦️ Important dependency updates
- update `go-libp2p` to [v0.45.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.45.0) (incl. [v0.44.0](https://github.com/libp2p/go-libp2p/releases/tag/v0.44.0)) with self-healing UPnP port mappings and go-log/slog interop fixes

View File

@ -1128,7 +1128,7 @@ Kubo will filter out routing results which are not actionable, for example, all
graphsync providers will be skipped. If you need a generic pass-through, see
standalone router implementation named [someguy](https://github.com/ipfs/someguy).
Default: `false`
Default: `true`
Type: `flag`

View File

@ -7,7 +7,7 @@ go 1.25
replace github.com/ipfs/kubo => ./../../..
require (
github.com/ipfs/boxo v0.35.2
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263
github.com/ipfs/kubo v0.0.0-00010101000000-000000000000
github.com/libp2p/go-libp2p v0.45.0
github.com/multiformats/go-multiaddr v0.16.1

View File

@ -291,8 +291,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/boxo v0.35.2 h1:0QZJJh6qrak28abENOi5OA8NjBnZM4p52SxeuIDqNf8=
github.com/ipfs/boxo v0.35.2/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 h1:7sSi4euS5Rb+RwQZOXrd/fURpC9kgbESD4DPykaLy0I=
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk=

2
go.mod
View File

@ -22,7 +22,7 @@ require (
github.com/hashicorp/go-version v1.7.0
github.com/ipfs-shipyard/nopfs v0.0.14
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0
github.com/ipfs/boxo v0.35.2
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263
github.com/ipfs/go-block-format v0.2.3
github.com/ipfs/go-cid v0.5.0
github.com/ipfs/go-cidutil v0.1.0

4
go.sum
View File

@ -358,8 +358,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/boxo v0.35.2 h1:0QZJJh6qrak28abENOi5OA8NjBnZM4p52SxeuIDqNf8=
github.com/ipfs/boxo v0.35.2/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 h1:7sSi4euS5Rb+RwQZOXrd/fURpC9kgbESD4DPykaLy0I=
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk=

View File

@ -2,9 +2,13 @@ package cli
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/ipfs/boxo/autoconf"
"github.com/ipfs/boxo/ipns"
"github.com/ipfs/boxo/routing/http/client"
"github.com/ipfs/boxo/routing/http/types"
@ -14,8 +18,14 @@ import (
"github.com/ipfs/kubo/test/cli/harness"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// swarmPeersOutput is used to parse the JSON output of 'ipfs swarm peers --enc=json'
type swarmPeersOutput struct {
Peers []struct{} `json:"Peers"`
}
func TestRoutingV1Server(t *testing.T) {
t.Parallel()
@ -143,4 +153,132 @@ func TestRoutingV1Server(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "/ipfs/"+cidStr, value.String())
})
t.Run("GetClosestPeers returns error when DHT is disabled", func(t *testing.T) {
t.Parallel()
// Test various routing types that don't support DHT
routingTypes := []string{"none", "delegated", "custom"}
for _, routingType := range routingTypes {
t.Run("routing_type="+routingType, func(t *testing.T) {
t.Parallel()
// Create node with specified routing type (DHT disabled)
node := harness.NewT(t).NewNode().Init()
node.UpdateConfig(func(cfg *config.Config) {
cfg.Gateway.ExposeRoutingAPI = config.True
cfg.Routing.Type = config.NewOptionalString(routingType)
// For custom routing type, we need to provide minimal valid config
// otherwise daemon startup will fail
if routingType == "custom" {
// Configure a minimal HTTP router (no DHT)
cfg.Routing.Routers = map[string]config.RouterParser{
"http-only": {
Router: config.Router{
Type: config.RouterTypeHTTP,
Parameters: config.HTTPRouterParams{
Endpoint: "https://delegated-ipfs.dev",
},
},
},
}
cfg.Routing.Methods = map[config.MethodName]config.Method{
config.MethodNameProvide: {RouterName: "http-only"},
config.MethodNameFindProviders: {RouterName: "http-only"},
config.MethodNameFindPeers: {RouterName: "http-only"},
config.MethodNameGetIPNS: {RouterName: "http-only"},
config.MethodNamePutIPNS: {RouterName: "http-only"},
}
}
// For delegated routing type, ensure we have at least one HTTP router
// to avoid daemon startup failure
if routingType == "delegated" {
// Use a minimal delegated router configuration
cfg.Routing.DelegatedRouters = []string{"https://delegated-ipfs.dev"}
// Delegated routing doesn't support providing, must be disabled
cfg.Provide.Enabled = config.False
}
})
node.StartDaemon()
c, err := client.New(node.GatewayURL())
require.NoError(t, err)
// Try to get closest peers - should fail gracefully with an error
testCid, err := cid.Decode("QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn")
require.NoError(t, err)
_, err = c.GetClosestPeers(context.Background(), testCid)
require.Error(t, err)
// All these routing types should indicate DHT is not available
// The exact error message may vary based on implementation details
errStr := err.Error()
assert.True(t,
strings.Contains(errStr, "not supported") ||
strings.Contains(errStr, "not available") ||
strings.Contains(errStr, "500"),
"Expected error indicating DHT not available for routing type %s, got: %s", routingType, errStr)
})
}
})
t.Run("GetClosestPeers returns peers for self", func(t *testing.T) {
t.Parallel()
routingTypes := []string{"auto", "autoclient", "dht", "dhtclient"}
for _, routingType := range routingTypes {
t.Run("routing_type="+routingType, func(t *testing.T) {
t.Parallel()
// Single node with DHT and real bootstrap peers
node := harness.NewT(t).NewNode().Init()
node.UpdateConfig(func(cfg *config.Config) {
cfg.Gateway.ExposeRoutingAPI = config.True
cfg.Routing.Type = config.NewOptionalString(routingType)
// Set real bootstrap peers from boxo/autoconf
cfg.Bootstrap = autoconf.FallbackBootstrapPeers
})
node.StartDaemon()
// Wait for node to connect to bootstrap peers and populate WAN DHT routing table
minPeers := len(autoconf.FallbackBootstrapPeers)
require.EventuallyWithT(t, func(t *assert.CollectT) {
res := node.RunIPFS("swarm", "peers", "--enc=json")
var output swarmPeersOutput
err := json.Unmarshal(res.Stdout.Bytes(), &output)
assert.NoError(t, err)
peerCount := len(output.Peers)
// Wait until we have at least minPeers connected
assert.GreaterOrEqual(t, peerCount, minPeers,
"waiting for at least %d bootstrap peers, currently have %d", minPeers, peerCount)
}, 30*time.Second, time.Second)
c, err := client.New(node.GatewayURL())
require.NoError(t, err)
// Query for closest peers to our own peer ID
key := peer.ToCid(node.PeerID())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resultsIter, err := c.GetClosestPeers(ctx, key)
require.NoError(t, err)
records, err := iter.ReadAllResults(resultsIter)
require.NoError(t, err)
// Verify we got some peers back from WAN DHT
assert.NotEmpty(t, records, "should return some peers close to own peerid")
// Verify structure of returned records
for _, record := range records {
assert.Equal(t, types.SchemaPeer, record.Schema)
assert.NotNil(t, record.ID)
assert.NotEmpty(t, record.Addrs, "peer record should have addresses")
}
})
}
})
}

View File

@ -19,13 +19,14 @@ import (
// (https://specs.ipfs.tech/routing/http-routing-v1/) server implementation
// based on github.com/ipfs/boxo/routing/http/server
type MockHTTPContentRouter struct {
m sync.Mutex
provideBitswapCalls int
findProvidersCalls int
findPeersCalls int
providers map[cid.Cid][]types.Record
peers map[peer.ID][]*types.PeerRecord
Debug bool
m sync.Mutex
provideBitswapCalls int
findProvidersCalls int
findPeersCalls int
getClosestPeersCalls int
providers map[cid.Cid][]types.Record
peers map[peer.ID][]*types.PeerRecord
Debug bool
}
func (r *MockHTTPContentRouter) FindProviders(ctx context.Context, key cid.Cid, limit int) (iter.ResultIter[types.Record], error) {
@ -115,3 +116,30 @@ func (r *MockHTTPContentRouter) AddProvider(key cid.Cid, record types.Record) {
r.peers[*pid] = append(r.peers[*pid], peerRecord)
}
}
func (r *MockHTTPContentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) {
r.m.Lock()
defer r.m.Unlock()
r.getClosestPeersCalls++
if r.peers == nil {
r.peers = make(map[peer.ID][]*types.PeerRecord)
}
pid, err := peer.FromCid(key)
if err != nil {
return iter.FromSlice([]iter.Result[*types.PeerRecord]{}), nil
}
records, found := r.peers[pid]
if !found {
return iter.FromSlice([]iter.Result[*types.PeerRecord]{}), nil
}
results := make([]iter.Result[*types.PeerRecord], len(records))
for i, rec := range records {
results[i] = iter.Result[*types.PeerRecord]{Val: rec}
if r.Debug {
fmt.Printf("MockHTTPContentRouter.GetPeers(%s) result: %+v\n", pid.String(), rec)
}
}
return iter.FromSlice(results), nil
}

View File

@ -136,7 +136,7 @@ require (
github.com/huin/goupnp v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/boxo v0.35.2 // indirect
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 // indirect
github.com/ipfs/go-bitfield v1.1.0 // indirect
github.com/ipfs/go-block-format v0.2.3 // indirect
github.com/ipfs/go-cid v0.5.0 // indirect

View File

@ -334,8 +334,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/boxo v0.35.2 h1:0QZJJh6qrak28abENOi5OA8NjBnZM4p52SxeuIDqNf8=
github.com/ipfs/boxo v0.35.2/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 h1:7sSi4euS5Rb+RwQZOXrd/fURpC9kgbESD4DPykaLy0I=
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk=