@cryptotaxi247 / kubo / commits / 73ab037d1

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=