Skip to content

Commit 73ab037

Browse files
hsanjuanlidel
andauthored
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: ipfs/specs#476 It is based on work from ipfs/boxo#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 <[email protected]>
1 parent 030d64f commit 73ab037

File tree

12 files changed

+247
-18
lines changed

12 files changed

+247
-18
lines changed

config/gateway.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const (
88
DefaultInlineDNSLink = false
99
DefaultDeserializedResponses = true
1010
DefaultDisableHTMLErrors = false
11-
DefaultExposeRoutingAPI = false
11+
DefaultExposeRoutingAPI = true
1212
DefaultDiagnosticServiceURL = "https://check.ipfs.network"
1313

1414
// Gateway limit defaults from boxo

core/corehttp/routing.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package corehttp
22

33
import (
44
"context"
5+
"errors"
6+
"fmt"
57
"net"
68
"net/http"
79
"time"
@@ -13,6 +15,9 @@ import (
1315
"github.com/ipfs/boxo/routing/http/types/iter"
1416
cid "github.com/ipfs/go-cid"
1517
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"
1621
"github.com/libp2p/go-libp2p/core/peer"
1722
"github.com/libp2p/go-libp2p/core/routing"
1823
)
@@ -96,6 +101,60 @@ func (r *contentRouter) PutIPNS(ctx context.Context, name ipns.Name, record *ipn
96101
return r.n.Routing.PutValue(ctx, string(name.RoutingKey()), raw)
97102
}
98103

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+
99158
type peerChanIter struct {
100159
ch <-chan peer.AddrInfo
101160
cancel context.CancelFunc

docs/changelogs/v0.39.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ The `go-ipfs` name was deprecated in 2022 and renamed to `kubo`. Starting with t
168168

169169
All users should migrate to the `kubo` name in their scripts and configurations.
170170

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+
171175
### 📦️ Important dependency updates
172176

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,7 +1128,7 @@ Kubo will filter out routing results which are not actionable, for example, all
11281128
graphsync providers will be skipped. If you need a generic pass-through, see
11291129
standalone router implementation named [someguy](https://github.com/ipfs/someguy).
11301130

1131-
Default: `false`
1131+
Default: `true`
11321132

11331133
Type: `flag`
11341134

docs/examples/kubo-as-a-library/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ go 1.25
77
replace github.com/ipfs/kubo => ./../../..
88

99
require (
10-
github.com/ipfs/boxo v0.35.2
10+
github.com/ipfs/boxo v0.35.3-0.20251118170232-e71f50ea2263
1111
github.com/ipfs/kubo v0.0.0-00010101000000-000000000000
1212
github.com/libp2p/go-libp2p v0.45.0
1313
github.com/multiformats/go-multiaddr v0.16.1

docs/examples/kubo-as-a-library/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd
291291
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU=
292292
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
293293
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=
296296
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
297297
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
298298
github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk=

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ require (
2222
github.com/hashicorp/go-version v1.7.0
2323
github.com/ipfs-shipyard/nopfs v0.0.14
2424
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
2626
github.com/ipfs/go-block-format v0.2.3
2727
github.com/ipfs/go-cid v0.5.0
2828
github.com/ipfs/go-cidutil v0.1.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,8 @@ github.com/ipfs-shipyard/nopfs/ipfs v0.25.0 h1:OqNqsGZPX8zh3eFMO8Lf8EHRRnSGBMqcd
358358
github.com/ipfs-shipyard/nopfs/ipfs v0.25.0/go.mod h1:BxhUdtBgOXg1B+gAPEplkg/GpyTZY+kCMSfsJvvydqU=
359359
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
360360
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=
363363
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
364364
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
365365
github.com/ipfs/go-block-format v0.0.3/go.mod h1:4LmD4ZUw0mhO+JSKdpWwrzATiEfM7WWgQ8H5l6P8MVk=

test/cli/delegated_routing_v1_http_server_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ package cli
22

33
import (
44
"context"
5+
"encoding/json"
6+
"strings"
57
"testing"
8+
"time"
69

710
"github.com/google/uuid"
11+
"github.com/ipfs/boxo/autoconf"
812
"github.com/ipfs/boxo/ipns"
913
"github.com/ipfs/boxo/routing/http/client"
1014
"github.com/ipfs/boxo/routing/http/types"
@@ -14,8 +18,14 @@ import (
1418
"github.com/ipfs/kubo/test/cli/harness"
1519
"github.com/libp2p/go-libp2p/core/peer"
1620
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
1722
)
1823

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+
1929
func TestRoutingV1Server(t *testing.T) {
2030
t.Parallel()
2131

@@ -143,4 +153,132 @@ func TestRoutingV1Server(t *testing.T) {
143153
assert.NoError(t, err)
144154
assert.Equal(t, "/ipfs/"+cidStr, value.String())
145155
})
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+
})
146284
}

test/cli/testutils/httprouting/mock_http_content_router.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import (
1919
// (https://specs.ipfs.tech/routing/http-routing-v1/) server implementation
2020
// based on github.com/ipfs/boxo/routing/http/server
2121
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
2930
}
3031

3132
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) {
115116
r.peers[*pid] = append(r.peers[*pid], peerRecord)
116117
}
117118
}
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+
}

0 commit comments

Comments
 (0)