OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident-gateway commits / 3186d4c

Refactor to P2P VPN architecture with single network topic
BREAKING CHANGE: Complete architectural refactor from service-specific topics to shared network topic model. Changes: - HyperswarmRouter: Join one configurable network topic instead of per-service topics - Announcement protocol: Gateways announce owned IPs on connection - Routing table: Map IPs to peer connections for packet forwarding - UTUNManager: Forward all subnet packets, not just registered services - GatewayIdentity: Add getOwnedIPs() for announcements - Config: Add networkTopic setting (default: "hyperident-network") This transforms the gateway from a service-specific router to a true P2P VPN where all gateways share one network and can reach each other by IP address, regardless of which services are running. Benefits: - Simpler: One topic instead of N topics - Flexible: No pre-configuration needed for peer IPs - Scalable: Routing table built dynamically from announcements - Isolated: Different networkTopic values = different LANs Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
3186d4cd38e0a2b92f04061617f6ad4dd5ff1455
by plankatron <fleeky@gmail.com> · 2026-01-17T18:09:01.000Z

Changed files (4) +265 -183

gateway.js +6 -5
……@@ -139,11 +139,12 @@ async function startGateway(config) {
139139 // Initialize HyperswarmRouter (before UTUNManager so we can reference it)
140140 const hyperswarmRouter = new HyperswarmRouter({
141141 gatewayIdentity,
142 networkTopic: config.gateway.networkTopic || 'hyperident-network',
142143 onError: (error) => {
143144 console.error('❌ Hyperswarm Error:', error.message)
144145 },
145 onConnection: (connection, service, isInitiator) => {
146 console.log(` Connected to peer for ${service.name} (${isInitiator ? 'outgoing' : 'incoming'})`)
146 onConnection: (connection, isInitiator) => {
147 console.log(` Connected to peer (${isInitiator ? 'outgoing' : 'incoming'})`)
147148 }
148149 })
149150
……@@ -159,7 +160,7 @@ async function startGateway(config) {
159160
160161 onPacketReceived: (data) => {
161162 // Forward packet to P2P network
162 hyperswarmRouter.forwardToP2P(data.packet, data.service)
163 hyperswarmRouter.forwardToP2P(data.packet, data.destIP)
163164 },
164165
165166 onError: (error) => {
……@@ -175,9 +176,9 @@ async function startGateway(config) {
175176 await utunManager.open()
176177 console.log()
177178
178 // Start Hyperswarm router and join service topics
179 // Start Hyperswarm router and join network topic
179180 await hyperswarmRouter.start()
180 await hyperswarmRouter.joinAllServices()
181 await hyperswarmRouter.joinNetwork()
181182 console.log()
182183
183184 // Setup graceful shutdown
lib/gateway-identity.js +16 -0
……@@ -179,6 +179,22 @@ export class GatewayIdentity {
179179 return Array.from(this.services.values())
180180 }
181181
182 /**
183 * Get all IPs owned by this gateway
184 * Used for announcement protocol in P2P VPN
185 * @returns {Array<string>} - Array of IP addresses
186 */
187 getOwnedIPs() {
188 const ips = []
189
190 // Add all service IPs
191 for (const service of this.services.values()) {
192 ips.push(service.ip)
193 }
194
195 return ips
196 }
197
182198 /**
183199 * Get service by name
184200 */
lib/hyperswarm-router.js +218 -171
……@@ -1,37 +1,53 @@
11/**
22 * HyperswarmRouter - Routes packets between TUN device and Hyperswarm P2P network
33 *
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
1010 */
1111
1212import Hyperswarm from 'hyperswarm'
1313import crypto from 'hypercore-crypto'
1414
15// Message types for the announcement protocol
16const MSG_TYPES = {
17 ANNOUNCE: 0x01, // Announce owned IPs
18 PACKET: 0x02 // IP packet forwarding
19}
20
1521export class HyperswarmRouter {
1622 constructor(config = {}) {
1723 this.config = config
1824 this.gatewayIdentity = config.gatewayIdentity
1925 this.utunManager = config.utunManager
26 this.networkTopic = config.networkTopic || 'hyperident-network'
2027
2128 this.swarm = null
2229 this.isRunning = false
2330
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()
2641
27 // Map: connection → { topic, service, isInitiator, stats }
28 this.connections = new Map()
42 // Connection metadata: connection → { peerIPs: Set, stats }
43 this.connectionInfo = new Map()
2944
3045 // Statistics
3146 this.stats = {
32 topicsJoined: 0,
3347 connectionsOpened: 0,
3448 connectionsClosed: 0,
49 announcementsSent: 0,
50 announcementsReceived: 0,
3551 packetsToP2P: 0,
3652 packetsFromP2P: 0,
3753 bytesToP2P: 0,
……@@ -45,7 +61,7 @@ export class HyperswarmRouter {
4561 }
4662
4763 /**
48 * Initialize Hyperswarm and start routing
64 * Initialize Hyperswarm and join the network topic
4965 */
5066 async start() {
5167 if (this.swarm) {
……@@ -55,10 +71,7 @@ export class HyperswarmRouter {
5571 console.log('📝 Initializing Hyperswarm...')
5672
5773 // 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()
6275
6376 // Handle new connections
6477 this.swarm.on('connection', (connection, info) => {
……@@ -76,68 +89,30 @@ export class HyperswarmRouter {
7689 }
7790
7891 /**
79 * Join a Hyperswarm topic for a service
92 * Join the network topic
8093 */
81 async joinService(service) {
94 async joinNetwork() {
8295 if (!this.swarm) {
8396 throw new Error('HyperswarmRouter not started')
8497 }
8598
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')
114102
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)}...`)
118105
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 })
135111
136 for (const service of services) {
137 await this.joinService(service)
138 }
112 await discovery.flushed()
139113
140 console.log()
114 console.log('✅ Network topic joined')
115 console.log(' Waiting for peers to connect...\n')
141116 }
142117
143118 /**
……@@ -145,22 +120,14 @@ export class HyperswarmRouter {
145120 * @private
146121 */
147122 _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
158123 const isInitiator = info.client
159124
125 console.log(`📡 New P2P connection`)
126 console.log(` Role: ${isInitiator ? 'client' : 'server'}`)
127
160128 // Track connection
161129 const connInfo = {
162 topic: info.topics[0],
163 service,
130 peerIPs: new Set(),
164131 isInitiator,
165132 stats: {
166133 bytesReceived: 0,
……@@ -170,26 +137,21 @@ export class HyperswarmRouter {
170137 }
171138 }
172139
173 this.connections.set(connection, connInfo)
174 topicInfo.connections.add(connection)
140 this.connections.add(connection)
141 this.connectionInfo.set(connection, connInfo)
175142 this.stats.connectionsOpened++
176143
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}`)
186145
187146 // Notify callback
188 this.onConnection(connection, service, isInitiator)
147 this.onConnection(connection, isInitiator)
189148
190 // Handle incoming data from P2P peer
149 // Send announcement immediately
150 this._sendAnnouncement(connection)
151
152 // Handle incoming messages
191153 connection.on('data', (data) => {
192 this._handleP2PData(connection, data, connInfo)
154 this._handleMessage(connection, data, connInfo)
193155 })
194156
195157 // Handle connection close
……@@ -199,31 +161,129 @@ export class HyperswarmRouter {
199161
200162 // Handle connection errors
201163 connection.on('error', (err) => {
202 console.error(`❌ Connection error for ${service.name}:`, err.message)
164 console.error(`❌ Connection error:`, err.message)
203165 this.stats.errors++
204166 })
205167 }
206168
207169 /**
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
210171 * @private
211172 */
212 _handleP2PData(connection, data, connInfo) {
173 _sendAnnouncement(connection) {
213174 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
215275 connInfo.stats.packetsReceived++
216276 this.stats.packetsFromP2P++
217 this.stats.bytesFromP2P += data.length
277 this.stats.bytesFromP2P += packetData.length
218278
219279 // Write packet to TUN interface
220280 if (this.utunManager) {
221 this.utunManager.tryWritePacket(data)
281 this.utunManager.writePacket(packetData)
222282 }
223283
224284 } catch (error) {
225285 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}`))
227287 }
228288 }
229289
……@@ -232,60 +292,66 @@ export class HyperswarmRouter {
232292 * @private
233293 */
234294 _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 }
240306 }
241307
242308 this.connections.delete(connection)
309 this.connectionInfo.delete(connection)
243310 this.stats.connectionsClosed++
244311
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}`)
247313 }
248314
249315 /**
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
252318 */
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)
256322
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}`)
259325 return false
260326 }
261327
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 ])
266333
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)
270336 let sent = false
271337
272 for (const connection of topicInfo.connections) {
338 for (const connection of connections) {
273339 try {
274 connection.write(packet)
340 connection.write(message)
275341
276 const connInfo = this.connections.get(connection)
342 const connInfo = this.connectionInfo.get(connection)
277343 if (connInfo) {
278 connInfo.stats.bytesSent += packet.length
344 connInfo.stats.bytesSent += message.length
279345 connInfo.stats.packetsSent++
280346 }
281347
282348 this.stats.packetsToP2P++
283 this.stats.bytesToP2P += packet.length
349 this.stats.bytesToP2P += message.length
284350 sent = true
285351
286352 } catch (error) {
287353 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}`))
289355 }
290356 }
291357
……@@ -293,50 +359,32 @@ export class HyperswarmRouter {
293359 }
294360
295361 /**
296 * Get router statistics
362 * Print routing table for debugging
363 * @private
297364 */
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 }
314373 }
374 console.log()
315375 }
316376
317377 /**
318 * Leave a service topic
378 * Get router statistics
319379 */
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
331387 }
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}`)
340388 }
341389
342390 /**
……@@ -351,10 +399,9 @@ export class HyperswarmRouter {
351399
352400 this.isRunning = false
353401
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()
358405 }
359406
360407 // Destroy swarm
lib/utun-manager.js +25 -7
……@@ -113,20 +113,19 @@ export class UTUNManager {
113113 return
114114 }
115115
116 // Check if destination is a registered service
117 const service = this.gatewayIdentity ? this.gatewayIdentity.lookup(ipPacket.dstIP) : null
118
119 if (!service) {
120 // No service registered for this IP
116 // Check if destination is in our subnet
117 if (!this._isInSubnet(ipPacket.dstIP)) {
118 // Packet destined for outside our network - drop it
121119 this.stats.packetsDropped++
122120 return
123121 }
124122
125 // Pass packet to router for P2P forwarding
123 // Pass ALL subnet packets to router for P2P forwarding
124 // Router will check routing table to find correct peer
126125 this.onPacketReceived({
127126 packet,
128127 ipPacket,
129 service
128 destIP: ipPacket.dstIP
130129 })
131130
132131 } catch (error) {
……@@ -188,6 +187,25 @@ export class UTUNManager {
188187 }
189188 }
190189
190 /**
191 * Check if IP is in our subnet
192 * @private
193 */
194 _isInSubnet(ip) {
195 // Parse subnet config (e.g., "10.22.0.0/16")
196 const [network, cidr] = this.config.subnet.split('/')
197 const networkParts = network.split('.')
198 const ipParts = ip.split('.')
199 const maskBits = parseInt(cidr)
200
201 // Check octets based on CIDR
202 if (maskBits >= 8 && networkParts[0] !== ipParts[0]) return false
203 if (maskBits >= 16 && networkParts[1] !== ipParts[1]) return false
204 if (maskBits >= 24 && networkParts[2] !== ipParts[2]) return false
205
206 return true
207 }
208
191209 /**
192210 * Write packet to TUN interface
193211 * This is called when we receive a packet from P2P that needs to go to local apps