OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / p2p / discovery / topic-manager.js

282 lines · javascript ref:
/**
 * Topic Manager for Pear-Git P2P
 *
 * Manages Hyperswarm topic announcements for repositories.
 * Each repository gets a unique topic derived from:
 *   topic = hash(serverPublicKey + repoName)
 *
 * This ensures:
 * - Only the server with the private key can serve the topic
 * - Topics are deterministic and reproducible by clients
 * - No name squatting (key-based, not name-based)
 *
 * Uses a shared Hyperswarm instance injected from the server.
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import crypto from 'hypercore-crypto'
import Hyperswarm from 'hyperswarm'
import { ServerIdentity } from '../identity/server-identity.js'

export class TopicManager {
  constructor(serverIdentity, options = {}) {
    this.identity = serverIdentity
    this.swarm = options.swarm || null
    this.topics = new Map() // repoName -> { topic, discovery }
    this.connectionHandler = options.onConnection || null
    this.options = options
  }

  /**
   * Initialize the topic manager
   * Requires swarm to be injected via constructor options
   */
  async init() {
    if (!this.swarm) {
      throw new Error('TopicManager requires a shared Hyperswarm instance')
    }

    this.swarm.on('connection', (conn, peerInfo) => {
      var peerKeyHex = peerInfo.publicKey.toString('hex').slice(0, 8)
      console.log(`[P2P] ${conn.isInitiator ? 'Outgoing' : 'Incoming'} connection ${peerKeyHex}...`)

      if (this.connectionHandler) {
        this.connectionHandler(conn, peerInfo)
      }
    })

    console.log('[P2P] TopicManager initialized (shared swarm)')
  }

  /**
   * Announce a repository on the DHT
   * @param {string} repoName - Repository name
   * @returns {Object} - { topic, topicHex }
   */
  async announceRepo(repoName) {
    if (this.topics.has(repoName)) {
      return this.topics.get(repoName)
    }

    const topic = this.identity.deriveTopic(repoName)
    const topicHex = topic.toString('hex')

    const discovery = this.swarm.join(topic, { server: true, client: false })
    await discovery.flushed()

    const entry = { topic, topicHex, repoName, discovery }
    this.topics.set(repoName, entry)

    console.log(`[P2P] Announced: ${repoName} -> ${topicHex.slice(0, 8)}...`)
    return entry
  }

  /**
   * Announce multiple repositories — join all topics then flush once.
   * Previous: Promise.all(65x flushed()) overwhelmed the DHT.
   * Now: join all (instant), then swarm.flush() once for the batch.
   * @param {Array<string>} repoNames - List of repository names
   */
  async announceRepos(repoNames) {
    for (const name of repoNames) {
      if (this.topics.has(name)) continue
      const topic = this.identity.deriveTopic(name)
      const topicHex = topic.toString('hex')
      const discovery = this.swarm.join(topic, { server: true, client: false })
      this.topics.set(name, { topic, topicHex, repoName: name, discovery })
      console.log(`[P2P] Announced: ${name} -> ${topicHex.slice(0, 8)}...`)
    }
    // Single flush for all topics — much faster than per-repo flushed()
    await this.swarm.flush()
  }

  /**
   * Stop announcing a repository
   * @param {string} repoName - Repository name
   */
  async unannounceRepo(repoName) {
    const entry = this.topics.get(repoName)
    if (!entry) return

    await entry.discovery.destroy()
    this.topics.delete(repoName)

    console.log(`[P2P] Unannounced: ${repoName}`)
  }

  /**
   * Get topic info for a repository
   * @param {string} repoName - Repository name
   * @returns {Object|null}
   */
  getTopicInfo(repoName) {
    return this.topics.get(repoName) || null
  }

  /**
   * List all announced topics
   * @returns {Array}
   */
  listTopics() {
    return Array.from(this.topics.values()).map(entry => ({
      repoName: entry.repoName,
      topicHex: entry.topicHex.slice(0, 16) + '...'
    }))
  }

  /**
   * Announce the admin topic (__admin__)
   * Uses a reserved name that cannot collide with repo names.
   */
  async announceAdminTopic () {
    return this.announceRepo('__admin__')
  }

  /**
   * Get the punch:// URL for a repository (P2P git transport)
   * @param {string} repoName - Repository name
   * @returns {string}
   */
  getPunchUrl(repoName) {
    const serverKey = this.identity.getPublicKeyHex()
    return `punch://${serverKey}/${repoName}`
  }

  /**
   * Get a short punch:// URL for display
   * @param {string} repoName - Repository name
   * @returns {string}
   */
  getShortPunchUrl(repoName) {
    const serverKey = this.identity.getPublicKeyHex()
    return `punch://${serverKey.slice(0, 8)}.../${repoName}`
  }

  /**
   * Get connection count
   */
  getConnectionCount() {
    return this.swarm ? this.swarm.connections.size : 0
  }

  /**
   * Cleanup topic announcements (does NOT destroy the shared swarm)
   */
  async destroy() {
    for (const [name, entry] of this.topics) {
      await entry.discovery.destroy()
    }
    this.topics.clear()
    this.swarm = null
    console.log('[P2P] TopicManager cleaned up')
  }
}

