UNPKG

libp2p

Version:

JavaScript implementation of libp2p, a modular peer to peer network stack

785 lines (647 loc) • 27.1 kB
import { InvalidMultiaddrError, TooManyInboundProtocolStreamsError, TooManyOutboundProtocolStreamsError, LimitedConnectionError, InvalidPeerIdError } from '@libp2p/interface' import * as mss from '@libp2p/multistream-select' import { peerIdFromString } from '@libp2p/peer-id' import { trackedMap } from '@libp2p/utils/tracked-map' import { anySignal } from 'any-signal' import { setMaxListeners } from 'main-event' import { CustomProgressEvent } from 'progress-events' import { raceSignal } from 'race-signal' import { createConnection } from './connection/index.js' import { PROTOCOL_NEGOTIATION_TIMEOUT, INBOUND_UPGRADE_TIMEOUT } from './connection-manager/constants.js' import { ConnectionDeniedError, ConnectionInterceptedError, EncryptionFailedError, MuxerUnavailableError } from './errors.js' import { DEFAULT_MAX_INBOUND_STREAMS, DEFAULT_MAX_OUTBOUND_STREAMS } from './registrar.js' import type { Libp2pEvents, AbortOptions, ComponentLogger, MultiaddrConnection, Connection, Stream, ConnectionProtector, NewStreamOptions, ConnectionEncrypter, SecuredConnection, ConnectionGater, Metrics, PeerId, PeerStore, StreamMuxer, StreamMuxerFactory, Upgrader as UpgraderInterface, UpgraderOptions, ConnectionLimits, SecureConnectionOptions, CounterGroup, ClearableSignal } from '@libp2p/interface' import type { ConnectionManager, Registrar } from '@libp2p/interface-internal' import type { TypedEventTarget } from 'main-event' interface CreateConnectionOptions { cryptoProtocol: string direction: 'inbound' | 'outbound' maConn: MultiaddrConnection upgradedConn: MultiaddrConnection remotePeer: PeerId muxerFactory?: StreamMuxerFactory limits?: ConnectionLimits } interface OnStreamOptions { connection: Connection stream: Stream protocol: string } export interface CryptoResult extends SecuredConnection<MultiaddrConnection> { protocol: string } export interface UpgraderInit { connectionEncrypters: ConnectionEncrypter[] streamMuxers: StreamMuxerFactory[] /** * An amount of ms by which an inbound connection upgrade must complete * * @default 3000 */ inboundUpgradeTimeout?: number /** * When a new incoming stream is opened on a multiplexed connection, protocol * negotiation on that stream must complete within this many ms * * @default 2000 */ inboundStreamProtocolNegotiationTimeout?: number /** * When a new incoming stream is opened on a multiplexed connection, protocol * negotiation on that stream must complete within this many ms * * @default 2000 */ outboundStreamProtocolNegotiationTimeout?: number } function findIncomingStreamLimit (protocol: string, registrar: Registrar): number | undefined { try { const { options } = registrar.getHandler(protocol) return options.maxInboundStreams } catch (err: any) { if (err.name !== 'UnhandledProtocolError') { throw err } } return DEFAULT_MAX_INBOUND_STREAMS } function findOutgoingStreamLimit (protocol: string, registrar: Registrar, options: NewStreamOptions = {}): number { try { const { options } = registrar.getHandler(protocol) if (options.maxOutboundStreams != null) { return options.maxOutboundStreams } } catch (err: any) { if (err.name !== 'UnhandledProtocolError') { throw err } } return options.maxOutboundStreams ?? DEFAULT_MAX_OUTBOUND_STREAMS } function countStreams (protocol: string, direction: 'inbound' | 'outbound', connection: Connection): number { let streamCount = 0 connection.streams.forEach(stream => { if (stream.direction === direction && stream.protocol === protocol) { streamCount++ } }) return streamCount } export interface UpgraderComponents { peerId: PeerId metrics?: Metrics connectionManager: ConnectionManager connectionGater: ConnectionGater connectionProtector?: ConnectionProtector registrar: Registrar peerStore: PeerStore events: TypedEventTarget<Libp2pEvents> logger: ComponentLogger } type ConnectionDeniedType = keyof Pick<ConnectionGater, 'denyOutboundConnection' | 'denyInboundEncryptedConnection' | 'denyOutboundEncryptedConnection' | 'denyInboundUpgradedConnection' | 'denyOutboundUpgradedConnection'> export class Upgrader implements UpgraderInterface { private readonly components: UpgraderComponents private readonly connectionEncrypters: Map<string, ConnectionEncrypter> private readonly streamMuxers: Map<string, StreamMuxerFactory> private readonly inboundUpgradeTimeout: number private readonly inboundStreamProtocolNegotiationTimeout: number private readonly outboundStreamProtocolNegotiationTimeout: number private readonly events: TypedEventTarget<Libp2pEvents> private readonly metrics: { dials?: CounterGroup<'inbound' | 'outbound'> errors?: CounterGroup<'inbound' | 'outbound'> inboundErrors?: CounterGroup outboundErrors?: CounterGroup } constructor (components: UpgraderComponents, init: UpgraderInit) { this.components = components this.connectionEncrypters = trackedMap({ name: 'libp2p_upgrader_connection_encrypters', metrics: this.components.metrics }) init.connectionEncrypters.forEach(encrypter => { this.connectionEncrypters.set(encrypter.protocol, encrypter) }) this.streamMuxers = trackedMap({ name: 'libp2p_upgrader_stream_multiplexers', metrics: this.components.metrics }) init.streamMuxers.forEach(muxer => { this.streamMuxers.set(muxer.protocol, muxer) }) this.inboundUpgradeTimeout = init.inboundUpgradeTimeout ?? INBOUND_UPGRADE_TIMEOUT this.inboundStreamProtocolNegotiationTimeout = init.inboundStreamProtocolNegotiationTimeout ?? PROTOCOL_NEGOTIATION_TIMEOUT this.outboundStreamProtocolNegotiationTimeout = init.outboundStreamProtocolNegotiationTimeout ?? PROTOCOL_NEGOTIATION_TIMEOUT this.events = components.events this.metrics = { dials: components.metrics?.registerCounterGroup('libp2p_connection_manager_dials_total'), errors: components.metrics?.registerCounterGroup('libp2p_connection_manager_dial_errors_total'), inboundErrors: components.metrics?.registerCounterGroup('libp2p_connection_manager_dials_inbound_errors_total'), outboundErrors: components.metrics?.registerCounterGroup('libp2p_connection_manager_dials_outbound_errors_total') } } readonly [Symbol.toStringTag] = '@libp2p/upgrader' async shouldBlockConnection (connectionType: 'denyInboundConnection', maConn: MultiaddrConnection): Promise<void> async shouldBlockConnection (connectionType: ConnectionDeniedType, remotePeer: PeerId, maConn: MultiaddrConnection): Promise<void> async shouldBlockConnection (method: ConnectionDeniedType | 'denyInboundConnection', ...args: any[]): Promise<void> { const denyOperation: any = this.components.connectionGater[method] if (denyOperation == null) { return } const result = await denyOperation.apply(this.components.connectionGater, args) if (result === true) { throw new ConnectionInterceptedError(`The multiaddr connection is blocked by gater.${method}`) } } createInboundAbortSignal (signal: AbortSignal): ClearableSignal { const output = anySignal([ AbortSignal.timeout(this.inboundUpgradeTimeout), signal ]) setMaxListeners(Infinity, output) return output } /** * Upgrades an inbound connection */ async upgradeInbound (maConn: MultiaddrConnection, opts: UpgraderOptions): Promise<void> { let accepted = false // always apply upgrade timeout for incoming upgrades const signal = this.createInboundAbortSignal(opts.signal) try { this.metrics.dials?.increment({ inbound: true }) accepted = await raceSignal(this.components.connectionManager.acceptIncomingConnection(maConn), signal) if (!accepted) { throw new ConnectionDeniedError('Connection denied') } await raceSignal(this.shouldBlockConnection('denyInboundConnection', maConn), signal) await this._performUpgrade(maConn, 'inbound', { ...opts, signal }) } catch (err: any) { this.metrics.errors?.increment({ inbound: true }) this.metrics.inboundErrors?.increment({ [err.name ?? 'Error']: true }) throw err } finally { signal.clear() if (accepted) { this.components.connectionManager.afterUpgradeInbound() } } } /** * Upgrades an outbound connection */ async upgradeOutbound (maConn: MultiaddrConnection, opts: UpgraderOptions): Promise<Connection> { try { this.metrics.dials?.increment({ outbound: true }) const idStr = maConn.remoteAddr.getPeerId() let remotePeerId: PeerId | undefined if (idStr != null) { remotePeerId = peerIdFromString(idStr) await raceSignal(this.shouldBlockConnection('denyOutboundConnection', remotePeerId, maConn), opts.signal) } let direction: 'inbound' | 'outbound' = 'outbound' // act as the multistream-select server if we are not to be the initiator if (opts.initiator === false) { direction = 'inbound' } return await this._performUpgrade(maConn, direction, opts) } catch (err: any) { this.metrics.errors?.increment({ outbound: true }) this.metrics.outboundErrors?.increment({ [err.name ?? 'Error']: true }) throw err } } private async _performUpgrade (maConn: MultiaddrConnection, direction: 'inbound' | 'outbound', opts: UpgraderOptions): Promise<Connection> { let encryptedConn: MultiaddrConnection let remotePeer: PeerId let upgradedConn: MultiaddrConnection let muxerFactory: StreamMuxerFactory | undefined let cryptoProtocol this.components.metrics?.trackMultiaddrConnection(maConn) maConn.log.trace('starting the %s connection upgrade', direction) // Protect let protectedConn = maConn if (opts?.skipProtection !== true) { const protector = this.components.connectionProtector if (protector != null) { maConn.log('protecting the %s connection', direction) protectedConn = await protector.protect(maConn, opts) } } try { // Encrypt the connection encryptedConn = protectedConn if (opts?.skipEncryption !== true) { opts?.onProgress?.(new CustomProgressEvent(`upgrader:encrypt-${direction}-connection`)); ({ conn: encryptedConn, remotePeer, protocol: cryptoProtocol, streamMuxer: muxerFactory } = await (direction === 'inbound' ? this._encryptInbound(protectedConn, opts) : this._encryptOutbound(protectedConn, opts) )) const maConn: MultiaddrConnection = { ...protectedConn, ...encryptedConn } await this.shouldBlockConnection(direction === 'inbound' ? 'denyInboundEncryptedConnection' : 'denyOutboundEncryptedConnection', remotePeer, maConn) } else { const idStr = maConn.remoteAddr.getPeerId() if (idStr == null) { throw new InvalidMultiaddrError(`${direction} connection that skipped encryption must have a peer id`) } const remotePeerId = peerIdFromString(idStr) cryptoProtocol = 'native' remotePeer = remotePeerId } // this can happen if we dial a multiaddr without a peer id, we only find // out the identity of the remote after the connection is encrypted if (remotePeer.equals(this.components.peerId)) { const err = new InvalidPeerIdError('Can not dial self') maConn.abort(err) throw err } upgradedConn = encryptedConn if (opts?.muxerFactory != null) { muxerFactory = opts.muxerFactory } else if (muxerFactory == null && this.streamMuxers.size > 0) { opts?.onProgress?.(new CustomProgressEvent(`upgrader:multiplex-${direction}-connection`)) // Multiplex the connection const multiplexed = await (direction === 'inbound' ? this._multiplexInbound({ ...protectedConn, ...encryptedConn }, this.streamMuxers, opts) : this._multiplexOutbound({ ...protectedConn, ...encryptedConn }, this.streamMuxers, opts)) muxerFactory = multiplexed.muxerFactory upgradedConn = multiplexed.stream } } catch (err: any) { maConn.log.error('failed to upgrade inbound connection %s %a - %e', direction === 'inbound' ? 'from' : 'to', maConn.remoteAddr, err) throw err } await this.shouldBlockConnection(direction === 'inbound' ? 'denyInboundUpgradedConnection' : 'denyOutboundUpgradedConnection', remotePeer, maConn) maConn.log('successfully upgraded %s connection', direction) return this._createConnection({ cryptoProtocol, direction, maConn, upgradedConn, muxerFactory, remotePeer, limits: opts?.limits }) } /** * A convenience method for generating a new `Connection` */ _createConnection (opts: CreateConnectionOptions): Connection { const { cryptoProtocol, direction, maConn, upgradedConn, remotePeer, muxerFactory, limits } = opts let muxer: StreamMuxer | undefined let newStream: ((multicodecs: string[], options?: AbortOptions) => Promise<Stream>) | undefined let connection: Connection // eslint-disable-line prefer-const if (muxerFactory != null) { // Create the muxer muxer = muxerFactory.createStreamMuxer({ direction, // Run anytime a remote stream is created onIncomingStream: muxedStream => { if (connection == null) { return } const signal = AbortSignal.timeout(this.inboundStreamProtocolNegotiationTimeout) setMaxListeners(Infinity, signal) void Promise.resolve() .then(async () => { const protocols = this.components.registrar.getProtocols() const { stream, protocol } = await mss.handle(muxedStream, protocols, { signal, log: muxedStream.log, yieldBytes: false }) if (connection == null) { return } connection.log('incoming stream opened on %s', protocol) const incomingLimit = findIncomingStreamLimit(protocol, this.components.registrar) const streamCount = countStreams(protocol, 'inbound', connection) if (streamCount === incomingLimit) { const err = new TooManyInboundProtocolStreamsError(`Too many inbound protocol streams for protocol "${protocol}" - limit ${incomingLimit}`) muxedStream.abort(err) throw err } // after the handshake the returned stream can have early data so override // the source/sink muxedStream.source = stream.source muxedStream.sink = stream.sink muxedStream.protocol = protocol // allow closing the write end of a not-yet-negotiated stream if (stream.closeWrite != null) { muxedStream.closeWrite = stream.closeWrite } // allow closing the read end of a not-yet-negotiated stream if (stream.closeRead != null) { muxedStream.closeRead = stream.closeRead } // make sure we don't try to negotiate a stream we are closing if (stream.close != null) { muxedStream.close = stream.close } // If a protocol stream has been successfully negotiated and is to be passed to the application, // the peer store should ensure that the peer is registered with that protocol await this.components.peerStore.merge(remotePeer, { protocols: [protocol] }, { signal }) this.components.metrics?.trackProtocolStream(muxedStream, connection) this._onStream({ connection, stream: muxedStream, protocol }) }) .catch(async err => { connection.log.error('error handling incoming stream id %s - %e', muxedStream.id, err) if (muxedStream.timeline.close == null) { await muxedStream.close({ signal }) .catch(err => muxedStream.abort(err)) } }) } }) newStream = async (protocols: string[], options: NewStreamOptions = {}): Promise<Stream> => { if (muxer == null) { throw new MuxerUnavailableError('Connection is not multiplexed') } connection.log.trace('starting new stream for protocols %s', protocols) const muxedStream = await muxer.newStream() connection.log.trace('started new stream %s for protocols %s', muxedStream.id, protocols) try { if (options.signal == null) { muxedStream.log('no abort signal was passed while trying to negotiate protocols %s falling back to default timeout', protocols) const signal = AbortSignal.timeout(this.outboundStreamProtocolNegotiationTimeout) setMaxListeners(Infinity, signal) options = { ...options, signal } } muxedStream.log.trace('selecting protocol from protocols %s', protocols) const { stream, protocol } = await mss.select(muxedStream, protocols, { ...options, log: muxedStream.log, yieldBytes: true }) muxedStream.log.trace('selected protocol %s', protocol) const outgoingLimit = findOutgoingStreamLimit(protocol, this.components.registrar, options) const streamCount = countStreams(protocol, 'outbound', connection) if (streamCount >= outgoingLimit) { const err = new TooManyOutboundProtocolStreamsError(`Too many outbound protocol streams for protocol "${protocol}" - ${streamCount}/${outgoingLimit}`) muxedStream.abort(err) throw err } // If a protocol stream has been successfully negotiated and is to be passed to the application, // the peer store should ensure that the peer is registered with that protocol await this.components.peerStore.merge(remotePeer, { protocols: [protocol] }) // after the handshake the returned stream can have early data so override // the source/sink muxedStream.source = stream.source muxedStream.sink = stream.sink muxedStream.protocol = protocol // allow closing the write end of a not-yet-negotiated stream if (stream.closeWrite != null) { muxedStream.closeWrite = stream.closeWrite } // allow closing the read end of a not-yet-negotiated stream if (stream.closeRead != null) { muxedStream.closeRead = stream.closeRead } // make sure we don't try to negotiate a stream we are closing if (stream.close != null) { muxedStream.close = stream.close } this.components.metrics?.trackProtocolStream(muxedStream, connection) return muxedStream } catch (err: any) { connection.log.error('could not create new outbound stream on connection %s %a for protocols %s - %e', direction === 'inbound' ? 'from' : 'to', opts.maConn.remoteAddr, protocols, err) if (muxedStream.timeline.close == null) { muxedStream.abort(err) } throw err } } // Pipe all data through the muxer void Promise.all([ muxer.sink(upgradedConn.source), upgradedConn.sink(muxer.source) ]).catch(err => { connection.log.error('error piping data through muxer - %e', err) }) } const _timeline = maConn.timeline maConn.timeline = new Proxy(_timeline, { set: (...args) => { if (args[1] === 'close' && args[2] != null && _timeline.close == null) { // Wait for close to finish before notifying of the closure (async () => { try { if (connection.status === 'open') { await connection.close() } } catch (err: any) { connection.log.error('error closing connection after timeline close %e', err) } finally { this.events.safeDispatchEvent('connection:close', { detail: connection }) } })().catch(err => { connection.log.error('error thrown while dispatching connection:close event %e', err) }) } return Reflect.set(...args) } }) maConn.timeline.upgraded = Date.now() const errConnectionNotMultiplexed = (): any => { throw new MuxerUnavailableError('Connection is not multiplexed') } // Create the connection connection = createConnection({ remoteAddr: maConn.remoteAddr, remotePeer, status: 'open', direction, timeline: maConn.timeline, multiplexer: muxer?.protocol, encryption: cryptoProtocol, limits, logger: this.components.logger, newStream: newStream ?? errConnectionNotMultiplexed, getStreams: () => { return muxer?.streams ?? [] }, close: async (options?: AbortOptions) => { // ensure remaining streams are closed gracefully await muxer?.close(options) // close the underlying transport await maConn.close(options) }, abort: (err) => { maConn.abort(err) // ensure remaining streams are aborted muxer?.abort(err) } }) this.events.safeDispatchEvent('connection:open', { detail: connection }) // @ts-expect-error nah connection.__maConnTimeline = _timeline return connection } /** * Routes incoming streams to the correct handler */ _onStream (opts: OnStreamOptions): void { const { connection, stream, protocol } = opts const { handler, options } = this.components.registrar.getHandler(protocol) if (connection.limits != null && options.runOnLimitedConnection !== true) { throw new LimitedConnectionError('Cannot open protocol stream on limited connection') } handler({ connection, stream }) } /** * Attempts to encrypt the incoming `connection` with the provided `cryptos` */ async _encryptInbound (connection: MultiaddrConnection, options?: AbortOptions): Promise<CryptoResult> { const protocols = Array.from(this.connectionEncrypters.keys()) try { const { stream, protocol } = await mss.handle(connection, protocols, { ...options, log: connection.log }) const encrypter = this.connectionEncrypters.get(protocol) if (encrypter == null) { throw new EncryptionFailedError(`no crypto module found for ${protocol}`) } connection.log('encrypting inbound connection to %a using %s', connection.remoteAddr, protocol) return { ...await encrypter.secureInbound(stream, options), protocol } } catch (err: any) { connection.log.error('encrypting inbound connection from %a failed', connection.remoteAddr, err) throw new EncryptionFailedError(err.message) } } /** * Attempts to encrypt the given `connection` with the provided connection encrypters. * The first `ConnectionEncrypter` module to succeed will be used */ async _encryptOutbound (connection: MultiaddrConnection, options: SecureConnectionOptions): Promise<CryptoResult> { const protocols = Array.from(this.connectionEncrypters.keys()) try { connection.log.trace('selecting encrypter from %s', protocols) const { stream, protocol } = await mss.select(connection, protocols, { ...options, log: connection.log, yieldBytes: true }) const encrypter = this.connectionEncrypters.get(protocol) if (encrypter == null) { throw new EncryptionFailedError(`no crypto module found for ${protocol}`) } connection.log('encrypting outbound connection to %a using %s', connection.remoteAddr, protocol) return { ...await encrypter.secureOutbound(stream, options), protocol } } catch (err: any) { connection.log.error('encrypting outbound connection to %a failed', connection.remoteAddr, err) throw new EncryptionFailedError(err.message) } } /** * Selects one of the given muxers via multistream-select. That * muxer will be used for all future streams on the connection. */ async _multiplexOutbound (connection: MultiaddrConnection, muxers: Map<string, StreamMuxerFactory>, options: AbortOptions): Promise<{ stream: MultiaddrConnection, muxerFactory?: StreamMuxerFactory }> { const protocols = Array.from(muxers.keys()) connection.log('outbound selecting muxer %s', protocols) try { connection.log.trace('selecting stream muxer from %s', protocols) const { stream, protocol } = await mss.select(connection, protocols, { ...options, log: connection.log, yieldBytes: true }) connection.log('selected %s as muxer protocol', protocol) const muxerFactory = muxers.get(protocol) return { stream, muxerFactory } } catch (err: any) { connection.log.error('error multiplexing outbound connection', err) throw new MuxerUnavailableError(String(err)) } } /** * Registers support for one of the given muxers via multistream-select. The * selected muxer will be used for all future streams on the connection. */ async _multiplexInbound (connection: MultiaddrConnection, muxers: Map<string, StreamMuxerFactory>, options: AbortOptions): Promise<{ stream: MultiaddrConnection, muxerFactory?: StreamMuxerFactory }> { const protocols = Array.from(muxers.keys()) connection.log('inbound handling muxers %s', protocols) try { const { stream, protocol } = await mss.handle(connection, protocols, { ...options, log: connection.log }) const muxerFactory = muxers.get(protocol) return { stream, muxerFactory } } catch (err: any) { connection.log.error('error multiplexing inbound connection', err) throw new MuxerUnavailableError(String(err)) } } getConnectionEncrypters (): Map<string, ConnectionEncrypter<unknown>> { return this.connectionEncrypters } getStreamMuxers (): Map<string, StreamMuxerFactory> { return this.streamMuxers } }