| … | … | @@ -1,37 +1,53 @@ |
| 1 | 1 | /** |
| 2 | 2 | * HyperswarmRouter - Routes packets between TUN device and Hyperswarm P2P network |
| 3 | 3 | * |
| 4 | | * Responsibilities: |
| 5 | | * - Join Hyperswarm topics for each service |
| 6 | | * - Handle incoming/outgoing P2P connections |
| 7 | | * - Forward packets from TUN to P2P peers |
| 8 | | * - Forward packets from P2P peers to TUN |
| 9 | | * - Track connection state per service |
| 4 | * NEW ARCHITECTURE: |
| 5 | * - Join ONE shared network topic (configurable, e.g., "hyperident-network") |
| 6 | * - Announce owned IPs to peers |
| 7 | * - Build routing table from peer announcements |
| 8 | * - Forward packets based on routing table (IP → peer connection) |
| 9 | * - Like a P2P VPN/LAN |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import Hyperswarm from 'hyperswarm' |
| 13 | 13 | import crypto from 'hypercore-crypto' |
| 14 | 14 | |
| 15 | // Message types for the announcement protocol |
| 16 | const MSG_TYPES = { |
| 17 | ANNOUNCE: 0x01, // Announce owned IPs |
| 18 | PACKET: 0x02 // IP packet forwarding |
| 19 | } |
| 20 | |
| 15 | 21 | export class HyperswarmRouter { |
| 16 | 22 | constructor(config = {}) { |
| 17 | 23 | this.config = config |
| 18 | 24 | this.gatewayIdentity = config.gatewayIdentity |
| 19 | 25 | this.utunManager = config.utunManager |
| 26 | this.networkTopic = config.networkTopic || 'hyperident-network' |
| 20 | 27 | |
| 21 | 28 | this.swarm = null |
| 22 | 29 | this.isRunning = false |
| 23 | 30 | |
| 24 | | // Map: topic hex → { service, connections: Set, server: boolean } |
| 25 | | this.topics = new Map() |
| 31 | // Network topic info |
| 32 | this.topic = null |
| 33 | this.topicBuffer = null |
| 34 | |
| 35 | // All peer connections on the network |
| 36 | this.connections = new Set() |
| 37 | |
| 38 | // Routing table: IP → Set of connections that can reach it |
| 39 | // Example: "10.22.0.2" → Set([conn1, conn2]) |
| 40 | this.routingTable = new Map() |
| 26 | 41 | |
| 27 | | // Map: connection → { topic, service, isInitiator, stats } |
| 28 | | this.connections = new Map() |
| 42 | // Connection metadata: connection → { peerIPs: Set, stats } |
| 43 | this.connectionInfo = new Map() |
| 29 | 44 | |
| 30 | 45 | // Statistics |
| 31 | 46 | this.stats = { |
| 32 | | topicsJoined: 0, |
| 33 | 47 | connectionsOpened: 0, |
| 34 | 48 | connectionsClosed: 0, |
| 49 | announcementsSent: 0, |
| 50 | announcementsReceived: 0, |
| 35 | 51 | packetsToP2P: 0, |
| 36 | 52 | packetsFromP2P: 0, |
| 37 | 53 | bytesToP2P: 0, |
| … | … | @@ -45,7 +61,7 @@ export class HyperswarmRouter { |
| 45 | 61 | } |
| 46 | 62 | |
| 47 | 63 | /** |
| 48 | | * Initialize Hyperswarm and start routing |
| 64 | * Initialize Hyperswarm and join the network topic |
| 49 | 65 | */ |
| 50 | 66 | async start() { |
| 51 | 67 | if (this.swarm) { |
| … | … | @@ -55,10 +71,7 @@ export class HyperswarmRouter { |
| 55 | 71 | console.log('📝 Initializing Hyperswarm...') |
| 56 | 72 | |
| 57 | 73 | // Create Hyperswarm instance |
| 58 | | this.swarm = new Hyperswarm({ |
| 59 | | // Use default DHT bootstrap nodes |
| 60 | | // maxConnections: 0 means unlimited |
| 61 | | }) |
| 74 | this.swarm = new Hyperswarm() |
| 62 | 75 | |
| 63 | 76 | // Handle new connections |
| 64 | 77 | this.swarm.on('connection', (connection, info) => { |
| … | … | @@ -76,68 +89,30 @@ export class HyperswarmRouter { |
| 76 | 89 | } |
| 77 | 90 | |
| 78 | 91 | /** |
| 79 | | * Join a Hyperswarm topic for a service |
| 92 | * Join the network topic |
| 80 | 93 | */ |
| 81 | | async joinService(service) { |
| 94 | async joinNetwork() { |
| 82 | 95 | if (!this.swarm) { |
| 83 | 96 | throw new Error('HyperswarmRouter not started') |
| 84 | 97 | } |
| 85 | 98 | |
| 86 | | const topicHex = service.topic.toString('hex') |
| 87 | | |
| 88 | | // Check if already joined |
| 89 | | if (this.topics.has(topicHex)) { |
| 90 | | console.log(` ⏭️ Already joined topic for ${service.name}`) |
| 91 | | return |
| 92 | | } |
| 93 | | |
| 94 | | try { |
| 95 | | // Join the topic as both client and server |
| 96 | | // server: true - accept incoming connections |
| 97 | | // client: true - make outgoing connections (default) |
| 98 | | const discovery = this.swarm.join(service.topic, { |
| 99 | | server: true, |
| 100 | | client: true |
| 101 | | }) |
| 102 | | |
| 103 | | // Wait for the topic to be fully announced |
| 104 | | await discovery.flushed() |
| 105 | | |
| 106 | | // Store topic info |
| 107 | | this.topics.set(topicHex, { |
| 108 | | service, |
| 109 | | connections: new Set(), |
| 110 | | discovery |
| 111 | | }) |
| 112 | | |
| 113 | | this.stats.topicsJoined++ |
| 99 | // Derive topic from network name |
| 100 | this.topicBuffer = crypto.hash(Buffer.from(this.networkTopic, 'utf8')).subarray(0, 32) |
| 101 | this.topic = this.topicBuffer.toString('hex') |
| 114 | 102 | |
| 115 | | console.log(` ✅ Joined swarm topic for ${service.name}`) |
| 116 | | console.log(` Topic: ${topicHex.substring(0, 16)}...`) |
| 117 | | console.log(` IP: ${service.ip}:${service.port}`) |
| 103 | console.log(`\n📝 Joining network topic: "${this.networkTopic}"`) |
| 104 | console.log(` Topic hash: ${this.topic.substring(0, 16)}...`) |
| 118 | 105 | |
| 119 | | } catch (error) { |
| 120 | | this.stats.errors++ |
| 121 | | throw new Error(`Failed to join topic for ${service.name}: ${error.message}`) |
| 122 | | } |
| 123 | | } |
| 124 | | |
| 125 | | /** |
| 126 | | * Join topics for all registered services |
| 127 | | */ |
| 128 | | async joinAllServices() { |
| 129 | | if (!this.gatewayIdentity) { |
| 130 | | throw new Error('GatewayIdentity not configured') |
| 131 | | } |
| 132 | | |
| 133 | | const services = this.gatewayIdentity.listServices() |
| 134 | | console.log(`\n📝 Joining ${services.length} service topics...`) |
| 106 | // Join as both server and client |
| 107 | const discovery = this.swarm.join(this.topicBuffer, { |
| 108 | server: true, |
| 109 | client: true |
| 110 | }) |
| 135 | 111 | |
| 136 | | for (const service of services) { |
| 137 | | await this.joinService(service) |
| 138 | | } |
| 112 | await discovery.flushed() |
| 139 | 113 | |
| 140 | | console.log() |
| 114 | console.log('✅ Network topic joined') |
| 115 | console.log(' Waiting for peers to connect...\n') |
| 141 | 116 | } |
| 142 | 117 | |
| 143 | 118 | /** |
| … | … | @@ -145,22 +120,14 @@ export class HyperswarmRouter { |
| 145 | 120 | * @private |
| 146 | 121 | */ |
| 147 | 122 | _handleConnection(connection, info) { |
| 148 | | const topicHex = info.topics[0].toString('hex') |
| 149 | | const topicInfo = this.topics.get(topicHex) |
| 150 | | |
| 151 | | if (!topicInfo) { |
| 152 | | console.log(`⚠️ Connection for unknown topic: ${topicHex.substring(0, 16)}...`) |
| 153 | | connection.destroy() |
| 154 | | return |
| 155 | | } |
| 156 | | |
| 157 | | const { service } = topicInfo |
| 158 | 123 | const isInitiator = info.client |
| 159 | 124 | |
| 125 | console.log(`📡 New P2P connection`) |
| 126 | console.log(` Role: ${isInitiator ? 'client' : 'server'}`) |
| 127 | |
| 160 | 128 | // Track connection |
| 161 | 129 | const connInfo = { |
| 162 | | topic: info.topics[0], |
| 163 | | service, |
| 130 | peerIPs: new Set(), |
| 164 | 131 | isInitiator, |
| 165 | 132 | stats: { |
| 166 | 133 | bytesReceived: 0, |
| … | … | @@ -170,26 +137,21 @@ export class HyperswarmRouter { |
| 170 | 137 | } |
| 171 | 138 | } |
| 172 | 139 | |
| 173 | | this.connections.set(connection, connInfo) |
| 174 | | topicInfo.connections.add(connection) |
| 140 | this.connections.add(connection) |
| 141 | this.connectionInfo.set(connection, connInfo) |
| 175 | 142 | this.stats.connectionsOpened++ |
| 176 | 143 | |
| 177 | | // Update service connection count |
| 178 | | service.connections.push({ |
| 179 | | isInitiator, |
| 180 | | createdAt: Date.now() |
| 181 | | }) |
| 182 | | |
| 183 | | console.log(`📡 New P2P connection: ${service.name}`) |
| 184 | | console.log(` Role: ${isInitiator ? 'client' : 'server'}`) |
| 185 | | console.log(` Total connections: ${topicInfo.connections.size}`) |
| 144 | console.log(` Total connections: ${this.connections.size}`) |
| 186 | 145 | |
| 187 | 146 | // Notify callback |
| 188 | | this.onConnection(connection, service, isInitiator) |
| 147 | this.onConnection(connection, isInitiator) |
| 189 | 148 | |
| 190 | | // Handle incoming data from P2P peer |
| 149 | // Send announcement immediately |
| 150 | this._sendAnnouncement(connection) |
| 151 | |
| 152 | // Handle incoming messages |
| 191 | 153 | connection.on('data', (data) => { |
| 192 | | this._handleP2PData(connection, data, connInfo) |
| 154 | this._handleMessage(connection, data, connInfo) |
| 193 | 155 | }) |
| 194 | 156 | |
| 195 | 157 | // Handle connection close |
| … | … | @@ -199,31 +161,129 @@ export class HyperswarmRouter { |
| 199 | 161 | |
| 200 | 162 | // Handle connection errors |
| 201 | 163 | connection.on('error', (err) => { |
| 202 | | console.error(`❌ Connection error for ${service.name}:`, err.message) |
| 164 | console.error(`❌ Connection error:`, err.message) |
| 203 | 165 | this.stats.errors++ |
| 204 | 166 | }) |
| 205 | 167 | } |
| 206 | 168 | |
| 207 | 169 | /** |
| 208 | | * Handle data received from P2P peer |
| 209 | | * This data should be an IP packet to write to the TUN interface |
| 170 | * Send announcement of owned IPs to a peer |
| 210 | 171 | * @private |
| 211 | 172 | */ |
| 212 | | _handleP2PData(connection, data, connInfo) { |
| 173 | _sendAnnouncement(connection) { |
| 213 | 174 | try { |
| 214 | | connInfo.stats.bytesReceived += data.length |
| 175 | if (!this.gatewayIdentity) { |
| 176 | return |
| 177 | } |
| 178 | |
| 179 | // Get all IPs this gateway owns |
| 180 | const ownedIPs = this.gatewayIdentity.getOwnedIPs() |
| 181 | |
| 182 | // Build announcement message |
| 183 | // Format: [MSG_TYPE (1 byte)] [IP_COUNT (1 byte)] [IP1 (15 bytes)] [IP2...] |
| 184 | const ipStrings = [] |
| 185 | for (const ip of ownedIPs) { |
| 186 | ipStrings.push(ip) |
| 187 | } |
| 188 | |
| 189 | const message = JSON.stringify({ |
| 190 | type: MSG_TYPES.ANNOUNCE, |
| 191 | ips: ipStrings |
| 192 | }) |
| 193 | |
| 194 | const buffer = Buffer.concat([ |
| 195 | Buffer.from([MSG_TYPES.ANNOUNCE]), |
| 196 | Buffer.from(message, 'utf8') |
| 197 | ]) |
| 198 | |
| 199 | connection.write(buffer) |
| 200 | this.stats.announcementsSent++ |
| 201 | |
| 202 | console.log(`📢 Sent announcement: ${ipStrings.length} IPs`) |
| 203 | console.log(` IPs: ${ipStrings.join(', ')}`) |
| 204 | |
| 205 | } catch (error) { |
| 206 | this.stats.errors++ |
| 207 | this.onError(new Error(`Failed to send announcement: ${error.message}`)) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Handle incoming message from peer |
| 213 | * @private |
| 214 | */ |
| 215 | _handleMessage(connection, data, connInfo) { |
| 216 | try { |
| 217 | // Check message type |
| 218 | const msgType = data[0] |
| 219 | |
| 220 | if (msgType === MSG_TYPES.ANNOUNCE) { |
| 221 | this._handleAnnouncement(connection, data.subarray(1), connInfo) |
| 222 | } else if (msgType === MSG_TYPES.PACKET) { |
| 223 | this._handlePacket(connection, data.subarray(1), connInfo) |
| 224 | } else { |
| 225 | console.warn(`⚠️ Unknown message type: ${msgType}`) |
| 226 | } |
| 227 | |
| 228 | } catch (error) { |
| 229 | this.stats.errors++ |
| 230 | this.onError(new Error(`Failed to handle message: ${error.message}`)) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * Handle IP announcement from peer |
| 236 | * @private |
| 237 | */ |
| 238 | _handleAnnouncement(connection, data, connInfo) { |
| 239 | try { |
| 240 | const message = JSON.parse(data.toString('utf8')) |
| 241 | const ips = message.ips |
| 242 | |
| 243 | console.log(`📢 Received announcement: ${ips.length} IPs`) |
| 244 | console.log(` IPs: ${ips.join(', ')}`) |
| 245 | |
| 246 | // Update connection info |
| 247 | connInfo.peerIPs = new Set(ips) |
| 248 | |
| 249 | // Update routing table |
| 250 | for (const ip of ips) { |
| 251 | if (!this.routingTable.has(ip)) { |
| 252 | this.routingTable.set(ip, new Set()) |
| 253 | } |
| 254 | this.routingTable.get(ip).add(connection) |
| 255 | } |
| 256 | |
| 257 | this.stats.announcementsReceived++ |
| 258 | |
| 259 | console.log(`✅ Routing table updated`) |
| 260 | this._printRoutingTable() |
| 261 | |
| 262 | } catch (error) { |
| 263 | this.stats.errors++ |
| 264 | this.onError(new Error(`Failed to handle announcement: ${error.message}`)) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Handle IP packet from peer |
| 270 | * @private |
| 271 | */ |
| 272 | _handlePacket(connection, packetData, connInfo) { |
| 273 | try { |
| 274 | connInfo.stats.bytesReceived += packetData.length |
| 215 | 275 | connInfo.stats.packetsReceived++ |
| 216 | 276 | this.stats.packetsFromP2P++ |
| 217 | | this.stats.bytesFromP2P += data.length |
| 277 | this.stats.bytesFromP2P += packetData.length |
| 218 | 278 | |
| 219 | 279 | // Write packet to TUN interface |
| 220 | 280 | if (this.utunManager) { |
| 221 | | this.utunManager.tryWritePacket(data) |
| 281 | this.utunManager.writePacket(packetData) |
| 222 | 282 | } |
| 223 | 283 | |
| 224 | 284 | } catch (error) { |
| 225 | 285 | this.stats.errors++ |
| 226 | | this.onError(new Error(`Failed to handle P2P data: ${error.message}`)) |
| 286 | this.onError(new Error(`Failed to handle packet: ${error.message}`)) |
| 227 | 287 | } |
| 228 | 288 | } |
| 229 | 289 | |
| … | … | @@ -232,60 +292,66 @@ export class HyperswarmRouter { |
| 232 | 292 | * @private |
| 233 | 293 | */ |
| 234 | 294 | _handleConnectionClose(connection, connInfo) { |
| 235 | | const topicHex = connInfo.topic.toString('hex') |
| 236 | | const topicInfo = this.topics.get(topicHex) |
| 237 | | |
| 238 | | if (topicInfo) { |
| 239 | | topicInfo.connections.delete(connection) |
| 295 | console.log(`📡 P2P connection closed`) |
| 296 | |
| 297 | // Remove from routing table |
| 298 | for (const ip of connInfo.peerIPs) { |
| 299 | const connections = this.routingTable.get(ip) |
| 300 | if (connections) { |
| 301 | connections.delete(connection) |
| 302 | if (connections.size === 0) { |
| 303 | this.routingTable.delete(ip) |
| 304 | } |
| 305 | } |
| 240 | 306 | } |
| 241 | 307 | |
| 242 | 308 | this.connections.delete(connection) |
| 309 | this.connectionInfo.delete(connection) |
| 243 | 310 | this.stats.connectionsClosed++ |
| 244 | 311 | |
| 245 | | console.log(`📡 P2P connection closed: ${connInfo.service.name}`) |
| 246 | | console.log(` Total connections: ${topicInfo ? topicInfo.connections.size : 0}`) |
| 312 | console.log(` Total connections: ${this.connections.size}`) |
| 247 | 313 | } |
| 248 | 314 | |
| 249 | 315 | /** |
| 250 | | * Forward packet from TUN to P2P |
| 251 | | * Called by UTUNManager when it receives a packet destined for a P2P service |
| 316 | * Forward packet to P2P peer(s) |
| 317 | * Called by UTUNManager when it receives a packet |
| 252 | 318 | */ |
| 253 | | async forwardToP2P(packet, service) { |
| 254 | | const topicHex = service.topic.toString('hex') |
| 255 | | const topicInfo = this.topics.get(topicHex) |
| 319 | async forwardToP2P(packet, destIP) { |
| 320 | // Look up which peer(s) can reach this IP |
| 321 | const connections = this.routingTable.get(destIP) |
| 256 | 322 | |
| 257 | | if (!topicInfo) { |
| 258 | | console.log(`⚠️ No topic joined for service: ${service.name}`) |
| 323 | if (!connections || connections.size === 0) { |
| 324 | console.log(`⚠️ No route to ${destIP}`) |
| 259 | 325 | return false |
| 260 | 326 | } |
| 261 | 327 | |
| 262 | | if (topicInfo.connections.size === 0) { |
| 263 | | console.log(`⚠️ No active connections for service: ${service.name}`) |
| 264 | | return false |
| 265 | | } |
| 328 | // Wrap packet with message type |
| 329 | const message = Buffer.concat([ |
| 330 | Buffer.from([MSG_TYPES.PACKET]), |
| 331 | packet |
| 332 | ]) |
| 266 | 333 | |
| 267 | | // Send to all connected peers (broadcast) |
| 268 | | // In a real implementation, you might want to track which peer |
| 269 | | // the packet should go to based on source IP or connection state |
| 334 | // Send to all peers that can reach this IP |
| 335 | // (Usually just one, but could be multiple for redundancy) |
| 270 | 336 | let sent = false |
| 271 | 337 | |
| 272 | | for (const connection of topicInfo.connections) { |
| 338 | for (const connection of connections) { |
| 273 | 339 | try { |
| 274 | | connection.write(packet) |
| 340 | connection.write(message) |
| 275 | 341 | |
| 276 | | const connInfo = this.connections.get(connection) |
| 342 | const connInfo = this.connectionInfo.get(connection) |
| 277 | 343 | if (connInfo) { |
| 278 | | connInfo.stats.bytesSent += packet.length |
| 344 | connInfo.stats.bytesSent += message.length |
| 279 | 345 | connInfo.stats.packetsSent++ |
| 280 | 346 | } |
| 281 | 347 | |
| 282 | 348 | this.stats.packetsToP2P++ |
| 283 | | this.stats.bytesToP2P += packet.length |
| 349 | this.stats.bytesToP2P += message.length |
| 284 | 350 | sent = true |
| 285 | 351 | |
| 286 | 352 | } catch (error) { |
| 287 | 353 | this.stats.errors++ |
| 288 | | this.onError(new Error(`Failed to forward packet to P2P: ${error.message}`)) |
| 354 | this.onError(new Error(`Failed to forward packet: ${error.message}`)) |
| 289 | 355 | } |
| 290 | 356 | } |
| 291 | 357 | |
| … | … | @@ -293,50 +359,32 @@ export class HyperswarmRouter { |
| 293 | 359 | } |
| 294 | 360 | |
| 295 | 361 | /** |
| 296 | | * Get router statistics |
| 362 | * Print routing table for debugging |
| 363 | * @private |
| 297 | 364 | */ |
| 298 | | getStats() { |
| 299 | | const topicStats = [] |
| 300 | | |
| 301 | | for (const [topicHex, info] of this.topics) { |
| 302 | | topicStats.push({ |
| 303 | | service: info.service.name, |
| 304 | | topic: topicHex.substring(0, 16) + '...', |
| 305 | | connections: info.connections.size |
| 306 | | }) |
| 307 | | } |
| 308 | | |
| 309 | | return { |
| 310 | | ...this.stats, |
| 311 | | topics: topicStats, |
| 312 | | totalConnections: this.connections.size, |
| 313 | | isRunning: this.isRunning |
| 365 | _printRoutingTable() { |
| 366 | console.log(`\n📋 Routing Table:`) |
| 367 | if (this.routingTable.size === 0) { |
| 368 | console.log(` (empty)`) |
| 369 | } else { |
| 370 | for (const [ip, connections] of this.routingTable) { |
| 371 | console.log(` ${ip} → ${connections.size} peer(s)`) |
| 372 | } |
| 314 | 373 | } |
| 374 | console.log() |
| 315 | 375 | } |
| 316 | 376 | |
| 317 | 377 | /** |
| 318 | | * Leave a service topic |
| 378 | * Get router statistics |
| 319 | 379 | */ |
| 320 | | async leaveService(service) { |
| 321 | | const topicHex = service.topic.toString('hex') |
| 322 | | const topicInfo = this.topics.get(topicHex) |
| 323 | | |
| 324 | | if (!topicInfo) { |
| 325 | | return |
| 326 | | } |
| 327 | | |
| 328 | | // Close all connections for this topic |
| 329 | | for (const connection of topicInfo.connections) { |
| 330 | | connection.destroy() |
| 380 | getStats() { |
| 381 | return { |
| 382 | ...this.stats, |
| 383 | totalConnections: this.connections.size, |
| 384 | routingTableSize: this.routingTable.size, |
| 385 | isRunning: this.isRunning, |
| 386 | networkTopic: this.networkTopic |
| 331 | 387 | } |
| 332 | | |
| 333 | | // Leave the topic |
| 334 | | await topicInfo.discovery.destroy() |
| 335 | | |
| 336 | | this.topics.delete(topicHex) |
| 337 | | this.stats.topicsJoined-- |
| 338 | | |
| 339 | | console.log(`✅ Left topic for ${service.name}`) |
| 340 | 388 | } |
| 341 | 389 | |
| 342 | 390 | /** |
| … | … | @@ -351,10 +399,9 @@ export class HyperswarmRouter { |
| 351 | 399 | |
| 352 | 400 | this.isRunning = false |
| 353 | 401 | |
| 354 | | // Leave all topics |
| 355 | | for (const [topicHex, info] of this.topics) { |
| 356 | | console.log(` Leaving topic for ${info.service.name}`) |
| 357 | | await this.leaveService(info.service) |
| 402 | // Close all connections |
| 403 | for (const connection of this.connections) { |
| 404 | connection.destroy() |
| 358 | 405 | } |
| 359 | 406 | |
| 360 | 407 | // Destroy swarm |