diff --git a/.aegir.js b/.aegir.js index 437b9f5ff3..022735ee0e 100644 --- a/.aegir.js +++ b/.aegir.js @@ -16,6 +16,7 @@ export default { const { MULTIADDRS_WEBSOCKETS } = await import('./dist/test/fixtures/browser.js') const { plaintext } = await import('./dist/src/insecure/index.js') const { default: Peers } = await import('./dist/test/fixtures/peers.js') + const { circuitRelayServer, circuitRelayTransport } = await import('./dist/src/circuit/index.js') // Use the last peer const peerId = await createFromJSON(Peers[Peers.length - 1]) @@ -28,6 +29,7 @@ export default { }, peerId, transports: [ + circuitRelayTransport(), webSockets() ], streamMuxers: [ @@ -37,13 +39,7 @@ export default { noise(), plaintext() ], - relay: { - enabled: true, - hop: { - enabled: true, - active: false - } - }, + relay: circuitRelayServer(), nat: { enabled: false } diff --git a/doc/CONFIGURATION.md b/doc/CONFIGURATION.md index 9d34d6244e..63a4ab4dba 100644 --- a/doc/CONFIGURATION.md +++ b/doc/CONFIGURATION.md @@ -20,6 +20,7 @@ - [Setup with Content and Peer Routing](#setup-with-content-and-peer-routing) - [Setup with Relay](#setup-with-relay) - [Setup with Automatic Reservations](#setup-with-automatic-reservations) + - [Setup with Preconfigured Reservations](#setup-with-preconfigured-reservations) - [Setup with Keychain](#setup-with-keychain) - [Configuring Dialing](#configuring-dialing) - [Configuring Connection Manager](#configuring-connection-manager) @@ -428,54 +429,87 @@ import { createLibp2p } from 'libp2p' import { tcp } from '@libp2p/tcp' import { mplex } from '@libp2p/mplex' import { noise } from '@chainsafe/libp2p-noise' +import { circuitRelayTransport, circuitRelayServer } from 'libp2p/circuit-relay' const node = await createLibp2p({ - transports: [tcp()], + transports: [ + tcp(), + circuitRelayTransport({ // allows the current node to make and accept relayed connections + discoverRelays: 0, // how many network relays to find + reservationConcurrency: 1 // how many relays to attempt to reserve slots on at once + }) + ], streamMuxers: [mplex()], connectionEncryption: [noise()], - relay: { // Circuit Relay options - enabled: true, // Allows you to dial and accept relayed connections. Does not make you a relay. - hop: { - enabled: true, // Allows you to be a relay for other peers. - timeout: 30 * 1000, // Incoming hop requests must complete within this timeout - applyConnectionLimits: true // Apply data/duration limits to relayed connections (default: true) - limit: { - duration: 120 * 1000 // the maximum amount of ms a relayed connection can be open for - data: BigInt(1 << 17) // the maximum amount of data that can be transferred over a relayed connection - } - }, - advertise: { - enabled: true, // Allows you to disable advertising the Hop service - bootDelay: 15 * 60 * 1000, // Delay before HOP relay service is advertised on the network - ttl: 30 * 60 * 1000 // Delay Between HOP relay service advertisements on the network + relay: circuitRelayServer({ // makes the node function as a relay server + hopTimeout: 30 * 1000, // incoming relay requests must be resolved within this time limit + advertise: { // if set, use content routing to broadcast availability of this relay + bootDelay: 30 * 1000 // how long to wait after startup before broadcast }, - reservationManager: { // the reservation manager creates reservations on discovered relays - enabled: true, // enable the reservation manager, default: false - maxReservations: 1 // the maximum number of relays to create reservations on + reservations: { + maxReservations: 15 // how many peers are allowed to reserve relay slots on this server + reservationClearInterval: 300 * 1000 // how often to reclaim stale reservations + applyDefaultLimit: true // whether to apply default data/duration limits to each relayed connection + defaultDurationLimit: 2 * 60 * 1000 // the default maximum amount of time a relayed connection can be open for + defaultDataLimit: BigInt(2 << 7) // the default maximum number of bytes that can be transferred over a relayed connection + acl: { + // fine grained control over which peers are allow to reserve slots or connect to other peers + allowReserve: (peer: PeerId, addr: Multiaddr) => Promise, + allowConnect: (src: PeerId, addr: Multiaddr, dst: PeerId) => Promise + } + maxInboundHopStreams: 32 // how many inbound HOP streams are allow simultaneously + maxOutboundHopStreams: 64 // how many outbound HOP streams are allow simultaneously } - } + }) }) ``` #### Setup with Automatic Reservations +In this configuration the libp2p node will search the network for one relay with a free reservation slot. When it has found one and negotiated a relay reservation, the relayed address will appear in the output of `libp2p.getMultiaddrs()`. + ```js import { createLibp2p } from 'libp2p' import { tcp } from '@libp2p/tcp' import { mplex } from '@libp2p/mplex' import { noise } from '@chainsafe/libp2p-noise' +import { circuitRelayTransport } from 'libp2p/circuit-relay' const node = await createLibp2p({ - transports: [tcp()], + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + streamMuxers: [mplex()], + connectionEncryption: [noise()] +}) +``` + +#### Setup with Preconfigured Reservations + +In this configuration the libp2p node is a circuit relay client which connects to a relay, `/ip4/123.123.123.123/p2p/QmRelay` which has been configured to have slots available. + +```js +import { createLibp2p } from 'libp2p' +import { tcp } from '@libp2p/tcp' +import { mplex } from '@libp2p/mplex' +import { noise } from '@chainsafe/libp2p-noise' +import { circuitRelayTransport } from 'libp2p/circuit-relay' + +const node = await createLibp2p({ + transports: [ + tcp(), + circuitRelayTransport() + ], + addresses: { + listen: [ + '/ip4/123.123.123.123/p2p/QmRelay/p2p-circuit' // a known relay node with reservation slots available + ] + }, streamMuxers: [mplex()], connectionEncryption: [noise()] - relay: { // Circuit Relay options (this config is part of libp2p core configurations) - enabled: true, // Allows you to dial and accept relayed connections. Does not make you a relay. - reservationManager: { - enabled: true, // Allows you to bind to relays with HOP enabled for improving node dialability - maxListeners: 2 // Configure maximum number of HOP relays to use - } - } }) ``` diff --git a/examples/auto-relay/dialer.js b/examples/auto-relay/dialer.js index 9f1a761769..8089bc7cb3 100644 --- a/examples/auto-relay/dialer.js +++ b/examples/auto-relay/dialer.js @@ -3,6 +3,7 @@ import { webSockets } from '@libp2p/websockets' import { noise } from '@chainsafe/libp2p-noise' import { mplex } from '@libp2p/mplex' import { multiaddr } from '@multiformats/multiaddr' +import { circuitRelayTransport } from 'libp2p/circuit-relay' async function main () { const autoRelayNodeAddr = process.argv[2] @@ -12,7 +13,8 @@ async function main () { const node = await createLibp2p({ transports: [ - webSockets() + webSockets(), + circuitRelayTransport() ], connectionEncryption: [ noise() diff --git a/examples/auto-relay/listener.js b/examples/auto-relay/listener.js index 1e2adbc5d2..c52d045610 100644 --- a/examples/auto-relay/listener.js +++ b/examples/auto-relay/listener.js @@ -3,6 +3,7 @@ import { webSockets } from '@libp2p/websockets' import { noise } from '@chainsafe/libp2p-noise' import { mplex } from '@libp2p/mplex' import { multiaddr } from '@multiformats/multiaddr' +import { circuitRelayTransport } from 'libp2p/circuit-relay' async function main () { const relayAddr = process.argv[2] @@ -12,21 +13,17 @@ async function main () { const node = await createLibp2p({ transports: [ - webSockets() + webSockets(), + circuitRelayTransport({ + discoverRelays: 2 + }) ], connectionEncryption: [ noise() ], streamMuxers: [ mplex() - ], - relay: { - enabled: true, - autoRelay: { - enabled: true, - maxListeners: 2 - } - } + ] }) console.log(`Node started with id ${node.peerId.toString()}`) diff --git a/examples/auto-relay/relay.js b/examples/auto-relay/relay.js index 93205e73a6..a153e41fd7 100644 --- a/examples/auto-relay/relay.js +++ b/examples/auto-relay/relay.js @@ -2,6 +2,7 @@ import { createLibp2p } from 'libp2p' import { webSockets } from '@libp2p/websockets' import { noise } from '@chainsafe/libp2p-noise' import { mplex } from '@libp2p/mplex' +import { circuitRelayServer } from 'libp2p/circuit-relay' async function main () { const node = await createLibp2p({ @@ -19,15 +20,7 @@ async function main () { streamMuxers: [ mplex() ], - relay: { - enabled: true, - hop: { - enabled: true - }, - advertise: { - enabled: true, - } - } + relay: circuitRelayServer() }) console.log(`Node started with id ${node.peerId.toString()}`) diff --git a/examples/delegated-routing/src/libp2p-bundle.js b/examples/delegated-routing/src/libp2p-bundle.js index e7c6beab9c..02a868a28a 100644 --- a/examples/delegated-routing/src/libp2p-bundle.js +++ b/examples/delegated-routing/src/libp2p-bundle.js @@ -9,6 +9,7 @@ import { noise } from '@chainsafe/libp2p-noise' import { delegatedPeerRouting } from '@libp2p/delegated-peer-routing' import { delegatedContentRouting } from '@libp2p/delegated-content-routing' import { create as createIpfsHttpClient } from 'ipfs-http-client' +import { circuitRelayTransport } from 'libp2p/circuit-relay' export default function Libp2pBundle ({peerInfo, peerBook}) { const wrtcstar = new webRTCStar() @@ -34,7 +35,8 @@ export default function Libp2pBundle ({peerInfo, peerBook}) { ], transports: [ wrtcstar.transport, - webSockets() + webSockets(), + circuitRelayTransport() ], streamMuxers: [ mplex() @@ -44,15 +46,6 @@ export default function Libp2pBundle ({peerInfo, peerBook}) { ], connectionEncryption: [ noise() - ], - connectionManager: { - autoDial: false - }, - relay: { - enabled: true, - hop: { - enabled: false - } - } + ] }) } diff --git a/examples/discovery-mechanisms/3.js b/examples/discovery-mechanisms/3.js index edee24de80..559babea1a 100644 --- a/examples/discovery-mechanisms/3.js +++ b/examples/discovery-mechanisms/3.js @@ -7,6 +7,7 @@ import { noise } from '@chainsafe/libp2p-noise' import { floodsub } from '@libp2p/floodsub' import { bootstrap } from '@libp2p/bootstrap' import { pubsubPeerDiscovery } from '@libp2p/pubsub-peer-discovery' +import { circuitRelayTransport, circuitRelayServer } from 'libp2p/circuit-relay' const createNode = async (bootstrappers) => { const node = await createLibp2p({ @@ -37,7 +38,7 @@ const createNode = async (bootstrappers) => { '/ip4/0.0.0.0/tcp/0' ] }, - transports: [tcp()], + transports: [tcp(), circuitRelayTransport()], streamMuxers: [mplex()], connectionEncryption: [noise()], pubsub: floodsub(), @@ -46,12 +47,7 @@ const createNode = async (bootstrappers) => { interval: 1000 }) ], - relay: { - enabled: true, // Allows you to dial and accept relayed connections. Does not make you a relay. - hop: { - enabled: true // Allows you to be a relay for other peers - } - } + relay: circuitRelayServer() }) console.log(`libp2p relay started with id: ${relay.peerId.toString()}`) diff --git a/package.json b/package.json index c3486ef908..2bdf4ba5c5 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,13 @@ ], "exports": { ".": { - "types": "./src/index.d.ts", + "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" }, + "./circuit-relay": { + "types": "./dist/src/circuit/index.d.ts", + "import": "./dist/src/circuit/index.js" + }, "./insecure": { "types": "./dist/src/insecure/index.d.ts", "import": "./dist/src/insecure/index.js" @@ -123,6 +127,7 @@ "@libp2p/peer-id-factory": "^2.0.0", "@libp2p/peer-record": "^5.0.0", "@libp2p/peer-store": "^6.0.4", + "@libp2p/topology": "^4.0.1", "@libp2p/tracked-map": "^3.0.0", "@libp2p/utils": "^3.0.2", "@multiformats/mafmt": "^11.0.2", @@ -143,7 +148,7 @@ "it-map": "^2.0.0", "it-merge": "^2.0.0", "it-pair": "^2.0.2", - "it-pb-stream": "^3.0.0", + "it-pb-stream": "^3.2.0", "it-pipe": "^2.0.3", "it-sort": "^2.0.0", "it-stream-types": "^1.0.4", @@ -151,6 +156,7 @@ "multiformats": "^11.0.0", "p-defer": "^4.0.0", "p-fifo": "^1.0.0", + "p-queue": "^7.3.4", "p-retry": "^5.0.0", "private-ip": "^3.0.0", "protons-runtime": "^5.0.0", @@ -181,7 +187,6 @@ "@libp2p/mplex": "^7.0.0", "@libp2p/pubsub": "^6.0.0", "@libp2p/tcp": "^6.0.0", - "@libp2p/topology": "^4.0.0", "@libp2p/webrtc-star": "^6.0.0", "@libp2p/websockets": "^5.0.0", "@types/p-fifo": "^1.0.0", diff --git a/src/circuit/client.ts b/src/circuit/client.ts deleted file mode 100644 index 9ffaa0028e..0000000000 --- a/src/circuit/client.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { logger } from '@libp2p/logger' -import { RELAY_V2_HOP_CODEC } from './multicodec.js' -import { getExpiration, namespaceToCid } from './utils.js' -import { - CIRCUIT_PROTO_CODE, - DEFAULT_MAX_RESERVATIONS, - RELAY_RENDEZVOUS_NS -} from './constants.js' -import type { PeerId } from '@libp2p/interface-peer-id' -import type { AddressSorter } from '@libp2p/interface-peer-store' -import type { Connection } from '@libp2p/interface-connection' -import sort from 'it-sort' -import all from 'it-all' -import { pipe } from 'it-pipe' -import { publicAddressesFirst } from '@libp2p/utils/address-sort' -import { reserve } from './hop.js' -import { EventEmitter, CustomEvent } from '@libp2p/interfaces/events' -import type { Startable } from '@libp2p/interfaces/startable' -import type { Components } from '../components.js' -import type { RelayReservationManagerConfig } from './index.js' -import { PeerSet, PeerMap, PeerList } from '@libp2p/peer-collections' - -const log = logger('libp2p:circuit:client') - -const noop = () => {} - -/** - * CircuitServiceInit initializes the circuit service using values - * from the provided config and an @type{AddressSorter}. - */ -export interface CircuitServiceInit extends RelayReservationManagerConfig { - /** - * Allows prioritizing addresses from the peerstore for dialing. The - * default behavior is to prioritise public addresses. - */ - addressSorter?: AddressSorter - /** - * A callback to invoke when an error occurs in the circuit service. - */ - onError?: (error: Error) => void -} - -export interface RelayReservationManagerEvents { - 'relay:reservation': CustomEvent -} - -/** - * ReservationManager automatically makes a circuit v2 reservation on any connected - * peers that support the circuit v2 HOP protocol. - */ -export class RelayReservationManager extends EventEmitter implements Startable { - private readonly components: Components - private readonly addressSorter: AddressSorter - private readonly maxReservations: number - private readonly relays: PeerSet - private readonly reservationMap: PeerMap> - private readonly onError: (error: Error) => void - private started: boolean - - constructor (components: Components, init: CircuitServiceInit) { - super() - this.started = false - this.components = components - this.addressSorter = init.addressSorter ?? publicAddressesFirst - this.maxReservations = init.maxReservations ?? DEFAULT_MAX_RESERVATIONS - this.relays = new PeerSet() - this.reservationMap = new PeerMap() - this.onError = init.onError ?? noop - - this._onProtocolChange = this._onProtocolChange.bind(this) - this._onPeerDisconnected = this._onPeerDisconnected.bind(this) - this._onPeerConnect = this._onPeerConnect.bind(this) - - this.components.peerStore.addEventListener('change:protocols', (evt) => { - void this._onProtocolChange(evt.detail).catch(err => { - log.error('handling protocol change failed', err) - }) - }) - - this.components.connectionManager.addEventListener('peer:disconnect', this._onPeerDisconnected) - this.components.connectionManager.addEventListener('peer:connect', this._onPeerConnect) - } - - isStarted () { - return this.started - } - - start () { - void this._listenOnAvailableHopRelays().catch(err => { log.error('error listening on relays', err) }) - this.started = true - } - - async stop () { - this.reservationMap.forEach((timer) => clearTimeout(timer)) - this.reservationMap.clear() - this.relays.clear() - } - - /** - * Check if a peer supports the relay protocol. - * If the protocol is not supported, check if it was supported before and remove it as a listen relay. - * If the protocol is supported, check if the peer supports **HOP** and add it as a listener if - * inside the threshold. - */ - async _onProtocolChange ({ peerId, protocols }: {peerId: PeerId, protocols: string[]}) { - if (peerId.equals(this.components.peerId)) { - return - } - - // Check if it has the protocol - const hasProtocol = protocols.includes(RELAY_V2_HOP_CODEC) - log.trace('Peer %p protocol change %p', peerId, this.components.peerId) - - // If no protocol, check if we were keeping the peer before as a listenRelay - if (!hasProtocol) { - if (this.relays.has(peerId)) { - await this._removeListenRelay(peerId) - } - return - } - - if (this.relays.has(peerId)) { - return - } - - // If protocol, check if can hop, store info in the metadataBook and listen on it - try { - const connections = this.components.connectionManager.getConnections(peerId) - - // if no connections, try to listen on relay - if (connections.length === 0) { - void this._tryToListenOnRelay(peerId) - return - } - const connection = connections[0] - - // Do not hop on a relayed connection - if (connection.remoteAddr.protoCodes().includes(CIRCUIT_PROTO_CODE)) { - log('relayed connection to %p will not be used to hop on', peerId) - return - } - - await this._addListenRelay(connection, peerId) - } catch (err: any) { - log.error('could not add %p as relay', peerId) - this.onError(err) - } - } - - /** - * Handle case when peer connects. If we already have the peer in the protobook, - * we treat this event as an `onProtocolChange`. - */ - _onPeerConnect ({ detail: connection }: CustomEvent) { - void this.components.peerStore.protoBook.get(connection.remotePeer) - .then((protocols) => { - void this._onProtocolChange({ peerId: connection.remotePeer, protocols }) - .catch((err) => log.error('handling reconnect failed', err)) - }, - (err) => { - // this is not necessarily an error as we will only have the protocols stored - // in case of a reconnect - log.trace('could not fetch protocols for peer: %p', connection.remotePeer, err) - }) - } - - /** - * Peer disconnects - */ - _onPeerDisconnected (evt: CustomEvent) { - const connection = evt.detail - const peerId = connection.remotePeer - clearTimeout(this.reservationMap.get(peerId)) - this.reservationMap.delete(peerId) - - // Not listening on this relay - if (!this.relays.has(peerId)) { - return - } - - this._removeListenRelay(peerId).catch(err => { - log.error(err) - }) - } - - /** - * Attempt to listen on the given relay connection - */ - async _addListenRelay (connection: Connection, peerId: PeerId): Promise { - log.trace('peerId %p is being added as relay', peerId) - try { - // Check if already enough relay reservations - if (this.relays.size >= this.maxReservations) { - return - } - - await this.createOrRefreshReservation(peerId) - - // Get peer known addresses and sort them with public addresses first - const remoteAddrs = await pipe( - await this.components.peerStore.addressBook.get(connection.remotePeer), - (source) => sort(source, this.addressSorter), - async (source) => await all(source) - ) - - // Attempt to listen on relay - const result = await Promise.all( - remoteAddrs.map(async addr => { - let multiaddr = addr.multiaddr - - if (multiaddr.getPeerId() == null) { - multiaddr = multiaddr.encapsulate(`/p2p/${connection.remotePeer.toString()}`) - } - multiaddr = multiaddr.encapsulate('/p2p-circuit') - try { - // Announce multiaddrs will update on listen success by TransportManager event being triggered - await this.components.transportManager.listen([multiaddr]) - return true - } catch (err: any) { - log.error('error listening on circuit address', multiaddr, err) - this.onError(err) - } - - return false - }) - ) - - if (result.includes(true)) { - this.relays.add(peerId) - } - } catch (err: any) { - this.relays.delete(peerId) - log.error('error adding relay for %p %s', peerId, err) - this.onError(err) - } - } - - /** - * Remove listen relay - */ - async _removeListenRelay (PeerId: PeerId) { - const recheck = this.relays.has(PeerId) - this.relays.delete(PeerId) - if (recheck) { - // TODO: this should be responsibility of the connMgr - await this._listenOnAvailableHopRelays(new PeerList([PeerId])) - } - } - - /** - * Try to listen on available hop relay connections. - * The following order will happen while we do not have enough relays. - * 1. Check the metadata store for known relays, try to listen on the ones we are already connected. - * 2. Dial and try to listen on the peers we know that support hop but are not connected. - * 3. Search the network. - */ - async _listenOnAvailableHopRelays (peersToIgnore: PeerList = new PeerList([])) { - // Check if already listening on enough relays - if (this.relays.size >= this.maxReservations) { - return - } - - const knownHopsToDial: PeerId[] = [] - const peers = (await this.components.peerStore.all()) - // filter by a list of peers supporting RELAY_V2_HOP and ones we are not listening on - .filter(({ id, protocols }) => - protocols.includes(RELAY_V2_HOP_CODEC) && !this.relays.has(id) && !peersToIgnore.includes(id) - ) - .map(({ id }) => { - const connections = this.components.connectionManager.getConnections(id) - if (connections.length === 0) { - knownHopsToDial.push(id) - return [id, null] - } - return [id, connections[0]] - }) - .sort(() => Math.random() - 0.5) - - // Check if we have known hop peers to use and attempt to listen on the already connected - for (const [id, conn] of peers) { - await this._addListenRelay(conn as Connection, id as PeerId) - - // Check if already listening on enough relays - if (this.relays.size >= this.maxReservations) { - return - } - } - - // Try to listen on known peers that are not connected - for (const peerId of knownHopsToDial) { - // Check if already listening on enough relays - if (this.relays.size >= this.maxReservations) { - return - } - - await this._tryToListenOnRelay(peerId) - } - - // Try to find relays to hop on the network - try { - const cid = await namespaceToCid(RELAY_RENDEZVOUS_NS) - for await (const provider of this.components.contentRouting.findProviders(cid)) { - if ( - provider.multiaddrs.length > 0 && - !provider.id.equals(this.components.peerId) - ) { - const peerId = provider.id - - await this.components.peerStore.addressBook.add(peerId, provider.multiaddrs) - await this._tryToListenOnRelay(peerId) - - // Check if already listening on enough relays - if (this.relays.size >= this.maxReservations) { - return - } - } - } - } catch (err: any) { - log.error('failed when finding relays on the network', err) - this.onError(err) - } - } - - async _tryToListenOnRelay (peerId: PeerId) { - try { - if (peerId.equals(this.components.peerId)) { - log.trace('Skipping dialling self %p', peerId.toString()) - return - } - const connection = await this.components.connectionManager.openConnection(peerId) - await this._addListenRelay(connection, peerId) - } catch (err: any) { - log.error('Could not connect and listen on relay %p', peerId, err) - this.onError(err) - } - } - - private readonly createOrRefreshReservation = async (peerId: PeerId) => { - try { - const connections = this.components.connectionManager.getConnections(peerId) - - if (connections.length === 0) { - throw new Error('No connections to peer') - } - - const connection = connections[0] - - const reservation = await reserve(connection) - - const refreshReservation = this.createOrRefreshReservation - - if (reservation != null) { - log('new reservation on %p', peerId) - - // clear any previous timeouts - const previous = this.reservationMap.get(peerId) - if (previous != null) { - clearTimeout(previous) - } - - const timeout = setTimeout( - (peerId: PeerId) => { - void refreshReservation(peerId).catch(err => { - log.error('error refreshing reservation for %p', peerId, err) - }) - }, - Math.max(getExpiration(reservation.expire) - 100, 0), - peerId - ) - this.reservationMap.set( - peerId, - timeout - ) - this.dispatchEvent(new CustomEvent('relay:reservation')) - } - } catch (err: any) { - log.error(err) - await this._removeListenRelay(peerId) - } - } -} diff --git a/src/circuit/constants.ts b/src/circuit/constants.ts index 49a927be6c..d35f45881a 100644 --- a/src/circuit/constants.ts +++ b/src/circuit/constants.ts @@ -1,4 +1,5 @@ -const minute = 60 * 1000 +const second = 1000 +const minute = 60 * second /** * Delay before HOP relay service is advertised on the network @@ -21,17 +22,51 @@ export const CIRCUIT_PROTO_CODE = 290 export const RELAY_RENDEZVOUS_NS = '/libp2p/relay' /** - * Maximum reservations for auto relay + * The maximum number of relay reservations the relay server will accept */ -export const DEFAULT_MAX_RESERVATIONS = 1 +export const DEFAULT_MAX_RESERVATION_STORE_SIZE = 15 -export const RELAY_DESTINATION_TAG = 'relay-destination' +/** + * How often to check for reservation expiry + */ +export const DEFAULT_MAX_RESERVATION_CLEAR_INTERVAL = 300 * second + +/** + * How often to check for reservation expiry + */ +export const DEFAULT_MAX_RESERVATION_TTL = 2 * 60 * minute + +export const DEFAULT_RESERVATION_CONCURRENCY = 1 + +export const RELAY_SOURCE_TAG = 'circuit-relay-source' + +export const RELAY_TAG = 'circuit-relay-relay' // circuit v2 connection limits // https://github.com/libp2p/go-libp2p/blob/master/p2p/protocol/circuitv2/relay/resources.go#L61-L66 // 2 min is the default connection duration -export const DEFAULT_DURATION_LIMIT = 2 * 60 * 1000 +export const DEFAULT_DURATION_LIMIT = 2 * minute // 128k is the default data limit export const DEFAULT_DATA_LIMIT = BigInt(1 << 17) + +/** + * The hop protocol + */ +export const RELAY_V2_HOP_CODEC = '/libp2p/circuit/relay/0.2.0/hop' + +/** + * the stop protocol + */ +export const RELAY_V2_STOP_CODEC = '/libp2p/circuit/relay/0.2.0/stop' + +/** + * Hop messages must be exchanged inside this timeout + */ +export const DEFAULT_HOP_TIMEOUT = 30 * second + +/** + * How long to wait before starting to advertise the relay service + */ +export const DEFAULT_ADVERT_BOOT_DELAY = 30 * second diff --git a/src/circuit/hop.ts b/src/circuit/hop.ts deleted file mode 100644 index a93c3128ba..0000000000 --- a/src/circuit/hop.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { PeerId } from '@libp2p/interface-peer-id' -import { RecordEnvelope } from '@libp2p/peer-record' -import { logger } from '@libp2p/logger' -import type { Connection, Stream } from '@libp2p/interface-connection' -import { HopMessage, Limit, Reservation, Status, StopMessage } from './pb/index.js' -import type { Multiaddr } from '@multiformats/multiaddr' -import { multiaddr } from '@multiformats/multiaddr' -import type { Acl, ReservationStore } from './interfaces.js' -import { RELAY_V2_HOP_CODEC } from './multicodec.js' -import { stop } from './stop.js' -import { ReservationVoucherRecord } from './reservation-voucher.js' -import { peerIdFromBytes } from '@libp2p/peer-id' -import type { ConnectionManager } from '@libp2p/interface-connection-manager' -import type { ProtobufStream } from 'it-pb-stream' -import { pbStream } from 'it-pb-stream' -import { CIRCUIT_PROTO_CODE, RELAY_DESTINATION_TAG } from './constants.js' -import type { PeerStore } from '@libp2p/interface-peer-store' -import { createLimitedRelay } from './utils.js' - -const log = logger('libp2p:circuit:v2:hop') - -export interface HopProtocolOptions { - connection: Connection - request: HopMessage - stream: ProtobufStream - relayPeer: PeerId - relayAddrs: Multiaddr[] - limit?: Limit - acl?: Acl - reservationStore: ReservationStore - connectionManager: ConnectionManager - peerStore: PeerStore -} - -export async function handleHopProtocol (options: HopProtocolOptions): Promise { - const { stream, request } = options - log('received hop message') - switch (request.type) { - case HopMessage.Type.RESERVE: await handleReserve(options); break - case HopMessage.Type.CONNECT: await handleConnect(options); break - default: { - log.error('invalid hop request type %s via peer %s', options.request.type, options.connection.remotePeer) - stream.pb(HopMessage).write({ type: HopMessage.Type.STATUS, status: Status.UNEXPECTED_MESSAGE }) - } - } -} - -export async function reserve (connection: Connection): Promise { - log('requesting reservation from %s', connection.remotePeer) - const stream = await connection.newStream([RELAY_V2_HOP_CODEC]) - const pbstr = pbStream(stream) - const hopstr = pbstr.pb(HopMessage) - hopstr.write({ type: HopMessage.Type.RESERVE }) - - let response: HopMessage - try { - response = await hopstr.read() - } catch (err: any) { - log.error('error passing reserve message response from %s because', connection.remotePeer, err.message) - stream.close() - throw err - } - - if (response.status === Status.OK && (response.reservation != null)) { - return response.reservation - } - const errMsg = `reservation failed with status ${response.status ?? 'undefined'}` - log.error(errMsg) - throw new Error(errMsg) -} - -const isRelayAddr = (ma: Multiaddr): boolean => ma.protoCodes().includes(CIRCUIT_PROTO_CODE) - -async function handleReserve ({ connection, stream: pbstr, relayPeer, relayAddrs, acl, reservationStore, peerStore }: HopProtocolOptions): Promise { - const hopstr = pbstr.pb(HopMessage) - log('hop reserve request from %s', connection.remotePeer) - - if (isRelayAddr(connection.remoteAddr)) { - log.error('relay reservation over circuit connection denied for peer: %p', connection.remotePeer) - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.PERMISSION_DENIED }) - return - } - - if ((await acl?.allowReserve?.(connection.remotePeer, connection.remoteAddr)) === false) { - log.error('acl denied reservation to %s', connection.remotePeer) - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.PERMISSION_DENIED }) - return - } - - const result = await reservationStore.reserve(connection.remotePeer, connection.remoteAddr) - - if (result.status !== Status.OK) { - hopstr.write({ type: HopMessage.Type.STATUS, status: result.status }) - return - } - - try { - // tag relayed peer - // result.expire is non-null if `ReservationStore.reserve` returns with status == OK - if (result.expire != null) { - const ttl = new Date().getTime() - result.expire - await peerStore.tagPeer(relayPeer, RELAY_DESTINATION_TAG, { value: 1, ttl }) - } - hopstr.write({ - type: HopMessage.Type.STATUS, - status: Status.OK, - reservation: await makeReservation(relayAddrs, relayPeer, connection.remotePeer, BigInt(result.expire ?? 0)), - limit: (await reservationStore.get(relayPeer))?.limit - }) - log('sent confirmation response to %s', connection.remotePeer) - } catch (err) { - log.error('failed to send confirmation response to %s', connection.remotePeer) - await reservationStore.removeReservation(connection.remotePeer) - } -} - -async function handleConnect (options: HopProtocolOptions): Promise { - const { connection, stream, request, reservationStore, connectionManager, acl } = options - const hopstr = stream.pb(HopMessage) - - log('hop connect request from %s', connection.remotePeer) - - let dstPeer: PeerId - try { - if (request.peer == null) { - log.error('no peer info in hop connect request') - throw new Error('no peer info in request') - } - request.peer.addrs.forEach(multiaddr) - dstPeer = peerIdFromBytes(request.peer.id) - } catch (err) { - log.error('invalid hop connect request via peer %p %s', connection.remotePeer, err) - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.MALFORMED_MESSAGE }) - return - } - - if (acl?.allowConnect !== undefined) { - const status = await acl.allowConnect(connection.remotePeer, connection.remoteAddr, dstPeer) - if (status !== Status.OK) { - log.error('hop connect denied for %s with status %s', connection.remotePeer, status) - hopstr.write({ type: HopMessage.Type.STATUS, status: status }) - return - } - } - - if (!await reservationStore.hasReservation(dstPeer)) { - log.error('hop connect denied for %s with status %s', connection.remotePeer, Status.NO_RESERVATION) - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.NO_RESERVATION }) - return - } - - const connections = connectionManager.getConnections(dstPeer) - if (connections.length === 0) { - log('hop connect denied for %s as there is no destination connection', connection.remotePeer) - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.NO_RESERVATION }) - return - } - const destinationConnection = connections[0] - log('hop connect request from %s to %s is valid', connection.remotePeer, dstPeer) - - const destinationStream = await stop({ - connection: destinationConnection, - request: { - type: StopMessage.Type.CONNECT, - peer: { - id: connection.remotePeer.toBytes(), - addrs: [multiaddr('/p2p/' + connection.remotePeer.toString()).bytes] - } - } - }) - - if (destinationStream == null) { - log.error('failed to open stream to destination peer %s', destinationConnection?.remotePeer) - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.CONNECTION_FAILED }) - return - } - - hopstr.write({ type: HopMessage.Type.STATUS, status: Status.OK }) - const sourceStream = stream.unwrap() - - log('connection to destination established, short circuiting streams...') - const limit = (await reservationStore.get(dstPeer))?.limit - // Short circuit the two streams to create the relayed connection - return createLimitedRelay(sourceStream, destinationStream, limit) -} - -async function makeReservation ( - relayAddrs: Multiaddr[], - relayPeerId: PeerId, - remotePeer: PeerId, - expire: bigint -): Promise { - const addrs = [] - - for (const relayAddr of relayAddrs) { - addrs.push(relayAddr.bytes) - } - - const voucher = await RecordEnvelope.seal(new ReservationVoucherRecord({ - peer: remotePeer, - relay: relayPeerId, - expiration: Number(expire) - }), relayPeerId) - - return { - addrs, - expire, - voucher: voucher.marshal() - } -} diff --git a/src/circuit/index.ts b/src/circuit/index.ts index e09bdee41a..ee33f7b600 100644 --- a/src/circuit/index.ts +++ b/src/circuit/index.ts @@ -1,73 +1,23 @@ -/** - * RelayConfig configures the circuit v2 relay transport. - */ -export interface RelayConfig { - /** - * Enable dialing a client over a relay and receiving relayed connections. - * This in itself does not enable the node to act as a relay. - */ - enabled: boolean - advertise: RelayAdvertiseConfig - hop: HopConfig - reservationManager: RelayReservationManagerConfig +import type { EventEmitter } from '@libp2p/interfaces/events' +import type { PeerMap } from '@libp2p/peer-collections' +import type { Limit } from './pb/index.js' +import type { Multiaddr } from '@multiformats/multiaddr' + +export interface RelayReservation { + expire: Date + addr: Multiaddr + limit?: Limit } -/** - * RelayReservationManagerConfig allows the node to automatically listen - * on any discovered relays upto a specified maximum. - */ -export interface RelayReservationManagerConfig { - /** - * enable or disable autorelay (default: false) - */ - enabled?: boolean - - /** - * maximum number of relays to listen (default: 1) - */ - maxReservations?: number -} - -/** - * Configures using the node as a HOP relay - */ -export interface HopConfig { - /** - * If true this node will function as a limited relay (default: false) - */ - enabled?: boolean - - /** - * timeout for hop requests to complete - */ - timeout: number - - /** - * If false, no connection limits will be applied to relayed connections (default: true) - */ - applyConnectionLimits?: boolean - - /** - * Limits to apply to incoming relay connections - relayed connections will be closed if - * these limits are exceeded. - */ - limit?: { - /** - * How long to relay a connection for in milliseconds (default: 2m) - */ - duration?: number - - /** - * How many bytes to allow to be transferred over a relayed connection (default: 128k) - */ - data?: bigint - } +export interface CircuitRelayServiceEvents { + 'relay:reservation': CustomEvent + 'relay:advert:success': CustomEvent + 'relay:advert:error': CustomEvent } -export interface RelayAdvertiseConfig { - bootDelay?: number - enabled?: boolean - ttl?: number +export interface CircuitRelayService extends EventEmitter { + reservations: PeerMap } -export { Relay } from './relay.js' +export { circuitRelayServer } from './server/index.js' +export { circuitRelayTransport } from './transport/index.js' diff --git a/src/circuit/interfaces.ts b/src/circuit/interfaces.ts deleted file mode 100644 index 3c0d428c68..0000000000 --- a/src/circuit/interfaces.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { PeerId } from '@libp2p/interface-peer-id' -import type { Multiaddr } from '@multiformats/multiaddr' -import type { Limit, Status } from './pb/index.js' - -export type ReservationStatus = Status.OK | Status.PERMISSION_DENIED | Status.RESERVATION_REFUSED - -export interface Reservation { - addr: Multiaddr - expire: Date - limit?: Limit -} - -export interface ReservationStore { - reserve: (peer: PeerId, addr: Multiaddr, limit?: Limit) => {status: ReservationStatus, expire?: number} - removeReservation: (peer: PeerId) => void - hasReservation: (dst: PeerId) => boolean - get: (peer: PeerId) => Reservation | undefined -} - -export type AclStatus = Status.OK | Status.RESOURCE_LIMIT_EXCEEDED | Status.PERMISSION_DENIED - -export interface Acl { - allowReserve: (peer: PeerId, addr: Multiaddr) => Promise - /** - * Checks if connection should be allowed - */ - allowConnect: (src: PeerId, addr: Multiaddr, dst: PeerId) => Promise -} diff --git a/src/circuit/listener.ts b/src/circuit/listener.ts deleted file mode 100644 index 7a6422729d..0000000000 --- a/src/circuit/listener.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { CustomEvent, EventEmitter } from '@libp2p/interfaces/events' -import type { ConnectionManager } from '@libp2p/interface-connection-manager' -import type { PeerStore } from '@libp2p/interface-peer-store' -import type { Listener } from '@libp2p/interface-transport' -import { peerIdFromString } from '@libp2p/peer-id' -import type { Multiaddr } from '@multiformats/multiaddr' -import { multiaddr } from '@multiformats/multiaddr' - -export interface ListenerOptions { - peerStore: PeerStore - connectionManager: ConnectionManager -} - -export function createListener (options: ListenerOptions): Listener { - const listeningAddrs = new Map() - - /** - * Add swarm handler and listen for incoming connections - */ - async function listen (addr: Multiaddr): Promise { - const addrString = addr.toString().split('/p2p-circuit').find(a => a !== '') - const ma = multiaddr(addrString) - - const relayPeerStr = ma.getPeerId() - - if (relayPeerStr == null) { - throw new Error('Could not determine relay peer from multiaddr') - } - - const relayPeerId = peerIdFromString(relayPeerStr) - - await options.peerStore.addressBook.add(relayPeerId, [ma]) - - const relayConn = await options.connectionManager.openConnection(relayPeerId) - const relayedAddr = relayConn.remoteAddr.encapsulate('/p2p-circuit') - - listeningAddrs.set(relayConn.remotePeer.toString(), relayedAddr) - listener.dispatchEvent(new CustomEvent('listening')) - } - - /** - * Get fixed up multiaddrs - * - * NOTE: This method will grab the peers multiaddrs and expand them such that: - * - * a) If it's an existing /p2p-circuit address for a specific relay i.e. - * `/ip4/0.0.0.0/tcp/0/ipfs/QmRelay/p2p-circuit` this method will expand the - * address to `/ip4/0.0.0.0/tcp/0/ipfs/QmRelay/p2p-circuit/ipfs/QmPeer` where - * `QmPeer` is this peers id - * b) If it's not a /p2p-circuit address, it will encapsulate the address as a /p2p-circuit - * addr, such when dialing over a relay with this address, it will create the circuit using - * the encapsulated transport address. This is useful when for example, a peer should only - * be dialed over TCP rather than any other transport - * - * @returns {Multiaddr[]} - */ - function getAddrs () { - const addrs = [] - for (const addr of listeningAddrs.values()) { - addrs.push(addr) - } - return addrs - } - - const listener: Listener = Object.assign(new EventEmitter(), { - close: async () => await Promise.resolve(), - listen, - getAddrs - }) - - // Remove listeningAddrs when a peer disconnects - options.connectionManager.addEventListener('peer:disconnect', (evt) => { - const { detail: connection } = evt - const deleted = listeningAddrs.delete(connection.remotePeer.toString()) - - if (deleted) { - // Announce listen addresses change - listener.dispatchEvent(new CustomEvent('close')) - } - }) - - return listener -} diff --git a/src/circuit/multicodec.ts b/src/circuit/multicodec.ts deleted file mode 100644 index f1c290732c..0000000000 --- a/src/circuit/multicodec.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export const RELAY_V2_HOP_CODEC = '/libp2p/circuit/relay/0.2.0/hop' -export const RELAY_V2_STOP_CODEC = '/libp2p/circuit/relay/0.2.0/stop' diff --git a/src/circuit/relay.ts b/src/circuit/relay.ts deleted file mode 100644 index 6ec8e3e015..0000000000 --- a/src/circuit/relay.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { logger } from '@libp2p/logger' -import { codes } from '../errors.js' -import { - setDelayedInterval, - clearDelayedInterval - // @ts-expect-error set-delayed-interval does not export types -} from 'set-delayed-interval' -import { namespaceToCid } from './utils.js' -import { - RELAY_RENDEZVOUS_NS -} from './constants.js' -import type { AddressSorter } from '@libp2p/interface-peer-store' -import type { Startable } from '@libp2p/interfaces/startable' -import type { Components } from '../components.js' -import type { HopConfig, RelayAdvertiseConfig } from './index.js' - -const log = logger('libp2p:circuit:relay') - -export interface RelayInit { - addressSorter?: AddressSorter - maxListeners?: number - hop: HopConfig - advertise: RelayAdvertiseConfig -} - -export class Relay implements Startable { - private readonly components: Components - private readonly init: RelayInit - private timeout?: any - private started: boolean - - /** - * Creates an instance of Relay - */ - constructor (components: Components, init: RelayInit) { - this.components = components - this.started = false - this.init = init - this._advertiseService = this._advertiseService.bind(this) - } - - isStarted () { - return this.started - } - - /** - * Start Relay service - */ - async start () { - // Advertise service if HOP enabled and advertising enabled - if (this.init.hop.enabled === true && this.init.advertise.enabled === true) { - this.timeout = setDelayedInterval( - this._advertiseService, this.init.advertise.ttl, this.init.advertise.bootDelay - ) - } - - this.started = true - } - - /** - * Stop Relay service - */ - async stop () { - try { - clearDelayedInterval(this.timeout) - } catch (err) { } - - this.started = false - } - - /** - * Advertise hop relay service in the network. - */ - async _advertiseService () { - try { - const cid = await namespaceToCid(RELAY_RENDEZVOUS_NS) - await this.components.contentRouting.provide(cid) - } catch (err: any) { - if (err.code === codes.ERR_NO_ROUTERS_AVAILABLE) { - log.error('a content router, such as a DHT, must be provided in order to advertise the relay service', err) - await this.stop() - } else { - log.error('could not advertise service: ', err) - } - } - } -} diff --git a/src/circuit/server/advert-service.ts b/src/circuit/server/advert-service.ts new file mode 100644 index 0000000000..ede49548a2 --- /dev/null +++ b/src/circuit/server/advert-service.ts @@ -0,0 +1,108 @@ +import { logger } from '@libp2p/logger' +import { codes } from '../../errors.js' +import { namespaceToCid } from '../utils.js' +import { + DEFAULT_ADVERT_BOOT_DELAY, + RELAY_RENDEZVOUS_NS +} from '../constants.js' +import type { Startable } from '@libp2p/interfaces/startable' +import type { ContentRouting } from '@libp2p/interface-content-routing' +import { CustomEvent, EventEmitter } from '@libp2p/interfaces/events' +import pRetry from 'p-retry' + +const log = logger('libp2p:circuit-relay:advert-service') + +export interface AdvertServiceInit { + /** + * How long to wait after startup to begin advertising the service + * - if some configured content routers take a while to warm up (for + * example, the DHT needs some peers to be able to publish) this + * value should be high enough that they will have warmed up + */ + bootDelay?: number +} + +export interface AdvertServiceComponents { + contentRouting: ContentRouting +} + +export interface AdvertServiceEvents { + 'advert:success': CustomEvent + 'advert:error': CustomEvent +} + +export class AdvertService extends EventEmitter implements Startable { + private readonly contentRouting: ContentRouting + private timeout?: any + private started: boolean + private readonly bootDelay: number + + /** + * Creates an instance of Relay + */ + constructor (components: AdvertServiceComponents, init?: AdvertServiceInit) { + super() + + this.contentRouting = components.contentRouting + this.bootDelay = init?.bootDelay ?? DEFAULT_ADVERT_BOOT_DELAY + this.started = false + } + + isStarted () { + return this.started + } + + /** + * Start Relay service + */ + start () { + if (this.started) { + return + } + + // Advertise service if HOP enabled and advertising enabled + this.timeout = setTimeout(() => { + this._advertiseService().catch(err => { + log.error('could not advertise service', err) + }) + }, this.bootDelay) + + this.started = true + } + + /** + * Stop Relay service + */ + stop () { + try { + clearTimeout(this.timeout) + } catch (err) { } + + this.started = false + } + + /** + * Advertise hop relay service in the network. + */ + async _advertiseService () { + await pRetry(async () => { + try { + const cid = await namespaceToCid(RELAY_RENDEZVOUS_NS) + await this.contentRouting.provide(cid) + + this.dispatchEvent(new CustomEvent('advert:success')) + } catch (err: any) { + this.safeDispatchEvent('advert:error', { detail: err }) + + if (err.code === codes.ERR_NO_ROUTERS_AVAILABLE) { + log.error('a content router, such as a DHT, must be provided in order to advertise the relay service', err) + await this.stop() + return + } + + log.error('could not advertise service', err) + throw err + } + }) + } +} diff --git a/src/circuit/server/index.ts b/src/circuit/server/index.ts new file mode 100644 index 0000000000..438a23f692 --- /dev/null +++ b/src/circuit/server/index.ts @@ -0,0 +1,459 @@ +import { logger } from '@libp2p/logger' +import { createLimitedRelay } from '../utils.js' +import { + CIRCUIT_PROTO_CODE, + DEFAULT_HOP_TIMEOUT, + RELAY_SOURCE_TAG + , RELAY_V2_HOP_CODEC, RELAY_V2_STOP_CODEC +} from '../constants.js' +import type { PeerStore } from '@libp2p/interface-peer-store' +import type { Startable } from '@libp2p/interfaces/startable' +import { ReservationStore, ReservationStoreInit } from './reservation-store.js' +import type { IncomingStreamData, Registrar } from '@libp2p/interface-registrar' +import { AdvertService, AdvertServiceComponents, AdvertServiceInit } from './advert-service.js' +import pDefer from 'p-defer' +import { pbStream, ProtobufStream } from 'it-pb-stream' +import { HopMessage, Reservation, Status, StopMessage } from '../pb/index.js' +import { Multiaddr, multiaddr } from '@multiformats/multiaddr' +import type { Connection, Stream } from '@libp2p/interface-connection' +import { peerIdFromBytes } from '@libp2p/peer-id' +import type { PeerId } from '@libp2p/interface-peer-id' +import { RecordEnvelope } from '@libp2p/peer-record' +import { ReservationVoucherRecord } from './reservation-voucher.js' +import type { AddressManager } from '@libp2p/interface-address-manager' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' +import type { CircuitRelayService, RelayReservation } from '../index.js' +import { CustomEvent, EventEmitter } from '@libp2p/interfaces/events' +import { setMaxListeners } from 'events' + +const log = logger('libp2p:circuit-relay:server') + +const isRelayAddr = (ma: Multiaddr): boolean => ma.protoCodes().includes(CIRCUIT_PROTO_CODE) + +export enum AclStatus { + OK = Status.OK, + RESOURCE_LIMIT_EXCEEDED = Status.RESOURCE_LIMIT_EXCEEDED, + PERMISSION_DENIED = Status.PERMISSION_DENIED +} + +export interface Acl { + /** + * Checks if a peer should be allowed to use this relay + */ + allowReserve?: (peer: PeerId, addr: Multiaddr) => Promise + + /** + * Checks if a peer should be allowed to make a connection to another peer + */ + allowConnect?: (src: PeerId, addr: Multiaddr, dst: PeerId) => Promise +} + +export interface CircuitRelayServerInit { + /** + * Incoming hop requests must complete within this time in ms otherwise + * the stream will be reset (default: 30s) + */ + hopTimeout?: number + + /** + * If true, advertise this service via libp2p content routing to allow + * peers to locate us on the network (default: false) + */ + advertise?: boolean | AdvertServiceInit + + /** + * Configuration of reservations + */ + reservations?: ReservationStoreInit + + /** + * functions to control allow/deny of the creation of new reservations + */ + acl?: Acl + + /** + * The maximum number of simultaneous HOP inbound streams that can be open at once + */ + maxInboundHopStreams?: number + + /** + * The maximum number of simultaneous HOP outbound streams that can be open at once + */ + maxOutboundHopStreams?: number +} + +export interface HopProtocolOptions { + connection: Connection + request: HopMessage + stream: ProtobufStream +} + +export interface StopOptions { + connection: Connection + request: StopMessage +} + +export interface CircuitRelayServerComponents extends AdvertServiceComponents { + registrar: Registrar + peerStore: PeerStore + addressManager: AddressManager + peerId: PeerId + connectionManager: ConnectionManager +} + +export interface RelayServerEvents { + 'relay:reservation': CustomEvent + 'relay:advert:success': CustomEvent + 'relay:advert:error': CustomEvent +} + +class CircuitRelayServer extends EventEmitter implements Startable, CircuitRelayService { + private readonly registrar: Registrar + private readonly peerStore: PeerStore + private readonly addressManager: AddressManager + private readonly peerId: PeerId + private readonly connectionManager: ConnectionManager + private readonly reservationStore: ReservationStore + private readonly advertService: AdvertService | undefined + private started: boolean + private readonly hopTimeout: number + private readonly acl?: Acl + private readonly shutdownController: AbortController + private readonly maxInboundHopStreams?: number + private readonly maxOutboundHopStreams?: number + + /** + * Creates an instance of Relay + */ + constructor (components: CircuitRelayServerComponents, init: CircuitRelayServerInit = {}) { + super() + + this.registrar = components.registrar + this.peerStore = components.peerStore + this.addressManager = components.addressManager + this.peerId = components.peerId + this.connectionManager = components.connectionManager + this.started = false + this.hopTimeout = init?.hopTimeout ?? DEFAULT_HOP_TIMEOUT + this.acl = init?.acl + this.shutdownController = new AbortController() + this.maxInboundHopStreams = init.maxInboundHopStreams + this.maxOutboundHopStreams = init.maxOutboundHopStreams + + try { + // fails on node < 15.4 + setMaxListeners?.(Infinity, this.shutdownController.signal) + } catch { } + + if (init.advertise != null && init.advertise !== false) { + this.advertService = new AdvertService(components, init.advertise === true ? undefined : init.advertise) + this.advertService.addEventListener('advert:success', () => { + this.dispatchEvent(new CustomEvent('relay:advert:success')) + }) + this.advertService.addEventListener('advert:error', (evt) => { + this.safeDispatchEvent('relay:advert:error', { detail: evt.detail }) + }) + } + + this.reservationStore = new ReservationStore(init.reservations) + } + + isStarted () { + return this.started + } + + /** + * Start Relay service + */ + async start () { + if (this.started) { + return + } + + // Advertise service if HOP enabled and advertising enabled + this.advertService?.start() + + await this.registrar.handle(RELAY_V2_HOP_CODEC, (data) => { + void this.onHop(data).catch(err => { + log.error(err) + }) + }, { + maxInboundStreams: this.maxInboundHopStreams, + maxOutboundStreams: this.maxOutboundHopStreams + }) + + this.reservationStore.start() + + this.started = true + } + + /** + * Stop Relay service + */ + async stop () { + this.advertService?.stop() + this.reservationStore.stop() + this.shutdownController.abort() + await this.registrar.unhandle(RELAY_V2_HOP_CODEC) + + this.started = false + } + + async onHop ({ connection, stream }: IncomingStreamData) { + log('received circuit v2 hop protocol stream from %s', connection.remotePeer) + + const hopTimeoutPromise = pDefer() + const timeout = setTimeout(() => { + hopTimeoutPromise.reject('timed out') + }, this.hopTimeout) + const pbstr = pbStream(stream) + + try { + const request: HopMessage = await Promise.race([ + pbstr.pb(HopMessage).read(), + hopTimeoutPromise.promise + ]) + + if (request?.type == null) { + throw new Error('request was invalid, could not read from stream') + } + + log('received', request.type) + + await Promise.race([ + this.handleHopProtocol({ + connection, + stream: pbstr, + request + }), + hopTimeoutPromise.promise + ]) + } catch (err: any) { + log.error('error while handling hop', err) + pbstr.pb(HopMessage).write({ + type: HopMessage.Type.STATUS, + status: Status.MALFORMED_MESSAGE + }) + stream.abort(err) + } finally { + clearTimeout(timeout) + } + } + + async handleHopProtocol ({ stream, request, connection }: HopProtocolOptions): Promise { + log('received hop message') + switch (request.type) { + case HopMessage.Type.RESERVE: await this.handleReserve({ stream, request, connection }); break + case HopMessage.Type.CONNECT: await this.handleConnect({ stream, request, connection }); break + default: { + log.error('invalid hop request type %s via peer %s', request.type, connection.remotePeer) + stream.pb(HopMessage).write({ type: HopMessage.Type.STATUS, status: Status.UNEXPECTED_MESSAGE }) + } + } + } + + async handleReserve ({ stream, request, connection }: HopProtocolOptions): Promise { + const hopstr = stream.pb(HopMessage) + log('hop reserve request from %s', connection.remotePeer) + + if (isRelayAddr(connection.remoteAddr)) { + log.error('relay reservation over circuit connection denied for peer: %p', connection.remotePeer) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.PERMISSION_DENIED }) + return + } + + if ((await this.acl?.allowReserve?.(connection.remotePeer, connection.remoteAddr)) === false) { + log.error('acl denied reservation to %s', connection.remotePeer) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.PERMISSION_DENIED }) + return + } + + const result = await this.reservationStore.reserve(connection.remotePeer, connection.remoteAddr) + + if (result.status !== Status.OK) { + hopstr.write({ type: HopMessage.Type.STATUS, status: result.status }) + return + } + + try { + // tag relay target peer + // result.expire is non-null if `ReservationStore.reserve` returns with status == OK + if (result.expire != null) { + const ttl = new Date().getTime() - result.expire + await this.peerStore.tagPeer(connection.remotePeer, RELAY_SOURCE_TAG, { value: 1, ttl }) + } + + hopstr.write({ + type: HopMessage.Type.STATUS, + status: Status.OK, + reservation: await this.makeReservation(connection.remotePeer, BigInt(result.expire ?? 0)), + limit: (await this.reservationStore.get(connection.remotePeer))?.limit + }) + log('sent confirmation response to %s', connection.remotePeer) + } catch (err) { + log.error('failed to send confirmation response to %p', connection.remotePeer, err) + await this.reservationStore.removeReservation(connection.remotePeer) + } + } + + async makeReservation ( + remotePeer: PeerId, + expire: bigint + ): Promise { + const addrs = [] + + for (const relayAddr of this.addressManager.getAddresses()) { + addrs.push(relayAddr.bytes) + } + + const voucher = await RecordEnvelope.seal(new ReservationVoucherRecord({ + peer: remotePeer, + relay: this.peerId, + expiration: Number(expire) + }), this.peerId) + + return { + addrs, + expire, + voucher: voucher.marshal() + } + } + + async handleConnect ({ stream, request, connection }: HopProtocolOptions): Promise { + const hopstr = stream.pb(HopMessage) + + if (isRelayAddr(connection.remoteAddr)) { + log.error('relay reservation over circuit connection denied for peer: %p', connection.remotePeer) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.PERMISSION_DENIED }) + return + } + + log('hop connect request from %s', connection.remotePeer) + + let dstPeer: PeerId + + try { + if (request.peer == null) { + log.error('no peer info in hop connect request') + throw new Error('no peer info in request') + } + + request.peer.addrs.forEach(multiaddr) + dstPeer = peerIdFromBytes(request.peer.id) + } catch (err) { + log.error('invalid hop connect request via peer %p %s', connection.remotePeer, err) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.MALFORMED_MESSAGE }) + return + } + + if (isRelayAddr(connection.remoteAddr)) { + log.error('hop connect request over circuit connection denied for peer: %p', connection.remotePeer) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.PERMISSION_DENIED }) + return + } + + if (!await this.reservationStore.hasReservation(dstPeer)) { + log.error('hop connect denied for destination peer %p not having a reservation for %p with status %s', dstPeer, connection.remotePeer, Status.NO_RESERVATION) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.NO_RESERVATION }) + return + } + + if (this.acl?.allowConnect !== undefined) { + const aclResult = await this.acl.allowConnect(connection.remotePeer, connection.remoteAddr, dstPeer) + + if (aclResult !== AclStatus.OK) { + log.error('hop connect denied by acl for %p with status %s', connection.remotePeer, aclResult) + + let status = Status.PERMISSION_DENIED + + if (aclResult === AclStatus.RESOURCE_LIMIT_EXCEEDED) { + status = Status.RESOURCE_LIMIT_EXCEEDED + } + + hopstr.write({ type: HopMessage.Type.STATUS, status: status }) + return + } + } + + const connections = this.connectionManager.getConnections(dstPeer) + + if (connections.length === 0) { + log('hop connect denied for destination peer %p not having a connection for %p as there is no destination connection', dstPeer, connection.remotePeer) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.NO_RESERVATION }) + return + } + + const destinationConnection = connections[0] + log('hop connect request from %s to %s is valid', connection.remotePeer, dstPeer) + + const destinationStream = await this.stopHop({ + connection: destinationConnection, + request: { + type: StopMessage.Type.CONNECT, + peer: { + id: connection.remotePeer.toBytes(), + addrs: [multiaddr(`/p2p/${connection.remotePeer.toString()}`).bytes] + } + } + }) + + if (destinationStream == null) { + log.error('failed to open stream to destination peer %s', destinationConnection?.remotePeer) + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.CONNECTION_FAILED }) + return + } + + hopstr.write({ type: HopMessage.Type.STATUS, status: Status.OK }) + const sourceStream = stream.unwrap() + + log('connection to destination established - joining streams together') + const limit = (await this.reservationStore.get(dstPeer))?.limit + // Short circuit the two streams to create the relayed connection + createLimitedRelay(sourceStream, destinationStream, this.shutdownController.signal, limit) + } + + /** + * Send a STOP request to the target peer that the dialing peer wants to contact + */ + async stopHop ({ + connection, + request + }: StopOptions): Promise { + log('starting circuit relay v2 stop request to %s', connection.remotePeer) + const stream = await connection.newStream([RELAY_V2_STOP_CODEC]) + const pbstr = pbStream(stream) + const stopstr = pbstr.pb(StopMessage) + stopstr.write(request) + let response + + try { + response = await stopstr.read() + } catch (err) { + log.error('error parsing stop message response from %s', connection.remotePeer) + } + + if (response == null) { + log.error('could not read response from %s', connection.remotePeer) + stream.close() + return + } + + if (response.status === Status.OK) { + log('stop request to %s was successful', connection.remotePeer) + return pbstr.unwrap() + } + + log('stop request failed with code %d', response.status) + stream.close() + } + + get reservations () { + return this.reservationStore.reservations + } +} + +export function circuitRelayServer (init: CircuitRelayServerInit = {}): (components: CircuitRelayServerComponents) => CircuitRelayService { + return (components) => { + return new CircuitRelayServer(components, init) + } +} diff --git a/src/circuit/reservation-store.ts b/src/circuit/server/reservation-store.ts similarity index 51% rename from src/circuit/reservation-store.ts rename to src/circuit/server/reservation-store.ts index b48cbff8b9..6c6bcba80d 100644 --- a/src/circuit/reservation-store.ts +++ b/src/circuit/server/reservation-store.ts @@ -1,29 +1,31 @@ -import { Limit, Status } from './pb/index.js' -import type { ReservationStore as IReservationStore, ReservationStatus, Reservation } from './interfaces.js' +import { Limit, Status } from '../pb/index.js' import type { Multiaddr } from '@multiformats/multiaddr' import type { PeerId } from '@libp2p/interface-peer-id' import type { Startable } from '@libp2p/interfaces/startable' import { PeerMap } from '@libp2p/peer-collections' import type { RecursivePartial } from '@libp2p/interfaces' -import { DEFAULT_DATA_LIMIT, DEFAULT_DURATION_LIMIT } from './constants.js' +import { DEFAULT_DATA_LIMIT, DEFAULT_DURATION_LIMIT, DEFAULT_MAX_RESERVATION_CLEAR_INTERVAL, DEFAULT_MAX_RESERVATION_STORE_SIZE, DEFAULT_MAX_RESERVATION_TTL } from '../constants.js' +import type { RelayReservation } from '../index.js' + +export type ReservationStatus = Status.OK | Status.PERMISSION_DENIED | Status.RESERVATION_REFUSED export interface ReservationStoreInit { /* * maximum number of reservations allowed, default: 15 */ - maxReservations: number + maxReservations?: number /* * interval after which stale reservations are cleared, default: 300s */ - reservationClearInterval: number + reservationClearInterval?: number /* * apply default relay limits to a new reservation, default: true */ - applyDefaultLimit: boolean + applyDefaultLimit?: boolean /** * reservation ttl, default: 2 hours */ - reservationTtl: number + reservationTtl?: number /** * The maximum time a relayed connection can be open for */ @@ -36,21 +38,24 @@ export interface ReservationStoreInit { export type ReservationStoreOptions = RecursivePartial -export class ReservationStore implements IReservationStore, Startable { - private readonly reservations = new PeerMap() - private _started = false; +export class ReservationStore implements Startable { + public readonly reservations = new PeerMap() + private _started = false private interval: any - private readonly init: ReservationStoreInit + private readonly maxReservations: number + private readonly reservationClearInterval: number + private readonly applyDefaultLimit: boolean + private readonly reservationTtl: number + private readonly defaultDurationLimit: number + private readonly defaultDataLimit: bigint - constructor (options?: ReservationStoreOptions) { - this.init = { - maxReservations: options?.maxReservations ?? 15, - reservationClearInterval: options?.reservationClearInterval ?? 300 * 1000, - applyDefaultLimit: options?.applyDefaultLimit !== false, - reservationTtl: options?.reservationTtl ?? 2 * 60 * 60 * 1000, - defaultDurationLimit: options?.defaultDurationLimit ?? DEFAULT_DURATION_LIMIT, - defaultDataLimit: options?.defaultDataLimit ?? DEFAULT_DATA_LIMIT - } + constructor (options: ReservationStoreOptions = {}) { + this.maxReservations = options.maxReservations ?? DEFAULT_MAX_RESERVATION_STORE_SIZE + this.reservationClearInterval = options.reservationClearInterval ?? DEFAULT_MAX_RESERVATION_CLEAR_INTERVAL + this.applyDefaultLimit = options.applyDefaultLimit !== false + this.reservationTtl = options.reservationTtl ?? DEFAULT_MAX_RESERVATION_TTL + this.defaultDurationLimit = options.defaultDurationLimit ?? DEFAULT_DURATION_LIMIT + this.defaultDataLimit = options.defaultDataLimit ?? DEFAULT_DATA_LIMIT } isStarted () { @@ -71,7 +76,7 @@ export class ReservationStore implements IReservationStore, Startable { } }) }, - this.init.reservationClearInterval + this.reservationClearInterval ) } @@ -80,13 +85,13 @@ export class ReservationStore implements IReservationStore, Startable { } reserve (peer: PeerId, addr: Multiaddr, limit?: Limit): { status: ReservationStatus, expire?: number } { - if (this.reservations.size >= this.init.maxReservations && !this.reservations.has(peer)) { + if (this.reservations.size >= this.maxReservations && !this.reservations.has(peer)) { return { status: Status.RESERVATION_REFUSED } } - const expire = new Date(Date.now() + this.init.reservationTtl) + const expire = new Date(Date.now() + this.reservationTtl) let checkedLimit: Limit | undefined - if (this.init.applyDefaultLimit) { - checkedLimit = limit ?? { data: this.init.defaultDataLimit, duration: this.init.defaultDurationLimit } + if (this.applyDefaultLimit) { + checkedLimit = limit ?? { data: this.defaultDataLimit, duration: this.defaultDurationLimit } } this.reservations.set(peer, { addr, expire, limit: checkedLimit }) return { status: Status.OK, expire: expire.getTime() } @@ -100,7 +105,7 @@ export class ReservationStore implements IReservationStore, Startable { return this.reservations.has(dst) } - get (peer: PeerId): Reservation | undefined { + get (peer: PeerId): RelayReservation | undefined { return this.reservations.get(peer) } } diff --git a/src/circuit/reservation-voucher.ts b/src/circuit/server/reservation-voucher.ts similarity index 95% rename from src/circuit/reservation-voucher.ts rename to src/circuit/server/reservation-voucher.ts index 9979ae0692..1062865dc9 100644 --- a/src/circuit/reservation-voucher.ts +++ b/src/circuit/server/reservation-voucher.ts @@ -1,6 +1,6 @@ import type { PeerId } from '@libp2p/interface-peer-id' import type { Record } from '@libp2p/interface-record' -import { ReservationVoucher } from './pb/index.js' +import { ReservationVoucher } from '../pb/index.js' export interface ReservationVoucherOptions { relay: PeerId diff --git a/src/circuit/stop.ts b/src/circuit/stop.ts deleted file mode 100644 index e650fe6683..0000000000 --- a/src/circuit/stop.ts +++ /dev/null @@ -1,92 +0,0 @@ - -import { Status, StopMessage } from './pb/index.js' -import type { Connection, Stream } from '@libp2p/interface-connection' - -import { logger } from '@libp2p/logger' -import { RELAY_V2_STOP_CODEC } from './multicodec.js' -import { multiaddr } from '@multiformats/multiaddr' -import { pbStream, ProtobufStream } from 'it-pb-stream' - -const log = logger('libp2p:circuit:v2:stop') - -export interface HandleStopOptions { - connection: Connection - request: StopMessage - pbstr: ProtobufStream -} - -const isValidStop = (request: StopMessage): boolean => { - if (request.peer == null) { - return false - } - try { - request.peer.addrs.forEach(multiaddr) - } catch (_err) { - return false - } - return true -} -export async function handleStop ({ - connection, - request, - pbstr -}: HandleStopOptions) { - const stopstr = pbstr.pb(StopMessage) - log('new circuit relay v2 stop stream from %s', connection.remotePeer) - // Validate the STOP request has the required input - if (request.type !== StopMessage.Type.CONNECT) { - log.error('invalid stop connect request via peer %s', connection.remotePeer) - stopstr.write({ type: StopMessage.Type.STATUS, status: Status.UNEXPECTED_MESSAGE }) - return - } - if (!isValidStop(request)) { - log.error('invalid stop connect request via peer %s', connection.remotePeer) - stopstr.write({ type: StopMessage.Type.STATUS, status: Status.MALFORMED_MESSAGE }) - return - } - - // TODO: go-libp2p marks connection transient if there is limit field present in request. - // Cannot find any reference to transient connections in js-libp2p - - stopstr.write({ type: StopMessage.Type.STATUS, status: Status.OK }) - return pbstr.unwrap() -} - -export interface StopOptions { - connection: Connection - request: StopMessage -} - -/** - * Creates a STOP request - * - */ -export async function stop ({ - connection, - request -}: StopOptions): Promise { - const stream = await connection.newStream([RELAY_V2_STOP_CODEC]) - log('starting circuit relay v2 stop request to %s', connection.remotePeer) - const pbstr = pbStream(stream) - const stopstr = pbstr.pb(StopMessage) - stopstr.write(request) - let response - try { - response = await stopstr.read() - } catch (err) { - log.error('error parsing stop message response from %s', connection.remotePeer) - } - - if (response == null) { - log.error('could not read response from %s', connection.remotePeer) - stream.close() - return - } - if (response.status === Status.OK) { - log('stop request to %s was successful', connection.remotePeer) - return pbstr.unwrap() - } - - log('stop request failed with code %d', response.status) - stream.close() -} diff --git a/src/circuit/transport.ts b/src/circuit/transport.ts deleted file mode 100644 index f1957dd855..0000000000 --- a/src/circuit/transport.ts +++ /dev/null @@ -1,313 +0,0 @@ -import * as CircuitV2 from './pb/index.js' -import { ReservationStore } from './reservation-store.js' -import { logger } from '@libp2p/logger' -import createError from 'err-code' -import * as mafmt from '@multiformats/mafmt' -import { multiaddr } from '@multiformats/multiaddr' -import { codes } from '../errors.js' -import { streamToMaConnection } from '@libp2p/utils/stream-to-ma-conn' -import { RELAY_V2_HOP_CODEC, RELAY_V2_STOP_CODEC } from './multicodec.js' -import { createListener } from './listener.js' -import { symbol, TransportManager, Upgrader } from '@libp2p/interface-transport' -import { peerIdFromString } from '@libp2p/peer-id' -import type { AbortOptions } from '@libp2p/interfaces' -import type { IncomingStreamData, Registrar } from '@libp2p/interface-registrar' -import type { Listener, Transport, CreateListenerOptions, ConnectionHandler } from '@libp2p/interface-transport' -import type { Connection, Stream } from '@libp2p/interface-connection' -import type { RelayConfig } from './index.js' -import type { PeerId } from '@libp2p/interface-peer-id' -import { handleHopProtocol } from './hop.js' -import { handleStop } from './stop.js' -import type { Multiaddr } from '@multiformats/multiaddr' -import type { PeerStore } from '@libp2p/interface-peer-store' -import type { Startable } from '@libp2p/interfaces/dist/src/startable' -import type { ConnectionManager } from '@libp2p/interface-connection-manager' -import type { AddressManager } from '@libp2p/interface-address-manager' -import { pbStream } from 'it-pb-stream' -import pDefer from 'p-defer' - -const log = logger('libp2p:circuit') - -export interface CircuitOptions { - limit?: number -} - -export interface CircuitComponents { - peerId: PeerId - peerStore: PeerStore - registrar: Registrar - connectionManager: ConnectionManager - upgrader: Upgrader - addressManager: AddressManager - transportManager: TransportManager -} - -interface ConnectOptions { - stream: Stream - connection: Connection - destinationPeer: PeerId - destinationAddr: Multiaddr - relayAddr: Multiaddr - ma: Multiaddr - disconnectOnFailure: boolean -} - -export class Circuit implements Transport, Startable { - private handler?: ConnectionHandler - private readonly components: CircuitComponents - private readonly reservationStore: ReservationStore - private readonly _init: RelayConfig - private _started: boolean - - constructor (components: CircuitComponents, options: RelayConfig) { - this.components = components - this._init = options - this.reservationStore = new ReservationStore({ - defaultDataLimit: options.hop?.limit?.data, - defaultDurationLimit: options.hop?.limit?.duration - }) - this._started = false - } - - isStarted () { - return this._started - } - - async start (): Promise { - if (this._started) { - return - } - - this._started = true - - // only handle hop if enabled - if (this._init.hop.enabled === true) { - void this.components.registrar.handle(RELAY_V2_HOP_CODEC, (data) => { - void this.onHop(data).catch(err => { - log.error(err) - }) - }) - .catch(err => { - log.error(err) - }) - } - - void this.components.registrar.handle(RELAY_V2_STOP_CODEC, (data) => { - void this.onStop(data).catch(err => { - log.error(err) - }) - }) - .catch(err => { - log.error(err) - }) - - if (this._init.hop.enabled === true) { - void this.reservationStore.start() - } - } - - async stop () { - if (this._init.hop.enabled === true) { - this.reservationStore.stop() - await this.components.registrar.unhandle(RELAY_V2_HOP_CODEC) - } - await this.components.registrar.unhandle(RELAY_V2_STOP_CODEC) - } - - get [symbol] (): true { - return true - } - - get [Symbol.toStringTag] () { - return 'libp2p/circuit-relay-v2' - } - - async onHop ({ connection, stream }: IncomingStreamData) { - log('received circuit v2 hop protocol stream from %s', connection.remotePeer) - - const hopTimeoutPromise = pDefer() - const timeout = setTimeout(() => { - hopTimeoutPromise.reject('timed out') - }, this._init.hop.timeout) - const pbstr = pbStream(stream) - - try { - const request: CircuitV2.HopMessage = await Promise.race([ - pbstr.pb(CircuitV2.HopMessage).read(), - hopTimeoutPromise.promise as any - ]) - - if (request?.type == null) { - throw new Error('request was invalid, could not read from stream') - } - - await Promise.race([ - handleHopProtocol({ - connection, - stream: pbstr, - connectionManager: this.components.connectionManager, - relayPeer: this.components.peerId, - relayAddrs: this.components.addressManager.getListenAddrs(), - reservationStore: this.reservationStore, - peerStore: this.components.peerStore, - request - }), - hopTimeoutPromise.promise - ]) - } catch (_err) { - pbstr.pb(CircuitV2.HopMessage).write({ - type: CircuitV2.HopMessage.Type.STATUS, - status: CircuitV2.Status.MALFORMED_MESSAGE - }) - stream.abort(_err as Error) - } finally { - clearTimeout(timeout) - } - } - - async onStop ({ connection, stream }: IncomingStreamData) { - const pbstr = pbStream(stream) - const request = await pbstr.readPB(CircuitV2.StopMessage) - log('received circuit v2 stop protocol request from %s', connection.remotePeer) - if (request?.type === undefined) { - return - } - - const mStream = await handleStop({ - connection, - pbstr, - request - }) - - if (mStream != null) { - const remoteAddr = multiaddr(request.peer?.addrs?.[0]) - const localAddr = this.components.transportManager.getAddrs()[0] - const maConn = streamToMaConnection({ - stream: mStream as any, - remoteAddr, - localAddr - }) - log('new inbound connection %s', maConn.remoteAddr) - const conn = await this.components.upgrader.upgradeInbound(maConn) - log('%s connection %s upgraded', 'inbound', maConn.remoteAddr) - this.handler?.(conn) - } - } - - /** - * Dial a peer over a relay - */ - async dial (ma: Multiaddr, options: AbortOptions = {}): Promise { - // Check the multiaddr to see if it contains a relay and a destination peer - const addrs = ma.toString().split('/p2p-circuit') - const relayAddr = multiaddr(addrs[0]) - const destinationAddr = multiaddr(addrs[addrs.length - 1]) - const relayId = relayAddr.getPeerId() - const destinationId = destinationAddr.getPeerId() - - if (relayId == null || destinationId == null) { - const errMsg = 'Circuit relay dial failed as addresses did not have peer id' - log.error(errMsg) - throw createError(new Error(errMsg), codes.ERR_RELAYED_DIAL) - } - - const relayPeer = peerIdFromString(relayId) - const destinationPeer = peerIdFromString(destinationId) - - let disconnectOnFailure = false - const relayConnections = this.components.connectionManager.getConnections(relayPeer) - let relayConnection = relayConnections[0] - - if (relayConnection == null) { - await this.components.peerStore.addressBook.add(relayPeer, [relayAddr]) - relayConnection = await this.components.connectionManager.openConnection(relayPeer, options) - disconnectOnFailure = true - } - - try { - const stream = await relayConnection.newStream([RELAY_V2_HOP_CODEC]) - return await this.connectV2({ - stream, - connection: relayConnection, - destinationPeer, - destinationAddr, - relayAddr, - ma, - disconnectOnFailure - }) - } catch (err: any) { - log.error('Circuit relay dial failed', err) - disconnectOnFailure && await relayConnection.close() - throw err - } - } - - async connectV2 ( - { - stream, connection, destinationPeer, - destinationAddr, relayAddr, ma, - disconnectOnFailure - }: ConnectOptions - ) { - try { - const pbstr = pbStream(stream) - const hopstr = pbstr.pb(CircuitV2.HopMessage) - hopstr.write({ - type: CircuitV2.HopMessage.Type.CONNECT, - peer: { - id: destinationPeer.toBytes(), - addrs: [multiaddr(destinationAddr).bytes] - } - }) - - const status = await hopstr.read() - if (status.status !== CircuitV2.Status.OK) { - throw createError(new Error(`failed to connect via relay with status ${status?.status?.toString() ?? 'undefined'}`), codes.ERR_HOP_REQUEST_FAILED) - } - - // TODO: do something with limit and transient connection - - let localAddr = relayAddr - localAddr = localAddr.encapsulate(`/p2p-circuit/p2p/${this.components.peerId.toString()}`) - const maConn = streamToMaConnection({ - stream: pbstr.unwrap(), - remoteAddr: ma, - localAddr - }) - log('new outbound connection %s', maConn.remoteAddr) - const conn = await this.components.upgrader.upgradeOutbound(maConn) - return conn - } catch (err) { - log.error('Circuit relay dial failed', err) - disconnectOnFailure && await connection.close() - throw err - } - } - - /** - * Create a listener - */ - createListener (options: CreateListenerOptions): Listener { - // Called on successful HOP and STOP requests - this.handler = options.handler - - return createListener({ - connectionManager: this.components.connectionManager, - peerStore: this.components.peerStore - }) - } - - /** - * Filter check for all Multiaddrs that this transport can dial on - * - * @param {Multiaddr[]} multiaddrs - * @returns {Multiaddr[]} - */ - filter (multiaddrs: Multiaddr[]): Multiaddr[] { - multiaddrs = Array.isArray(multiaddrs) ? multiaddrs : [multiaddrs] - - return multiaddrs.filter((ma) => { - return mafmt.Circuit.matches(ma) - }) - } -} diff --git a/src/circuit/transport/discovery.ts b/src/circuit/transport/discovery.ts new file mode 100644 index 0000000000..37c635f8c1 --- /dev/null +++ b/src/circuit/transport/discovery.ts @@ -0,0 +1,127 @@ +import { logger } from '@libp2p/logger' +import { namespaceToCid } from '../utils.js' +import { + RELAY_RENDEZVOUS_NS + , RELAY_V2_HOP_CODEC +} from '../constants.js' +import type { PeerId } from '@libp2p/interface-peer-id' +import type { PeerStore } from '@libp2p/interface-peer-store' +import { EventEmitter } from '@libp2p/interfaces/events' +import type { Startable } from '@libp2p/interfaces/startable' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' +import type { TransportManager } from '@libp2p/interface-transport' +import type { ContentRouting } from '@libp2p/interface-content-routing' +import type { Registrar } from '@libp2p/interface-registrar' +import { createTopology } from '@libp2p/topology' + +const log = logger('libp2p:circuit-relay:discover-relays') + +export interface RelayDiscoveryEvents { + 'relay:discover': CustomEvent +} + +export interface RelayDiscoveryComponents { + peerId: PeerId + peerStore: PeerStore + connectionManager: ConnectionManager + transportManager: TransportManager + contentRouting: ContentRouting + registrar: Registrar +} + +/** + * ReservationManager automatically makes a circuit v2 reservation on any connected + * peers that support the circuit v2 HOP protocol. + */ +export class RelayDiscovery extends EventEmitter implements Startable { + private readonly peerId: PeerId + private readonly peerStore: PeerStore + private readonly contentRouting: ContentRouting + private readonly registrar: Registrar + private started: boolean + private topologyId?: string + + constructor (components: RelayDiscoveryComponents) { + super() + this.started = false + this.peerId = components.peerId + this.peerStore = components.peerStore + this.contentRouting = components.contentRouting + this.registrar = components.registrar + } + + isStarted () { + return this.started + } + + async start () { + // register a topology listener for when new peers are encountered + // that support the hop protocol + this.topologyId = await this.registrar.register(RELAY_V2_HOP_CODEC, createTopology({ + onConnect: (peerId) => { + this.safeDispatchEvent('relay:discover', { detail: peerId }) + } + })) + + void this.discover() + .catch(err => { + log.error('error listening on relays', err) + }) + + this.started = true + } + + stop () { + if (this.topologyId != null) { + this.registrar.unregister(this.topologyId) + } + + this.started = false + } + + /** + * Try to listen on available hop relay connections. + * The following order will happen while we do not have enough relays: + * + * 1. Check the metadata store for known relays, try to listen on the ones we are already connected + * 2. Dial and try to listen on the peers we know that support hop but are not connected + * 3. Search the network + */ + async discover () { + log('searching peer store for relays') + const peers = (await this.peerStore.all()) + // filter by a list of peers supporting RELAY_V2_HOP and ones we are not listening on + .filter(({ id, protocols }) => protocols.includes(RELAY_V2_HOP_CODEC)) + .sort(() => Math.random() - 0.5) + + for (const peer of peers) { + log('found relay peer %p in content peer store', peer.id) + this.safeDispatchEvent('relay:discover', { detail: peer.id }) + } + + log('found %d relay peers in peer store', peers.length) + + try { + log('searching content routing for relays') + const cid = await namespaceToCid(RELAY_RENDEZVOUS_NS) + + let found = 0 + + for await (const provider of this.contentRouting.findProviders(cid)) { + if (provider.multiaddrs.length > 0 && !provider.id.equals(this.peerId)) { + const peerId = provider.id + + found++ + log('found relay peer %p in content routing', peerId) + await this.peerStore.addressBook.add(peerId, provider.multiaddrs) + + this.safeDispatchEvent('relay:discover', { detail: peerId }) + } + } + + log('found %d relay peers in content routing', found) + } catch (err: any) { + log.error('failed when finding relays on the network', err) + } + } +} diff --git a/src/circuit/transport/index.ts b/src/circuit/transport/index.ts new file mode 100644 index 0000000000..d69320c8ac --- /dev/null +++ b/src/circuit/transport/index.ts @@ -0,0 +1,315 @@ +import { StopMessage, HopMessage, Status } from '../pb/index.js' +import { logger } from '@libp2p/logger' +import createError from 'err-code' +import * as mafmt from '@multiformats/mafmt' +import { multiaddr } from '@multiformats/multiaddr' +import { codes } from '../../errors.js' +import { streamToMaConnection } from '@libp2p/utils/stream-to-ma-conn' +import { createListener } from './listener.js' +import { symbol, Upgrader } from '@libp2p/interface-transport' +import { peerIdFromBytes, peerIdFromString } from '@libp2p/peer-id' +import type { AbortOptions } from '@libp2p/interfaces' +import type { IncomingStreamData, Registrar } from '@libp2p/interface-registrar' +import type { Listener, Transport, CreateListenerOptions } from '@libp2p/interface-transport' +import type { Connection, Stream } from '@libp2p/interface-connection' +import type { PeerId } from '@libp2p/interface-peer-id' +import type { Multiaddr } from '@multiformats/multiaddr' +import type { PeerStore } from '@libp2p/interface-peer-store' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' +import type { AddressManager } from '@libp2p/interface-address-manager' +import { pbStream } from 'it-pb-stream' +import type { ContentRouting } from '@libp2p/interface-content-routing' +import { CIRCUIT_PROTO_CODE, RELAY_V2_HOP_CODEC, RELAY_V2_STOP_CODEC } from '../constants.js' +import { RelayStoreInit, ReservationStore } from './reservation-store.js' +import { RelayDiscovery, RelayDiscoveryComponents } from './discovery.js' + +const log = logger('libp2p:circuit-relay:transport') + +const isValidStop = (request: StopMessage): request is Required => { + if (request.peer == null) { + return false + } + + try { + request.peer.addrs.forEach(multiaddr) + } catch { + return false + } + + return true +} + +export interface CircuitRelayTransportComponents extends RelayDiscoveryComponents { + peerId: PeerId + peerStore: PeerStore + registrar: Registrar + connectionManager: ConnectionManager + upgrader: Upgrader + addressManager: AddressManager + contentRouting: ContentRouting +} + +interface ConnectOptions { + stream: Stream + connection: Connection + destinationPeer: PeerId + destinationAddr: Multiaddr + relayAddr: Multiaddr + ma: Multiaddr + disconnectOnFailure: boolean +} + +/** + * RelayConfig configures the circuit v2 relay transport. + */ +export interface CircuitRelayTransportInit extends RelayStoreInit { + /** + * The number of peers running diable relays to search for and + * connect to. (default: 0) + */ + discoverRelays?: number +} + +class CircuitRelayTransport implements Transport { + private readonly discovery?: RelayDiscovery + private readonly registrar: Registrar + private readonly peerStore: PeerStore + private readonly connectionManager: ConnectionManager + private readonly peerId: PeerId + private readonly upgrader: Upgrader + private readonly addressManager: AddressManager + private readonly reservationStore: ReservationStore + private started: boolean + + constructor (components: CircuitRelayTransportComponents, init: CircuitRelayTransportInit) { + this.registrar = components.registrar + this.peerStore = components.peerStore + this.connectionManager = components.connectionManager + this.peerId = components.peerId + this.upgrader = components.upgrader + this.addressManager = components.addressManager + + if (init.discoverRelays != null && init.discoverRelays > 0) { + this.discovery = new RelayDiscovery(components) + this.discovery.addEventListener('relay:discover', (evt) => { + this.reservationStore.addRelay(evt.detail, 'discovered') + .catch(err => { + log.error('could not add discovered relay %p', evt.detail, err) + }) + }) + } + + this.reservationStore = new ReservationStore(components, init) + this.reservationStore.addEventListener('relay:not-enough-relays', () => { + this.discovery?.discover() + .catch(err => { + log.error('could not discover relays', err) + }) + }) + + this.started = false + } + + isStarted () { + return this.started + } + + async start () { + await this.reservationStore.start() + await this.discovery?.start() + + await this.registrar.handle(RELAY_V2_STOP_CODEC, (data) => { + void this.onStop(data).catch(err => { + log.error(err) + }) + }) + + this.started = true + } + + async stop () { + this.discovery?.stop() + await this.reservationStore.stop() + await this.registrar.unhandle(RELAY_V2_STOP_CODEC) + + this.started = false + } + + get [symbol] (): true { + return true + } + + get [Symbol.toStringTag] () { + return 'libp2p/circuit-relay-v2' + } + + /** + * Dial a peer over a relay + */ + async dial (ma: Multiaddr, options: AbortOptions = {}): Promise { + if (ma.protoCodes().filter(code => code === CIRCUIT_PROTO_CODE).length !== 1) { + const errMsg = 'Invalid circuit relay address' + log.error(errMsg, ma) + throw createError(new Error(errMsg), codes.ERR_RELAYED_DIAL) + } + + // Check the multiaddr to see if it contains a relay and a destination peer + const addrs = ma.toString().split('/p2p-circuit') + const relayAddr = multiaddr(addrs[0]) + const destinationAddr = multiaddr(addrs[addrs.length - 1]) + const relayId = relayAddr.getPeerId() + const destinationId = destinationAddr.getPeerId() + + if (relayId == null || destinationId == null) { + const errMsg = 'Circuit relay dial failed as addresses did not have peer id' + log.error(errMsg) + throw createError(new Error(errMsg), codes.ERR_RELAYED_DIAL) + } + + const relayPeer = peerIdFromString(relayId) + const destinationPeer = peerIdFromString(destinationId) + + let disconnectOnFailure = false + const relayConnections = this.connectionManager.getConnections(relayPeer) + let relayConnection = relayConnections[0] + + if (relayConnection == null) { + await this.peerStore.addressBook.add(relayPeer, [relayAddr]) + relayConnection = await this.connectionManager.openConnection(relayPeer, options) + disconnectOnFailure = true + } + + try { + const stream = await relayConnection.newStream([RELAY_V2_HOP_CODEC]) + + return await this.connectV2({ + stream, + connection: relayConnection, + destinationPeer, + destinationAddr, + relayAddr, + ma, + disconnectOnFailure + }) + } catch (err: any) { + log.error('Circuit relay dial failed', err) + disconnectOnFailure && await relayConnection.close() + throw err + } + } + + async connectV2 ( + { + stream, connection, destinationPeer, + destinationAddr, relayAddr, ma, + disconnectOnFailure + }: ConnectOptions + ) { + try { + const pbstr = pbStream(stream) + const hopstr = pbstr.pb(HopMessage) + hopstr.write({ + type: HopMessage.Type.CONNECT, + peer: { + id: destinationPeer.toBytes(), + addrs: [multiaddr(destinationAddr).bytes] + } + }) + + const status = await hopstr.read() + + if (status.status !== Status.OK) { + throw createError(new Error(`failed to connect via relay with status ${status?.status?.toString() ?? 'undefined'}`), codes.ERR_HOP_REQUEST_FAILED) + } + + // TODO: do something with limit and transient connection + + const maConn = streamToMaConnection({ + stream: pbstr.unwrap(), + remoteAddr: ma, + localAddr: relayAddr.encapsulate(`/p2p-circuit/p2p/${this.peerId.toString()}`) + }) + + log('new outbound connection %s', maConn.remoteAddr) + return await this.upgrader.upgradeOutbound(maConn) + } catch (err) { + log.error('Circuit relay dial failed', err) + disconnectOnFailure && await connection.close() + throw err + } + } + + /** + * Create a listener + */ + createListener (options: CreateListenerOptions): Listener { + return createListener({ + connectionManager: this.connectionManager, + relayStore: this.reservationStore + }) + } + + /** + * Filter check for all Multiaddrs that this transport can dial on + * + * @param {Multiaddr[]} multiaddrs + * @returns {Multiaddr[]} + */ + filter (multiaddrs: Multiaddr[]): Multiaddr[] { + multiaddrs = Array.isArray(multiaddrs) ? multiaddrs : [multiaddrs] + + return multiaddrs.filter((ma) => { + return mafmt.Circuit.matches(ma) + }) + } + + /** + * An incoming STOP request means a remote peer wants to dial us via a relay + */ + async onStop ({ connection, stream }: IncomingStreamData) { + const pbstr = pbStream(stream) + const request = await pbstr.readPB(StopMessage) + log('received circuit v2 stop protocol request from %s', connection.remotePeer) + + if (request?.type === undefined) { + return + } + + const stopstr = pbstr.pb(StopMessage) + log('new circuit relay v2 stop stream from %s', connection.remotePeer) + + // Validate the STOP request has the required input + if (request.type !== StopMessage.Type.CONNECT) { + log.error('invalid stop connect request via peer %s', connection.remotePeer) + stopstr.write({ type: StopMessage.Type.STATUS, status: Status.UNEXPECTED_MESSAGE }) + return + } + + if (!isValidStop(request)) { + log.error('invalid stop connect request via peer %s', connection.remotePeer) + stopstr.write({ type: StopMessage.Type.STATUS, status: Status.MALFORMED_MESSAGE }) + return + } + + stopstr.write({ type: StopMessage.Type.STATUS, status: Status.OK }) + + const remotePeerId = peerIdFromBytes(request.peer.id) + const remoteAddr = connection.remoteAddr.encapsulate(`/p2p-circuit/p2p/${remotePeerId.toString()}`) + const localAddr = this.addressManager.getAddresses()[0] + const maConn = streamToMaConnection({ + stream: pbstr.unwrap(), + remoteAddr, + localAddr + }) + + log('new inbound connection %s', maConn.remoteAddr) + await this.upgrader.upgradeInbound(maConn) + log('%s connection %s upgraded', 'inbound', maConn.remoteAddr) + } +} + +export function circuitRelayTransport (init: CircuitRelayTransportInit = {}): (components: CircuitRelayTransportComponents) => Transport { + return (components) => { + return new CircuitRelayTransport(components, init) + } +} diff --git a/src/circuit/transport/listener.ts b/src/circuit/transport/listener.ts new file mode 100644 index 0000000000..f9161e849f --- /dev/null +++ b/src/circuit/transport/listener.ts @@ -0,0 +1,101 @@ +import { CustomEvent, EventEmitter } from '@libp2p/interfaces/events' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' +import type { Listener, ListenerEvents } from '@libp2p/interface-transport' +import type { Multiaddr } from '@multiformats/multiaddr' +import { multiaddr } from '@multiformats/multiaddr' +import type { ReservationStore } from './reservation-store' +import type { PeerId } from '@libp2p/interface-peer-id' +import { PeerMap } from '@libp2p/peer-collections' +import { logger } from '@libp2p/logger' + +const log = logger('libp2p:circuit-relay:transport:listener') + +export interface CircuitRelayTransportListenerComponents { + connectionManager: ConnectionManager + relayStore: ReservationStore +} + +class CircuitRelayTransportListener extends EventEmitter implements Listener { + private readonly connectionManager: ConnectionManager + private readonly relayStore: ReservationStore + private readonly listeningAddrs: PeerMap + + constructor (components: CircuitRelayTransportListenerComponents) { + super() + + this.connectionManager = components.connectionManager + this.relayStore = components.relayStore + this.listeningAddrs = new PeerMap() + + // remove listening addrs when a relay is removed + this.relayStore.addEventListener('relay:removed', (evt) => { + this.#removeRelayPeer(evt.detail) + }) + + // remove listening addrs when a peer disconnects + this.connectionManager.addEventListener('peer:disconnect', (evt) => { + this.#removeRelayPeer(evt.detail.remotePeer) + }) + } + + async listen (addr: Multiaddr): Promise { + log('listen on %s', addr) + + const addrString = addr.toString().split('/p2p-circuit').find(a => a !== '') + const ma = multiaddr(addrString) + const relayConn = await this.connectionManager.openConnection(ma) + + if (!this.relayStore.hasReservation(relayConn.remotePeer)) { + // addRelay calls transportManager.listen which calls this listen method + await this.relayStore.addRelay(relayConn.remotePeer, 'configured') + return + } + + if (this.listeningAddrs.has(relayConn.remotePeer)) { + log('already listening on relay %p', relayConn.remotePeer) + return + } + + this.listeningAddrs.set(relayConn.remotePeer, addr) + this.dispatchEvent(new CustomEvent('listening')) + } + + /** + * Get fixed up multiaddrs + * + * NOTE: This method will grab the peers multiaddrs and expand them such that: + * + * a) If it's an existing /p2p-circuit address for a specific relay i.e. + * `/ip4/0.0.0.0/tcp/0/ipfs/QmRelay/p2p-circuit` this method will expand the + * address to `/ip4/0.0.0.0/tcp/0/ipfs/QmRelay/p2p-circuit/ipfs/QmPeer` where + * `QmPeer` is this peers id + * b) If it's not a /p2p-circuit address, it will encapsulate the address as a /p2p-circuit + * addr, such when dialing over a relay with this address, it will create the circuit using + * the encapsulated transport address. This is useful when for example, a peer should only + * be dialed over TCP rather than any other transport + * + * @returns {Multiaddr[]} + */ + getAddrs () { + return [...this.listeningAddrs.values()] + } + + async close () { + + } + + #removeRelayPeer (peerId: PeerId) { + const had = this.listeningAddrs.has(peerId) + + this.listeningAddrs.delete(peerId) + + if (had) { + // Announce listen addresses change + this.dispatchEvent(new CustomEvent('close')) + } + } +} + +export function createListener (options: CircuitRelayTransportListenerComponents): Listener { + return new CircuitRelayTransportListener(options) +} diff --git a/src/circuit/transport/reservation-store.ts b/src/circuit/transport/reservation-store.ts new file mode 100644 index 0000000000..c3a5d1e01f --- /dev/null +++ b/src/circuit/transport/reservation-store.ts @@ -0,0 +1,249 @@ +import type { PeerId } from '@libp2p/interface-peer-id' +import { PeerMap } from '@libp2p/peer-collections' +import PQueue from 'p-queue' +import { DEFAULT_RESERVATION_CONCURRENCY, RELAY_TAG, RELAY_V2_HOP_CODEC } from '../constants.js' +import { logger } from '@libp2p/logger' +import { pbStream } from 'it-pb-stream' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' +import type { Connection } from '@libp2p/interface-connection' +import type { Reservation } from '../pb/index.js' +import { HopMessage, Status } from '../pb/index.js' +import { getExpiration } from '../utils.js' +import type { TransportManager } from '@libp2p/interface-transport' +import type { Startable } from '@libp2p/interfaces/dist/src/startable.js' +import { CustomEvent, EventEmitter } from '@libp2p/interfaces/events' +import type { PeerStore } from '@libp2p/interface-peer-store' +import { multiaddr } from '@multiformats/multiaddr' + +const log = logger('libp2p:circuit-relay:transport:reservation-store') + +// allow refreshing a relay reservation if it will expire in the next 10 minutes +const REFRESH_WINDOW = (60 * 1000) * 10 + +// try to refresh relay reservations 5 minutes before expiry +const REFRESH_TIMEOUT = (60 * 1000) * 5 + +export interface RelayStoreComponents { + peerId: PeerId + connectionManager: ConnectionManager + transportManager: TransportManager + peerStore: PeerStore +} + +export interface RelayStoreInit { + /** + * Multiple relays may be discovered simultaneously - to prevent listening + * on too many relays, this value controls how many to attempt to reserve a + * slot on at once. If set to more than one, we may end up listening on + * more relays than the `maxReservations` value, but on networks with poor + * connectivity the user may wish to attempt to reserve on multiple relays + * simultaneously. (default: 1) + */ + reservationConcurrency?: number + + /** + * How many discovered relays to allow in the reservation store + */ + discoverRelays?: number +} + +export type RelayType = 'discovered' | 'configured' + +interface RelayEntry { + timeout: ReturnType + type: RelayType + reservation: Reservation +} + +export interface ReservationStoreEvents { + 'relay:not-enough-relays': CustomEvent + 'relay:removed': CustomEvent +} + +export class ReservationStore extends EventEmitter implements Startable { + private readonly peerId: PeerId + private readonly connectionManager: ConnectionManager + private readonly transportManager: TransportManager + private readonly peerStore: PeerStore + private readonly reserveQueue: PQueue + private readonly reservations: PeerMap + private readonly maxDiscoveredRelays: number + private started: boolean + + constructor (components: RelayStoreComponents, init?: RelayStoreInit) { + super() + + this.peerId = components.peerId + this.connectionManager = components.connectionManager + this.transportManager = components.transportManager + this.peerStore = components.peerStore + this.reservations = new PeerMap() + this.maxDiscoveredRelays = init?.discoverRelays ?? 0 + this.started = false + + // ensure we don't listen on multiple relays simultaneously + this.reserveQueue = new PQueue({ + concurrency: init?.reservationConcurrency ?? DEFAULT_RESERVATION_CONCURRENCY + }) + + // When a peer disconnects, if we had a reservation on that peer + // remove the reservation and multiaddr and maybe trigger search + // for new relays + this.connectionManager.addEventListener('peer:disconnect', (evt) => { + this.removeRelay(evt.detail.remotePeer) + }) + } + + isStarted () { + return this.started + } + + async start () { + this.started = true + } + + async stop () { + this.reservations.forEach(({ timeout }) => { + clearTimeout(timeout) + }) + this.reservations.clear() + + this.started = true + } + + /** + * If the number of current relays is beneath the configured `maxReservations` + * value, and the passed peer id is not our own, and we have a non-relayed connection + * to the remote, and the remote peer speaks the hop protocol, try to reserve a slot + * on the remote peer + */ + async addRelay (peerId: PeerId, type: RelayType) { + log('add relay', this.reserveQueue.size) + + await this.reserveQueue.add(async () => { + try { + if (this.peerId.equals(peerId)) { + log('not trying to use self as relay') + return + } + + // allow refresh of an existing reservation if it is about to expire + const existingReservation = this.reservations.get(peerId) + + if (existingReservation != null) { + if (getExpiration(existingReservation.reservation.expire) > REFRESH_WINDOW) { + log('already have reservation on relay peer %p and it expires in more than 10 minutes', peerId) + return + } + + clearTimeout(existingReservation.timeout) + this.reservations.delete(peerId) + } + + if (type === 'discovered' && [...this.reservations.values()].reduce((acc, curr) => { + if (curr.type === 'discovered') { + acc++ + } + + return acc + }, 0) >= this.maxDiscoveredRelays) { + log('already have enough discovered relays') + return + } + + const connection = await this.connectionManager.openConnection(peerId) + + if (connection.remoteAddr.protoNames().includes('p2p-circuit')) { + log('not creating reservation over relayed connection') + return + } + + const reservation = await this.#createReservation(connection) + + log('created reservation on relay peer %p', peerId) + + const expiration = getExpiration(reservation.expire) + + const timeout = setTimeout(() => { + this.addRelay(peerId, type).catch(err => { + log.error('could not refresh reservation to relay %p', peerId, err) + }) + }, Math.max(expiration - REFRESH_TIMEOUT, 0)) + + this.reservations.set(peerId, { + timeout, + reservation, + type + }) + + // ensure we don't close the connection to the relay + await this.peerStore.tagPeer(peerId, RELAY_TAG, { + value: 1, + ttl: expiration + }) + + await this.transportManager.listen( + reservation.addrs.map(ma => { + return multiaddr(ma).encapsulate('/p2p-circuit') + }) + ) + } catch (err) { + log.error('could not reserve slot on %p', peerId, err) + } + }) + } + + hasReservation (peerId: PeerId) { + return this.reservations.has(peerId) + } + + async #createReservation (connection: Connection): Promise { + log('requesting reservation from %s', connection.remotePeer) + const stream = await connection.newStream(RELAY_V2_HOP_CODEC) + const pbstr = pbStream(stream) + const hopstr = pbstr.pb(HopMessage) + hopstr.write({ type: HopMessage.Type.RESERVE }) + + let response: HopMessage + + try { + response = await hopstr.read() + } catch (err: any) { + log.error('error parsing reserve message response from %s because', connection.remotePeer, err.message) + throw err + } finally { + stream.close() + } + + if (response.status === Status.OK && (response.reservation != null)) { + return response.reservation + } + + const errMsg = `reservation failed with status ${response.status ?? 'undefined'}` + log.error(errMsg) + + throw new Error(errMsg) + } + + /** + * Remove listen relay + */ + removeRelay (peerId: PeerId) { + const existingReservation = this.reservations.get(peerId) + + if (existingReservation == null) { + return + } + + log('removing relay %p', peerId) + + clearTimeout(existingReservation.timeout) + this.reservations.delete(peerId) + + this.safeDispatchEvent('relay:removed', { detail: peerId }) + + if (this.reservations.size < this.maxDiscoveredRelays) { + this.dispatchEvent(new CustomEvent('relay:not-enough-relays')) + } + } +} diff --git a/src/circuit/utils.ts b/src/circuit/utils.ts index bad7597ba4..99cabe0d5a 100644 --- a/src/circuit/utils.ts +++ b/src/circuit/utils.ts @@ -5,111 +5,65 @@ import type { Uint8ArrayList } from 'uint8arraylist' import type { Limit } from './pb/index.js' import { logger } from '@libp2p/logger' import type { Stream } from '@libp2p/interface-connection' +import { DEFAULT_DATA_LIMIT, DEFAULT_DURATION_LIMIT } from './constants.js' -const log = logger('libp2p:circuit:v2:util') +const log = logger('libp2p:circuit-relay:utils') -const doRelay = (src: Stream, dst: Stream) => { - queueMicrotask(() => { - void dst.sink(src.source).catch(err => log.error('error while relating streams:', err)) - }) +async function * countStreamBytes (source: Source, limit: bigint): AsyncGenerator { + let total = 0n - queueMicrotask(() => { - void src.sink(dst.source).catch(err => log.error('error while relaying streams:', err)) - }) -} + for await (const buf of source) { + const len = BigInt(buf.byteLength) + if (total + len > limit) { + // this is a safe downcast since len is guarantee to be in the range for a number + const remaining = Number(limit - total) -export function createLimitedRelay (source: Stream, destination: Stream, limit?: Limit) { - // trivial case - if (limit == null) { - doRelay(source, destination) - return - } - - const dataLimit = limit.data ?? 0n - const durationLimit = limit.duration ?? 0 - const src = durationLimitDuplex(dataLimitDuplex(source, dataLimit), durationLimit) - const dst = durationLimitDuplex(dataLimitDuplex(destination, dataLimit), durationLimit) - - doRelay(src, dst) -} - -const dataLimitSource = (stream: Stream, limit: bigint): Stream => { - if (limit === 0n) { - return stream - } - - const source = stream.source - - stream.source = (async function * (): Source { - let total = 0n - - for await (const buf of source) { - const len = BigInt(buf.byteLength) - if (total + len > limit) { - // this is a safe downcast since len is guarantee to be in the range for a number - const remaining = Number(limit - total) - try { - if (remaining !== 0) { - yield buf - } - } finally { - stream.abort(new Error('data limit exceeded')) + try { + if (remaining !== 0) { + yield buf.subarray(0, remaining) } - return + } catch (err: any) { + log.error(err) } - yield buf - - total += len + throw new Error('data limit exceeded') } - })() - - return stream -} -const dataLimitSink = (stream: Stream, limit: bigint): Stream => { - if (limit === 0n) { - return stream + total += len + yield buf } +} - const sink = stream.sink - - stream.sink = async (source: Source) => { - await sink((async function * (): Source { - let total = 0n - - for await (const buf of source) { - const len = BigInt(buf.byteLength) - if (total + len > limit) { - // this is a safe downcast since len is guarantee to be in the range for a number - const remaining = Number(limit - total) - try { - if (remaining !== 0) { - yield buf.subarray(0, remaining) - } - } finally { - stream.abort(new Error('data limit exceeded')) - } - return - } - - total += len - yield buf - } - })()) - } +const doRelay = (src: Stream, dst: Stream, limit: bigint) => { + queueMicrotask(() => { + void dst.sink(countStreamBytes(src.source, limit)) + .catch(err => { + log.error('error while relaying streams src -> dst', err) + src.abort(err) + dst.abort(err) + }) + }) - return stream + queueMicrotask(() => { + void src.sink(countStreamBytes(dst.source, limit)) + .catch(err => { + log.error('error while relaying streams dst -> src', err) + src.abort(err) + dst.abort(err) + }) + }) } -const dataLimitDuplex = (stream: Stream, limit: bigint): Stream => { - dataLimitSource(stream, limit) - dataLimitSink(stream, limit) +export function createLimitedRelay (source: Stream, destination: Stream, abortSignal: AbortSignal, limit?: Limit): void { + const dataLimit = limit?.data ?? BigInt(DEFAULT_DATA_LIMIT) + const durationLimit = limit?.duration ?? DEFAULT_DURATION_LIMIT + const src = durationLimitDuplex(source, durationLimit, abortSignal) + const dst = durationLimitDuplex(destination, durationLimit, abortSignal) - return stream + doRelay(src, dst, dataLimit) } -const durationLimitDuplex = (stream: Stream, limit: number): Stream => { +const durationLimitDuplex = (stream: Stream, limit: number, abortSignal: AbortSignal): Stream => { if (limit === 0) { return stream } @@ -124,6 +78,11 @@ const durationLimitDuplex = (stream: Stream, limit: number): Stream => { ) const source = stream.source + const listener = () => { + timedOut = true + stream.abort(new Error('exceeded connection duration limit')) + } + abortSignal.addEventListener('abort', listener) stream.source = (async function * (): Source { try { @@ -135,6 +94,7 @@ const durationLimitDuplex = (stream: Stream, limit: number): Stream => { } } finally { clearTimeout(timeout) + abortSignal.removeEventListener('abort', listener) } })() @@ -151,7 +111,9 @@ export async function namespaceToCid (namespace: string): Promise { return CID.createV0(hash) } -/** returns number of ms beween now and expiration time */ +/** + * returns number of ms between now and expiration time + */ export function getExpiration (expireTime: bigint): number { return Number(expireTime) - new Date().getTime() } diff --git a/src/config.ts b/src/config.ts index 925b2f105f..bc5f6a02fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,6 @@ import mergeOptions from 'merge-options' import { dnsaddrResolver } from '@multiformats/multiaddr/resolvers' import * as Constants from './constants.js' import { AGENT_VERSION } from './identify/consts.js' -import * as RelayConstants from './circuit/constants.js' import { publicAddressesFirst } from '@libp2p/utils/address-sort' import { FaultTolerance } from '@libp2p/interface-transport' import type { Multiaddr } from '@multiformats/multiaddr' @@ -49,22 +48,6 @@ const DefaultConfig: Partial = { ttl: 7200, keepAlive: true }, - relay: { - enabled: true, - advertise: { - bootDelay: RelayConstants.ADVERTISE_BOOT_DELAY, - enabled: false, - ttl: RelayConstants.ADVERTISE_TTL - }, - hop: { - enabled: false, - timeout: 30000 - }, - reservationManager: { - enabled: false, - maxReservations: 2 - } - }, identify: { protocolPrefix: 'ipfs', host: { diff --git a/src/index.ts b/src/index.ts index db2a467ee9..95d6526783 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,6 @@ import type { DualDHT } from '@libp2p/interface-dht' import type { Datastore } from 'interface-datastore' import type { PeerStoreInit } from '@libp2p/interface-peer-store' import type { PeerId } from '@libp2p/interface-peer-id' -import type { RelayConfig } from './circuit/index.js' import type { PeerDiscovery } from '@libp2p/interface-peer-discovery' import type { ConnectionGater, ConnectionProtector } from '@libp2p/interface-connection' import type { Transport } from '@libp2p/interface-transport' @@ -42,6 +41,7 @@ import type { NatManagerInit } from './nat-manager.js' import type { AddressManagerInit } from './address-manager/index.js' import type { PeerRoutingInit } from './peer-routing.js' import type { ConnectionManagerInit } from './connection-manager/index.js' +import type { CircuitRelayService } from './circuit/index.js' /** * For Libp2p configurations and modules details read the [Configuration Document](./CONFIGURATION.md). @@ -103,7 +103,7 @@ export interface Libp2pInit { * If configured as a relay this node will relay certain * types of traffic for other peers */ - relay: RelayConfig + relay: (components: Components) => CircuitRelayService /** * libp2p identify protocol options diff --git a/src/libp2p.ts b/src/libp2p.ts index 2f15c2538e..7fce47690d 100644 --- a/src/libp2p.ts +++ b/src/libp2p.ts @@ -1,4 +1,3 @@ -import { RelayReservationManager } from './circuit/client.js' import { logger } from '@libp2p/logger' import type { AbortOptions } from '@libp2p/interfaces' import { EventEmitter, CustomEvent } from '@libp2p/interfaces/events' @@ -11,8 +10,6 @@ import { codes } from './errors.js' import { DefaultAddressManager } from './address-manager/index.js' import { DefaultConnectionManager } from './connection-manager/index.js' import { AutoDialler } from './connection-manager/auto-dialler.js' -import { Circuit } from './circuit/transport.js' -import { Relay } from './circuit/index.js' import { DefaultKeyChain } from '@libp2p/keychain' import { DefaultTransportManager } from './transport-manager.js' import { DefaultUpgrader } from './upgrader.js' @@ -53,6 +50,7 @@ import { peerIdFromString } from '@libp2p/peer-id' import type { Datastore } from 'interface-datastore' import type { KeyChain } from '@libp2p/interface-keychain' import mergeOptions from 'merge-options' +import type { CircuitRelayService } from './circuit/index.js' const log = logger('libp2p') @@ -61,7 +59,7 @@ export class Libp2pNode extends EventEmitter implements Libp2p { public dht: DualDHT public pubsub: PubSub public identifyService: IdentifyService - public circuitService?: RelayReservationManager + public circuitService?: CircuitRelayService public fetchService: FetchService public pingService: PingService public components: Components @@ -173,24 +171,12 @@ export class Libp2pNode extends EventEmitter implements Libp2p { // Create the Nat Manager this.services.push(new NatManager(this.components, init.nat)) - init.transports.forEach((fn) => { - this.components.transportManager.add(this.configureComponent(fn(this.components))) - }) - // Add the identify service this.identifyService = new IdentifyService(this.components, { ...init.identify }) this.configureComponent(this.identifyService) - if (init.relay.reservationManager.enabled === true) { - this.circuitService = new RelayReservationManager(this.components, { - addressSorter: init.connectionManager.addressSorter, - ...init.relay.reservationManager - }) - this.services.push(this.circuitService) - } - // dht provided components (peerRouting, contentRouting, dht) if (init.dht != null) { this.dht = this.components.dht = init.dht(this.components) @@ -236,14 +222,6 @@ export class Libp2pNode extends EventEmitter implements Libp2p { routers: contentRouters })) - if (init.relay.enabled) { - this.components.transportManager.add(this.configureComponent(new Circuit(this.components, init.relay))) - - this.configureComponent(new Relay(this.components, { - ...init.relay - })) - } - this.fetchService = this.configureComponent(new FetchService(this.components, { ...init.fetch })) @@ -252,6 +230,10 @@ export class Libp2pNode extends EventEmitter implements Libp2p { ...init.ping })) + if (init.relay != null) { + this.circuitService = this.configureComponent(init.relay(this.components)) + } + // Discovery modules for (const fn of init.peerDiscovery ?? []) { const service = this.configureComponent(fn(this.components)) @@ -260,6 +242,11 @@ export class Libp2pNode extends EventEmitter implements Libp2p { this.onDiscoveryPeer(evt) }) } + + // Transport modules + init.transports.forEach((fn) => { + this.components.transportManager.add(this.configureComponent(fn(this.components))) + }) } private configureComponent (component: T): T { diff --git a/src/transport-manager.ts b/src/transport-manager.ts index fc4a0dbdb5..70092ad969 100644 --- a/src/transport-manager.ts +++ b/src/transport-manager.ts @@ -55,7 +55,7 @@ export class DefaultTransportManager extends EventEmitter { + let local: Libp2pNode + let remote: Libp2pNode + let relay: Libp2pNode + + beforeEach(async () => { + // create relay first so it has time to advertise itself via content routing + relay = await createNode({ + config: createNodeOptions({ + transports: [ + tcp() + ], + relay: circuitRelayServer({ + advertise: { + bootDelay: 10 + } + }), + contentRouters: [ + mockContentRouting() + ] + }) + }) + + if (relay.circuitService != null) { + // wait for relay to advertise service successfully + await pEvent(relay.circuitService, 'relay:advert:success') + } + + // now create client nodes + [local, remote] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + contentRouters: [ + mockContentRouting() + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + contentRouters: [ + mockContentRouting() + ] + }) + }) + ]) + }) + + afterEach(async () => { + MockContentRouting.reset() + + // Stop each node + return await Promise.all([local, remote, relay].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should find provider for relay and add it as listen relay', async () => { + // both nodes should discover the relay - they have no direct connection + // so it will be via content routing + const localRelayPeerId = await hasRelay(local) + expect(localRelayPeerId.toString()).to.equal(relay.peerId.toString()) + + const remoteRelayPeerId = await hasRelay(remote) + expect(remoteRelayPeerId.toString()).to.equal(relay.peerId.toString()) + + const relayedAddr = getRelayAddress(remote) + // Dial from remote through the relayed address + const conn = await local.dial(relayedAddr) + + expect(conn).to.exist() + }) +}) diff --git a/test/circuit/hop.spec.ts b/test/circuit/hop.spec.ts index fd757b948e..76492711e7 100644 --- a/test/circuit/hop.spec.ts +++ b/test/circuit/hop.spec.ts @@ -1,788 +1,343 @@ +/* eslint-env mocha */ +/* eslint max-nested-callbacks: ['error', 5] */ + import type { Connection, Stream } from '@libp2p/interface-connection' -import { mockConnection, mockDuplex, mockMultiaddrConnection, mockStream } from '@libp2p/interface-mocks' -import type { PeerId } from '@libp2p/interface-peer-id' +import { mockRegistrar, mockUpgrader, mockNetwork, mockConnectionManager } from '@libp2p/interface-mocks' import { expect } from 'aegir/chai' -import { pair } from 'it-pair' -import * as sinon from 'sinon' -import { Circuit } from '../../src/circuit/transport.js' -import { handleHopProtocol } from '../../src/circuit/hop.js' -import { HopMessage, Status, StopMessage } from '../../src/circuit/pb/index.js' -import { ReservationStore } from '../../src/circuit/reservation-store.js' -import { Components, DefaultComponents } from '../../src/components.js' -import { DefaultConnectionManager } from '../../src/connection-manager/index.js' -import { DefaultRegistrar } from '../../src/registrar.js' -import { DefaultUpgrader } from '../../src/upgrader.js' -import * as peerUtils from '../utils/creators/peer.js' -import * as Constants from '../../src/constants.js' -import { dnsaddrResolver } from '@multiformats/multiaddr/resolvers' -import { publicAddressesFirst } from '@libp2p/utils/address-sort' -import { PersistentPeerStore } from '@libp2p/peer-store' -import { multiaddr } from '@multiformats/multiaddr' -import type { AclStatus } from '../../src/circuit/interfaces.js' -import { pbStream } from 'it-pb-stream' -import { pipe } from 'it-pipe' -import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' -import { duplexPair } from 'it-pair/duplex' -import all from 'it-all' +import { circuitRelayServer, CircuitRelayService, circuitRelayTransport } from '../../src/circuit/index.js' +import { HopMessage, Status } from '../../src/circuit/pb/index.js' +import { MessageStream, pbStream } from 'it-pb-stream' import type { PeerStore } from '@libp2p/interface-peer-store' -import { MemoryDatastore } from 'datastore-core' -import { Uint8ArrayList } from 'uint8arraylist' -import type { Duplex } from 'it-stream-types' -import { pushable } from 'it-pushable' - -/* eslint-env mocha */ - -describe('Circuit v2 - hop protocol', function () { - describe('reserve', function () { - let relayPeer: PeerId, - conn: Connection, - stream: Stream, - reservationStore: ReservationStore, - peerStore: PeerStore - - beforeEach(async () => { - [, relayPeer] = await peerUtils.createPeerIds(2) - conn = mockConnection(mockMultiaddrConnection(mockDuplex(), relayPeer)) - stream = mockStream(pair()) - reservationStore = new ReservationStore() - peerStore = new PersistentPeerStore(new DefaultComponents({ datastore: new MemoryDatastore() })) +import { StubbedInstance, stubInterface } from 'sinon-ts' +import type { AddressManager } from '@libp2p/interface-address-manager' +import type { ContentRouting } from '@libp2p/interface-content-routing' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import type { Registrar } from '@libp2p/interface-registrar' +import type { Transport, TransportManager, Upgrader } from '@libp2p/interface-transport' +import { isStartable } from '@libp2p/interfaces/startable' +import { Multiaddr, multiaddr } from '@multiformats/multiaddr' +import type { PeerId } from '@libp2p/interface-peer-id' +import { DEFAULT_MAX_RESERVATION_STORE_SIZE, RELAY_SOURCE_TAG, RELAY_V2_HOP_CODEC } from '../../src/circuit/constants.js' +import { PeerMap } from '@libp2p/peer-collections' +import { matchPeerId } from '../fixtures/match-peer-id.js' +import type { CircuitRelayServerInit } from '../../src/circuit/server/index.js' +import { AclStatus } from '../../src/circuit/server/index.js' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' + +interface Node { + peerId: PeerId + multiaddr: Multiaddr + registrar: Registrar + peerStore: StubbedInstance + circuitRelayService: CircuitRelayService + upgrader: Upgrader + connectionManager: ConnectionManager + circuitRelayTransport: Transport +} + +let peerIndex = 0 + +describe('circuit-relay hop protocol', function () { + let relayNode: Node + let clientNode: Node + let targetNode: Node + let nodes: Node[] + + async function createNode (circuitRelayInit?: CircuitRelayServerInit): Promise { + peerIndex++ + + const peerId = await createEd25519PeerId() + const registrar = mockRegistrar() + const connections = new PeerMap() + + const octet = peerIndex + 100 + const port = peerIndex + 10000 + const ma = multiaddr(`/ip4/${octet}.${octet}.${octet}.${octet}/tcp/${port}/p2p/${peerId.toString()}`) + + const addressManager = stubInterface() + addressManager.getAddresses.returns([ + ma + ]) + const peerStore = stubInterface() + + const connectionManager = mockConnectionManager({ + peerId, + registrar }) - this.afterEach(async function () { - sinon.restore() - await conn.close() + const upgrader = mockUpgrader({ + registrar }) - - it('error on unknown message type', async function () { - const stream = mockStream(pair()) - const pbstr = pbStream(stream) - await handleHopProtocol({ - connection: mockConnection(mockMultiaddrConnection(mockDuplex(), await peerUtils.createPeerId())), - stream: pbstr, - request: {}, - relayPeer, - relayAddrs: [], - reservationStore, - connectionManager: sinon.stub() as any, - peerStore - }) - const msg = await pbstr.pb(HopMessage).read() - expect(msg.type).to.be.equal(HopMessage.Type.STATUS) - expect(msg.status).to.be.equal(Status.UNEXPECTED_MESSAGE) + upgrader.addEventListener('connection', (evt) => { + const conn = evt.detail + connections.set(conn.remotePeer, conn) }) - - it('should reserve slot', async function () { - const expire: number = 123 - const reserveStub = sinon.stub(reservationStore, 'reserve') - reserveStub.resolves({ status: Status.OK, expire }) - const pbstr = pbStream(stream) - await handleHopProtocol({ - request: { - type: HopMessage.Type.RESERVE - }, - connection: conn, - stream: pbstr, - relayPeer, - connectionManager: sinon.stub() as any, - relayAddrs: [multiaddr('/ip4/127.0.0.1/udp/1234')], - peerStore, - reservationStore - }) - expect(reserveStub.calledOnceWith(conn.remotePeer, conn.remoteAddr)).to.be.true() - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.limit).to.be.undefined() - expect(response.status).to.be.equal(Status.OK) - expect(response.reservation?.expire).to.be.equal(BigInt(expire)) - expect(response.reservation?.voucher).to.not.be.undefined() - expect(response.reservation?.addrs?.length).to.be.greaterThan(0) + upgrader.addEventListener('connectionEnd', (evt) => { + const conn = evt.detail + connections.delete(conn.remotePeer) }) - it('should fail to reserve slot - relayed connection', async function () { - const reserveStub = sinon.stub(reservationStore, 'reserve') - const connStub = sinon.stub(conn, 'remoteAddr') - connStub.value(multiaddr('/ip4/127.0.0.1/tcp/1234/p2p-circuit')) - const pbstr = pbStream(stream) - await handleHopProtocol({ - request: { - type: HopMessage.Type.RESERVE - }, - connection: conn, - stream: pbstr, - relayPeer, - connectionManager: sinon.stub() as any, - peerStore, - relayAddrs: [multiaddr('/ip4/127.0.0.1/udp/1234')], - reservationStore - }) - expect(reserveStub.notCalled).to.be.true() - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.limit).to.be.undefined() - expect(response.status).to.be.equal(Status.PERMISSION_DENIED) + const service = circuitRelayServer(circuitRelayInit)({ + addressManager, + contentRouting: stubInterface(), + connectionManager, + peerId, + peerStore, + registrar }) - it('should fail to reserve slot - acl denied', async function () { - const reserveStub = sinon.stub(reservationStore, 'reserve') - const pbstr = pbStream(stream) - await handleHopProtocol({ - request: { - type: HopMessage.Type.RESERVE - }, - connection: conn, - stream: pbstr, - relayPeer, - connectionManager: sinon.stub() as any, - relayAddrs: [multiaddr('/ip4/127.0.0.1/udp/1234')], - peerStore, - reservationStore, - acl: { allowReserve: async function () { return false }, allowConnect: sinon.stub() as any } - }) - expect(reserveStub.notCalled).to.be.true() - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.limit).to.be.undefined() - expect(response.status).to.be.equal(Status.PERMISSION_DENIED) + if (isStartable(service)) { + await service.start() + } + + const transport = circuitRelayTransport({})({ + addressManager, + connectionManager, + contentRouting: stubInterface(), + peerId, + peerStore, + registrar, + transportManager: stubInterface(), + upgrader }) - it('should fail to reserve slot - resource exceeded', async function () { - const reserveStub = sinon.stub(reservationStore, 'reserve') - reserveStub.resolves({ status: Status.RESERVATION_REFUSED }) - const pbstr = pbStream(stream) - await handleHopProtocol({ - request: { - type: HopMessage.Type.RESERVE - }, - connection: conn, - stream: pbstr, - relayPeer, - connectionManager: sinon.stub() as any, - relayAddrs: [multiaddr('/ip4/127.0.0.1/udp/1234')], - peerStore, - reservationStore - }) - expect(reserveStub.calledOnce).to.be.true() - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.limit).to.be.undefined() - expect(response.status).to.be.equal(Status.RESERVATION_REFUSED) + if (isStartable(transport)) { + await transport.start() + } + + const node: Node = { + peerId, + multiaddr: ma, + registrar, + circuitRelayService: service, + peerStore, + upgrader, + connectionManager, + circuitRelayTransport: transport + } + + mockNetwork.addNode(node) + nodes.push(node) + + return node + } + + async function openStream (client: Node, relay: Node, protocol: string): Promise> { + const connection = await client.connectionManager.openConnection(relay.peerId) + const clientStream = await connection.newStream(protocol) + return pbStream(clientStream).pb(HopMessage) + } + + async function makeReservation (client: Node, relay: Node): Promise<{ response: HopMessage, clientPbStream: MessageStream }> { + const clientPbStream = await openStream(client, relay, RELAY_V2_HOP_CODEC) + + // send reserve message + clientPbStream.write({ + type: HopMessage.Type.RESERVE }) - it('should fail to reserve slot - failed to write response', async function () { - const reserveStub = sinon.stub(reservationStore, 'reserve') - const removeReservationStub = sinon.stub(reservationStore, 'removeReservation') - reserveStub.resolves({ status: Status.OK, expire: 123 }) - removeReservationStub.resolves() - const pbstr = pbStream(stream) - const backup = pbstr.write - pbstr.write = function () { throw new Error('connection reset') } - await handleHopProtocol({ - request: { - type: HopMessage.Type.RESERVE - }, - connection: conn, - stream: pbstr, - relayPeer, - connectionManager: sinon.stub() as any, - relayAddrs: [multiaddr('/ip4/127.0.0.1/udp/1234')], - peerStore, - reservationStore - }) - expect(reserveStub.calledOnce).to.be.true() - expect(removeReservationStub.calledOnce).to.be.true() - pbstr.write = backup + return { + response: await clientPbStream.read(), + clientPbStream + } + } + + async function sendConnect (client: Node, target: Node, relay: Node): Promise<{ response: HopMessage, clientPbStream: MessageStream }> { + const clientPbStream = await openStream(client, relay, RELAY_V2_HOP_CODEC) + + // send reserve message + clientPbStream.write({ + type: HopMessage.Type.CONNECT, + peer: { + id: target.peerId.toBytes(), + addrs: [ + target.multiaddr.bytes + ] + } }) - it('should tag peer', async () => { - const expire: number = 123 - const reserveStub = sinon.stub(reservationStore, 'reserve') - reserveStub.resolves({ status: Status.OK, expire }) - const pbstr = pbStream(stream) - await handleHopProtocol({ - request: { - type: HopMessage.Type.RESERVE - }, - connection: conn, - stream: pbstr, - relayPeer, - connectionManager: sinon.stub() as any, - relayAddrs: [multiaddr('/ip4/127.0.0.1/udp/1234')], - peerStore, - reservationStore - }) - expect(reserveStub.calledOnceWith(conn.remotePeer, conn.remoteAddr)).to.be.true() - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.limit).to.be.undefined() - expect(response.status).to.be.equal(Status.OK) - expect(response.reservation?.expire).to.be.equal(BigInt(expire)) - expect(response.reservation?.voucher).to.not.be.undefined() - expect(response.reservation?.addrs?.length).to.be.greaterThan(0) - - const tags = await peerStore.getTags(relayPeer) - expect(tags).length(1) - expect(tags[0].value).equal(1) - }) + return { + response: await clientPbStream.read(), + clientPbStream + } + } + + beforeEach(async () => { + nodes = [] + + relayNode = await createNode() + clientNode = await createNode() + targetNode = await createNode() }) - describe('connect', function () { - let relayPeer: PeerId, - dstPeer: PeerId, - conn: Connection, - stream: Stream, - reservationStore: ReservationStore, - circuit: Circuit, - components: Components - - beforeEach(async () => { - [, relayPeer, dstPeer] = await peerUtils.createPeerIds(3) - conn = mockConnection(mockMultiaddrConnection(mockDuplex(), relayPeer)) - stream = mockStream(pair()) - reservationStore = new ReservationStore() - // components - components = new DefaultComponents({ datastore: new MemoryDatastore() }) - components.connectionManager = new DefaultConnectionManager(components, - { - maxConnections: 300, - minConnections: 50, - autoDial: true, - autoDialInterval: 10000, - maxParallelDials: Constants.MAX_PARALLEL_DIALS, - maxDialsPerPeer: Constants.MAX_PER_PEER_DIALS, - dialTimeout: Constants.DIAL_TIMEOUT, - inboundUpgradeTimeout: Constants.INBOUND_UPGRADE_TIMEOUT, - resolvers: { - dnsaddr: dnsaddrResolver - }, - addressSorter: publicAddressesFirst - } - ) - components.peerStore = new PersistentPeerStore(components) - components.registrar = new DefaultRegistrar(components) - components.upgrader = new DefaultUpgrader(components, { - connectionEncryption: [], - muxers: [], - inboundUpgradeTimeout: 10000 - }) + afterEach(async () => { + for (const node of nodes) { + if (isStartable(node.circuitRelayService)) { + await node.circuitRelayService.stop() + } - circuit = new Circuit(components, { - enabled: true, - advertise: { - enabled: false - }, - hop: { - enabled: true, - timeout: 30000 - }, - reservationManager: { - enabled: false, - maxReservations: 2 - } - }) - }) + if (isStartable(node.circuitRelayTransport)) { + await node.circuitRelayTransport.stop() + } + } - this.afterEach(async function () { - await conn.close() - }) + mockNetwork.reset() + }) - it('should succeed to connect', async function () { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - const getReservationStub = sinon.stub(reservationStore, 'get') - hasReservationStub.resolves(true) - getReservationStub.resolves({ expire: new Date(Date.now() + 2 * 60 * 1000), addr: multiaddr('/ip4/0.0.0.0') }) - const dstConn = mockConnection( - mockMultiaddrConnection(pair(), dstPeer) - ) - const streamStub = sinon.stub(dstConn, 'newStream') - const dstStream = mockStream(pair()) - streamStub.resolves(dstStream) - const dstStreamHandler = pbStream(dstStream) - dstStreamHandler.pb(StopMessage).write({ - type: StopMessage.Type.STATUS, - status: Status.OK - }) - const pbstr = pbStream(stream) - const stub = sinon.stub(components.connectionManager, 'getConnections') - stub.returns([dstConn]) - await handleHopProtocol({ - connection: conn, - stream: pbstr, - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: components.peerStore, - connectionManager: components.connectionManager + describe('reserve', function () { + it('error on unknown message type', async () => { + const clientPbStream = await openStream(clientNode, relayNode, RELAY_V2_HOP_CODEC) + + // wrong initial message + clientPbStream.write({ + type: HopMessage.Type.STATUS, + status: Status.MALFORMED_MESSAGE }) - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.OK) + + const msg = await clientPbStream.read() + expect(msg).to.have.property('type', HopMessage.Type.STATUS) + expect(msg).to.have.property('status', Status.UNEXPECTED_MESSAGE) }) - it('should fail to connect - invalid request', async function () { - const pbstr = pbStream(stream) - await handleHopProtocol({ - connection: conn, - stream: pbstr, - request: { - type: HopMessage.Type.CONNECT, - // @ts-expect-error {} is missing the following properties from peer: id, addrs - peer: {} - }, - reservationStore, - circuit + it('should reserve slot', async () => { + const { response } = await makeReservation(clientNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.OK) + expect(response).to.have.nested.property('reservation.expire').that.is.a('bigint') + expect(response).to.have.nested.property('reservation.addrs').that.satisfies((val: Uint8Array[]) => { + return val + .map(buf => multiaddr(buf)) + .map(ma => ma.toString()) + .includes(relayNode.multiaddr.toString()) }) - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.MALFORMED_MESSAGE) + expect(response.limit).to.have.property('data').that.is.a('bigint') + expect(response.limit).to.have.property('duration').that.is.a('number') + + const reservation = relayNode.circuitRelayService.reservations.get(clientNode.peerId) + expect(reservation).to.have.nested.property('limit.data', response.limit?.data) + expect(reservation).to.have.nested.property('limit.duration', response.limit?.duration) }) - it('should failed to connect - acl denied', async function () { - const pbstr = pbStream(stream) - const acl = { - allowConnect: async () => await Promise.resolve(Status.PERMISSION_DENIED as AclStatus), - allowReserve: async () => await Promise.resolve(false) + it('should fail to reserve slot - acl denied', async () => { + // @ts-expect-error private field + relayNode.circuitRelayService.acl = { + allowReserve: async () => await Promise.resolve(false), + allowConnect: async () => await Promise.resolve(true) } - await handleHopProtocol({ - connection: conn, - stream: pbstr, - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: components.peerStore, - connectionManager: components.connectionManager, - acl - }) - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.PERMISSION_DENIED) - }) - it('should fail to connect - no reservation', async function () { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - hasReservationStub.resolves(false) - const pbstr = pbStream(stream) - await handleHopProtocol({ - connection: conn, - stream: pbstr, - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: sinon.stub() as any, - connectionManager: components.connectionManager - }) - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.NO_RESERVATION) + const { response } = await makeReservation(clientNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.PERMISSION_DENIED) + + expect(relayNode.circuitRelayService.reservations.get(clientNode.peerId)).to.be.undefined() }) - it('should fail to connect - no connection', async function () { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - hasReservationStub.resolves(true) - const stub = sinon.stub(components.connectionManager, 'getConnections') - stub.returns([]) - const pbstr = pbStream(stream) - await handleHopProtocol({ - connection: conn, - stream: pbstr, - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: sinon.stub() as any, - connectionManager: components.connectionManager - }) - const response = await pbstr.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.NO_RESERVATION) - expect(stub.calledOnce).to.be.true() + it('should fail to reserve slot - resource exceeded', async () => { + // fill all the available reservation slots + for (let i = 0; i < DEFAULT_MAX_RESERVATION_STORE_SIZE; i++) { + const peer = await createNode() + const { response } = await makeReservation(peer, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.OK) + } + + // next reservation should fail + const { response } = await makeReservation(clientNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.RESERVATION_REFUSED) + + expect(relayNode.circuitRelayService.reservations.get(clientNode.peerId)).to.be.undefined() }) - }) - describe('connection limits', () => { - let relayPeer: PeerId, - dstPeer: PeerId, - conn: Connection, - reservationStore: ReservationStore, - components: Components - - beforeEach(async () => { - [, relayPeer, dstPeer] = await peerUtils.createPeerIds(3) - conn = mockConnection(mockMultiaddrConnection(mockDuplex(), relayPeer)) - reservationStore = new ReservationStore() - // components - components = new DefaultComponents({ datastore: new MemoryDatastore() }) - components.connectionManager = new DefaultConnectionManager(components, - - { - maxConnections: 300, - minConnections: 50, - autoDial: true, - autoDialInterval: 10000, - maxParallelDials: Constants.MAX_PARALLEL_DIALS, - maxDialsPerPeer: Constants.MAX_PER_PEER_DIALS, - dialTimeout: Constants.DIAL_TIMEOUT, - inboundUpgradeTimeout: Constants.INBOUND_UPGRADE_TIMEOUT, - resolvers: { - dnsaddr: dnsaddrResolver - }, - addressSorter: publicAddressesFirst - } - ) - components.peerStore = new PersistentPeerStore(components) - components.registrar = new DefaultRegistrar(components) - components.upgrader = new DefaultUpgrader(components, { - connectionEncryption: [], - muxers: [], - inboundUpgradeTimeout: 10000 - }) + it('should refresh previous reservation when store is full', async () => { + const peers: Node[] = [] + + // fill all the available reservation slots + for (let i = 0; i < DEFAULT_MAX_RESERVATION_STORE_SIZE; i++) { + const peer = await createNode() + peers.push(peer) + + const { response } = await makeReservation(peer, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.OK) + } + + // next reservation should fail + const { response: failureResponse } = await makeReservation(clientNode, relayNode) + expect(failureResponse).to.have.property('type', HopMessage.Type.STATUS) + expect(failureResponse).to.have.property('status', Status.RESERVATION_REFUSED) + expect(relayNode.circuitRelayService.reservations.get(clientNode.peerId)).to.be.undefined() + + // should be able to refresh older reservation + const { response: successResponse } = await makeReservation(peers[0], relayNode) + expect(successResponse).to.have.property('type', HopMessage.Type.STATUS) + expect(successResponse).to.have.property('status', Status.OK) + expect(relayNode.circuitRelayService.reservations.get(peers[0].peerId)).to.be.ok() }) - it('should connect - data limit - src to dest', async () => { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - const getReservationStub = sinon.stub(reservationStore, 'get') - hasReservationStub.resolves(true) - getReservationStub.resolves({ - expire: new Date(Date.now() + 2 * 60 * 1000), - addr: multiaddr('/ip4/0.0.0.0'), - // set limit - limit: { - data: BigInt(5), - duration: 0 - } - }) - const dstConn = mockConnection( - mockMultiaddrConnection(pair(), dstPeer) - ) - const [dstServer, dstClient] = duplexPair() - const [srcServer, srcClient] = duplexPair() - - // resolve the destination stream for the server - const dstStream = mockStream(dstServer) - const dstStreamAbortStub = sinon.stub(dstStream, 'abort') - const streamStub = sinon.stub(dstConn, 'newStream') - streamStub.resolves(dstStream) - - const stub = sinon.stub(components.connectionManager, 'getConnections') - stub.returns([dstConn]) - const srcServerStream = mockStream(srcServer) - const srcServerAbort = sinon.spy(srcServerStream, 'abort') - const handleHop = expect(handleHopProtocol({ - connection: conn, - stream: pbStream(srcServerStream), - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: components.peerStore, - connectionManager: components.connectionManager - })).to.eventually.fulfilled() - - const dstClientPbStream = pbStream(dstClient) - const stopConnectRequest = await dstClientPbStream.pb(StopMessage).read() - expect(stopConnectRequest.type).to.eq(StopMessage.Type.CONNECT) - // write response - dstClientPbStream.pb(StopMessage).write({ - type: StopMessage.Type.STATUS, - status: Status.OK - }) + it('should tag peer making reservation', async () => { + const { response } = await makeReservation(clientNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.OK) - await handleHop - const srcClientPbStream = pbStream(srcClient) - const response = await srcClientPbStream.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.OK) - - const sourceStream = srcClientPbStream.unwrap() - const destStream = dstClientPbStream.unwrap() - - const sender = pushable() - void pipe(sender, sourceStream) - // source to dest, write 4 bytes - sender.push(uint8arrayFromString('0123')) - // source to dest, exceed stream limit - sender.push(uint8arrayFromString('extra')) - const data = await all(destStream.source) - const sum = data.reduce((prev: number, cur: Uint8ArrayList) => prev + cur.length, 0) - expect(sum).eql(5) - expect(dstStreamAbortStub.callCount).to.equal(1) - expect(srcServerAbort.callCount).to.equal(1) + expect(relayNode.peerStore.tagPeer.calledWith(matchPeerId(clientNode.peerId), RELAY_SOURCE_TAG)).to.be.true() }) + }) - it('should connect - data limit - dest to src', async () => { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - const getReservationStub = sinon.stub(reservationStore, 'get') - hasReservationStub.resolves(true) - getReservationStub.resolves({ - expire: new Date(Date.now() + 2 * 60 * 1000), - addr: multiaddr('/ip4/0.0.0.0'), - // set limit - limit: { - data: BigInt(5), - duration: 0 - } - }) - const dstConn = mockConnection( - mockMultiaddrConnection(pair(), dstPeer) - ) - const [dstServer, dstClient] = duplexPair() - const [srcServer, srcClient] = duplexPair() - - // resolve the destination stream for the server - const streamStub = sinon.stub(dstConn, 'newStream') - const dstServerStream = mockStream(dstServer) - const dstServerStreamAbortStub = sinon.spy(dstServerStream, 'abort') - streamStub.resolves(dstServerStream) - - const stub = sinon.stub(components.connectionManager, 'getConnections') - stub.returns([dstConn]) - - // source stream on the server - const srcServerStream = mockStream(srcServer) - const srcServerStreamAbortStub = sinon.stub(srcServerStream, 'abort') - const handleHop = expect(handleHopProtocol({ - connection: conn, - stream: pbStream(srcServerStream), - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: components.peerStore, - connectionManager: components.connectionManager - })).to.eventually.fulfilled() - - const dstClientPbStream = pbStream(dstClient) - const stopConnectRequest = await dstClientPbStream.pb(StopMessage).read() - expect(stopConnectRequest.type).to.eq(StopMessage.Type.CONNECT) - // write response - dstClientPbStream.pb(StopMessage).write({ - type: StopMessage.Type.STATUS, - status: Status.OK - }) + describe('connect', () => { + it('should connect successfully', async () => { + // both peers make a reservation on the relay + await expect(makeReservation(clientNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) + await expect(makeReservation(targetNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) - await handleHop - const srcClientPbStream = pbStream(srcClient) - const response = await srcClientPbStream.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.OK) - - const sourceStream = srcClientPbStream.unwrap() - const destStream = dstClientPbStream.unwrap() - - const sender = pushable() - void pipe(sender, destStream) - // dest to source, write 4 bytes - sender.push(uint8arrayFromString('0123')) - // dest to source, exceed stream limit - sender.push(uint8arrayFromString('extra')) - const data = await all(sourceStream.source) - const sum = data.reduce((prev: number, cur: Uint8ArrayList) => prev + cur.length, 0) - expect(sum).equal(5) - expect(dstServerStreamAbortStub.callCount).to.equal(1) - expect(srcServerStreamAbortStub.callCount).to.equal(1) + // client peer sends CONNECT to target peer + const { response } = await sendConnect(clientNode, targetNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.OK) }) - it('should connect - duration limit - dest to src', async () => { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - const getReservationStub = sinon.stub(reservationStore, 'get') - hasReservationStub.resolves(true) - getReservationStub.resolves({ - expire: new Date(Date.now() + 2 * 60 * 1000), - addr: multiaddr('/ip4/0.0.0.0'), - // set limit - limit: { - // 500 ms duration limit - duration: 500 - } - }) - const dstConn = mockConnection( - mockMultiaddrConnection(pair(), dstPeer) - ) - const [dstServer, dstClient] = duplexPair() - const [srcServer, srcClient] = duplexPair() - - // resolve the destination stream for the server - const streamStub = sinon.stub(dstConn, 'newStream') - const dstServerStream = mockStream(dstServer) - streamStub.resolves(dstServerStream) - - const dstAbortStub = sinon.stub(dstServerStream, 'abort') - - const stub = sinon.stub(components.connectionManager, 'getConnections') - stub.returns([dstConn]) - - const srcServerStream = mockStream(srcServer) - const srcServerAbortStub = sinon.stub(srcServerStream, 'abort') - const handleHop = expect(handleHopProtocol({ - connection: conn, - stream: pbStream(srcServerStream), - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: components.peerStore, - connectionManager: components.connectionManager - })).to.eventually.fulfilled() - - const dstClientPbStream = pbStream(dstClient) - const stopConnectRequest = await dstClientPbStream.pb(StopMessage).read() - expect(stopConnectRequest.type).to.eq(StopMessage.Type.CONNECT) - // write response - dstClientPbStream.pb(StopMessage).write({ - type: StopMessage.Type.STATUS, - status: Status.OK + it('should fail to connect - invalid request', async () => { + // both peers make a reservation on the relay + await expect(makeReservation(clientNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) + await expect(makeReservation(targetNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) + + const clientPbStream = await openStream(clientNode, relayNode, RELAY_V2_HOP_CODEC) + clientPbStream.write({ + type: HopMessage.Type.CONNECT, + // @ts-expect-error {} is missing the following properties from peer: id, addrs + peer: {} }) - await handleHop - const srcClientPbStream = pbStream(srcClient) - const response = await srcClientPbStream.pb(HopMessage).read() + const response = await clientPbStream.read() expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.OK) - - const sourceStream = srcClientPbStream.unwrap() as Duplex - const destStream = dstClientPbStream.unwrap() - - const periodicSender = (period: number, count: number) => async function * () { - const data = new Uint8ArrayList(new Uint8Array([0, 0, 0, 0])) - while (count > 0) { - await new Promise((resolve) => setTimeout(resolve, period)) - yield data - count-- - } + expect(response.status).to.be.equal(Status.MALFORMED_MESSAGE) + }) + + it('should failed to connect - acl denied', async () => { + // @ts-expect-error private field + relayNode.circuitRelayService.acl = { + allowReserve: async () => await Promise.resolve(true), + allowConnect: async () => await Promise.resolve(AclStatus.PERMISSION_DENIED) } - // dest to source, write 4 messages - void pipe(periodicSender(200, 4), destStream) - const received = await all(sourceStream.source) - expect(received.reduce((p, c) => p + c.length, 0)).to.equal(8) - expect(dstAbortStub.callCount).to.equal(1) - expect(srcServerAbortStub.callCount).to.equal(1) + // both peers make a reservation on the relay + await expect(makeReservation(clientNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) + await expect(makeReservation(targetNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) + + // client peer sends CONNECT to target peer + const { response } = await sendConnect(clientNode, targetNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.PERMISSION_DENIED) }) - it('should connect - duration limit - src to dest', async () => { - const hasReservationStub = sinon.stub(reservationStore, 'hasReservation') - const getReservationStub = sinon.stub(reservationStore, 'get') - hasReservationStub.resolves(true) - getReservationStub.resolves({ - expire: new Date(Date.now() + 2 * 60 * 1000), - addr: multiaddr('/ip4/0.0.0.0'), - // set limit - limit: { - // 500 ms duration limit - duration: 500 - } - }) - const dstConn = mockConnection( - mockMultiaddrConnection(pair(), dstPeer) - ) - const [dstServer, dstClient] = duplexPair() - const [srcServer, srcClient] = duplexPair() - - // resolve the destination stream for the server - const streamStub = sinon.stub(dstConn, 'newStream') - const dstServerStream = mockStream(dstServer) - const dstServerAbortStub = sinon.stub(dstServerStream, 'abort') - streamStub.resolves(dstServerStream) - - const stub = sinon.stub(components.connectionManager, 'getConnections') - stub.returns([dstConn]) - const srcServerStream = mockStream(srcServer) - const srcAbortStub = sinon.stub(srcServerStream, 'abort') - const handleHop = expect(handleHopProtocol({ - connection: conn, - stream: pbStream(srcServerStream), - request: { - type: HopMessage.Type.CONNECT, - peer: { - id: dstPeer.toBytes(), - addrs: [] - } - }, - relayPeer: relayPeer, - relayAddrs: [], - reservationStore, - peerStore: components.peerStore, - connectionManager: components.connectionManager - })).to.eventually.fulfilled() - - const dstClientPbStream = pbStream(dstClient) - const stopConnectRequest = await dstClientPbStream.pb(StopMessage).read() - expect(stopConnectRequest.type).to.eq(StopMessage.Type.CONNECT) - // write response - dstClientPbStream.pb(StopMessage).write({ - type: StopMessage.Type.STATUS, - status: Status.OK - }) + it('should fail to connect - no connection', async () => { + // target peer has no reservation on the relay + await expect(makeReservation(clientNode, relayNode)).to.eventually.have.nested.property('response.status', Status.OK) - await handleHop - const srcClientPbStream = pbStream(srcClient) - const response = await srcClientPbStream.pb(HopMessage).read() - expect(response.type).to.be.equal(HopMessage.Type.STATUS) - expect(response.status).to.be.equal(Status.OK) - - const sourceStream = srcClientPbStream.unwrap() as Duplex - const destStream = dstClientPbStream.unwrap() - - const periodicSender = (period: number, count: number) => async function * () { - const data = new Uint8ArrayList(new Uint8Array([0, 0, 0, 0])) - while (count > 0) { - await new Promise((resolve) => setTimeout(resolve, period)) - yield data - count-- - } - } - // dest to source, write 4 messages - void pipe(periodicSender(200, 4), sourceStream) - - const received = await all(destStream.source) - const sum = received.reduce((prev: number, cur: Uint8ArrayList) => prev + cur.length, 0) - expect(sum).equals(8) - expect(srcAbortStub.callCount).to.equal(1) - expect(dstServerAbortStub.callCount).to.equal(1) + // client peer sends CONNECT to target peer + const { response } = await sendConnect(clientNode, targetNode, relayNode) + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.NO_RESERVATION) }) }) }) diff --git a/test/circuit/relay.node.ts b/test/circuit/relay.node.ts new file mode 100644 index 0000000000..68946b306e --- /dev/null +++ b/test/circuit/relay.node.ts @@ -0,0 +1,725 @@ +/* eslint-env mocha */ +/* eslint max-nested-callbacks: ['error', 6] */ + +import { expect } from 'aegir/chai' +import defer from 'p-defer' +import pWaitFor from 'p-wait-for' +import sinon from 'sinon' +import { RELAY_V2_HOP_CODEC } from '../../src/circuit/constants.js' +import { createNode } from '../utils/creators/peer.js' +import type { Libp2pNode } from '../../src/libp2p.js' +import { createNodeOptions, discoveredRelayConfig, getRelayAddress, hasRelay, usingAsRelay } from './utils.js' +import { circuitRelayServer, circuitRelayTransport } from '../../src/circuit/index.js' +import { tcp } from '@libp2p/tcp' +import { Uint8ArrayList } from 'uint8arraylist' +import delay from 'delay' +import type { Libp2p } from '@libp2p/interface-libp2p' +import { pbStream } from 'it-pb-stream' +import { HopMessage, Status } from '../../src/circuit/pb/index.js' + +describe('circuit-relay', () => { + describe('flows with 1 listener', () => { + let local: Libp2p + let relay1: Libp2p + let relay2: Libp2p + let relay3: Libp2p + + beforeEach(async () => { + // create 1 node and 3 relays + [local, relay1, relay2, relay3] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + relay: circuitRelayServer() + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + relay: circuitRelayServer() + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + relay: circuitRelayServer() + }) + }) + ]) + }) + + afterEach(async () => { + // Stop each node + await Promise.all([local, relay1, relay2, relay3].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should ask if node supports hop on protocol change (relay protocol) and add to listen multiaddrs', async () => { + // discover relay + await local.dial(relay1.getMultiaddrs()[0]) + await discoveredRelayConfig(local, relay1) + + // wait for peer added as listen relay + await usingAsRelay(local, relay1) + + // peer has relay multicodec + const knownProtocols = await local.peerStore.protoBook.get(relay1.peerId) + expect(knownProtocols).to.include(RELAY_V2_HOP_CODEC) + }) + + it('should only add discovered relays relayed addresses', async () => { + // discover all relays and connect + await Promise.all([relay1, relay2, relay3].map(async relay => { + await local.dial(relay.getMultiaddrs()[0]) + await discoveredRelayConfig(local, relay) + })) + + const relayPeerId = await hasRelay(local) + + // find the relay we aren't using + const nonRelays = [relay1, relay2, relay3].filter(node => !node.peerId.equals(relayPeerId)) + + // should not be listening on two of them + expect(nonRelays).to.have.lengthOf(2) + + await Promise.all( + nonRelays.map(async nonRelay => { + // wait to guarantee the dialed peer is not added as a listen relay + await expect(usingAsRelay(local, nonRelay, { + timeout: 1000 + })).to.eventually.be.rejected() + }) + ) + }) + + it('should not listen on a relayed address after we disconnect from peer', async () => { + // discover one relay and connect + await local.dial(relay1.getMultiaddrs()[0]) + await discoveredRelayConfig(local, relay1) + + // wait for listening on the relay + await usingAsRelay(local, relay1) + + // disconnect from peer used for relay + await local.hangUp(relay1.peerId) + + // stop the relay so we don't reconnect to it + await relay1.stop() + + // wait for removed listening on the relay + await expect(usingAsRelay(local, relay1, { + timeout: 1000 + })).to.eventually.be.rejected() + }) + + it('should try to listen on other connected peers relayed address if one used relay disconnects', async () => { + // connect to all relays + await Promise.all([relay1, relay2, relay3].map(async relay => { + await local.dial(relay.getMultiaddrs()[0]) + })) + + // discover one relay and connect + const relayPeerId = await hasRelay(local) + + // shut down the connected relay + const relay = [local, relay1, relay2, relay3].find(node => node.peerId.equals(relayPeerId)) + + if (relay == null) { + throw new Error('could not find relay') + } + + await relay.stop() + await pWaitFor(() => local.getConnections(relay.peerId).length === 0) + + // should not be using the relay any more + await expect(usingAsRelay(local, relay, { + timeout: 1000 + })).to.eventually.be.rejected() + + // should connect to another available relay + const newRelayPeerId = await hasRelay(local) + expect(newRelayPeerId.toString()).to.not.equal(relayPeerId.toString()) + }) + + it('should try to listen on stored peers relayed address if one used relay disconnects and there are not enough connected', async () => { + // discover one relay and connect + await local.dial(relay1.getMultiaddrs()[0]) + + // wait for peer to be used as a relay + await usingAsRelay(local, relay1) + + // discover an extra relay and connect to gather its Hop support + await local.dial(relay2.getMultiaddrs()[0]) + + // wait for identify for newly dialed peer + await discoveredRelayConfig(local, relay2) + + // disconnect not used listen relay + await local.hangUp(relay2.peerId) + + // shut down connected relay + await relay1.stop() + await pWaitFor(() => local.getConnections(relay1.peerId).length === 0) + + // should have retrieved other relay details from peer store and connected to it + await usingAsRelay(local, relay2) + }) + + it('should not fail when trying to dial unreachable peers to add as hop relay and replaced removed ones', async () => { + const deferred = defer() + + // discover one relay and connect + await relay1.dial(relay2.getMultiaddrs()[0]) + + // wait for peer to be used as a relay + await usingAsRelay(relay1, relay2) + + // discover an extra relay and connect to gather its Hop support + await relay1.dial(relay3.getMultiaddrs()[0]) + + // wait for identify for newly dialled peer + await discoveredRelayConfig(relay1, relay3) + + // disconnect not used listen relay + await relay1.hangUp(relay3.peerId) + + // Stub dial + // @ts-expect-error private field + sinon.stub(relay1.components.connectionManager, 'openConnection').callsFake(async () => { + deferred.resolve() + return await Promise.reject(new Error('failed to dial')) + }) + + // Remove peer used as relay from peerStore and disconnect it + await relay1.hangUp(relay2.peerId) + await relay1.peerStore.delete(relay2.peerId) + expect(relay1.getConnections()).to.be.empty() + + // Wait for failed dial + await deferred.promise + }) + }) + + describe('flows with 2 listeners', () => { + let local: Libp2p + let remote: Libp2p + let relay1: Libp2p + let relay2: Libp2p + let relay3: Libp2p + + beforeEach(async () => { + [local, remote, relay1, relay2, relay3] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + relay: circuitRelayServer() + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + relay: circuitRelayServer() + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ], + relay: circuitRelayServer() + }) + }) + ]) + }) + + afterEach(async () => { + // Stop each node + return await Promise.all([local, remote, relay1, relay2, relay3].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should not add listener to a already relayed connection', async () => { + // Relay 1 discovers Relay 3 and connect + await relay1.peerStore.addressBook.add(relay3.peerId, relay3.getMultiaddrs()) + await relay1.dial(relay3.peerId) + await usingAsRelay(relay1, relay3) + + // Relay 2 discovers Relay 3 and connect + await relay2.peerStore.addressBook.add(relay3.peerId, relay3.getMultiaddrs()) + await relay2.dial(relay3.peerId) + await usingAsRelay(relay2, relay3) + + // Relay 1 discovers Relay 2 relayed multiaddr via Relay 3 + const ma2RelayedBy3 = relay2.getMultiaddrs()[relay2.getMultiaddrs().length - 1] + await relay1.peerStore.addressBook.add(relay2.peerId, [ma2RelayedBy3]) + await relay1.dial(relay2.peerId) + + // Peer not added as listen relay + await expect(usingAsRelay(relay1, relay2, { + timeout: 1000 + })).to.eventually.be.rejected() + }) + + it('should be able to dial a peer from its relayed address previously added', async () => { + // discover relay and make reservation + await remote.dial(relay1.getMultiaddrs()[0]) + await usingAsRelay(remote, relay1) + + // dial the remote through the relay + const ma = getRelayAddress(remote) + await local.dial(ma) + }) + + it('should not stay connected to a relay when not already connected and HOP fails', async () => { + // dial the remote through the relay + const relayedMultiaddr = relay1.getMultiaddrs()[0].encapsulate(`/p2p-circuit/p2p/${remote.peerId.toString()}`) + + await expect(local.dial(relayedMultiaddr)) + .to.eventually.be.rejected() + .and.to.have.property('message').that.matches(/NO_RESERVATION/) + + // we should not be connected to the relay, because we weren't before the dial + expect(local.getConnections(relay1.peerId)).to.be.empty() + }) + + it('dialer should stay connected to an already connected relay on hop failure', async () => { + await local.dial(relay1.getMultiaddrs()[0]) + + // dial the remote through the relay + const relayedMultiaddr = relay1.getMultiaddrs()[0].encapsulate(`/p2p-circuit/p2p/${remote.peerId.toString()}`) + + await expect(local.dial(relayedMultiaddr)) + .to.eventually.be.rejected() + .and.to.have.property('message').that.matches(/NO_RESERVATION/) + + // we should still be connected to the relay + const conns = local.getConnections(relay1.peerId) + expect(conns).to.have.lengthOf(1) + expect(conns).to.have.nested.property('[0].stat.status', 'OPEN') + }) + + it('destination peer should stay connected to an already connected relay on hop failure', async () => { + await local.dial(relay1.getMultiaddrs()[0]) + await usingAsRelay(local, relay1) + + const conns = relay1.getConnections(local.peerId) + expect(conns).to.have.lengthOf(1) + + // this should fail as the local peer has HOP disabled + await expect(conns[0].newStream(RELAY_V2_HOP_CODEC)) + .to.be.rejected() + + // we should still be connected to the relay + const remoteConns = local.getConnections(relay1.peerId) + expect(remoteConns).to.have.lengthOf(1) + expect(remoteConns).to.have.nested.property('[0].stat.status', 'OPEN') + }) + + it('should fail to dial remote over relay over relay', async () => { + // relay1 dials relay2 + await relay1.dial(relay2.getMultiaddrs()[0]) + await usingAsRelay(relay1, relay2) + + // remote dials relay2 + await remote.dial(relay2.getMultiaddrs()[0]) + await usingAsRelay(remote, relay2) + + // local dials remote via relay1 via relay2 + const ma = getRelayAddress(relay1).encapsulate(`/p2p-circuit/p2p/${remote.peerId.toString()}`) + + await expect(local.dial(ma)).to.eventually.be.rejected + .with.property('code', 'ERR_RELAYED_DIAL') + }) + + it('should fail to open connection over relayed connection', async () => { + // relay1 dials relay2 + await relay1.dial(relay2.getMultiaddrs()[0]) + await usingAsRelay(relay1, relay2) + + // remote dials relay2 + await remote.dial(relay2.getMultiaddrs()[0]) + await usingAsRelay(remote, relay2) + + // local dials relay1 via relay2 + const ma = getRelayAddress(relay1) + + // open hop stream and try to connect to remote + const stream = await local.dialProtocol(ma, RELAY_V2_HOP_CODEC) + + const hopStream = pbStream(stream).pb(HopMessage) + + hopStream.write({ + type: HopMessage.Type.CONNECT, + peer: { + id: remote.peerId.toBytes(), + addrs: [] + } + }) + + const response = await hopStream.read() + expect(response).to.have.property('type', HopMessage.Type.STATUS) + expect(response).to.have.property('status', Status.PERMISSION_DENIED) + }) + }) + + describe('flows with data limit', () => { + let local: Libp2pNode + let remote: Libp2pNode + let relay: Libp2pNode + + beforeEach(async () => { + [local, remote, relay] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp() + ], + relay: circuitRelayServer({ + reservations: { + defaultDataLimit: 1024n + } + }) + }) + }) + ]) + }) + + afterEach(async () => { + // Stop each node + return await Promise.all([local, remote, relay].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should close the connection when too much data is sent', async () => { + // local discover relay + await local.dial(relay.getMultiaddrs()[0]) + await usingAsRelay(local, relay) + + // remote discover relay + await remote.dial(relay.getMultiaddrs()[0]) + await usingAsRelay(remote, relay) + + // collect transferred data + const transferred = new Uint8ArrayList() + + // set up an echo server on the remote + const protocol = '/test/protocol/1.0.0' + await remote.handle(protocol, ({ stream }) => { + void Promise.resolve().then(async () => { + for await (const buf of stream.source) { + transferred.append(buf) + } + }) + }) + + // dial the remote from the local through the relay + const ma = getRelayAddress(remote) + const stream = await local.dialProtocol(ma, protocol) + + try { + await stream.sink(async function * () { + while (true) { + await delay(100) + yield new Uint8Array(2048) + } + }()) + } catch {} + + // we cannot be exact about this figure because mss, encryption and other + // protocols all send data over connections when they are opened + expect(transferred.byteLength).to.be.lessThan(1024) + }) + }) + + describe('flows with duration limit', () => { + let local: Libp2pNode + let remote: Libp2pNode + let relay: Libp2pNode + + beforeEach(async () => { + [local, remote, relay] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport({ + discoverRelays: 1 + }) + ] + }) + }), + createNode({ + config: createNodeOptions({ + transports: [ + tcp() + ], + relay: circuitRelayServer({ + reservations: { + defaultDurationLimit: 1000 + } + }) + }) + }) + ]) + }) + + afterEach(async () => { + // Stop each node + return await Promise.all([local, remote, relay].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should close the connection when connection is open for too long', async () => { + // local discover relay + await local.dial(relay.getMultiaddrs()[0]) + await usingAsRelay(local, relay) + + // remote discover relay + await remote.dial(relay.getMultiaddrs()[0]) + await usingAsRelay(remote, relay) + + // collect transferred data + const transferred = new Uint8ArrayList() + + // set up an echo server on the remote + const protocol = '/test/protocol/1.0.0' + await remote.handle(protocol, ({ stream }) => { + void Promise.resolve().then(async () => { + for await (const buf of stream.source) { + transferred.append(buf) + } + }) + }) + + // dial the remote from the local through the relay + const ma = getRelayAddress(remote) + const stream = await local.dialProtocol(ma, protocol) + + try { + await stream.sink(async function * () { + while (true) { + await delay(100) + yield new Uint8Array(10) + await delay(5000) + } + }()) + } catch {} + + expect(transferred.byteLength).to.equal(10) + }) + }) + + describe('preconfigured relay address', () => { + let local: Libp2p + let remote: Libp2p + let relay: Libp2p + + beforeEach(async () => { + relay = await createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport() + ], + relay: circuitRelayServer() + }) + }) + + ;[local, remote] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport() + ] + }) + }), + createNode({ + config: createNodeOptions({ + addresses: { + listen: [ + `${relay.getMultiaddrs()[0].toString()}/p2p-circuit` + ] + }, + transports: [ + tcp(), + circuitRelayTransport() + ] + }) + }) + ]) + }) + + afterEach(async () => { + // Stop each node + await Promise.all([local, remote, relay].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should be able to dial remote on preconfigured relay address', async () => { + const ma = getRelayAddress(remote) + + await expect(local.dial(ma)).to.eventually.be.ok() + }) + }) + + describe('preconfigured relay without a peer id', () => { + let local: Libp2p + let remote: Libp2p + let relay: Libp2p + + beforeEach(async () => { + relay = await createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport() + ], + relay: circuitRelayServer() + }) + }) + + ;[local, remote] = await Promise.all([ + createNode({ + config: createNodeOptions({ + transports: [ + tcp(), + circuitRelayTransport() + ] + }) + }), + createNode({ + config: createNodeOptions({ + addresses: { + listen: [ + `${relay.getMultiaddrs()[0].toString().split('/p2p')[0]}/p2p-circuit` + ] + }, + transports: [ + tcp(), + circuitRelayTransport() + ] + }) + }) + ]) + }) + + afterEach(async () => { + // Stop each node + await Promise.all([local, remote, relay].map(async libp2p => { + if (libp2p != null) { + await libp2p.stop() + } + })) + }) + + it('should be able to dial remote on preconfigured relay address', async () => { + const ma = getRelayAddress(remote) + + await expect(local.dial(ma)).to.eventually.be.ok() + }) + }) +}) diff --git a/test/circuit/reservation-store.spec.ts b/test/circuit/reservation-store.spec.ts index 9682f65c14..048cb84392 100644 --- a/test/circuit/reservation-store.spec.ts +++ b/test/circuit/reservation-store.spec.ts @@ -1,13 +1,13 @@ +/* eslint-env mocha */ + import { multiaddr } from '@multiformats/multiaddr' import { expect } from 'aegir/chai' import { DEFAULT_DATA_LIMIT, DEFAULT_DURATION_LIMIT } from '../../src/circuit/constants.js' import { Status } from '../../src/circuit/pb/index.js' -import { ReservationStore } from '../../src/circuit/reservation-store.js' +import { ReservationStore } from '../../src/circuit/server/reservation-store.js' import { createPeerId } from '../utils/creators/peer.js' -/* eslint-env mocha */ - -describe('Circuit v2 - reservation store', function () { +describe('circuit-relay server reservation store', function () { it('should add reservation', async function () { const store = new ReservationStore({ maxReservations: 2 }) const peer = await createPeerId() @@ -16,6 +16,7 @@ describe('Circuit v2 - reservation store', function () { expect(result.expire).to.not.be.undefined() expect(await store.hasReservation(peer)).to.be.true() }) + it('should add reservation if peer already has reservation', async function () { const store = new ReservationStore({ maxReservations: 1 }) const peer = await createPeerId() diff --git a/test/circuit/stop.spec.ts b/test/circuit/stop.spec.ts index 2c67eb1e89..ae16c913ef 100644 --- a/test/circuit/stop.spec.ts +++ b/test/circuit/stop.spec.ts @@ -1,64 +1,114 @@ -import { pair } from 'it-pair' -import type { Connection, Stream } from '@libp2p/interface-connection' -import type { PeerId } from '@libp2p/interface-peer-id' -import { createPeerIds } from '../utils/creators/peer.js' -import { handleStop, stop } from '../../src/circuit/stop.js' -import { Status, StopMessage } from '../../src/circuit/pb/index.js' -import { expect } from 'aegir/chai' -import sinon from 'sinon' -import { mockConnection, mockMultiaddrConnection, mockStream } from '@libp2p/interface-mocks' -import { pbStream, ProtobufStream } from 'it-pb-stream' - /* eslint-env mocha */ -describe('Circuit v2 - stop protocol', function () { - let srcPeer: PeerId, relayPeer: PeerId, conn: Connection, pbstr: ProtobufStream +import type { AddressManager } from '@libp2p/interface-address-manager' +import type { ConnectionManager } from '@libp2p/interface-connection-manager' +import type { Connection } from '@libp2p/interface-connection' +import type { ContentRouting } from '@libp2p/interface-content-routing' +import { mockStream } from '@libp2p/interface-mocks' +import type { PeerStore } from '@libp2p/interface-peer-store' +import type { Registrar, StreamHandler } from '@libp2p/interface-registrar' +import type { Transport, TransportManager, Upgrader } from '@libp2p/interface-transport' +import { isStartable } from '@libp2p/interfaces/startable' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { expect } from 'aegir/chai' +import { pbStream, MessageStream } from 'it-pb-stream' +import { stubInterface } from 'sinon-ts' +import { circuitRelayTransport } from '../../src/circuit/index.js' +import { Status, StopMessage } from '../../src/circuit/pb/index.js' +import type { PeerId } from '@libp2p/interface-peer-id' +import { duplexPair } from 'it-pair/duplex' + +describe('circuit-relay stop protocol', function () { + let transport: Transport + let handler: StreamHandler + let pbstr: MessageStream + let sourcePeer: PeerId beforeEach(async () => { - [srcPeer, relayPeer] = await createPeerIds(2) - conn = mockConnection(mockMultiaddrConnection(pair(), relayPeer)) - pbstr = pbStream(mockStream(pair())) + const components = { + addressManager: stubInterface(), + connectionManager: stubInterface(), + contentRouting: stubInterface(), + peerId: await createEd25519PeerId(), + peerStore: stubInterface(), + registrar: stubInterface(), + transportManager: stubInterface(), + upgrader: stubInterface() + } + + transport = circuitRelayTransport({})(components) + + if (isStartable(transport)) { + await transport.start() + } + + sourcePeer = await createEd25519PeerId() + + handler = components.registrar.handle.getCall(0).args[1] + + const [localStream, remoteStream] = duplexPair() + + handler({ + stream: mockStream(remoteStream), + connection: stubInterface() + }) + + pbstr = pbStream(localStream).pb(StopMessage) }) this.afterEach(async function () { - await conn.close() + if (isStartable(transport)) { + await transport.stop() + } }) it('handle stop - success', async function () { - await handleStop({ connection: conn, request: { type: StopMessage.Type.CONNECT, peer: { id: srcPeer.toBytes(), addrs: [] } }, pbstr }) - const response = await pbstr.pb(StopMessage).read() + pbstr.write({ + type: StopMessage.Type.CONNECT, + peer: { + id: sourcePeer.toBytes(), + addrs: [] + } + }) + + const response = await pbstr.read() expect(response.status).to.be.equal(Status.OK) }) it('handle stop error - invalid request - wrong type', async function () { - await handleStop({ connection: conn, request: { type: StopMessage.Type.STATUS, peer: { id: srcPeer.toBytes(), addrs: [] } }, pbstr }) - const response = await pbstr.pb(StopMessage).read() + pbstr.write({ + type: StopMessage.Type.STATUS, + peer: { + id: sourcePeer.toBytes(), + addrs: [] + } + }) + + const response = await pbstr.read() expect(response.status).to.be.equal(Status.UNEXPECTED_MESSAGE) }) it('handle stop error - invalid request - missing peer', async function () { - await handleStop({ connection: conn, request: { type: StopMessage.Type.CONNECT }, pbstr }) - const response = await pbstr.pb(StopMessage).read() - expect(response.status).to.be.equal(Status.MALFORMED_MESSAGE) - }) + pbstr.write({ + type: StopMessage.Type.CONNECT + }) - it('handle stop error - invalid request - invalid peer addr', async function () { - await handleStop({ connection: conn, request: { type: StopMessage.Type.CONNECT, peer: { id: srcPeer.toBytes(), addrs: [new Uint8Array(32)] } }, pbstr }) - const response = await pbstr.pb(StopMessage).read() + const response = await pbstr.read() expect(response.status).to.be.equal(Status.MALFORMED_MESSAGE) }) - it('send stop - success', async function () { - const streamStub = sinon.stub(conn, 'newStream') - streamStub.resolves(mockStream(pair())) - await stop({ connection: conn, request: { type: StopMessage.Type.CONNECT, peer: { id: srcPeer.toBytes(), addrs: [] } } }) - pbstr.pb(StopMessage).write({ type: StopMessage.Type.STATUS, status: Status.OK }) - }) + it('handle stop error - invalid request - invalid peer addr', async function () { + pbstr.write({ + type: StopMessage.Type.CONNECT, + peer: { + id: sourcePeer.toBytes(), + addrs: [ + new Uint8Array(32) + ] + } + }) - it('send stop - should not fall apart with invalid status response', async function () { - const streamStub = sinon.stub(conn, 'newStream') - streamStub.resolves(mockStream(pair())) - await stop({ connection: conn, request: { type: StopMessage.Type.CONNECT, peer: { id: srcPeer.toBytes(), addrs: [] } } }) - pbstr.write(new Uint8Array(10)) + const response = await pbstr.read() + expect(response.status).to.be.equal(Status.MALFORMED_MESSAGE) }) }) diff --git a/test/circuit/utils.ts b/test/circuit/utils.ts new file mode 100644 index 0000000000..2efd22cc70 --- /dev/null +++ b/test/circuit/utils.ts @@ -0,0 +1,168 @@ +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 { AbortOptions } from '@libp2p/interfaces' +import type { CID, Version } from 'multiformats' +import type { Libp2pOptions } from '../../src/index.js' +import { createBaseOptions } from '../utils/base-options.js' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import type { AddressManager } from '@libp2p/interface-address-manager' +import type { Libp2p } from '@libp2p/interface-libp2p' +import type { Options as PWaitForOptions } from 'p-wait-for' +import pWaitFor from 'p-wait-for' +import { peerIdFromString } from '@libp2p/peer-id' +import { RELAY_V2_HOP_CODEC } from '../../src/circuit/constants.js' +import type { Multiaddr } from '@multiformats/multiaddr' + +const listenAddr = '/ip4/127.0.0.1/tcp/0' + +export function createNodeOptions (...overrides: Libp2pOptions[]): Libp2pOptions { + return createBaseOptions({ + addresses: { + listen: [listenAddr] + }, + connectionManager: { + autoDial: false + } + }, ...overrides) +} + +export async function usingAsRelay (node: Libp2p, relay: Libp2p, opts?: PWaitForOptions) { + // Wait for peer to be used as a relay + await pWaitFor(() => { + const relayAddrs = node.getMultiaddrs().filter(addr => addr.protoNames().includes('p2p-circuit')) + + if (relayAddrs.length > 0) { + const search = `${relay.peerId.toString()}/p2p-circuit` + + if (relayAddrs.find(addr => addr.toString().includes(search)) != null) { + return true + } + + throw new Error('node had relay addresses that did not include the expected relay server') + } + + return false + }, opts) +} + +export async function hasRelay (node: Libp2p, opts?: PWaitForOptions): Promise { + let relayPeerId: PeerId | undefined + + // Wait for peer to be used as a relay + await pWaitFor(() => { + const relayAddrs = node.getMultiaddrs().filter(addr => addr.protoNames().includes('p2p-circuit')) + + if (relayAddrs.length === 0) { + return false + } + + if (relayAddrs.length !== 1) { + throw new Error(`node listening on too many relays - ${relayAddrs.length}`) + } + + for (const [code, value] of relayAddrs[0].stringTuples()) { + if (code === 421 && value != null) { + relayPeerId = peerIdFromString(value) + break + } + } + + if (relayPeerId == null) { + throw new Error('node had circuit relay address but address had no peer id') + } + + if (relayPeerId.equals(node.peerId)) { + throw new Error('node was listening on itself as a relay') + } + + return true + }, opts) + + if (relayPeerId == null) { + throw new Error('could not find relay peer id') + } + + return relayPeerId +} + +export async function discoveredRelayConfig (node: Libp2p, relay: Libp2p, opts?: PWaitForOptions) { + await pWaitFor(async () => { + const peerData = await node.peerStore.get(relay.peerId) + return peerData.protocols.includes(RELAY_V2_HOP_CODEC) + }, opts) +} + +export function getRelayAddress (node: Libp2p): Multiaddr { + const relayAddrs = node.getMultiaddrs().filter(addr => addr.protoNames().includes('p2p-circuit')) + + if (relayAddrs.length === 0) { + throw new Error('could not find relay address') + } + + if (relayAddrs.length > 1) { + throw new Error('had too many relay addresses') + } + + return relayAddrs[0] +} + +export interface MockContentRoutingComponents { + peerId: PeerId + addressManager: AddressManager +} + +export class MockContentRouting implements ContentRouting { + static providers: Map = new Map() + static data: Map = new Map() + + static reset () { + MockContentRouting.providers.clear() + MockContentRouting.data.clear() + } + + private readonly peerId: PeerId + private readonly addressManager: AddressManager + + constructor (components: MockContentRoutingComponents) { + this.peerId = components.peerId + this.addressManager = components.addressManager + } + + async provide (cid: CID, options?: AbortOptions) { + let providers = MockContentRouting.providers.get(cid.toString()) ?? [] + providers = providers.filter(peerInfo => !peerInfo.id.equals(this.peerId)) + + providers.push({ + id: this.peerId, + multiaddrs: this.addressManager.getAddresses(), + protocols: [] + }) + + MockContentRouting.providers.set(cid.toString(), providers) + } + + async * findProviders (cid: CID, options?: AbortOptions | undefined) { + yield * MockContentRouting.providers.get(cid.toString()) ?? [] + } + + async put (key: Uint8Array, value: Uint8Array, options?: AbortOptions) { + MockContentRouting.data.set(uint8ArrayToString(key, 'base58btc'), value) + } + + async get (key: Uint8Array, options?: AbortOptions) { + const value = MockContentRouting.data.get(uint8ArrayToString(key, 'base58btc')) + + if (value != null) { + return await Promise.resolve(value) + } + + return await Promise.reject(new Error('Not found')) + } +} + +export function mockContentRouting (): (components: MockContentRoutingComponents) => ContentRouting { + return (components: MockContentRoutingComponents) => { + return new MockContentRouting(components) + } +} diff --git a/test/configuration/protocol-prefix.node.ts b/test/configuration/protocol-prefix.node.ts index 6418dd093f..e53291bc83 100644 --- a/test/configuration/protocol-prefix.node.ts +++ b/test/configuration/protocol-prefix.node.ts @@ -33,7 +33,6 @@ describe('Protocol prefix is configurable', () => { const protocols = await libp2p.peerStore.protoBook.get(libp2p.peerId) expect(protocols).to.include.members([ `/${testProtocol}/fetch/0.0.1`, - '/libp2p/circuit/relay/0.2.0/stop', `/${testProtocol}/id/1.0.0`, `/${testProtocol}/id/push/1.0.0`, `/${testProtocol}/ping/1.0.0` @@ -46,7 +45,6 @@ describe('Protocol prefix is configurable', () => { const protocols = await libp2p.peerStore.protoBook.get(libp2p.peerId) expect(protocols).to.include.members([ - '/libp2p/circuit/relay/0.2.0/stop', '/ipfs/id/1.0.0', '/ipfs/id/push/1.0.0', '/ipfs/ping/1.0.0', diff --git a/test/configuration/utils.ts b/test/configuration/utils.ts index d1983f9a26..721b636e9e 100644 --- a/test/configuration/utils.ts +++ b/test/configuration/utils.ts @@ -9,6 +9,7 @@ import type { Message, PublishResult, PubSubInit, PubSubRPC, PubSubRPCMessage } import type { Libp2pInit, Libp2pOptions } from '../../src/index.js' import type { PeerId } from '@libp2p/interface-peer-id' import * as cborg from 'cborg' +import { circuitRelayTransport } from '../../src/circuit/index.js' const relayAddr = MULTIADDRS_WEBSOCKETS[0] @@ -73,6 +74,7 @@ export const pubsubSubsystemOptions: Libp2pOptions = mergeOptions(baseOptions, { listen: [`${relayAddr.toString()}/p2p-circuit`] }, transports: [ - webSockets({ filter: filters.all }) + webSockets({ filter: filters.all }), + circuitRelayTransport() ] }) diff --git a/test/dialing/resolver.spec.ts b/test/dialing/resolver.spec.ts index 0334a607bf..c7a1e3a13d 100644 --- a/test/dialing/resolver.spec.ts +++ b/test/dialing/resolver.spec.ts @@ -10,13 +10,12 @@ import { createBaseOptions } from '../utils/base-options.browser.js' import { MULTIADDRS_WEBSOCKETS } from '../fixtures/browser.js' import type { PeerId } from '@libp2p/interface-peer-id' import type { Libp2pNode } from '../../src/libp2p.js' -import { Circuit } from '../../src/circuit/transport.js' import pDefer from 'p-defer' import { mockConnection, mockDuplex, mockMultiaddrConnection } from '@libp2p/interface-mocks' import { peerIdFromString } from '@libp2p/peer-id' -import { pEvent } from 'p-event' import { createFromJSON } from '@libp2p/peer-id-factory' -import { RELAY_V2_HOP_CODEC } from '../../src/circuit/multicodec.js' +import { RELAY_V2_HOP_CODEC } from '../../src/circuit/constants.js' +import { circuitRelayServer } from '../../src/circuit/index.js' const relayAddr = MULTIADDRS_WEBSOCKETS[0] @@ -53,18 +52,8 @@ describe('Dialing (resolvable addresses)', () => { resolvers: { dnsaddr: resolver } - }, - relay: { - enabled: true, - reservationManager: { - enabled: true - }, - hop: { - enabled: false - } } - }), - started: true + }) }), createNode({ config: createBaseOptions({ @@ -77,26 +66,19 @@ describe('Dialing (resolvable addresses)', () => { dnsaddr: resolver } }, - relay: { - enabled: true, - reservationManager: { - enabled: true - }, - hop: { - enabled: false - } - } - }), - started: true + relay: circuitRelayServer() + }) }) ]) - - await Promise.all([libp2p, remoteLibp2p].map(async n => await n.start())) }) afterEach(async () => { sinon.restore() - await Promise.all([libp2p, remoteLibp2p].map(async n => await n.stop())) + await Promise.all([libp2p, remoteLibp2p].map(async n => { + if (n != null) { + await n.stop() + } + })) }) it('resolves dnsaddr to ws local address', async () => { @@ -111,18 +93,12 @@ describe('Dialing (resolvable addresses)', () => { const relayedAddrFetched = multiaddr(relayedAddr(remoteId)) // Transport spy - const transport = getTransport(libp2p, Circuit.prototype[Symbol.toStringTag]) + const transport = getTransport(libp2p, 'libp2p/circuit-relay-v2') const transportDialSpy = sinon.spy(transport, 'dial') // Resolver stub resolver.onCall(0).returns(Promise.resolve(getDnsRelayedAddrStub(remoteId))) - // create reservation on relay - if (remoteLibp2p.circuitService == null) { - throw new Error('remote libp2p has no circuit service') - } - await pEvent(remoteLibp2p.circuitService, 'relay:reservation') - // Dial with address resolve const connection = await libp2p.dial(dialAddr) expect(connection).to.exist() @@ -144,14 +120,8 @@ describe('Dialing (resolvable addresses)', () => { // ensure remote libp2p creates reservation on relay await remoteLibp2p.components.peerStore.protoBook.add(relayId, [RELAY_V2_HOP_CODEC]) - // create reservation on relay - if (remoteLibp2p.circuitService == null) { - throw new Error('remote libp2p has no circuit service') - } - await pEvent(remoteLibp2p.circuitService, 'relay:reservation') - // Transport spy - const transport = getTransport(libp2p, Circuit.prototype[Symbol.toStringTag]) + const transport = getTransport(libp2p, 'libp2p/circuit-relay-v2') const transportDialSpy = sinon.spy(transport, 'dial') // Resolver stub @@ -216,14 +186,8 @@ describe('Dialing (resolvable addresses)', () => { // ensure remote libp2p creates reservation on relay await remoteLibp2p.components.peerStore.protoBook.add(relayId, [RELAY_V2_HOP_CODEC]) - // create reservation on relay - if (remoteLibp2p.circuitService == null) { - throw new Error('remote libp2p has no circuit service') - } - await pEvent(remoteLibp2p.circuitService, 'relay:reservation') - // Transport spy - const transport = getTransport(libp2p, Circuit.prototype[Symbol.toStringTag]) + const transport = getTransport(libp2p, 'libp2p/circuit-relay-v2') const transportDialSpy = sinon.spy(transport, 'dial') // Resolver stub diff --git a/test/fixtures/match-peer-id.ts b/test/fixtures/match-peer-id.ts new file mode 100644 index 0000000000..340a0dda8c --- /dev/null +++ b/test/fixtures/match-peer-id.ts @@ -0,0 +1,6 @@ +import type { PeerId } from '@libp2p/interface-peer-id' +import Sinon from 'sinon' + +export function matchPeerId (peerId: PeerId): Sinon.SinonMatcher { + return Sinon.match(p => p.toString() === peerId.toString()) +} diff --git a/test/interop.ts b/test/interop.ts index f326bbef0e..67da7cc3ea 100644 --- a/test/interop.ts +++ b/test/interop.ts @@ -19,6 +19,7 @@ import type { PeerId } from '@libp2p/interface-peer-id' import { peerIdFromKeys } from '@libp2p/peer-id' import { floodsub } from '@libp2p/floodsub' import { gossipsub } from '@chainsafe/libp2p-gossipsub' +import { circuitRelayServer, circuitRelayTransport } from '../src/circuit/index.js' /** * @packageDocumentation @@ -121,7 +122,7 @@ async function createJsPeer (options: SpawnOptions): Promise { addresses: { listen: options.noListen === true ? [] : ['/ip4/127.0.0.1/tcp/0'] }, - transports: [tcp()], + transports: [tcp(), circuitRelayTransport()], streamMuxers: [], connectionEncryption: [noise()], nat: { @@ -143,14 +144,8 @@ async function createJsPeer (options: SpawnOptions): Promise { } } - opts.relay = { - enabled: true, - hop: { - enabled: options.relay === true - }, - reservationManager: { - enabled: false - } + if (options.relay === true) { + opts.relay = circuitRelayServer() } if (options.dht === true) { diff --git a/test/relay/auto-relay.node.ts b/test/relay/auto-relay.node.ts deleted file mode 100644 index e623db28cf..0000000000 --- a/test/relay/auto-relay.node.ts +++ /dev/null @@ -1,437 +0,0 @@ -/* eslint-env mocha */ - -import { expect } from 'aegir/chai' -import { pEvent } from 'p-event' -import defer from 'p-defer' -import pWaitFor from 'p-wait-for' -import sinon from 'sinon' -import { RELAY_V2_HOP_CODEC } from '../../src/circuit/multicodec.js' -import { createNode } from '../utils/creators/peer.js' -import type { Libp2pNode } from '../../src/libp2p.js' -import type { Options as PWaitForOptions } from 'p-wait-for' -import { createRelayOptions, createNodeOptions } from './utils.js' -import { protocols } from '@multiformats/multiaddr' -import { StubbedInstance, stubInterface } from 'sinon-ts' -import type { ContentRouting } from '@libp2p/interface-content-routing' - -async function usingAsRelay (node: Libp2pNode, relay: Libp2pNode, opts?: PWaitForOptions) { - // Wait for peer to be used as a relay - await pWaitFor(() => { - const search = `${relay.peerId.toString()}/p2p-circuit` - return node.getMultiaddrs().find(addr => addr.toString().includes(search)) !== undefined - }, opts) -} - -async function discoveredRelayConfig (node: Libp2pNode, relay: Libp2pNode) { - await pWaitFor(async () => { - const peerData = await node.peerStore.get(relay.peerId) - return peerData.protocols.includes(RELAY_V2_HOP_CODEC) - }) -} - -describe('auto-relay', () => { - describe('basics', () => { - let libp2p: Libp2pNode - let relayLibp2p: Libp2pNode - - beforeEach(async () => { - // Create 2 nodes, and turn HOP on for the relay - libp2p = await createNode({ - config: createNodeOptions() - }) - relayLibp2p = await createNode({ - config: createRelayOptions() - }) - }) - - beforeEach(async () => { - // Start each node - return await Promise.all([libp2p, relayLibp2p].map(async libp2p => await libp2p.start())) - }) - - afterEach(async () => { - // Stop each node - return await Promise.all([libp2p, relayLibp2p].map(async libp2p => await libp2p.stop())) - }) - - it('should ask if node supports hop on protocol change (relay protocol) and add to listen multiaddrs', async () => { - // Discover relay - await libp2p.peerStore.addressBook.add(relayLibp2p.peerId, relayLibp2p.getMultiaddrs()) - await libp2p.dial(relayLibp2p.peerId) - - // Wait for peer added as listen relay - await discoveredRelayConfig(libp2p, relayLibp2p) - - // Wait to start using peer as a relay - await usingAsRelay(libp2p, relayLibp2p) - - // Peer has relay multicodec - const knownProtocols = await libp2p.peerStore.protoBook.get(relayLibp2p.peerId) - expect(knownProtocols).to.include(RELAY_V2_HOP_CODEC) - }) - }) - - describe('flows with 1 listener max', () => { - let libp2p: Libp2pNode - let relayLibp2p1: Libp2pNode - let relayLibp2p2: Libp2pNode - let relayLibp2p3: Libp2pNode - - beforeEach(async () => { - // Create 4 nodes, and turn HOP on for the relay - [libp2p, relayLibp2p1, relayLibp2p2, relayLibp2p3] = await Promise.all([ - createNode({ config: createNodeOptions() }), - createNode({ config: createRelayOptions() }), - createNode({ config: createRelayOptions() }), - createNode({ config: createRelayOptions() }) - ]) - - // Start each node - await Promise.all([libp2p, relayLibp2p1, relayLibp2p2, relayLibp2p3].map(async libp2p => await libp2p.start())) - }) - - afterEach(async () => { - // Stop each node - await Promise.all([libp2p, relayLibp2p1, relayLibp2p2, relayLibp2p3].map(async libp2p => await libp2p.stop())) - }) - - it('should ask if node supports hop on protocol change (relay protocol) and add to listen multiaddrs', async () => { - // Discover relay - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p2) - - // Wait for peer added as listen relay - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // Peer has relay multicodec - const knownProtocols = await relayLibp2p1.peerStore.protoBook.get(relayLibp2p2.peerId) - expect(knownProtocols).to.include(RELAY_V2_HOP_CODEC) - }) - - it('should be able to dial a peer from its relayed address previously added', async () => { - // Discover relay - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p2) - - // Wait for peer added as listen relay - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // Dial from the other through a relay - const relayedMultiaddr2 = relayLibp2p1.getMultiaddrs()[0].encapsulate('/p2p-circuit') - await libp2p.peerStore.addressBook.add(relayLibp2p2.peerId, [relayedMultiaddr2]) - await libp2p.dial(relayLibp2p2.peerId) - }) - - it('should only add maxListeners relayed addresses', async () => { - // Discover one relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p2) - - // Wait for peer added as listen relay - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // Relay2 has relay multicodec - const knownProtocols2 = await relayLibp2p1.peerStore.protoBook.get(relayLibp2p2.peerId) - expect(knownProtocols2).to.include(RELAY_V2_HOP_CODEC) - - // Discover an extra relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p3.peerId, relayLibp2p3.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p3.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p3) - - // Wait to guarantee the dialed peer is not added as a listen relay - await expect(usingAsRelay(relayLibp2p1, relayLibp2p3, { - timeout: 1000 - })).to.eventually.be.rejected() - - // Relay2 has relay multicodec - const knownProtocols3 = await relayLibp2p1.peerStore.protoBook.get(relayLibp2p3.peerId) - expect(knownProtocols3).to.include(RELAY_V2_HOP_CODEC) - }) - - it('should not listen on a relayed address we disconnect from peer', async () => { - if (relayLibp2p1.identifyService == null) { - throw new Error('Identify service not configured') - } - - // Spy if identify push is fired on adding/removing listen addr - sinon.spy(relayLibp2p1.identifyService, 'pushToPeerStore') - - // Discover one relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p2) - - // Wait for listening on the relay - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // Disconnect from peer used for relay - await relayLibp2p1.hangUp(relayLibp2p2.peerId) - - // Wait for removed listening on the relay - await expect(usingAsRelay(relayLibp2p1, relayLibp2p2, { - timeout: 1000 - })).to.eventually.be.rejected() - }) - - it('should try to listen on other connected peers relayed address if one used relay disconnects', async () => { - // Discover one relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p2) - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // Discover an extra relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p3.peerId, relayLibp2p3.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p3.peerId) - await discoveredRelayConfig(relayLibp2p1, relayLibp2p3) - - // Only one will be used for listening - await expect(usingAsRelay(relayLibp2p1, relayLibp2p3, { - timeout: 1000 - })).to.eventually.be.rejected() - - // Disconnect from peer used for relay - const disconnectPromise = pEvent(relayLibp2p1.connectionManager, 'peer:disconnect', { timeout: 500 }) - await relayLibp2p2.stop() - const event = await disconnectPromise - expect(event.detail.remotePeer.toString()).to.equal(relayLibp2p2.peerId.toString()) - - // Should not be using the relay any more - await expect(usingAsRelay(relayLibp2p1, relayLibp2p2, { - timeout: 1000 - })).to.eventually.be.rejected() - - // Wait for other peer connected to be added as listen addr - await usingAsRelay(relayLibp2p1, relayLibp2p3) - }) - - it('should try to listen on stored peers relayed address if one used relay disconnects and there are not enough connected', async () => { - // Discover one relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - - // Wait for peer to be used as a relay - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // Discover an extra relay and connect to gather its Hop support - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p3.peerId, relayLibp2p3.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p3.peerId) - - // wait for identify for newly dialled peer - await discoveredRelayConfig(relayLibp2p1, relayLibp2p3) - - // Disconnect not used listen relay - await relayLibp2p1.hangUp(relayLibp2p3.peerId) - - // Remove peer used as relay from peerStore and disconnect it - await relayLibp2p1.hangUp(relayLibp2p2.peerId) - await relayLibp2p1.peerStore.delete(relayLibp2p2.peerId) - await pWaitFor(() => relayLibp2p1.getConnections().length === 0) - - // Wait for other peer connected to be added as listen addr - await usingAsRelay(relayLibp2p1, relayLibp2p3) - }) - - it('should not fail when trying to dial unreachable peers to add as hop relay and replaced removed ones', async () => { - const deferred = defer() - - // Discover one relay and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, relayLibp2p2.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p2.peerId) - - // Discover an extra relay and connect to gather its Hop support - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p3.peerId, relayLibp2p3.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p3.peerId) - - // Wait for peer to be used as a relay - await usingAsRelay(relayLibp2p1, relayLibp2p2) - - // wait for identify for newly dialled peer - await discoveredRelayConfig(relayLibp2p1, relayLibp2p3) - - // Disconnect not used listen relay - await relayLibp2p1.hangUp(relayLibp2p3.peerId) - - // Stub dial - sinon.stub(relayLibp2p1.components.connectionManager, 'openConnection').callsFake(async () => { - deferred.resolve() - return await Promise.reject(new Error('failed to dial')) - }) - - // Remove peer used as relay from peerStore and disconnect it - await relayLibp2p1.hangUp(relayLibp2p2.peerId) - await relayLibp2p1.peerStore.delete(relayLibp2p2.peerId) - expect(relayLibp2p1.getConnections()).to.be.empty() - - // Wait for failed dial - await deferred.promise - }) - }) - - describe('flows with 2 max listeners', () => { - let relayLibp2p1: Libp2pNode - let relayLibp2p2: Libp2pNode - let relayLibp2p3: Libp2pNode - - beforeEach(async () => { - // Create 3 nodes, and turn HOP on for the relay - [relayLibp2p1, relayLibp2p2, relayLibp2p3] = await Promise.all([ - createNode({ config: createRelayOptions() }), - createNode({ config: createRelayOptions() }), - createNode({ config: createRelayOptions() }) - ]) - - // Start each node - await Promise.all([relayLibp2p1, relayLibp2p2, relayLibp2p3].map(async libp2p => await libp2p.start())) - }) - - afterEach(async () => { - // Stop each node - return await Promise.all([relayLibp2p1, relayLibp2p2, relayLibp2p3].map(async libp2p => await libp2p.stop())) - }) - - it('should not add listener to a already relayed connection', async () => { - // Relay 1 discovers Relay 3 and connect - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p3.peerId, relayLibp2p3.getMultiaddrs()) - await relayLibp2p1.dial(relayLibp2p3.peerId) - await usingAsRelay(relayLibp2p1, relayLibp2p3) - - // Relay 2 discovers Relay 3 and connect - await relayLibp2p2.peerStore.addressBook.add(relayLibp2p3.peerId, relayLibp2p3.getMultiaddrs()) - await relayLibp2p2.dial(relayLibp2p3.peerId) - await usingAsRelay(relayLibp2p2, relayLibp2p3) - - // Relay 1 discovers Relay 2 relayed multiaddr via Relay 3 - const ma2RelayedBy3 = relayLibp2p2.getMultiaddrs()[relayLibp2p2.getMultiaddrs().length - 1] - await relayLibp2p1.peerStore.addressBook.add(relayLibp2p2.peerId, [ma2RelayedBy3]) - await relayLibp2p1.dial(relayLibp2p2.peerId) - - // Peer not added as listen relay - await expect(usingAsRelay(relayLibp2p1, relayLibp2p2, { - timeout: 1000 - })).to.eventually.be.rejected() - }) - }) - - describe('discovery', () => { - let local: Libp2pNode - let remote: Libp2pNode - let relayLibp2p: Libp2pNode - let localDelegate: StubbedInstance - let remoteDelegate: StubbedInstance - let relayDelegate: StubbedInstance - - beforeEach(async () => { - localDelegate = stubInterface() - localDelegate.findProviders.returns(async function * () {}()) - - remoteDelegate = stubInterface() - remoteDelegate.findProviders.returns(async function * () {}()) - - relayDelegate = stubInterface() - relayDelegate.provide.returns(Promise.resolve()) - relayDelegate.findProviders.returns(async function * () {}()) - - ;[local, remote, relayLibp2p] = await Promise.all([ - createNode({ - config: createNodeOptions({ - contentRouters: [ - () => localDelegate - ] - }) - }), - createNode({ - config: createNodeOptions({ - contentRouters: [ - () => remoteDelegate - ] - }) - }), - createNode({ - config: createRelayOptions({ - relay: { - advertise: { - bootDelay: 1000, - ttl: 1000, - enabled: true - }, - reservationManager: { - enabled: true, - maxReservations: 1 - } - }, - contentRouters: [ - () => relayDelegate - ] - }) - }) - ]) - }) - - beforeEach(async () => { - // Start each node - await Promise.all([local, remote, relayLibp2p].map(async libp2p => await libp2p.start())) - - // Should provide on start - await pWaitFor(() => relayDelegate.provide.callCount === 1) - - const provider = relayLibp2p.peerId - const multiaddrs = relayLibp2p.getMultiaddrs().map(ma => ma.decapsulateCode(protocols('p2p').code)) - - localDelegate.findProviders.returns(async function * () { - yield { - id: provider, - multiaddrs, - protocols: [] - } - }()) - }) - - afterEach(async () => { - // Stop each node - return await Promise.all([local, remote, relayLibp2p].map(async libp2p => await libp2p.stop())) - }) - - it('should find providers for relay and add it as listen relay', async () => { - const originalMultiaddrsLength = local.getMultiaddrs().length - - // Spy Find Providers - const relayAddr = relayLibp2p.getMultiaddrs().pop() - - if (relayAddr == null) { - throw new Error('Relay had no addresses') - } - - // connect to relay - await local.dial(relayAddr) - - // should start using the relay - await usingAsRelay(local, relayLibp2p) - - // disconnect from relay, should start looking for new relays - await local.hangUp(relayAddr) - - // Should try to find relay service providers - await pWaitFor(() => localDelegate.findProviders.callCount === 1, { - timeout: 1000 - }) - - // Wait for peer added as listen relay - await pWaitFor(() => local.getMultiaddrs().length === originalMultiaddrsLength + 1, { - timeout: 1000 - }) - - const relayedAddr = local.getMultiaddrs()[local.getMultiaddrs().length - 1] - await remote.peerStore.addressBook.set(local.peerId, [relayedAddr]) - - // Dial from remote through the relayed address - const conn = await remote.dial(local.peerId) - - expect(conn).to.exist() - }) - }) -}) diff --git a/test/relay/relay.node.ts b/test/relay/relay.node.ts deleted file mode 100644 index ba16029d4c..0000000000 --- a/test/relay/relay.node.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { expect } from 'aegir/chai' -import { multiaddr } from '@multiformats/multiaddr' -import { pipe } from 'it-pipe' -import { pEvent } from 'p-event' -import * as sinon from 'sinon' -import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' -import { RELAY_V2_HOP_CODEC } from '../../src/circuit/multicodec.js' -import { codes as Errors } from '../../src/errors.js' -import type { Libp2pNode } from '../../src/libp2p.js' -import { createNode } from '../utils/creators/peer.js' -import { createNodeOptions, createRelayOptions } from './utils.js' -import all from 'it-all' -import delay from 'delay' - -/* eslint-env mocha */ - -describe('Dialing (via relay, TCP)', () => { - let srcLibp2p: Libp2pNode - let relayLibp2p: Libp2pNode - let dstLibp2p: Libp2pNode - - beforeEach(async () => { - // Create 3 nodes, and turn HOP on for the relay - [srcLibp2p, relayLibp2p, dstLibp2p] = await Promise.all([ - createNode({ - config: createNodeOptions({ - relay: { - reservationManager: { - enabled: false - } - } - }) - }), - createNode({ - config: createRelayOptions({ - relay: { - reservationManager: { - enabled: false - } - } - }) - }), - createNode({ - config: createNodeOptions({ - relay: { - reservationManager: { - enabled: true - } - } - }) - }) - ]) - - await dstLibp2p.handle('/echo/1.0.0', ({ stream }) => { - void pipe(stream, stream) - }) - - // Start each node - await Promise.all([srcLibp2p, relayLibp2p, dstLibp2p].map(async libp2p => await libp2p.start())) - }) - - afterEach(async () => { - // Stop each node - return await Promise.all([srcLibp2p, relayLibp2p, dstLibp2p].map(async libp2p => await libp2p.stop())) - }) - - it('should be able to connect to a peer over a relay with active connections', async () => { - const relayAddr = relayLibp2p.components.transportManager.getAddrs()[0] - const relayIdString = relayLibp2p.peerId.toString() - - await dstLibp2p.dial(relayAddr.encapsulate(`/p2p/${relayIdString}`)) - // make sure we have reservation before trying to dial. Previously relay initiated connection. - if (dstLibp2p.circuitService == null) { - throw new Error('remote libp2p has no circuit service') - } - await pEvent(dstLibp2p.circuitService, 'relay:reservation') - const dialAddr = relayAddr - .encapsulate(`/p2p/${relayIdString}`) - .encapsulate(`/p2p-circuit/p2p/${dstLibp2p.peerId.toString()}`) - - const connection = await srcLibp2p.dial(dialAddr) - - expect(connection).to.exist() - expect(connection.remotePeer.toBytes()).to.eql(dstLibp2p.peerId.toBytes()) - expect(connection.remoteAddr).to.eql(dialAddr) - - const echoStream = await connection.newStream('/echo/1.0.0') - - const input = uint8ArrayFromString('hello') - const [output] = await pipe( - [input], - echoStream, - async (source) => await all(source) - ) - - expect(output.slice()).to.eql(input) - echoStream.close() - }) - - it('should fail to connect to a peer over a relay with inactive connections', async () => { - const relayAddr = relayLibp2p.components.transportManager.getAddrs()[0] - const relayIdString = relayLibp2p.peerId.toString() - - const dialAddr = relayAddr - .encapsulate(`/p2p/${relayIdString}`) - .encapsulate(`/p2p-circuit/p2p/${dstLibp2p.peerId.toString()}`) - - await expect(srcLibp2p.dial(dialAddr)) - .to.eventually.be.rejected() - .and.to.have.property('code', Errors.ERR_HOP_REQUEST_FAILED) - }) - - it('should not stay connected to a relay when not already connected and HOP fails', async () => { - const relayAddr = relayLibp2p.components.transportManager.getAddrs()[0] - const relayIdString = relayLibp2p.peerId.toString() - - const dialAddr = relayAddr - .encapsulate(`/p2p/${relayIdString}`) - .encapsulate(`/p2p-circuit/p2p/${dstLibp2p.peerId.toString()}`) - - await expect(srcLibp2p.dial(dialAddr)) - .to.eventually.be.rejected() - .and.to.have.property('code', Errors.ERR_HOP_REQUEST_FAILED) - - // We should not be connected to the relay, because we weren't before the dial - const srcToRelayConns = srcLibp2p.components.connectionManager.getConnections(relayLibp2p.peerId) - expect(srcToRelayConns).to.be.empty() - }) - - it('dialer should stay connected to an already connected relay on hop failure', async () => { - const relayIdString = relayLibp2p.peerId.toString() - const relayAddr = relayLibp2p.components.transportManager.getAddrs()[0].encapsulate(`/p2p/${relayIdString}`) - - const dialAddr = relayAddr - .encapsulate(`/p2p-circuit/p2p/${dstLibp2p.peerId.toString()}`) - - await srcLibp2p.dial(relayAddr) - - await expect(srcLibp2p.dial(dialAddr)) - .to.eventually.be.rejected() - .and.to.have.property('code', Errors.ERR_HOP_REQUEST_FAILED) - - const srcToRelayConn = srcLibp2p.components.connectionManager.getConnections(relayLibp2p.peerId) - expect(srcToRelayConn).to.have.lengthOf(1) - expect(srcToRelayConn).to.have.nested.property('[0].stat.status', 'OPEN') - }) - - it('destination peer should stay connected to an already connected relay on hop failure', async () => { - const relayIdString = relayLibp2p.peerId.toString() - const relayAddr = relayLibp2p.components.transportManager.getAddrs()[0].encapsulate(`/p2p/${relayIdString}`) - - const dialAddr = relayAddr - .encapsulate(`/p2p-circuit/p2p/${dstLibp2p.peerId.toString()}`) - - // Connect the destination peer and the relay - const tcpAddrs = dstLibp2p.components.transportManager.getAddrs() - sinon.stub(dstLibp2p.components.addressManager, 'getListenAddrs').returns([multiaddr(`${relayAddr.toString()}/p2p-circuit`)]) - - await dstLibp2p.components.transportManager.listen(dstLibp2p.components.addressManager.getListenAddrs()) - expect(dstLibp2p.components.transportManager.getAddrs()).to.have.deep.members([...tcpAddrs, dialAddr.decapsulate('p2p')]) - - // send an invalid relay message from the relay to the destination peer - const connections = relayLibp2p.getConnections(dstLibp2p.peerId) - // this should fail as the destination peer has HOP disabled - await expect(connections[0].newStream(RELAY_V2_HOP_CODEC)) - .to.be.rejectedWith(/protocol selection failed/) - // empty messages are encoded as { type: RESERVE } for the hop codec, - // so we make the message invalid by adding a zeroed byte - - // should still be connected - const dstToRelayConn = dstLibp2p.components.connectionManager.getConnections(relayLibp2p.peerId) - expect(dstToRelayConn).to.have.lengthOf(1) - expect(dstToRelayConn).to.have.nested.property('[0].stat.status', 'OPEN') - }) - - it('should time out when establishing a relay connection', async () => { - await relayLibp2p.stop() - - relayLibp2p = await createNode({ - config: createRelayOptions({ - relay: { - enabled: true, - hop: { - // very short timeout - timeout: 500 - } - } - }) - }) - - const relayAddr = relayLibp2p.components.transportManager.getAddrs()[0] - const dialAddr = relayAddr.encapsulate(`/p2p/${relayLibp2p.peerId.toString()}`) - - const connection = await srcLibp2p.dial(dialAddr) - // this should succeed as the timeout is only effective after - // multistream select negotiates the protocol - const stream = await connection.newStream([RELAY_V2_HOP_CODEC]) - - void stream.sink(async function * () { - // delay for longer than the timeout - await delay(1000) - yield Uint8Array.from([0]) - }()) - - // because we timed out, the remote should have reset the stream - await expect(all(stream.source)).to.eventually.be.rejected - .with.property('code', 'ERR_STREAM_RESET') - }) -}) diff --git a/test/relay/utils.ts b/test/relay/utils.ts deleted file mode 100644 index aa3739d2cf..0000000000 --- a/test/relay/utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Libp2pOptions } from '../../src/index.js' -import { createBaseOptions } from '../utils/base-options.js' - -const listenAddr = '/ip4/0.0.0.0/tcp/0' - -export function createNodeOptions (...overrides: Libp2pOptions[]): Libp2pOptions { - return createBaseOptions({ - addresses: { - listen: [listenAddr] - }, - connectionManager: { - autoDial: false - }, - relay: { - hop: { - enabled: false - }, - reservationManager: { - enabled: true, - maxReservations: 1 - } - } - }, ...overrides) -} - -export function createRelayOptions (...overrides: Libp2pOptions[]): Libp2pOptions { - return createNodeOptions({ - relay: { - hop: { - enabled: true - } - } - }, ...overrides) -} diff --git a/test/utils/base-options.browser.ts b/test/utils/base-options.browser.ts index 882d1ab1f7..4d489fefcf 100644 --- a/test/utils/base-options.browser.ts +++ b/test/utils/base-options.browser.ts @@ -5,13 +5,15 @@ import { mplex } from '@libp2p/mplex' import { plaintext } from '../../src/insecure/index.js' import type { Libp2pOptions } from '../../src' import mergeOptions from 'merge-options' +import { circuitRelayTransport } from '../../src/circuit/index.js' export function createBaseOptions (overrides?: Libp2pOptions): Libp2pOptions { const options: Libp2pOptions = { transports: [ webSockets({ filter: filters.all - }) + }), + circuitRelayTransport() ], streamMuxers: [ mplex() @@ -19,12 +21,6 @@ export function createBaseOptions (overrides?: Libp2pOptions): Libp2pOptions { connectionEncryption: [ plaintext() ], - relay: { - enabled: false, - hop: { - enabled: false - } - }, nat: { enabled: false } diff --git a/test/utils/base-options.ts b/test/utils/base-options.ts index 41c91405f1..64fa9b2021 100644 --- a/test/utils/base-options.ts +++ b/test/utils/base-options.ts @@ -15,12 +15,6 @@ export function createBaseOptions (...overrides: Libp2pOptions[]): Libp2pOptions connectionEncryption: [ plaintext() ], - relay: { - enabled: true, - hop: { - enabled: false - } - }, nat: { enabled: false } diff --git a/test/utils/creators/peer.ts b/test/utils/creators/peer.ts index d80eb153fc..6e22754b85 100644 --- a/test/utils/creators/peer.ts +++ b/test/utils/creators/peer.ts @@ -52,6 +52,7 @@ export async function createNode (options: CreatePeerOptions = {}): Promise