/**
 * Client-side topic connector
 * Used by git-remote-pear/git-remote-punch to connect to servers.
 * Creates its own Hyperswarm (runs in a separate process).
 *
 * Uses a persistent keypair so the peer's Hyperswarm public key is stable
 * across connections. This is required for P2P push auth — the server
 * identifies peers by their Noise public key.
 */
export class TopicConnector {
  constructor(options = {}) {
    this.swarm = null
    this.publicKey = null
    this.options = options
  }

  /**
   * Initialize the swarm with a persistent keypair
   */
  async init() {
    const keyPair = await this._loadOrCreateKeyPair()
    this.publicKey = keyPair.publicKey
    this.swarm = new Hyperswarm({ keyPair })
  }

  /**
   * Get this peer's public key hex (for registering with servers)
   */
  getPublicKeyHex() {
    return this.publicKey ? this.publicKey.toString('hex') : null
  }

  /**
   * Connect to a repository topic
   * @param {string|Buffer} serverKey - Server public key
   * @param {string} repoName - Repository name
   * @returns {Promise<Stream>} - Connection stream
   */
  async connect(serverKey, repoName) {
    const topic = ServerIdentity.deriveTopicFromKey(serverKey, repoName)

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error(`Connection timeout for ${repoName}`))
      }, this.options.timeout || 30000)

      this.swarm.join(topic, { server: false, client: true })

      this.swarm.once('connection', (conn, peerInfo) => {
        clearTimeout(timeout)
        resolve(conn)
      })

      this.swarm.once('error', (err) => {
        clearTimeout(timeout)
        reject(err)
      })
    })
  }

  /**
   * Shutdown the connector
   */
  async destroy() {
    if (this.swarm) {
      await this.swarm.destroy()
      this.swarm = null
    }
  }

  /**
   * Load or create a persistent Noise keypair for this peer.
   * Stored in ~/.config/pear-git/peer-identity.json
   */
  async _loadOrCreateKeyPair() {
    const configDir = path.join(os.homedir(), '.config', 'pear-git')
    const keyFile = path.join(configDir, 'peer-identity.json')

    try {
      const data = await fs.promises.readFile(keyFile, 'utf-8')
      const saved = JSON.parse(data)
      return {
        publicKey: Buffer.from(saved.publicKey, 'hex'),
        secretKey: Buffer.from(saved.secretKey, 'hex')
      }
    } catch {
      // Generate new persistent identity
      const keyPair = crypto.keyPair()

      try {
        await fs.promises.mkdir(configDir, { recursive: true })
      } catch {}

      await fs.promises.writeFile(keyFile, JSON.stringify({
        publicKey: keyPair.publicKey.toString('hex'),
        secretKey: keyPair.secretKey.toString('hex'),
        createdAt: Date.now()
      }, null, 2), { encoding: 'utf-8', mode: 0o600 })

      return keyPair
    }
  }
}

export default TopicManager