OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident-gateway / lib / hyperswarm-router.js

492 lines · javascript ref:
/**
 * 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')
  }
}