UNPKG

libp2p-gossipsub

Version:
543 lines (542 loc) 21.2 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PeerScore = void 0; const peer_score_params_1 = require("./peer-score-params"); const peer_stats_1 = require("./peer-stats"); const compute_score_1 = require("./compute-score"); const message_deliveries_1 = require("./message-deliveries"); const constants_1 = require("../constants"); const peer_id_1 = __importDefault(require("peer-id")); const debug = require("debug"); const pubsubErrors = require("libp2p-interfaces/src/pubsub/errors"); const { ERR_INVALID_SIGNATURE, ERR_MISSING_SIGNATURE } = pubsubErrors.codes; const log = debug('libp2p:gossipsub:score'); class PeerScore { constructor(params, connectionManager) { peer_score_params_1.validatePeerScoreParams(params); this.params = params; this._connectionManager = connectionManager; this.peerStats = new Map(); this.peerIPs = new Map(); this.scoreCache = new Map(); this.deliveryRecords = new message_deliveries_1.MessageDeliveries(); } /** * Start PeerScore instance * @returns {void} */ start() { if (this._backgroundInterval) { log('Peer score already running'); return; } this._backgroundInterval = setInterval(() => this.background(), this.params.decayInterval); log('started'); } /** * Stop PeerScore instance * @returns {void} */ stop() { if (!this._backgroundInterval) { log('Peer score already stopped'); return; } clearInterval(this._backgroundInterval); delete this._backgroundInterval; this.peerIPs.clear(); this.peerStats.clear(); this.deliveryRecords.clear(); log('stopped'); } /** * Periodic maintenance * @returns {void} */ background() { this._refreshScores(); this._updateIPs(); this.deliveryRecords.gc(); } /** * Decays scores, and purges score records for disconnected peers once their expiry has elapsed. * @returns {void} */ _refreshScores() { const now = Date.now(); const decayToZero = this.params.decayToZero; this.peerStats.forEach((pstats, id) => { if (!pstats.connected) { // has the retention perious expired? if (now > pstats.expire) { // yes, throw it away (but clean up the IP tracking first) this._removeIPs(id, pstats.ips); this.peerStats.delete(id); } // we don't decay retained scores, as the peer is not active. // this way the peer cannot reset a negative score by simply disconnecting and reconnecting, // unless the retention period has ellapsed. // similarly, a well behaved peer does not lose its score by getting disconnected. return; } Object.entries(pstats.topics).forEach(([topic, tstats]) => { const tparams = this.params.topics[topic]; if (!tparams) { // we are not scoring this topic // should be unreachable, we only add scored topics to pstats return; } // decay counters tstats.firstMessageDeliveries *= tparams.firstMessageDeliveriesDecay; if (tstats.firstMessageDeliveries < decayToZero) { tstats.firstMessageDeliveries = 0; } tstats.meshMessageDeliveries *= tparams.meshMessageDeliveriesDecay; if (tstats.meshMessageDeliveries < decayToZero) { tstats.meshMessageDeliveries = 0; } tstats.meshFailurePenalty *= tparams.meshFailurePenaltyDecay; if (tstats.meshFailurePenalty < decayToZero) { tstats.meshFailurePenalty = 0; } tstats.invalidMessageDeliveries *= tparams.invalidMessageDeliveriesDecay; if (tstats.invalidMessageDeliveries < decayToZero) { tstats.invalidMessageDeliveries = 0; } // update mesh time and activate mesh message delivery parameter if need be if (tstats.inMesh) { tstats.meshTime = now - tstats.graftTime; if (tstats.meshTime > tparams.meshMessageDeliveriesActivation) { tstats.meshMessageDeliveriesActive = true; } } }); // decay P7 counter pstats.behaviourPenalty *= this.params.behaviourPenaltyDecay; if (pstats.behaviourPenalty < decayToZero) { pstats.behaviourPenalty = 0; } this.scoreCache.set(id, { score: null, cacheUntil: 0 }); }); } /** * Return the score for a peer * @param {string} id * @returns {Number} */ score(id) { const pstats = this.peerStats.get(id); if (!pstats) { return 0; } const now = Date.now(); let cacheEntry = this.scoreCache.get(id); if (cacheEntry === undefined) { cacheEntry = { score: null, cacheUntil: 0 }; this.scoreCache.set(id, cacheEntry); } const { score, cacheUntil } = cacheEntry; if (cacheUntil > now && score !== null) return score; cacheEntry.score = compute_score_1.computeScore(id, pstats, this.params, this.peerIPs); // decayInterval is used to refresh score so we don't want to cache more than that cacheEntry.cacheUntil = now + this.params.decayInterval; return cacheEntry.score; } /** * Apply a behavioural penalty to a peer * @param {string} id * @param {Number} penalty * @returns {void} */ addPenalty(id, penalty) { const pstats = this.peerStats.get(id); if (!pstats) { return; } pstats.behaviourPenalty += penalty; this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * @param {string} id * @returns {void} */ addPeer(id) { // create peer stats (not including topic stats for each topic to be scored) // topic stats will be added as needed const pstats = peer_stats_1.createPeerStats({ connected: true }); this.peerStats.set(id, pstats); // get + update peer IPs const ips = this._getIPs(id); this._setIPs(id, ips, pstats.ips); pstats.ips = ips; } /** * @param {string} id * @returns {void} */ removePeer(id) { const pstats = this.peerStats.get(id); if (!pstats) { return; } // decide whether to retain the score; this currently only retains non-positive scores // to dissuade attacks on the score function. if (this.score(id) > 0) { this._removeIPs(id, pstats.ips); this.peerStats.delete(id); return; } // delete score cache this.scoreCache.delete(id); // furthermore, when we decide to retain the score, the firstMessageDelivery counters are // reset to 0 and mesh delivery penalties applied. Object.entries(pstats.topics).forEach(([topic, tstats]) => { tstats.firstMessageDeliveries = 0; const threshold = this.params.topics[topic].meshMessageDeliveriesThreshold; if (tstats.inMesh && tstats.meshMessageDeliveriesActive && tstats.meshMessageDeliveries < threshold) { const deficit = threshold - tstats.meshMessageDeliveries; tstats.meshFailurePenalty += deficit * deficit; } tstats.inMesh = false; }); pstats.connected = false; pstats.expire = Date.now() + this.params.retainScore; } /** * @param {string} id * @param {String} topic * @returns {void} */ graft(id, topic) { const pstats = this.peerStats.get(id); if (!pstats) { return; } const tstats = peer_stats_1.ensureTopicStats(topic, pstats, this.params); if (!tstats) { return; } tstats.inMesh = true; tstats.graftTime = Date.now(); tstats.meshTime = 0; tstats.meshMessageDeliveriesActive = false; this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * @param {string} id * @param {string} topic * @returns {void} */ prune(id, topic) { const pstats = this.peerStats.get(id); if (!pstats) { return; } const tstats = peer_stats_1.ensureTopicStats(topic, pstats, this.params); if (!tstats) { return; } // sticky mesh delivery rate failure penalty const threshold = this.params.topics[topic].meshMessageDeliveriesThreshold; if (tstats.meshMessageDeliveriesActive && tstats.meshMessageDeliveries < threshold) { const deficit = threshold - tstats.meshMessageDeliveries; tstats.meshFailurePenalty += deficit * deficit; } tstats.inMesh = false; this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * @param {InMessage} message * @returns {Promise<void>} */ validateMessage(msgIdStr) { return __awaiter(this, void 0, void 0, function* () { this.deliveryRecords.ensureRecord(msgIdStr); }); } /** * @param {InMessage} msg * @returns {Promise<void>} */ deliverMessage(msg, msgIdStr) { return __awaiter(this, void 0, void 0, function* () { const id = msg.receivedFrom; this._markFirstMessageDelivery(id, msg); const drec = this.deliveryRecords.ensureRecord(msgIdStr); const now = Date.now(); // defensive check that this is the first delivery trace -- delivery status should be unknown if (drec.status !== message_deliveries_1.DeliveryRecordStatus.unknown) { log('unexpected delivery: message from %s was first seen %s ago and has delivery status %d', id, now - drec.firstSeen, message_deliveries_1.DeliveryRecordStatus[drec.status]); return; } // mark the message as valid and reward mesh peers that have already forwarded it to us drec.status = message_deliveries_1.DeliveryRecordStatus.valid; drec.validated = now; drec.peers.forEach(p => { // this check is to make sure a peer can't send us a message twice and get a double count // if it is a first delivery. if (p !== id) { this._markDuplicateMessageDelivery(p, msg); } }); }); } /** * @param {InMessage} msg * @param {string} reason * @returns {Promise<void>} */ rejectMessage(msg, msgIdStr, reason) { return __awaiter(this, void 0, void 0, function* () { const id = msg.receivedFrom; switch (reason) { case ERR_MISSING_SIGNATURE: case ERR_INVALID_SIGNATURE: this._markInvalidMessageDelivery(id, msg); return; } const drec = this.deliveryRecords.ensureRecord(msgIdStr); // defensive check that this is the first rejection -- delivery status should be unknown if (drec.status !== message_deliveries_1.DeliveryRecordStatus.unknown) { log('unexpected rejection: message from %s was first seen %s ago and has delivery status %d', id, Date.now() - drec.firstSeen, message_deliveries_1.DeliveryRecordStatus[drec.status]); return; } switch (reason) { case constants_1.ERR_TOPIC_VALIDATOR_IGNORE: // we were explicitly instructed by the validator to ignore the message but not penalize the peer drec.status = message_deliveries_1.DeliveryRecordStatus.ignored; return; } // mark the message as invalid and penalize peers that have already forwarded it. drec.status = message_deliveries_1.DeliveryRecordStatus.invalid; this._markInvalidMessageDelivery(id, msg); drec.peers.forEach(p => { this._markInvalidMessageDelivery(p, msg); }); }); } /** * @param {InMessage} msg * @returns {Promise<void>} */ duplicateMessage(msg, msgIdStr) { return __awaiter(this, void 0, void 0, function* () { const id = msg.receivedFrom; const drec = this.deliveryRecords.ensureRecord(msgIdStr); if (drec.peers.has(id)) { // we have already seen this duplicate return; } switch (drec.status) { case message_deliveries_1.DeliveryRecordStatus.unknown: // the message is being validated; track the peer delivery and wait for // the Deliver/Reject/Ignore notification. drec.peers.add(id); break; case message_deliveries_1.DeliveryRecordStatus.valid: // mark the peer delivery time to only count a duplicate delivery once. drec.peers.add(id); this._markDuplicateMessageDelivery(id, msg, drec.validated); break; case message_deliveries_1.DeliveryRecordStatus.invalid: // we no longer track delivery time this._markInvalidMessageDelivery(id, msg); break; } }); } /** * Increments the "invalid message deliveries" counter for all scored topics the message is published in. * @param {string} id * @param {InMessage} msg * @returns {void} */ _markInvalidMessageDelivery(id, msg) { const pstats = this.peerStats.get(id); if (!pstats) { return; } msg.topicIDs.forEach(topic => { const tstats = peer_stats_1.ensureTopicStats(topic, pstats, this.params); if (!tstats) { return; } tstats.invalidMessageDeliveries += 1; }); this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * Increments the "first message deliveries" counter for all scored topics the message is published in, * as well as the "mesh message deliveries" counter, if the peer is in the mesh for the topic. * @param {string} id * @param {InMessage} msg * @returns {void} */ _markFirstMessageDelivery(id, msg) { const pstats = this.peerStats.get(id); if (!pstats) { return; } msg.topicIDs.forEach(topic => { const tstats = peer_stats_1.ensureTopicStats(topic, pstats, this.params); if (!tstats) { return; } let cap = this.params.topics[topic].firstMessageDeliveriesCap; tstats.firstMessageDeliveries += 1; if (tstats.firstMessageDeliveries > cap) { tstats.firstMessageDeliveries = cap; } if (!tstats.inMesh) { return; } cap = this.params.topics[topic].meshMessageDeliveriesCap; tstats.meshMessageDeliveries += 1; if (tstats.meshMessageDeliveries > cap) { tstats.meshMessageDeliveries = cap; } }); this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * Increments the "mesh message deliveries" counter for messages we've seen before, * as long the message was received within the P3 window. * @param {string} id * @param {InMessage} msg * @param {number} validatedTime * @returns {void} */ _markDuplicateMessageDelivery(id, msg, validatedTime = 0) { const pstats = this.peerStats.get(id); if (!pstats) { return; } const now = validatedTime ? Date.now() : 0; msg.topicIDs.forEach(topic => { const tstats = peer_stats_1.ensureTopicStats(topic, pstats, this.params); if (!tstats) { return; } if (!tstats.inMesh) { return; } const tparams = this.params.topics[topic]; // check against the mesh delivery window -- if the validated time is passed as 0, then // the message was received before we finished validation and thus falls within the mesh // delivery window. if (validatedTime && now > validatedTime + tparams.meshMessageDeliveriesWindow) { return; } const cap = tparams.meshMessageDeliveriesCap; tstats.meshMessageDeliveries += 1; if (tstats.meshMessageDeliveries > cap) { tstats.meshMessageDeliveries = cap; } }); this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * Gets the current IPs for a peer. * @param {string} id * @returns {Array<string>} */ _getIPs(id) { return this._connectionManager.getAll(peer_id_1.default.createFromB58String(id)) .map(c => c.remoteAddr.toOptions().host); } /** * Adds tracking for the new IPs in the list, and removes tracking from the obsolete IPs. * @param {string} id * @param {Array<string>} newIPs * @param {Array<string>} oldIPs * @returns {void} */ _setIPs(id, newIPs, oldIPs) { // add the new IPs to the tracking // eslint-disable-next-line no-labels addNewIPs: for (const ip of newIPs) { // check if it is in the old ips list for (const xip of oldIPs) { if (ip === xip) { // eslint-disable-next-line no-labels continue addNewIPs; } } // no, it's a new one -- add it to the tracker let peers = this.peerIPs.get(ip); if (!peers) { peers = new Set(); this.peerIPs.set(ip, peers); } peers.add(id); } // remove the obsolete old IPs from the tracking // eslint-disable-next-line no-labels removeOldIPs: for (const ip of oldIPs) { // check if it is in the new ips list for (const xip of newIPs) { if (ip === xip) { // eslint-disable-next-line no-labels continue removeOldIPs; } } // no, its obselete -- remove it from the tracker const peers = this.peerIPs.get(ip); if (!peers) { continue; } peers.delete(id); if (!peers.size) { this.peerIPs.delete(ip); } } this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * Removes an IP list from the tracking list for a peer. * @param {string} id * @param {Array<string>} ips * @returns {void} */ _removeIPs(id, ips) { ips.forEach(ip => { const peers = this.peerIPs.get(ip); if (!peers) { return; } peers.delete(id); if (!peers.size) { this.peerIPs.delete(ip); } }); this.scoreCache.set(id, { score: null, cacheUntil: 0 }); } /** * Update all peer IPs to currently open connections * @returns {void} */ _updateIPs() { this.peerStats.forEach((pstats, id) => { const newIPs = this._getIPs(id); this._setIPs(id, newIPs, pstats.ips); pstats.ips = newIPs; this.scoreCache.set(id, { score: null, cacheUntil: 0 }); }); } } exports.PeerScore = PeerScore;