OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / lan-discovery.js

298 lines · javascript ref:
#!/usr/bin/env node

import { spawn } from 'bare-subprocess'
import dgram from 'bare-dgram'
import os from 'bare-os'

/**
 * LAN Discovery for pear-git
 *
 * Features:
 * - Announce presence on local network
 * - Discover other pear-git instances
 * - mDNS/Bonjour service advertisement
 * - UDP broadcast discovery (fallback)
 */

export class LANDiscovery {
  constructor(options = {}) {
    this.port = options.port || 5005
    this.serviceName = options.serviceName || 'pear-git'
    this.identity = options.identity
    this.repos = options.repos || []

    this.peers = new Map()  // IP → peer info
    this.discoveryPort = 45454  // UDP broadcast port
  }

  /**
   * Start announcing presence on LAN
   */
  async start() {
    // Try mDNS first (requires avahi-daemon on Linux)
    await this.startMDNS()

    // Fallback to UDP broadcast
    await this.startUDPBroadcast()

    // Listen for other pear-git instances
    try {
      await this.listenForPeers()
    } catch (err) {
      console.log('LAN peer listener unavailable:', err.message)
    }
  }

  /**
   * Announce via mDNS/Bonjour
   */
  async startMDNS() {
    try {
      const hostname = os.hostname()
      const serviceName = `${this.serviceName}@${hostname}`

      const identityHex = 'pending'

      // Use avahi-publish-service on Linux
      const publish = spawn('avahi-publish-service', [
        serviceName,
        '_pear-git._tcp',
        this.port.toString(),
        `identity=${identityHex}`,
        `repos=${this.repos.length}`,
        `version=1.0.0`
      ])

      publish.on('error', (err) => {
        console.error('mDNS announcement failed:', err.message)
        console.log('Install avahi-daemon: sudo apt install avahi-daemon avahi-utils')
      })

      publish.on('exit', (code) => {
        if (code !== 0) {
          console.error('mDNS service exited with code', code)
        }
      })

      console.log('✓ Announcing via mDNS:')
      console.log(`  Service: ${serviceName}`)
      console.log(`  Port: ${this.port}`)
      console.log(`  Identity: ${identityHex.slice(0, 16)}...`)

      this.mdnsProcess = publish
    } catch (err) {
      console.error('Failed to start mDNS:', err.message)
    }
  }

  /**
   * Announce via UDP broadcast (fallback)
   * DISABLED: bare-dgram doesn't support setBroadcast()
   */
  async startUDPBroadcast() {
    console.log('⚠ UDP broadcast disabled (bare-dgram limitation)')
    console.log('  Using mDNS only for LAN discovery')
    // TODO: Implement UDP broadcast when bare-dgram adds broadcast support
  }

  /**
   * Listen for announcements from other peers
   */
  async listenForPeers() {
    const socket = dgram.createSocket('udp4')

    socket.on('message', (msg, rinfo) => {
      try {
        const data = JSON.parse(msg.toString())

        if (data.type === 'pear-git-announcement') {
          // Don't add ourselves
          const publicKey = this.identity.keetIdentity?.identityKeyPair?.publicKey
          const ourIdentity = publicKey ? publicKey.toString('hex') : null
          if (data.identity === ourIdentity) {
            return
          }

          // Add or update peer
          this.peers.set(rinfo.address, {
            ip: rinfo.address,
            port: data.port,
            hostname: data.hostname,
            identity: data.identity,
            repos: data.repos,
            lastSeen: Date.now()
          })

          console.log(`📡 Discovered peer: ${data.hostname} (${rinfo.address}:${data.port})`)
        }
      } catch (err) {
        // Invalid message, ignore
      }
    })

    socket.bind(this.discoveryPort, () => {
      console.log(`✓ Listening for peers on UDP ${this.discoveryPort}`)
    })

    this.listenerSocket = socket

    // Clean up stale peers every minute
    this.cleanupInterval = setInterval(() => {
      const now = Date.now()
      for (const [ip, peer] of this.peers.entries()) {
        if (now - peer.lastSeen > 120000) {  // 2 minutes
          console.log(`Peer timed out: ${peer.hostname} (${ip})`)
          this.peers.delete(ip)
        }
      }
    }, 60000)
  }

