/**
* HyperswarmRouter - Routes packets between TUN device and Hyperswarm P2P network
*
* NEW ARCHITECTURE:
* - Join ONE shared network topic (configurable, e.g., "hyperident-network")
* - Announce owned IPs to peers
* - Build routing table from peer announcements
* - Forward packets based on routing table (IP → peer connection)
* - Like a P2P VPN/LAN
*/
import Hyperswarm from 'hyperswarm'
import crypto from 'hypercore-crypto'
// Message types for the announcement protocol
const MSG_TYPES = {
ANNOUNCE: 0x01, // Announce owned IPs
PACKET: 0x02 // IP packet forwarding
}
export class HyperswarmRouter {
constructor(config = {}) {
this.config = config
this.gatewayIdentity = config.gatewayIdentity
this.utunManager = config.utunManager
this.networkTopic = config.networkTopic || 'hyperident-network'
// Passed straight to new Hyperswarm() (e.g. { bootstrap } for a testnet)
this.swarmOptions = config.swarmOptions || {}
this.swarm = null
this.isRunning = false
// Network topic info
this.topic = null
this.topicBuffer = null
// All peer connections on the network
this.connections = new Set()
// Routing table: IP → Set of connections that can reach it
// Example: "10.22.0.2" → Set([conn1, conn2])
this.routingTable = new Map()
// Connection metadata: connection → { peerIPs: Set, stats }
this.connectionInfo = new Map()
// Statistics
this.stats = {
connectionsOpened: 0,
connectionsClosed: 0,
announcementsSent: 0,
announcementsReceived: 0,
packetsToP2P: 0,
packetsFromP2P: 0,
bytesToP2P: 0,
bytesFromP2P: 0,
ipConflicts: 0,
errors: 0
}
// Callbacks
this.onError = config.onError || ((err) => console.error('HyperswarmRouter Error:', err))
this.onConnection = config.onConnection || (() => {})
this.onConflict = config.onConflict || (() => {})
}
/**
* Initialize Hyperswarm and join the network topic
*/
async start() {
if (this.swarm) {
throw new Error('HyperswarmRouter already started')
}
console.log('📝 Initializing Hyperswarm...')
// Create Hyperswarm instance
this.swarm = new Hyperswarm(this.swarmOptions)
// Handle new connections
this.swarm.on('connection', (connection, info) => {
this._handleConnection(connection, info)
})
// Handle errors
this.swarm.on('error', (err) => {
this.stats.errors++
this.onError(err)
})
this.isRunning = true
console.log('✅ Hyperswarm initialized')
}
/**
* Join the network topic
*/
async joinNetwork() {
if (!this.swarm) {
throw new Error('HyperswarmRouter not started')
}
// Derive topic from network name
this.topicBuffer = crypto.hash(Buffer.from(this.networkTopic, 'utf8')).subarray(0, 32)
this.topic = this.topicBuffer.toString('hex')
console.log(`\n📝 Joining network topic: "${this.networkTopic}"`)
console.log(` Topic hash: ${this.topic.substring(0, 16)}...`)
// Join as both server and client
const discovery = this.swarm.join(this.topicBuffer, {
server: true,
client: true
})
await discovery.flushed()
console.log('✅ Network topic joined')
console.log(' Waiting for peers to connect...\n')
}
/**
* Handle new P2P connection
* @private
*/
_handleConnection(connection, info) {
const isInitiator = info.client
console.log(`📡 New P2P connection`)
console.log(` Role: ${isInitiator ? 'client' : 'server'}`)
// Track connection
const connInfo = {
peerIPs: new Set(),
isInitiator,
stats: {
bytesReceived: 0,
bytesSent: 0,
packetsReceived: 0,
packetsSent: 0
}
}
this.connections.add(connection)
this.connectionInfo.set(connection, connInfo)
this.stats.connectionsOpened++
console.log(` Total connections: ${this.connections.size}`)
// Notify callback
this.onConnection(connection, isInitiator)
// Send announcement immediately
this._sendAnnouncement(connection)
// Handle incoming messages
connection.on('data', (data) => {
this._handleMessage(connection, data, connInfo)
})
// Handle connection close
connection.on('close', () => {
this._handleConnectionClose(connection, connInfo)
})
// Handle connection errors
connection.on('error', (err) => {
console.error(`❌ Connection error:`, err.message)
this.stats.errors++
})
}
/**
* Send announcement of owned IPs to a peer
* @private
*/
_sendAnnouncement(connection) {
try {
if (!this.gatewayIdentity) {
return
}
// Get all IPs this gateway owns
const ownedIPs = this.gatewayIdentity.getOwnedIPs()
// Build announcement message
// Format: [MSG_TYPE (1 byte)] [IP_COUNT (1 byte)] [IP1 (15 bytes)] [IP2...]
const ipStrings = []
for (const ip of ownedIPs) {
ipStrings.push(ip)
}
const message = JSON.stringify({
type: MSG_TYPES.ANNOUNCE,
ips: ipStrings
})
const buffer = Buffer.concat([
Buffer.from([MSG_TYPES.ANNOUNCE]),
Buffer.from(message, 'utf8')
])
connection.write(buffer)
this.stats.announcementsSent++
console.log(`📢 Sent announcement: ${ipStrings.length} IPs`)
console.log(` IPs: ${ipStrings.join(', ')}`)
} catch (error) {
this.stats.errors++
this.onError(new Error(`Failed to send announcement: ${error.message}`))
}
}
/**
* Handle incoming message from peer
* @private
*/
_handleMessage(connection, data, connInfo) {
try {
// Check message type
const msgType = data[0]
if (msgType === MSG_TYPES.ANNOUNCE) {
this._handleAnnouncement(connection, data.subarray(1), connInfo)
} else if (msgType === MSG_TYPES.PACKET) {
this._handlePacket(connection, data.subarray(1), connInfo)
} else {
console.warn(`⚠️ Unknown message type: ${msgType}`)
}
} catch (error) {
this.stats.errors++
this.onError(new Error(`Failed to handle message: ${error.message}`))
}
}
/**
* Handle IP announcement from peer
* @private
*/
_handleAnnouncement(connection, data, connInfo) {
try {
const message = JSON.parse(data.toString('utf8'))
const ips = message.ips
console.log(`📢 Received announcement: ${ips.length} IPs`)
console.log(` IPs: ${ips.join(', ')}`)
// Update connection info
connInfo.peerIPs = new Set(ips)
// Update routing table
const ownedIPs = this.gatewayIdentity ? this.gatewayIdentity.getOwnedIPs() : []
for (const ip of ips) {
this._detectConflict(connection, ip, ownedIPs)
if (!this.routingTable.has(ip)) {
this.routingTable.set(ip, new Set())
}
this.routingTable.get(ip).add(connection)
}
this.stats.announcementsReceived++
console.log(`✅ Routing table updated`)
this._printRoutingTable()
} catch (error) {
this.stats.errors++
this.onError(new Error(`Failed to handle announcement: ${error.message}`))
}
}
/**
* Detect a peer claiming an IP that we own, or that another live
* connection already claims. Detection only: routing is unchanged.
* @private
*/
_detectConflict(connection, ip, ownedIPs) {
let reason = null
if (ownedIPs.includes(ip)) {
reason = 'is our own IP'
} else {
const holders = this.routingTable.get(ip)
if (holders) {
for (const holder of holders) {
if (holder !== connection) {
reason = 'another peer already announced'
break
}
}
}
}
if (reason === null) return
this.stats.ipConflicts++
console.error(`❌ IP CONFLICT: peer announced ${ip}, which ${reason}`)
this.onConflict({ ip, reason })
}
/**
* Handle IP packet from peer
* @private
*/
_handlePacket(connection, packetData, connInfo) {
try {
connInfo.stats.bytesReceived += packetData.length
connInfo.stats.packetsReceived++
this.stats.packetsFromP2P++
this.stats.bytesFromP2P += packetData.length
// Write packet to TUN interface
if (this.utunManager) {
this.utunManager.writePacket(packetData)
}
} catch (error) {
this.stats.errors++
this.onError(new Error(`Failed to handle packet: ${error.message}`))
}
}
/**
* Handle connection close
* @private
*/
_handleConnectionClose(connection, connInfo) {
console.log(`📡 P2P connection closed`)
// Remove from routing table
for (const ip of connInfo.peerIPs) {
const connections = this.routingTable.get(ip)
if (connections) {
connections.delete(connection)
if (connections.size === 0) {
this.routingTable.delete(ip)
}
}
}
this.connections.delete(connection)
this.connectionInfo.delete(connection)
this.stats.connectionsClosed++
console.log(` Total connections: ${this.connections.size}`)
}
/**
* Forward packet to P2P peer(s)
* Called by UTUNManager when it receives a packet
*/
async forwardToP2P(packet, destIP) {
// Look up which peer(s) can reach this IP
const connections = this.routingTable.get(destIP)
if (!connections || connections.size === 0) {
console.log(`⚠️ No route to ${destIP}`)
return false
}
// Wrap packet with message type
const message = Buffer.concat([
Buffer.from([MSG_TYPES.PACKET]),
packet
])
// Send to all peers that can reach this IP
// (Usually just one, but could be multiple for redundancy)
let sent = false
for (const connection of connections) {
try {
connection.write(message)
const connInfo = this.connectionInfo.get(connection)
if (connInfo) {
connInfo.stats.bytesSent += message.length
connInfo.stats.packetsSent++
}
this.stats.packetsToP2P++
this.stats.bytesToP2P += message.length
sent = true
} catch (error) {
this.stats.errors++
this.onError(new Error(`Failed to forward packet: ${error.message}`))
}
}
return sent
}
/**
* Scan the network for all announced IPs (used for conflict detection).
* Joins the topic, listens for announcements for `duration` ms, then returns
* a map of IP -> array of peer info.
*
* @param {number} duration - How long to listen in ms (default 5000)
* @returns {Promise<Map<string, Array<{isInitiator: boolean}>>>}
*/
async scanNetworkIPs (duration = 5000) {
const peerIPs = new Map()
// Wait for announcements to arrive
await new Promise(resolve => setTimeout(resolve, duration))
// Collect all announced IPs from connected peers
for (const [connection, connInfo] of this.connectionInfo) {
for (const ip of connInfo.peerIPs) {
if (!peerIPs.has(ip)) peerIPs.set(ip, [])
peerIPs.get(ip).push({ isInitiator: connInfo.isInitiator })
}
}
return peerIPs
}
/**
* Check if a specific IP is already claimed by a peer on the network.
* Must be called after joinNetwork() + scanNetworkIPs().
*
* @param {string} ip - The IP to check
* @returns {{ taken: boolean, peers: number }}
*/
checkIPConflict (ip) {
const routes = this.routingTable.get(ip)
if (routes && routes.size > 0) {
return { taken: true, peers: routes.size }
}
return { taken: false, peers: 0 }
}
/**
* Print routing table for debugging
* @private
*/
_printRoutingTable() {
console.log(`\n📋 Routing Table:`)
if (this.routingTable.size === 0) {
console.log(` (empty)`)
} else {
for (const [ip, connections] of this.routingTable) {
console.log(` ${ip} → ${connections.size} peer(s)`)
}
}
console.log()
}
/**
* Get router statistics
*/
getStats() {
return {
...this.stats,
totalConnections: this.connections.size,
routingTableSize: this.routingTable.size,
isRunning: this.isRunning,
networkTopic: this.networkTopic
}
}
/**
* Graceful shutdown
*/
async stop() {
if (!this.swarm) {
return
}
console.log('\n📝 Stopping Hyperswarm router...')
this.isRunning = false
// Close all connections
for (const connection of this.connections) {
connection.destroy()
}
// Destroy swarm
await this.swarm.destroy()
this.swarm = null
console.log('✅ Hyperswarm router stopped')
}
}