From 6b20fcd69d22ab91fb7fd60f0d46b373a8e8fd2f Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 19 May 2023 15:45:52 +0100 Subject: [PATCH 1/2] fix: send user agent header with libp2p version The `AgentVersion` metadata is set by the identify service. If it's present, send it as the user agent header with all requests. N.b. this is currently broken in Chromium as it doesn't allow overriding the user agent in fetch requests event though the spec says it should. --- .aegir.js | 21 +++++++++++++++ package.json | 9 +++++-- src/index.ts | 41 +++++++++++++++++++++++++--- test/index.spec.ts | 67 +++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 128 insertions(+), 10 deletions(-) diff --git a/.aegir.js b/.aegir.js index 07040ea..01fe14f 100644 --- a/.aegir.js +++ b/.aegir.js @@ -4,19 +4,40 @@ import body from 'body-parser' export default { test: { before: async () => { + let lastRequest = { + headers: {}, + params: {}, + body: '' + } + const providers = new Map() const echoServer = new EchoServer() echoServer.polka.use(body.text()) echoServer.polka.post('/add-providers/:cid', (req, res) => { + lastRequest = { + headers: req.headers, + params: req.params, + body: req.body + } + providers.set(req.params.cid, req.body) res.end() }) echoServer.polka.get('/cid/:cid', (req, res) => { + lastRequest = { + headers: req.headers, + params: req.params, + body: req.body + } + const provs = providers.get(req.params.cid) ?? '[]' providers.delete(req.params.cid) res.end(provs) }) + echoServer.polka.get('/last-request', (req, res) => { + res.end(JSON.stringify(lastRequest)) + }) await echoServer.start() diff --git a/package.json b/package.json index 3115df8..ce14610 100644 --- a/package.json +++ b/package.json @@ -137,22 +137,27 @@ }, "dependencies": { "@libp2p/interface-content-routing": "^2.0.2", + "@libp2p/interface-peer-id": "^2.0.2", "@libp2p/interface-peer-info": "^1.0.9", + "@libp2p/interface-peer-store": "^2.0.3", "@libp2p/interfaces": "^3.3.1", "@libp2p/logger": "^2.0.7", "@libp2p/peer-id": "^2.0.3", "@multiformats/multiaddr": "^12.1.2", "any-signal": "^4.1.1", "browser-readablestream-to-it": "^2.0.2", + "D": "^1.0.0", "iterable-ndjson": "^1.1.0", "multiformats": "^11.0.2", "p-defer": "^4.0.0", - "p-queue": "^7.3.4" + "p-queue": "^7.3.4", + "sinon-ts": "^1.0.0" }, "devDependencies": { "@libp2p/peer-id-factory": "^2.0.3", "aegir": "^39.0.7", "body-parser": "^1.20.2", - "it-all": "^3.0.1" + "it-all": "^3.0.1", + "it-drain": "^3.0.2" } } diff --git a/src/index.ts b/src/index.ts index de91ce1..7d999a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,9 @@ import ndjson from 'iterable-ndjson' import defer from 'p-defer' import PQueue from 'p-queue' import type { ContentRouting } from '@libp2p/interface-content-routing' +import type { PeerId } from '@libp2p/interface-peer-id' import type { PeerInfo } from '@libp2p/interface-peer-info' +import type { PeerStore } from '@libp2p/interface-peer-store' import type { AbortOptions } from '@libp2p/interfaces' import type { Startable } from '@libp2p/interfaces/startable' import type { Multiaddr } from '@multiformats/multiaddr' @@ -40,6 +42,11 @@ export interface IpniContentRoutingInit { timeout?: number } +export interface IpniContentRoutingComponents { + peerId: PeerId + peerStore: PeerStore +} + const defaultValues = { concurrentRequests: 4, timeout: 30e3 @@ -54,11 +61,14 @@ class IpniContentRouting implements ContentRouting, Startable { private readonly shutDownController: AbortController private readonly clientUrl: URL private readonly timeout: number + private readonly peerId: PeerId + private readonly peerStore: PeerStore + private agentVersion?: string /** * Create a new DelegatedContentRouting instance */ - constructor (url: string | URL, init: IpniContentRoutingInit = {}) { + constructor (url: string | URL, init: IpniContentRoutingInit = {}, components: IpniContentRoutingComponents) { log('enabled IPNI routing via', url) this.started = false this.shutDownController = new AbortController() @@ -67,6 +77,8 @@ class IpniContentRouting implements ContentRouting, Startable { }) this.clientUrl = url instanceof URL ? url : new URL(url) this.timeout = init.timeout ?? defaultValues.timeout + this.peerId = components.peerId + this.peerStore = components.peerStore } isStarted (): boolean { @@ -83,6 +95,21 @@ class IpniContentRouting implements ContentRouting, Startable { this.started = false } + private async getAgentVersion (): Promise { + if (this.agentVersion == null) { + const peer = await this.peerStore.get(this.peerId) + const agentVersionBuf = peer.metadata.get('AgentVersion') + + if (agentVersionBuf != null) { + this.agentVersion = new TextDecoder().decode(agentVersionBuf) + } else { + this.agentVersion = '' + } + } + + return this.agentVersion + } + async * findProviders (key: CID, options: AbortOptions = {}): AsyncIterable { log('findProviders starts: %c', key) @@ -99,7 +126,13 @@ class IpniContentRouting implements ContentRouting, Startable { await onStart.promise const resource = `${this.clientUrl}cid/${key.toString()}?cascade=ipfs-dht` - const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal } + const getOptions = { + headers: { + Accept: 'application/x-ndjson', + 'User-Agent': await this.getAgentVersion() + }, + signal + } const a = await fetch(resource, getOptions) if (a.body == null) { @@ -153,6 +186,6 @@ class IpniContentRouting implements ContentRouting, Startable { } } -export function ipniContentRouting (url: string | URL, init: IpniContentRoutingInit = {}): () => ContentRouting { - return () => new IpniContentRouting(url, init) +export function ipniContentRouting (url: string | URL, init: IpniContentRoutingInit = {}): (components: IpniContentRoutingComponents) => ContentRouting { + return (components: IpniContentRoutingComponents) => new IpniContentRouting(url, init, components) } diff --git a/test/index.spec.ts b/test/index.spec.ts index 3996dd3..5125fc3 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -3,8 +3,12 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import all from 'it-all' +import drain from 'it-drain' import { CID } from 'multiformats/cid' +import { type StubbedInstance, stubInterface } from 'sinon-ts' import { ipniContentRouting } from '../src/index.js' +import type { PeerId } from '@libp2p/interface-peer-id' +import type { PeerStore } from '@libp2p/interface-peer-store' if (process.env.ECHO_SERVER == null) { throw new Error('Echo server not configured correctly') @@ -13,6 +17,17 @@ if (process.env.ECHO_SERVER == null) { const serverUrl = process.env.ECHO_SERVER describe('IPNIContentRouting', function () { + let peerId: PeerId + let peerStore: StubbedInstance + + beforeEach(async () => { + peerId = await createEd25519PeerId() + peerStore = stubInterface() + peerStore.get.withArgs(peerId).resolves({ + metadata: new Map() + }) + }) + it('should find providers', async () => { const providers = [{ Metadata: 'gBI=', @@ -38,7 +53,10 @@ describe('IPNIContentRouting', function () { body: providers.map(prov => JSON.stringify(prov)).join('\n') }) - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs.map(prov => ({ @@ -59,7 +77,10 @@ describe('IPNIContentRouting', function () { body: 'not json' }) - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs).to.be.empty() @@ -87,7 +108,10 @@ describe('IPNIContentRouting', function () { body: providers.map(prov => JSON.stringify(prov)).join('\n') }) - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs).to.be.empty() @@ -95,9 +119,44 @@ describe('IPNIContentRouting', function () { it('should handle empty input', async () => { const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') - const routing = ipniContentRouting(serverUrl)() + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) const provs = await all(routing.findProviders(cid)) expect(provs).to.be.empty() }) + + it('should send user agent header', async () => { + const agentVersion = 'herp/1.0.0 derp/1.0.0' + + // return user agent + peerStore.get.withArgs(peerId).resolves({ + metadata: new Map([['AgentVersion', new TextEncoder().encode(agentVersion)]]) + }) + + const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + + // load providers for the router to fetch + await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, { + method: 'POST', + body: '[]\n' + }) + + const routing = ipniContentRouting(serverUrl)({ + peerId, + peerStore + }) + + await drain(routing.findProviders(cid)) + + const response = await fetch(`${process.env.ECHO_SERVER}/last-request`, { + method: 'GET' + }) + const bodyText = await response.text() + const body = JSON.parse(bodyText) + + expect(body).to.have.nested.property('headers.user-agent', agentVersion) + }) }) From 96378b8e7c7cbd0d57eae5252a3f532efcb3b466 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 19 May 2023 15:49:15 +0100 Subject: [PATCH 2/2] chore: deps --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index ce14610..36ba749 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,6 @@ "@multiformats/multiaddr": "^12.1.2", "any-signal": "^4.1.1", "browser-readablestream-to-it": "^2.0.2", - "D": "^1.0.0", "iterable-ndjson": "^1.1.0", "multiformats": "^11.0.2", "p-defer": "^4.0.0",