UNPKG

libp2p-pubsub

Version:
467 lines 17 kB
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 } from './message/rpc.js'; import { PeerStreams } from './peer-streams.js'; import * as utils from './utils.js'; import { signMessage, verifySignature } from './message/sign.js'; /** * PubsubBaseProtocol handles the peers and connections logic for pubsub routers * and specifies the API that pubsub routers should have. */ export class PubsubBaseProtocol extends EventEmitter { constructor(props) { 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 */ _onIncomingStream({ protocol, stream, connection }) { 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 */ async _onPeerConnected(peerId, conn) { 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) { 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 */ _onPeerDisconnected(peerId, conn) { const idB58Str = peerId.toString(); this.log('connection ended', idB58Str); this._removePeer(peerId); } /** * Notifies the router that a peer has been connected */ _addPeer(peerId, protocol) { 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 */ _removePeer(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, stream, 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) { this._onPeerDisconnected(peerStreams.id, err); } } /** * Handles an rpc request from a peer */ async _processRpc(idB58Str, peerStreams, 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) { this.log.err(err); } })) .catch(err => this.log(err)); } return true; } /** * Handles a subscription change from a peer */ _processRpcSubOpt(id, subOpt) { 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) { 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) { 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.topicIDs.forEach((topic) => { if (this.subscriptions.has(topic)) { this.emit(topic, message); } }); } /** * The default msgID implementation * Child class can override this. */ getMsgId(msg) { 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) { return true; } /** * Decode Uint8Array into an RPC object. * This can be override to use a custom router protobuf. */ _decodeRpc(bytes) { return RPC.decode(bytes); } /** * Encode RPC object into a Uint8Array. * This can be override to use a custom router protobuf. */ _encodeRpc(rpc) { return RPC.encode(rpc).finish(); } /** * Send an rpc object to a peer */ _sendRpc(id, rpc) { 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, topics, subscribe) { 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) { 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. */ async _buildMessage(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) { 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, message) { 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); } /** * Subscribes to a given topic. */ subscribe(topic) { 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) { 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); } } //# sourceMappingURL=index.js.map