  /**
   * Discover pear-git instances using mDNS
   */
  async discoverViaMDNS() {
    return new Promise((resolve) => {
      const peers = []

      const browse = spawn('avahi-browse', ['-ptr', '_pear-git._tcp', '--resolve'])

      browse.stdout.on('data', (data) => {
        const text = data.toString()

        // Parse avahi-browse output
        const lines = text.split('\n')
        for (const line of lines) {
          if (line.includes('hostname') && line.includes('address')) {
            // Extract hostname and IP
            const hostnameMatch = line.match(/hostname \[([^\]]+)\]/)
            const addressMatch = line.match(/address \[([^\]]+)\]/)
            const portMatch = line.match(/port \[([^\]]+)\]/)

            if (hostnameMatch && addressMatch && portMatch) {
              peers.push({
                hostname: hostnameMatch[1],
                ip: addressMatch[1],
                port: parseInt(portMatch[1])
              })
            }
          }
        }
      })

      browse.on('exit', () => {
        resolve(peers)
      })

      // Timeout after 5 seconds
      setTimeout(() => {
        browse.kill()
        resolve(peers)
      }, 5000)
    })
  }

  /**
   * Get all discovered peers
   */
  getPeers() {
    return Array.from(this.peers.values())
  }

  /**
   * Stop discovery
   */
  async stop() {
    if (this.mdnsProcess) {
      this.mdnsProcess.kill()
    }

    if (this.broadcastInterval) {
      clearInterval(this.broadcastInterval)
    }

    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval)
    }

    if (this.broadcastSocket) {
      this.broadcastSocket.close()
    }

    if (this.listenerSocket) {
      this.listenerSocket.close()
    }
  }

  /**
   * Get local IP addresses
   * Note: bare-os doesn't have networkInterfaces, return placeholder
   */
  getLocalIPs() {
    // bare-os doesn't support networkInterfaces()
    // Use hostname as the best available network address
    var hostname = 'localhost'
    try { hostname = os.hostname() } catch (e) {}
    return [{
      interface: 'hostname',
      address: hostname
    }, {
      interface: 'local',
      address: 'localhost'
    }]
  }

  /**
   * Render LAN discovery UI
   */
  renderDiscoveryUI() {
    const localIPs = this.getLocalIPs()
    const peers = this.getPeers()

    return `
<div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; margin-top: 20px;">
  <h3 style="margin-bottom: 15px;">🌐 Local Network</h3>

  <div style="margin-bottom: 20px;">
    <h4 style="color: #8b949e; margin-bottom: 10px;">Your Addresses:</h4>
    ${localIPs.map(ip => `
      <div style="background: #0d1117; padding: 10px; border-radius: 4px; margin-bottom: 5px;">
        <code>${ip.interface}: ${ip.address}:${this.port}</code>
      </div>
    `).join('')}
  </div>

  <div>
    <h4 style="color: #8b949e; margin-bottom: 10px;">Discovered Peers (${peers.length}):</h4>
    ${peers.length === 0
      ? '<div style="color: #6e7681;">No peers discovered yet. Other pear-git instances will appear here automatically.</div>'
      : peers.map(peer => `
        <div style="background: #0d1117; padding: 10px; border-radius: 4px; margin-bottom: 5px; display: flex; justify-content: space-between; align-items: center;">
          <div>
            <div><strong>${peer.hostname}</strong></div>
            <div style="font-size: 12px; color: #8b949e;">
              ${peer.ip}:${peer.port} • ${peer.repos} repos
            </div>
          </div>
          <button onclick="location.href='http://${peer.ip}:${peer.port}'" style="background: #238636; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
            Connect
          </button>
        </div>
      `).join('')
    }
  </div>

  <div style="margin-top: 20px; padding: 15px; background: #0d1117; border-radius: 4px; border-left: 3px solid #58a6ff;">
    <div style="font-weight: 600; margin-bottom: 5px;">💡 Clone via LAN:</div>
    <code style="background: #161b22; padding: 5px; border-radius: 3px;">
      git clone http://${localIPs[0]?.address || 'localhost'}:${this.port}/username/repo-name
    </code>
  </div>
</div>
    `;
  }
}

// CLI Test - Disabled (must use pear-git-server.js as entry point)
// This module is designed to be imported, not run directly