feat: support GetClosesPeers (IPIP-476) and ExposeRoutingAPI by default (#10954)
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>
Hector Sanjuan committed
Nov 19, 2025 at 10:51 UTC
73ab037d1d22a04879ab24302336fa9dcbbfd959
12 files changed
+247
-18
config/gateway.go
+1
-1
@@ -8,7 +8,7 @@ const (
8
DefaultInlineDNSLink = false
9
DefaultDeserializedResponses = true
10
DefaultDisableHTMLErrors = false
11
- DefaultExposeRoutingAPI = false
11
+ DefaultExposeRoutingAPI = true
12
DefaultDiagnosticServiceURL = "https://check.ipfs.network"
13
14
// Gateway limit defaults from boxo
core/corehttp/routing.go
+59
@@ -2,6 +2,8 @@ package corehttp
2
3
import (
4
"context"
5
+ "errors"
6
+ "fmt"
7
"net"
8
"net/http"
9
"time"
@@ -13,6 +15,9 @@ import (
15
"github.com/ipfs/boxo/routing/http/types/iter"
16
cid "github.com/ipfs/go-cid"
17
core "github.com/ipfs/kubo/core"
18
+ dht "github.com/libp2p/go-libp2p-kad-dht"
19
+ "github.com/libp2p/go-libp2p-kad-dht/dual"
20
+ "github.com/libp2p/go-libp2p-kad-dht/fullrt"
21
"github.com/libp2p/go-libp2p/core/peer"
22
"github.com/libp2p/go-libp2p/core/routing"
23
)
@@ -96,6 +101,60 @@ func (r *contentRouter) PutIPNS(ctx context.Context, name ipns.Name, record *ipn
101
return r.n.Routing.PutValue(ctx, string(name.RoutingKey()), raw)
102
}
103
104
+func (r *contentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) {
105
+ // Per the spec, if the peer ID is empty, we should use self.
106
+ if key == cid.Undef {
107
+ return nil, errors.New("GetClosestPeers key is undefined")
108
+ }
109
+
110
+ keyStr := string(key.Hash())
111
+ var peers []peer.ID
112
+ var err error
113
+
114
+ if r.n.DHTClient == nil {
115
+ return nil, fmt.Errorf("GetClosestPeers not supported: DHT is not available")
116
+ }
117
+
118
+ switch dhtClient := r.n.DHTClient.(type) {
119
+ case *dual.DHT:
120
+ // Only use WAN DHT for public HTTP Routing API.
121
+ // LAN DHT contains private network peers that should not be exposed publicly.
122
+ if dhtClient.WAN == nil {
123
+ return nil, fmt.Errorf("GetClosestPeers not supported: WAN DHT is not available")
124
+ }
125
+ peers, err = dhtClient.WAN.GetClosestPeers(ctx, keyStr)
126
+ case *fullrt.FullRT:
127
+ peers, err = dhtClient.GetClosestPeers(ctx, keyStr)
128
+ case *dht.IpfsDHT:
129
+ peers, err = dhtClient.GetClosestPeers(ctx, keyStr)
130
+ default:
131
+ return nil, fmt.Errorf("GetClosestPeers not supported for DHT type %T", r.n.DHTClient)
132
+ }
133
+
134
+ if err != nil {
135
+ return nil, err
136
+ }
137
+
138
+ // We have some DHT-closest peers. Find addresses for them.
139
+ // The addresses should be in the peerstore.
140
+ records := make([]*types.PeerRecord, 0, len(peers))
141
+ for _, p := range peers {
142
+ addrs := r.n.Peerstore.Addrs(p)
143
+ rAddrs := make([]types.Multiaddr, len(addrs))
144
+ for i, addr := range addrs {
145
+ rAddrs[i] = types.Multiaddr{Multiaddr: addr}
146
+ }
147
+ record := types.PeerRecord{
148
+ ID: &p,
149
+ Schema: types.SchemaPeer,
150
+ Addrs: rAddrs,
151
+ }
152
+ records = append(records, &record)
153
+ }
154
+
155
+ return iter.ToResultIter(iter.FromSlice(records)), nil
156
+}
157
+
158
type peerChanIter struct {
159
ch <-chan peer.AddrInfo
160
cancel context.CancelFunc
docs/changelogs/v0.39.md
+4
@@ -168,6 +168,10 @@ The `go-ipfs` name was deprecated in 2022 and renamed to `kubo`. Starting with t
168
169
All users should migrate to the `kubo` name in their scripts and configurations.
170
171
+#### Routing V1 HTTP API now exposed by default
172
+
173
+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).
174
+
175
### 📦️ Important dependency updates
176
177
- 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
docs/config.md
+1
-1
@@ -1128,7 +1128,7 @@ Kubo will filter out routing results which are not actionable, for example, all
1128
graphsync providers will be skipped. If you need a generic pass-through, see
1129
standalone router implementation named [someguy](https://github.com/ipfs/someguy).
1130
1131
-Default: `false`
1131
+Default: `true`
1132
1133
Type: `flag`
1134
docs/examples/kubo-as-a-library/go.mod
+1
-1
@@ -7,7 +7,7 @@ go 1.25
7
replace github.com/ipfs/kubo => ./../../..
8
9
require (
10
- github.com/ipfs/boxo v0.35.2
10
+ github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263
11
github.com/ipfs/kubo v0.0.0-00010101000000-000000000000
12
github.com/libp2p/go-libp2p v0.45.0
13
github.com/multiformats/go-multiaddr v0.16.1
docs/examples/kubo-as-a-library/go.sum
+2
-2
@@ -291,8 +291,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd
291
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU=
292
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
293
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
294
-github.com/ipfs/boxo v0.35.2 h1:0QZJJh6qrak28abENOi5OA8NjBnZM4p52SxeuIDqNf8=
295
-github.com/ipfs/boxo v0.35.2/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
294
+github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 h1:7sSi4euS5Rb+RwQZOXrd/fURpC9kgbESD4DPykaLy0I=
295
+github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
296
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
297
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
298
github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk=
go.mod
+1
-1
@@ -22,7 +22,7 @@ require (
22
github.com/hashicorp/go-version v1.7.0
23
github.com/ipfs-shipyard/nopfs v0.0.14
24
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0
25
- github.com/ipfs/boxo v0.35.2
25
+ github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263
26
github.com/ipfs/go-block-format v0.2.3
27
github.com/ipfs/go-cid v0.5.0
28
github.com/ipfs/go-cidutil v0.1.0
go.sum
+2
-2
@@ -358,8 +358,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd
358
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU=
359
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
360
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
361
-github.com/ipfs/boxo v0.35.2 h1:0QZJJh6qrak28abENOi5OA8NjBnZM4p52SxeuIDqNf8=
362
-github.com/ipfs/boxo v0.35.2/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
361
+github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 h1:7sSi4euS5Rb+RwQZOXrd/fURpC9kgbESD4DPykaLy0I=
362
+github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
363
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
364
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
365
github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk=
test/cli/delegated_routing_v1_http_server_test.go
+138
@@ -2,9 +2,13 @@ package cli
2
3
import (
4
"context"
5
+ "encoding/json"
6
+ "strings"
7
"testing"
8
+ "time"
9
10
"github.com/google/uuid"
11
+ "github.com/ipfs/boxo/autoconf"
12
"github.com/ipfs/boxo/ipns"
13
"github.com/ipfs/boxo/routing/http/client"
14
"github.com/ipfs/boxo/routing/http/types"
@@ -14,8 +18,14 @@ import (
18
"github.com/ipfs/kubo/test/cli/harness"
19
"github.com/libp2p/go-libp2p/core/peer"
20
"github.com/stretchr/testify/assert"
21
+ "github.com/stretchr/testify/require"
22
)
23
24
+// swarmPeersOutput is used to parse the JSON output of 'ipfs swarm peers --enc=json'
25
+type swarmPeersOutput struct {
26
+ Peers []struct{} `json:"Peers"`
27
+}
28
+
29
func TestRoutingV1Server(t *testing.T) {
30
t.Parallel()
31
@@ -143,4 +153,132 @@ func TestRoutingV1Server(t *testing.T) {
153
assert.NoError(t, err)
154
assert.Equal(t, "/ipfs/"+cidStr, value.String())
155
})
156
+
157
+ t.Run("GetClosestPeers returns error when DHT is disabled", func(t *testing.T) {
158
+ t.Parallel()
159
+
160
+ // Test various routing types that don't support DHT
161
+ routingTypes := []string{"none", "delegated", "custom"}
162
+ for _, routingType := range routingTypes {
163
+ t.Run("routing_type="+routingType, func(t *testing.T) {
164
+ t.Parallel()
165
+
166
+ // Create node with specified routing type (DHT disabled)
167
+ node := harness.NewT(t).NewNode().Init()
168
+ node.UpdateConfig(func(cfg *config.Config) {
169
+ cfg.Gateway.ExposeRoutingAPI = config.True
170
+ cfg.Routing.Type = config.NewOptionalString(routingType)
171
+
172
+ // For custom routing type, we need to provide minimal valid config
173
+ // otherwise daemon startup will fail
174
+ if routingType == "custom" {
175
+ // Configure a minimal HTTP router (no DHT)
176
+ cfg.Routing.Routers = map[string]config.RouterParser{
177
+ "http-only": {
178
+ Router: config.Router{
179
+ Type: config.RouterTypeHTTP,
180
+ Parameters: config.HTTPRouterParams{
181
+ Endpoint: "https://delegated-ipfs.dev",
182
+ },
183
+ },
184
+ },
185
+ }
186
+ cfg.Routing.Methods = map[config.MethodName]config.Method{
187
+ config.MethodNameProvide: {RouterName: "http-only"},
188
+ config.MethodNameFindProviders: {RouterName: "http-only"},
189
+ config.MethodNameFindPeers: {RouterName: "http-only"},
190
+ config.MethodNameGetIPNS: {RouterName: "http-only"},
191
+ config.MethodNamePutIPNS: {RouterName: "http-only"},
192
+ }
193
+ }
194
+
195
+ // For delegated routing type, ensure we have at least one HTTP router
196
+ // to avoid daemon startup failure
197
+ if routingType == "delegated" {
198
+ // Use a minimal delegated router configuration
199
+ cfg.Routing.DelegatedRouters = []string{"https://delegated-ipfs.dev"}
200
+ // Delegated routing doesn't support providing, must be disabled
201
+ cfg.Provide.Enabled = config.False
202
+ }
203
+ })
204
+ node.StartDaemon()
205
+
206
+ c, err := client.New(node.GatewayURL())
207
+ require.NoError(t, err)
208
+
209
+ // Try to get closest peers - should fail gracefully with an error
210
+ testCid, err := cid.Decode("QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn")
211
+ require.NoError(t, err)
212
+
213
+ _, err = c.GetClosestPeers(context.Background(), testCid)
214
+ require.Error(t, err)
215
+ // All these routing types should indicate DHT is not available
216
+ // The exact error message may vary based on implementation details
217
+ errStr := err.Error()
218
+ assert.True(t,
219
+ strings.Contains(errStr, "not supported") ||
220
+ strings.Contains(errStr, "not available") ||
221
+ strings.Contains(errStr, "500"),
222
+ "Expected error indicating DHT not available for routing type %s, got: %s", routingType, errStr)
223
+ })
224
+ }
225
+ })
226
+
227
+ t.Run("GetClosestPeers returns peers for self", func(t *testing.T) {
228
+ t.Parallel()
229
+
230
+ routingTypes := []string{"auto", "autoclient", "dht", "dhtclient"}
231
+ for _, routingType := range routingTypes {
232
+ t.Run("routing_type="+routingType, func(t *testing.T) {
233
+ t.Parallel()
234
+
235
+ // Single node with DHT and real bootstrap peers
236
+ node := harness.NewT(t).NewNode().Init()
237
+ node.UpdateConfig(func(cfg *config.Config) {
238
+ cfg.Gateway.ExposeRoutingAPI = config.True
239
+ cfg.Routing.Type = config.NewOptionalString(routingType)
240
+ // Set real bootstrap peers from boxo/autoconf
241
+ cfg.Bootstrap = autoconf.FallbackBootstrapPeers
242
+ })
243
+ node.StartDaemon()
244
+
245
+ // Wait for node to connect to bootstrap peers and populate WAN DHT routing table
246
+ minPeers := len(autoconf.FallbackBootstrapPeers)
247
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
248
+ res := node.RunIPFS("swarm", "peers", "--enc=json")
249
+ var output swarmPeersOutput
250
+ err := json.Unmarshal(res.Stdout.Bytes(), &output)
251
+ assert.NoError(t, err)
252
+ peerCount := len(output.Peers)
253
+ // Wait until we have at least minPeers connected
254
+ assert.GreaterOrEqual(t, peerCount, minPeers,
255
+ "waiting for at least %d bootstrap peers, currently have %d", minPeers, peerCount)
256
+ }, 30*time.Second, time.Second)
257
+
258
+ c, err := client.New(node.GatewayURL())
259
+ require.NoError(t, err)
260
+
261
+ // Query for closest peers to our own peer ID
262
+ key := peer.ToCid(node.PeerID())
263
+
264
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
265
+ defer cancel()
266
+ resultsIter, err := c.GetClosestPeers(ctx, key)
267
+ require.NoError(t, err)
268
+
269
+ records, err := iter.ReadAllResults(resultsIter)
270
+ require.NoError(t, err)
271
+
272
+ // Verify we got some peers back from WAN DHT
273
+ assert.NotEmpty(t, records, "should return some peers close to own peerid")
274
+
275
+ // Verify structure of returned records
276
+ for _, record := range records {
277
+ assert.Equal(t, types.SchemaPeer, record.Schema)
278
+ assert.NotNil(t, record.ID)
279
+ assert.NotEmpty(t, record.Addrs, "peer record should have addresses")
280
+ }
281
+ })
282
+ }
283
+ })
284
}
test/cli/testutils/httprouting/mock_http_content_router.go
+35
-7
@@ -19,13 +19,14 @@ import (
19
// (https://specs.ipfs.tech/routing/http-routing-v1/) server implementation
20
// based on github.com/ipfs/boxo/routing/http/server
21
type MockHTTPContentRouter struct {
22
- m sync.Mutex
23
- provideBitswapCalls int
24
- findProvidersCalls int
25
- findPeersCalls int
26
- providers map[cid.Cid][]types.Record
27
- peers map[peer.ID][]*types.PeerRecord
28
- Debug bool
22
+ m sync.Mutex
23
+ provideBitswapCalls int
24
+ findProvidersCalls int
25
+ findPeersCalls int
26
+ getClosestPeersCalls int
27
+ providers map[cid.Cid][]types.Record
28
+ peers map[peer.ID][]*types.PeerRecord
29
+ Debug bool
30
}
31
32
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) {
116
r.peers[*pid] = append(r.peers[*pid], peerRecord)
117
}
118
}
119
+
120
+func (r *MockHTTPContentRouter) GetClosestPeers(ctx context.Context, key cid.Cid) (iter.ResultIter[*types.PeerRecord], error) {
121
+ r.m.Lock()
122
+ defer r.m.Unlock()
123
+ r.getClosestPeersCalls++
124
+
125
+ if r.peers == nil {
126
+ r.peers = make(map[peer.ID][]*types.PeerRecord)
127
+ }
128
+ pid, err := peer.FromCid(key)
129
+ if err != nil {
130
+ return iter.FromSlice([]iter.Result[*types.PeerRecord]{}), nil
131
+ }
132
+ records, found := r.peers[pid]
133
+ if !found {
134
+ return iter.FromSlice([]iter.Result[*types.PeerRecord]{}), nil
135
+ }
136
+
137
+ results := make([]iter.Result[*types.PeerRecord], len(records))
138
+ for i, rec := range records {
139
+ results[i] = iter.Result[*types.PeerRecord]{Val: rec}
140
+ if r.Debug {
141
+ fmt.Printf("MockHTTPContentRouter.GetPeers(%s) result: %+v\n", pid.String(), rec)
142
+ }
143
+ }
144
+ return iter.FromSlice(results), nil
145
+}
test/dependencies/go.mod
+1
-1
@@ -136,7 +136,7 @@ require (
136
github.com/huin/goupnp v1.3.0 // indirect
137
github.com/inconshreveable/mousetrap v1.1.0 // indirect
138
github.com/ipfs/bbloom v0.0.4 // indirect
139
- github.com/ipfs/boxo v0.35.2 // indirect
139
+ github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 // indirect
140
github.com/ipfs/go-bitfield v1.1.0 // indirect
141
github.com/ipfs/go-block-format v0.2.3 // indirect
142
github.com/ipfs/go-cid v0.5.0 // indirect
test/dependencies/go.sum
+2
-2
@@ -334,8 +334,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
334
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
335
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
336
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
337
-github.com/ipfs/boxo v0.35.2 h1:0QZJJh6qrak28abENOi5OA8NjBnZM4p52SxeuIDqNf8=
338
-github.com/ipfs/boxo v0.35.2/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
337
+github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263 h1:7sSi4euS5Rb+RwQZOXrd/fURpC9kgbESD4DPykaLy0I=
338
+github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263/go.mod h1:bZn02OFWwJtY8dDW9XLHaki59EC5o+TGDECXEbe1w8U=
339
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
340
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
341
github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk=