libp2p-pubsub
Version:
611 lines (525 loc) • 17.3 kB
text/typescript
import debug from 'debug'
import { EventEmitter } from 'events'
import errcode from 'err-code'
import { pipe } from 'it-pipe'
import Queue from 'p-queue'
import { MulticodecTopology } from 'libp2p-topology/multicodec-topology'
import { codes } from './errors.js'
import { RPC, IRPC } from './message/rpc.js'
import { PeerStreams } from './peer-streams.js'
import * as utils from './utils.js'
import type { PeerId } from 'libp2p-interfaces/peer-id'
import type { Registrar, IncomingStreamEvent } from 'libp2p-interfaces/registrar'
import type { Connection } from 'libp2p-interfaces/connection'
import type BufferList from 'bl'
import {
signMessage,
verifySignature
} from './message/sign.js'
import type { PubSub, Message, StrictNoSign, StrictSign, PubsubOptions } from 'libp2p-interfaces/pubsub'
import type { Startable } from 'libp2p-interfaces'
export interface TopicValidator { (topic: string, message: Message): Promise<void> }
/**
* PubsubBaseProtocol handles the peers and connections logic for pubsub routers
* and specifies the API that pubsub routers should have.
*/
export abstract class PubsubBaseProtocol extends EventEmitter implements PubSub, Startable {
public peerId: PeerId
public started: boolean
/**
* Map of topics to which peers are subscribed to
*/
public topics: Map<string, Set<string>>
/**
* List of our subscriptions
*/
public subscriptions: Set<string>
/**
* Map of peer streams
*/
public peers: Map<string, PeerStreams>
/**
* The signature policy to follow by default
*/
public globalSignaturePolicy: StrictNoSign | StrictSign
/**
* If router can relay received messages, even if not subscribed
*/
public canRelayMessage: boolean
/**
* if publish should emit to self, if subscribed
*/
public emitSelf: boolean
/**
* Topic validator map
*
* Keyed by topic
* Topic validators are functions with the following input:
*/
public topicValidators: Map<string, TopicValidator>
public queue: Queue
public registrar: Registrar
protected log: debug.Debugger & { err: debug.Debugger }
protected multicodecs: string[]
protected _libp2p: any
private _registrarId: string | undefined
constructor (props: PubsubOptions) {
super()
const {
debugName = 'libp2p:pubsub',
multicodecs = [],
libp2p = null,
globalSignaturePolicy = 'StrictSign',
canRelayMessage = false,
emitSelf = false,
messageProcessingConcurrency = 10
} = props
this.log = Object.assign(debug(debugName), {
err: debug(`${debugName}:error`)
})
this.multicodecs = utils.ensureArray(multicodecs)
this._libp2p = libp2p
this.registrar = libp2p.registrar
this.peerId = libp2p.peerId
this.started = false
this.topics = new Map()
this.subscriptions = new Set()
this.peers = new Map()
this.globalSignaturePolicy = globalSignaturePolicy === 'StrictNoSign' ? 'StrictNoSign' : 'StrictSign'
this.canRelayMessage = canRelayMessage
this.emitSelf = emitSelf
this.topicValidators = new Map()
this.queue = new Queue({ concurrency: messageProcessingConcurrency })
this._onIncomingStream = this._onIncomingStream.bind(this)
this._onPeerConnected = this._onPeerConnected.bind(this)
this._onPeerDisconnected = this._onPeerDisconnected.bind(this)
}
// LIFECYCLE METHODS
/**
* Register the pubsub protocol onto the libp2p node.
*
* @returns {void}
*/
start () {
if (this.started) {
return
}
this.log('starting')
// Incoming streams
// Called after a peer dials us
this.registrar.handle(this.multicodecs, this._onIncomingStream)
// register protocol with topology
// Topology callbacks called on connection manager changes
const topology = new MulticodecTopology({
multicodecs: this.multicodecs,
handlers: {
onConnect: this._onPeerConnected,
onDisconnect: this._onPeerDisconnected
}
})
this._registrarId = this.registrar.register(topology)
this.log('started')
this.started = true
}
/**
* Unregister the pubsub protocol and the streams with other peers will be closed.
*
* @returns {void}
*/
stop () {
if (!this.started) {
return
}
// unregister protocol and handlers
if (this._registrarId != null) {
this.registrar.unregister(this._registrarId)
}
this.log('stopping')
this.peers.forEach((peerStreams) => peerStreams.close())
this.peers = new Map()
this.subscriptions = new Set()
this.started = false
this.log('stopped')
}
isStarted () {
return this.started
}
/**
* On an inbound stream opened
*/
protected _onIncomingStream ({ protocol, stream, connection }: IncomingStreamEvent) {
const peerId = connection.remotePeer
const idB58Str = peerId.toString()
const peer = this._addPeer(peerId, protocol)
const inboundStream = peer.attachInboundStream(stream)
this._processMessages(idB58Str, inboundStream, peer)
.catch(err => this.log(err))
}
/**
* Registrar notifies an established connection with pubsub protocol
*/
protected async _onPeerConnected (peerId: PeerId, conn: Connection) {
const idB58Str = peerId.toString()
this.log('connected', idB58Str)
try {
const { stream, protocol } = await conn.newStream(this.multicodecs)
const peer = this._addPeer(peerId, protocol)
await peer.attachOutboundStream(stream)
} catch (err: any) {
this.log.err(err)
}
// Immediately send my own subscriptions to the newly established conn
this._sendSubscriptions(idB58Str, Array.from(this.subscriptions), true)
}
/**
* Registrar notifies a closing connection with pubsub protocol
*/
protected _onPeerDisconnected (peerId: PeerId, conn?: Connection) {
const idB58Str = peerId.toString()
this.log('connection ended', idB58Str)
this._removePeer(peerId)
}
/**
* Notifies the router that a peer has been connected
*/
protected _addPeer (peerId: PeerId, protocol: string) {
const id = peerId.toString()
const existing = this.peers.get(id)
// If peer streams already exists, do nothing
if (existing != null) {
return existing
}
// else create a new peer streams
this.log('new peer', id)
const peerStreams = new PeerStreams({
id: peerId,
protocol
})
this.peers.set(id, peerStreams)
peerStreams.once('close', () => this._removePeer(peerId))
return peerStreams
}
/**
* Notifies the router that a peer has been disconnected
*/
protected _removePeer (peerId: PeerId) {
const id = peerId.toString()
const peerStreams = this.peers.get(id)
if (peerStreams == null) return
// close peer streams
peerStreams.removeAllListeners()
peerStreams.close()
// delete peer streams
this.log('delete peer', id)
this.peers.delete(id)
// remove peer from topics map
for (const peers of this.topics.values()) {
peers.delete(id)
}
return peerStreams
}
// MESSAGE METHODS
/**
* Responsible for processing each RPC message received by other peers.
*/
async _processMessages (idB58Str: string, stream: AsyncIterable<Uint8Array|BufferList>, peerStreams: PeerStreams) {
try {
await pipe(
stream,
async (source) => {
for await (const data of source) {
const rpcBytes = data instanceof Uint8Array ? data : data.slice()
const rpcMsg = this._decodeRpc(rpcBytes)
// Since _processRpc may be overridden entirely in unsafe ways,
// the simplest/safest option here is to wrap in a function and capture all errors
// to prevent a top-level unhandled exception
// This processing of rpc messages should happen without awaiting full validation/execution of prior messages
this._processRpc(idB58Str, peerStreams, rpcMsg)
.catch(err => this.log(err))
}
}
)
} catch (err: any) {
this._onPeerDisconnected(peerStreams.id, err)
}
}
/**
* Handles an rpc request from a peer
*/
async _processRpc (idB58Str: string, peerStreams: PeerStreams, rpc: RPC) {
this.log('rpc from', idB58Str)
const subs = rpc.subscriptions
const msgs = rpc.msgs
if (subs.length > 0) {
// update peer subscriptions
subs.forEach((subOpt) => {
this._processRpcSubOpt(idB58Str, subOpt)
})
this.emit('pubsub:subscription-change', { peerId: peerStreams.id, subscriptions: subs })
}
if (!this._acceptFrom(idB58Str)) {
this.log('received message from unacceptable peer %s', idB58Str)
return false
}
if (msgs.length > 0) {
this.queue.addAll(msgs.map(message => async () => {
const topics = message.topicIDs != null ? message.topicIDs : []
const hasSubscription = topics.some((topic) => this.subscriptions.has(topic))
if (!hasSubscription && !this.canRelayMessage) {
this.log('received message we didn\'t subscribe to. Dropping.')
return
}
try {
const msg = utils.normalizeInRpcMessage(message, idB58Str)
await this._processRpcMessage(msg)
} catch (err: any) {
this.log.err(err)
}
}))
.catch(err => this.log(err))
}
return true
}
/**
* Handles a subscription change from a peer
*/
_processRpcSubOpt (id: string, subOpt: RPC.ISubOpts) {
const t = subOpt.topicID
if (t == null) {
return
}
let topicSet = this.topics.get(t)
if (topicSet == null) {
topicSet = new Set()
this.topics.set(t, topicSet)
}
if (subOpt.subscribe === true) {
// subscribe peer to new topic
topicSet.add(id)
} else {
// unsubscribe from existing topic
topicSet.delete(id)
}
}
/**
* Handles an message from a peer
*/
async _processRpcMessage (msg: Message) {
if ((msg.from != null) && this.peerId.equals(msg.from) && !this.emitSelf) {
return
}
// Ensure the message is valid before processing it
try {
await this.validate(msg)
} catch (err: any) {
this.log('Message is invalid, dropping it. %O', err)
return
}
// Emit to self
this._emitMessage(msg)
return await this._publish(utils.normalizeOutRpcMessage(msg))
}
/**
* Emit a message from a peer
*/
_emitMessage (message: Message) {
message.topicIDs.forEach((topic) => {
if (this.subscriptions.has(topic)) {
this.emit(topic, message)
}
})
}
/**
* The default msgID implementation
* Child class can override this.
*/
getMsgId (msg: Message) {
const signaturePolicy = this.globalSignaturePolicy
switch (signaturePolicy) {
case 'StrictSign':
// @ts-expect-error seqno is optional in protobuf definition but it will exist
return utils.msgId(msg.from, msg.seqno)
case 'StrictNoSign':
return utils.noSignMsgId(msg.data)
default:
throw errcode(new Error('Cannot get message id: unhandled signature policy'), codes.ERR_UNHANDLED_SIGNATURE_POLICY)
}
}
/**
* Whether to accept a message from a peer
* Override to create a graylist
*/
_acceptFrom (id: string) {
return true
}
/**
* Decode Uint8Array into an RPC object.
* This can be override to use a custom router protobuf.
*/
_decodeRpc (bytes: Uint8Array) {
return RPC.decode(bytes)
}
/**
* Encode RPC object into a Uint8Array.
* This can be override to use a custom router protobuf.
*/
_encodeRpc (rpc: IRPC) {
return RPC.encode(rpc).finish()
}
/**
* Send an rpc object to a peer
*/
_sendRpc (id: string, rpc: IRPC) {
const peerStreams = this.peers.get(id)
if ((peerStreams == null) || !peerStreams.isWritable) {
const msg = `Cannot send RPC to ${id} as there is no open stream to it available`
this.log.err(msg)
return
}
peerStreams.write(this._encodeRpc(rpc))
}
/**
* Send subscriptions to a peer
*/
_sendSubscriptions (id: string, topics: string[], subscribe: boolean) {
return this._sendRpc(id, {
subscriptions: topics.map(t => ({ topicID: t, subscribe: subscribe }))
})
}
/**
* Validates the given message. The signature will be checked for authenticity.
* Throws an error on invalid messages
*/
async validate (message: Message) { // eslint-disable-line require-await
const signaturePolicy = this.globalSignaturePolicy
switch (signaturePolicy) {
case 'StrictNoSign':
if (message.from != null) {
throw errcode(new Error('StrictNoSigning: from should not be present'), codes.ERR_UNEXPECTED_FROM)
}
if (message.signature != null) {
throw errcode(new Error('StrictNoSigning: signature should not be present'), codes.ERR_UNEXPECTED_SIGNATURE)
}
if (message.key != null) {
throw errcode(new Error('StrictNoSigning: key should not be present'), codes.ERR_UNEXPECTED_KEY)
}
if (message.seqno != null) {
throw errcode(new Error('StrictNoSigning: seqno should not be present'), codes.ERR_UNEXPECTED_SEQNO)
}
break
case 'StrictSign':
if (message.signature == null) {
throw errcode(new Error('StrictSigning: Signing required and no signature was present'), codes.ERR_MISSING_SIGNATURE)
}
if (message.seqno == null) {
throw errcode(new Error('StrictSigning: Signing required and no seqno was present'), codes.ERR_MISSING_SEQNO)
}
if (!(await verifySignature(message))) {
throw errcode(new Error('StrictSigning: Invalid message signature'), codes.ERR_INVALID_SIGNATURE)
}
break
default:
throw errcode(new Error('Cannot validate message: unhandled signature policy'), codes.ERR_UNHANDLED_SIGNATURE_POLICY)
}
for (const topic of message.topicIDs) {
const validatorFn = this.topicValidators.get(topic)
if (validatorFn != null) {
await validatorFn(topic, message)
}
}
}
/**
* Normalizes the message and signs it, if signing is enabled.
* Should be used by the routers to create the message to send.
*/
protected async _buildMessage (message: Message) {
const signaturePolicy = this.globalSignaturePolicy
switch (signaturePolicy) {
case 'StrictSign':
message.from = this.peerId.multihash.bytes
message.seqno = utils.randomSeqno()
return await signMessage(this.peerId, message)
case 'StrictNoSign':
return await Promise.resolve(message)
default:
throw errcode(new Error('Cannot build message: unhandled signature policy'), codes.ERR_UNHANDLED_SIGNATURE_POLICY)
}
}
// API METHODS
/**
* Get a list of the peer-ids that are subscribed to one topic.
*/
getSubscribers (topic: string) {
if (!this.started) {
throw errcode(new Error('not started yet'), 'ERR_NOT_STARTED_YET')
}
if (topic == null) {
throw errcode(new Error('topic is required'), 'ERR_NOT_VALID_TOPIC')
}
const peersInTopic = this.topics.get(topic)
if (peersInTopic == null) {
return []
}
return Array.from(peersInTopic)
}
/**
* Publishes messages to all subscribed peers
*/
async publish (topic: string, message: Uint8Array) {
if (!this.started) {
throw new Error('Pubsub has not started')
}
this.log('publish', topic, message)
const from = this.peerId.toString()
const msgObject = {
receivedFrom: from,
data: message,
topicIDs: [topic]
}
// ensure that the message follows the signature policy
const outMsg = await this._buildMessage(msgObject)
const msg = utils.normalizeInRpcMessage(outMsg)
// Emit to self if I'm interested and emitSelf enabled
this.emitSelf && this._emitMessage(msg)
// send to all the other peers
await this._publish(msg)
}
/**
* Overriding the implementation of publish should handle the appropriate algorithms for the publish/subscriber implementation.
* For example, a Floodsub implementation might simply publish each message to each topic for every peer
*/
abstract _publish (message: Message): Promise<void>
/**
* Subscribes to a given topic.
*/
subscribe (topic: string) {
if (!this.started) {
throw new Error('Pubsub has not started')
}
if (!this.subscriptions.has(topic)) {
this.subscriptions.add(topic)
this.peers.forEach((_, id) => this._sendSubscriptions(id, [topic], true))
}
}
/**
* Unsubscribe from the given topic.
*/
unsubscribe (topic: string) {
if (!this.started) {
throw new Error('Pubsub is not started')
}
if (this.subscriptions.has(topic) && this.listenerCount(topic) === 0) {
this.subscriptions.delete(topic)
this.peers.forEach((_, id) => this._sendSubscriptions(id, [topic], false))
}
}
/**
* Get the list of topics which the peer is subscribed to.
*/
getTopics () {
if (!this.started) {
throw new Error('Pubsub is not started')
}
return Array.from(this.subscriptions)
}
}