UNPKG

peerpigeon

Version:

WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server

694 lines (609 loc) 25.7 kB
import { EventEmitter } from './EventEmitter.js'; import { environmentDetector } from './EnvironmentDetector.js'; import DebugLogger from './DebugLogger.js'; /** * Manages gossip protocol for message propagation across the mesh network * Ensures all peers receive messages even if not directly connected */ export class GossipManager extends EventEmitter { constructor(mesh, connectionManager) { super(); this.mesh = mesh; this.connectionManager = connectionManager; this.debug = DebugLogger.create('GossipManager'); // Track message history to prevent infinite loops this.seenMessages = new Map(); // messageId -> { timestamp, ttl } this.messageHistory = new Map(); // messageId -> message content // Track key exchanges to prevent duplicates (separate from general message deduplication) this.processedKeyExchanges = new Map(); // "peerId:keyType" -> timestamp // Configuration this.maxTTL = 10; // Maximum hops before message expires this.messageExpiryTime = 5 * 60 * 1000; // 5 minutes this.cleanupInterval = 60 * 1000; // 1 minute this.cleanupTimer = null; // Track cleanup timer for proper cleanup this.startCleanupTimer(); } /** * Broadcast a message to all peers in the network using gossip protocol */ async broadcastMessage(content, messageType = 'chat') { // Validate content if (content === undefined || content === null) { this.debug.error('Cannot broadcast message with undefined/null content'); return null; } if (messageType === 'chat' && (typeof content !== 'string' || content.trim().length === 0)) { this.debug.error('Cannot broadcast empty chat message'); return null; } if (messageType === 'encrypted' && (typeof content !== 'object' || !content.encrypted)) { this.debug.error('Cannot broadcast invalid encrypted message'); return null; } const messageId = await this.generateMessageId(); const message = { id: messageId, type: 'gossip', subtype: messageType, content, from: this.mesh.peerId, networkName: this.mesh.networkName, // Include network namespace timestamp: Date.now(), ttl: this.maxTTL, path: [this.mesh.peerId] // Track propagation path }; this.debug.log(`Broadcasting ${messageType} message: ${messageId.substring(0, 8)}... content: "${content}"`); // Store our own message this.seenMessages.set(messageId, { timestamp: Date.now(), ttl: this.maxTTL }); this.messageHistory.set(messageId, message); // Send to all connected peers this.propagateMessage(message); // Emit locally if it's a chat or encrypted message if (messageType === 'chat' || messageType === 'encrypted') { this.emit('messageReceived', { from: this.mesh.peerId, content, timestamp: message.timestamp, messageId, encrypted: messageType === 'encrypted' }); } return messageId; } /** * Send a direct message to a specific peer using gossip routing (DM) * @param {string} targetPeerId - The destination peer's ID * @param {string|object} content - The message content * @param {string} subtype - Message subtype (default: 'dm') * @returns {string|null} The message ID if sent, or null on error */ async sendDirectMessage(targetPeerId, content, subtype = 'dm') { if (!targetPeerId || typeof targetPeerId !== 'string') { this.debug.error('Invalid targetPeerId for direct message'); return null; } // Validate peer ID format (40-character hex string) if (!/^[a-fA-F0-9]{40}$/.test(targetPeerId)) { this.debug.error('Invalid peer ID format for direct message:', targetPeerId); return null; } const messageId = await this.generateMessageId(); const message = { id: messageId, type: 'gossip', subtype, content, from: this.mesh.peerId, to: targetPeerId, networkName: this.mesh.networkName, // Include network namespace timestamp: Date.now(), ttl: this.maxTTL, path: [this.mesh.peerId] }; // Store our own message this.seenMessages.set(messageId, { timestamp: Date.now(), ttl: this.maxTTL }); this.messageHistory.set(messageId, message); // Route to closest peer this.propagateMessage(message); return messageId; } /** * Handle incoming gossip message from a peer */ async handleGossipMessage(message, fromPeerId) { this.debug.log(`🔥🔥🔥 GOSSIP MESSAGE RECEIVED! From: ${fromPeerId?.substring(0, 8)}...`); this.debug.log('🔥🔥🔥 Message:', message); const { id: messageId, ttl, from: originPeerId, subtype, content, timestamp, path, to, networkName } = message; // Validate message structure if (!messageId || !originPeerId || !subtype || content === undefined) { this.debug.error('Invalid gossip message structure:', message); return; } // Filter messages by network namespace const messageNetwork = networkName || 'global'; const currentNetwork = this.mesh.networkName; if (messageNetwork !== currentNetwork) { this.debug.log(`Filtering gossip message from different network: ${messageNetwork} (current: ${currentNetwork})`); return; } // Check if we've already seen this message if (this.seenMessages.has(messageId)) { this.debug.log(`Ignoring duplicate message: ${messageId.substring(0, 8)}...`); return; } // Check TTL if (ttl <= 0) { this.debug.log(`Message expired: ${messageId.substring(0, 8)}...`); return; } // Check for loops (our peer ID in path) if (path && path.includes(this.mesh.peerId)) { this.debug.log(`Preventing message loop: ${messageId.substring(0, 8)}...`); return; } this.debug.log(`Received gossip message: ${messageId.substring(0, 8)}... from ${fromPeerId.substring(0, 8)}... (TTL: ${ttl}, content: "${content}")`); // Store message to prevent duplicates this.seenMessages.set(messageId, { timestamp: Date.now(), ttl }); this.messageHistory.set(messageId, message); // Handle crypto-related messages first if (this.mesh.enableCrypto && this.mesh.cryptoManager) { const handled = await this._handleCryptoMessage(message, fromPeerId, originPeerId); if (handled) { return; // Don't propagate crypto messages further } } // Handle encrypted content decryption let processedContent = content; let isEncrypted = false; if (this.mesh.enableCrypto && this.mesh.cryptoManager && content && typeof content === 'object' && content.encrypted) { try { processedContent = await this.mesh.decryptMessage(content); isEncrypted = true; this.debug.log(`🔐 Decrypted message from ${originPeerId.substring(0, 8)}...`); } catch (error) { this.debug.error('Failed to decrypt message:', error); // Continue with original content processedContent = content; } } // Emit the message locally if (subtype === 'chat') { // Validate chat content (handle both encrypted objects and plain strings) if (isEncrypted || (typeof processedContent === 'string' && processedContent.trim().length > 0)) { this.emit('messageReceived', { from: originPeerId, content: processedContent, timestamp, messageId, hops: this.maxTTL - ttl, direct: false, // Flag to indicate this was a gossip message encrypted: isEncrypted }); } else { this.debug.warn('Ignoring gossip chat message with invalid content:', processedContent); return; } } else if (subtype === 'encrypted') { // Handle encrypted broadcast messages this.emit('messageReceived', { from: originPeerId, content: processedContent, timestamp, messageId, hops: this.maxTTL - ttl, direct: false, encrypted: true }); } else if (subtype === 'peer-announcement') { this.handlePeerAnnouncement(content, originPeerId); } else if (subtype === 'mediaEvent') { // Handle media streaming events this.handleMediaEvent(content, originPeerId); } else if (subtype === 'dm') { // Direct message logic if (typeof to === 'string' && typeof this.mesh.peerId === 'string' && to.trim().toLowerCase() === this.mesh.peerId.trim().toLowerCase()) { // This peer is the target - check if this message type should be filtered this.debug.log(`🔍 DM DEBUG: Received DM from ${originPeerId?.substring(0, 8)}, content type: ${typeof processedContent}`); if (typeof processedContent === 'object' && processedContent) { this.debug.log(`🔍 DM DEBUG: Content object has type: ${processedContent.type}`); } // Define message types that should be filtered from peer-readable messages // These messages are processed but not emitted as regular messages to UI/applications const filteredMessageTypes = new Set([ 'signaling-relay', 'peer-announce-relay', 'bootstrap-keepalive' ]); // Parse the content to check message type let messageType = null; let shouldFilter = false; // Check if content is already an object with type property if (typeof processedContent === 'object' && processedContent && processedContent.type) { messageType = processedContent.type; shouldFilter = filteredMessageTypes.has(messageType); } else if (typeof processedContent === 'string') { // Try to parse as JSON if it's a string try { const parsedContent = JSON.parse(processedContent); messageType = parsedContent.type; shouldFilter = filteredMessageTypes.has(messageType); } catch (e) { // Not JSON or doesn't have type - treat as regular message } } this.debug.log(`🔍 DM DEBUG: messageType=${messageType}, shouldFilter=${shouldFilter}`); if (shouldFilter) { this.debug.log(`🔇 FILTER: Processing filtered DM type '${messageType}' from ${originPeerId?.substring(0, 8)} (not emitted to UI)`); // Process the filtered message internally but don't emit to UI if (messageType === 'signaling-relay' && typeof processedContent === 'object') { // Handle signaling relay if (processedContent.signalingMessage && this.mesh.signalingHandler) { this.mesh.signalingHandler.handleSignalingMessage({ type: processedContent.signalingMessage.type, data: processedContent.signalingMessage.data, fromPeerId: processedContent.signalingMessage.fromPeerId || originPeerId, targetPeerId: processedContent.targetPeerId, timestamp: processedContent.timestamp }); } } else if (messageType === 'peer-announce-relay' && typeof processedContent === 'object') { // Handle peer announce relay if (processedContent.data && this.mesh.signalingHandler) { this.mesh.signalingHandler.handlePeerAnnouncement(processedContent.data, originPeerId); } } else if (messageType === 'bootstrap-keepalive') { // Handle bootstrap keepalive - content can be object or string if (this.mesh.peerDiscovery) { // For bootstrap keepalive from a specific source, use the 'from' field as the peer ID const keepalivePeerId = (typeof processedContent === 'object' && processedContent.from) ? processedContent.from : originPeerId; this.mesh.peerDiscovery.updateDiscoveryTimestamp(keepalivePeerId); } } // Do NOT emit to UI and do NOT relay further return; } // This is a regular (non-filtered) direct message - emit to UI this.emit('messageReceived', { from: originPeerId, content: processedContent, timestamp, messageId, hops: this.maxTTL - ttl, direct: true, // Flag to indicate this was a direct message encrypted: isEncrypted }); // Do NOT relay further if we are the recipient return; } else { // Not the target, relay silently (do not emit) } } else if (subtype === 'dht-routing') { // DHT routing message - check if we're the target if (typeof to === 'string' && typeof this.mesh.peerId === 'string' && to.trim().toLowerCase() === this.mesh.peerId.trim().toLowerCase()) { this.debug.log(`DHT: Received routed message for us from ${originPeerId.substring(0, 8)}`); // We are the target, deliver the DHT message locally if (this.mesh.webDHT && content) { // Simulate receiving the message from the original sender this.mesh.webDHT.handleMessage(content, originPeerId); } // Do NOT relay further since we are the recipient return; } else { this.debug.log(`DHT: Routing message for ${to?.substring(0, 8)} (not us)`); // Not the target, continue routing } } // Propagate to other peers with decremented TTL const updatedMessage = { ...message, ttl: ttl - 1, path: [...(path || []), this.mesh.peerId] }; this.propagateMessage(updatedMessage, fromPeerId); } /** * Handle peer announcements received via gossip */ handlePeerAnnouncement(announcementData, originPeerId) { const { peerId: announcedPeerId } = announcementData; this.debug.log(`Gossip peer announcement: ${announcedPeerId.substring(0, 8)}... via ${originPeerId.substring(0, 8)}...`); // Add to discovered peers if we don't know about them if (!this.mesh.peerDiscovery.hasPeer(announcedPeerId) && announcedPeerId !== this.mesh.peerId) { this.mesh.emit('statusChanged', { type: 'info', message: `Discovered peer ${announcedPeerId.substring(0, 8)}... via gossip` }); this.mesh.peerDiscovery.addDiscoveredPeer(announcedPeerId); } } /** * Handle media streaming events received via gossip */ handleMediaEvent(eventData, originPeerId) { const { event, peerId, hasVideo, hasAudio, timestamp } = eventData; this.debug.log(`Media event gossip: ${event} from ${peerId.substring(0, 8)}... via ${originPeerId.substring(0, 8)}...`); // Don't process our own events if (peerId === this.mesh.peerId) { return; } // Emit the media event for the UI to handle if (event === 'streamStarted') { this.mesh.emit('remoteStreamAnnouncement', { peerId, hasVideo, hasAudio, timestamp, event: 'started' }); } else if (event === 'streamStopped') { this.mesh.emit('remoteStreamAnnouncement', { peerId, timestamp, event: 'stopped' }); } } /** * Broadcast peer announcement via gossip when we connect */ async announcePeer(peerId = this.mesh.peerId) { const announcementData = { peerId, timestamp: Date.now() }; this.debug.log(`Gossiping peer announcement for: ${peerId.substring(0, 8)}...`); await this.broadcastMessage(announcementData, 'peer-announcement'); } /** * Propagate message to ALL peers that can receive messages - ignore connection states! */ propagateMessage(message, excludePeerId = null) { // DM and DHT routing: route to closest peer(s) for messages with specific targets if ((message.subtype === 'dm' || message.subtype === 'dht-routing') && message.to) { const targetId = message.to; const allPeers = Array.from(this.connectionManager.peers.values()); const capablePeers = allPeers.filter(peerConnection => { return peerConnection.dataChannel && peerConnection.dataChannel.readyState === 'open'; }); // Improved XOR distance for 40-char hex peer IDs function xorDistance(a, b) { if (!a || !b) return Number.MAX_SAFE_INTEGER; let dist = 0n; for (let i = 0; i < 40; i += 8) { const aChunk = a.substring(i, i + 8); const bChunk = b.substring(i, i + 8); dist = (dist << 32n) + (BigInt('0x' + aChunk) ^ BigInt('0x' + bChunk)); } return dist; } let minDist = null; let closestPeers = []; capablePeers.forEach(peerConnection => { if (peerConnection.peerId === excludePeerId || message.ttl <= 0) return; const dist = xorDistance(peerConnection.peerId, targetId); if (minDist === null || dist < minDist) { minDist = dist; closestPeers = [peerConnection]; } else if (dist === minDist) { closestPeers.push(peerConnection); } }); if (closestPeers.length > 0) { closestPeers.forEach(peer => { try { peer.sendMessage(message); const routingType = message.subtype === 'dht-routing' ? 'DHT' : 'DM'; this.debug.log(`${routingType} routed to closest peer: ${peer.peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`${message.subtype} routing failed:`, error); } }); } else { // Fallback: relay to all capable peers except sender capablePeers.forEach(peerConnection => { if (peerConnection.peerId === excludePeerId || message.ttl <= 0) return; try { peerConnection.sendMessage(message); const routingType = message.subtype === 'dht-routing' ? 'DHT' : 'DM'; this.debug.log(`${routingType} fallback relay to: ${peerConnection.peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`${message.subtype} fallback relay failed:`, error); } }); } return; } // AGGRESSIVE: Get ALL peers regardless of status - we only care if we can send a message const allPeers = Array.from(this.connectionManager.peers.values()); const capablePeers = allPeers.filter(peerConnection => { // ONLY requirement: does the peer have an open data channel? return peerConnection.dataChannel && peerConnection.dataChannel.readyState === 'open'; }); let propagatedTo = 0; this.debug.log(`🚀 AGGRESSIVE GOSSIP: Found ${capablePeers.length}/${allPeers.length} peers with open data channels`); this.debug.log(`Message: ${message.id.substring(0, 8)}..., TTL: ${message.ttl}, Exclude: ${excludePeerId?.substring(0, 8) || 'none'}`); // Debug: show state of ALL peers allPeers.forEach(peerConnection => { const status = peerConnection.getStatus(); const dataChannelState = peerConnection.dataChannel?.readyState || 'none'; const canSend = peerConnection.dataChannel && peerConnection.dataChannel.readyState === 'open'; const isExcluded = peerConnection.peerId === excludePeerId; this.debug.log(` ${peerConnection.peerId.substring(0, 8)}... - Status: ${status}, DataChannel: ${dataChannelState}, CanSend: ${canSend}, Excluded: ${isExcluded}`); }); capablePeers.forEach(peerConnection => { const peerId = peerConnection.peerId; // Don't send back to sender or if TTL expired if (peerId === excludePeerId || message.ttl <= 0) { return; } try { peerConnection.sendMessage(message); propagatedTo++; this.debug.log(`✅ GOSSIP SUCCESS: Sent to ${peerId.substring(0, 8)}...`); } catch (error) { this.debug.error(`❌ GOSSIP FAILED: Could not send to ${peerId.substring(0, 8)}...`, error); } }); this.debug.log(`🎯 GOSSIP RESULT: Propagated to ${propagatedTo}/${capablePeers.length} capable peers`); if (propagatedTo === 0 && allPeers.length > 0) { this.debug.error(`🚨 GOSSIP FAILURE: NO PROPAGATION! ${allPeers.length} total peers, ${capablePeers.length} with data channels`); } } /** * Get message statistics */ getStats() { return { seenMessages: this.seenMessages.size, storedMessages: this.messageHistory.size, maxTTL: this.maxTTL, messageExpiryTime: this.messageExpiryTime }; } /** * Generate unique message ID */ async generateMessageId() { const array = new Uint8Array(16); // Environment-aware random value generation if (typeof crypto !== 'undefined' && crypto.getRandomValues) { // Browser environment crypto.getRandomValues(array); } else if (typeof process !== 'undefined' && process.versions && process.versions.node) { // Node.js environment try { const crypto = await import('crypto'); const randomBytes = crypto.randomBytes(16); array.set(randomBytes); } catch (e) { console.warn('Could not use Node.js crypto, falling back to Math.random'); // Fallback to Math.random for (let i = 0; i < array.length; i++) { array[i] = Math.floor(Math.random() * 256); } } } else { // Fallback to Math.random for other environments for (let i = 0; i < array.length; i++) { array[i] = Math.floor(Math.random() * 256); } } return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); } /** * Clean up expired messages */ startCleanupTimer() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); } if (environmentDetector.isBrowser) { this.cleanupTimer = window.setInterval(() => { this.cleanupExpiredMessages(); }, this.cleanupInterval); } else { this.cleanupTimer = setInterval(() => { this.cleanupExpiredMessages(); }, this.cleanupInterval); } } /** * Stop cleanup timer */ stopCleanupTimer() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } } cleanupExpiredMessages() { const now = Date.now(); let cleaned = 0; let keyExchangesCleaned = 0; // Clean up seen messages this.seenMessages.forEach((data, messageId) => { if (now - data.timestamp > this.messageExpiryTime) { this.seenMessages.delete(messageId); this.messageHistory.delete(messageId); cleaned++; } }); // Clean up old key exchange tracking (keep for shorter time - 1 minute) this.processedKeyExchanges.forEach((timestamp, keyExchangeId) => { if (now - timestamp > 60000) { // 1 minute this.processedKeyExchanges.delete(keyExchangeId); keyExchangesCleaned++; } }); if (cleaned > 0) { this.debug.log(`Cleaned up ${cleaned} expired gossip messages`); } if (keyExchangesCleaned > 0) { this.debug.log(`Cleaned up ${keyExchangesCleaned} old key exchange tracking entries`); } } /** * Cleanup method */ cleanup() { this.stopCleanupTimer(); this.seenMessages.clear(); this.messageHistory.clear(); this.processedKeyExchanges.clear(); } /** * Handle crypto-related messages (key exchange, etc.) * @private */ async _handleCryptoMessage(message, fromPeerId, originPeerId) { const { subtype, content } = message; // Handle key exchange messages if (subtype === 'key_exchange' || subtype === 'key_exchange_response') { if (content && (content.type === 'key_exchange' || content.type === 'key_exchange_response') && content.publicKey) { // Create a unique identifier for this key exchange to prevent duplicates const keyExchangeId = `${originPeerId}:${content.type}:${content.timestamp || Date.now()}`; // Check if we've already processed this key exchange if (this.processedKeyExchanges.has(keyExchangeId)) { this.debug.log(`🔐 Ignoring duplicate ${content.type} from peer ${originPeerId.substring(0, 8)}... (already processed)`); return true; // Mark as handled to prevent further propagation } // Also check for recent key exchanges from the same peer (within last 5 seconds) const recentKeyExchangePattern = `${originPeerId}:${content.type}:`; const now = Date.now(); let foundRecent = false; for (const [existingId, timestamp] of this.processedKeyExchanges.entries()) { if (existingId.startsWith(recentKeyExchangePattern) && (now - timestamp) < 5000) { foundRecent = true; break; } } if (foundRecent) { this.debug.log(`🔐 Ignoring recent duplicate ${content.type} from peer ${originPeerId.substring(0, 8)}... (processed recently)`); return true; } this.debug.log(`🔐 Processing ${content.type} from peer ${originPeerId.substring(0, 8)}...`); // Mark this key exchange as processed this.processedKeyExchanges.set(keyExchangeId, now); // Use the mesh's key exchange handler which properly handles both pub and epub keys this.mesh._handleKeyExchange(content, originPeerId); // Don't propagate key exchange messages further return true; // Indicates message was handled and should not be processed further } } return false; // Message was not handled by crypto processing } }