OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident-gateway / gateway.js

561 lines · javascript ref:
#!/usr/bin/env bare
/**
 * Hyperident Gateway - Main Entry Point
 *
 * P2P gateway that bridges local services to the Hyperswarm network using
 * deterministic identity-based addressing via hyperident profiles.
 */

import { GatewayIdentity } from './lib/gateway-identity.js'
import { UTUNManager } from './lib/utun-manager.js'
import { HyperswarmRouter } from './lib/hyperswarm-router.js'
import { configDir, defaultConfigPath, stateDir } from './lib/paths.js'
import { RegistryNode, request as registryRequest } from './lib/registry-node.js'
import { RegistryPolicy } from './lib/registry-policy.js'
import { loadOrCreateSwarmKeyPair, loadDeviceCred } from './lib/node-keys.js'
import { writeHostsIfChanged } from './lib/hosts-file.js'
import { signAttested } from 'hyperident/lib/attestation.js'
import b4a from 'b4a'
import { describeRelease, stagedNotice, statusDoc, autoApplyDecision, APPLY_EXIT_CODE } from './lib/release-info.js'
import path from 'bare-path'
import { supervisorRefusal, capabilityRefusal, tunnelInUseRefusal } from './lib/safeguards.js'
import fs from 'bare-fs/promises'
import fsSync from 'bare-fs'
import os from 'bare-os'
import minimist from 'minimist'

const VERSION = '0.3.2'

// Parse command line arguments
// Bare.argv is [bare, gateway.js, ...args]
const argv = minimist(Bare.argv.slice(2), {
  string: ['config', 'admin', 'pool', 'role', 'ip', 'label'],
  boolean: ['help', 'version', 'scan-ips'],
  alias: {
    c: 'config',
    h: 'help',
    v: 'version'
  },
  default: {
    // Outside the install dir: a pgh update replaces that dir (lib/paths.js)
    config: defaultConfigPath(os.homedir())
  }
})

// Show help
if (argv.help) {
  console.log(`
Hyperident Gateway v${VERSION}

Usage:
  bare gateway.js [options]

Options:
  -c, --config <path>   Path to configuration file (default: ~/.config/hyperident-gateway/gateway-config.json)
  --scan-ips           Scan network for all active gateway IPs and exit
  registry <command>   Talk to the running gateway's address registry (#4):
                         status
                         found --admin <rootHex> [--admin ...] [--pool <cidr>]   (once, on the founding node)
                         seed [--ip <ip>] [--label <name>]   (bind this device, claim ip + name, bind swarm key)
                         admit <rootHex> [--role admin|member]
                         expel <rootHex>
  -h, --help           Show this help message
  -v, --version        Show version number

Examples:
  bare gateway.js
  bare gateway.js --config /etc/hyperident-gateway/config.json
  sudo systemctl start hyperident-gateway

For more information, see: docs/SETUP.md
`)
  Bare.exit(0)
}

// Show version
if (argv.version) {
  console.log(`Hyperident Gateway v${VERSION}`)
  Bare.exit(0)
}

/**
 * Load configuration from file
 */
async function loadConfig(configPath) {
  try {
    const data = await fs.readFile(configPath, 'utf8')
    return JSON.parse(data)
  } catch (error) {
    if (error.code === 'ENOENT') {
      throw new Error(`Configuration file not found: ${configPath}`)
    }
    throw new Error(`Failed to load configuration: ${error.message}`)
  }
}

/**
 * Hyperswarm options from config. gateway.bootstrap (optional) points the
 * swarm at a private DHT, e.g. a testnet for benchmarks; absent = public DHT.
 */
function swarmOptionsFromConfig(config) {
  if (config.gateway.bootstrap) return { bootstrap: config.gateway.bootstrap }
  return {}
}

/**
 * Main gateway initialization
 */
async function startGateway(config) {
  const startedAt = new Date().toISOString()
  console.log('🚀 Starting Hyperident Gateway v' + VERSION)
  console.log('============================================================\n')

  // Initialize GatewayIdentity
  console.log('📝 Initializing GatewayIdentity...')
  const gatewayIdentity = new GatewayIdentity({
    subnet: config.gateway.subnet,
    gatewayIP: config.gateway.virtualIP
  })
  console.log(`   Subnet: ${config.gateway.subnet}`)
  console.log(`   Gateway IP: ${config.gateway.virtualIP}\n`)

  // Address registry (#4), opt-in via gateway.registry.enabled. Log-only in this release: a registry
  // that fails to start is reported and the VPN runs without it.
  const registryConfig = config.gateway.registry || {}
  let swarmOptions = swarmOptionsFromConfig(config)
  let registryNode = null
  let policy = null
  if (registryConfig.enabled) {
    try {
      const keyPair = loadOrCreateSwarmKeyPair(configDir(os.homedir()))
      swarmOptions = { ...swarmOptions, keyPair }
      registryNode = new RegistryNode({
        dir: path.join(stateDir(os.homedir()), 'registry'),
        stateDir: stateDir(os.homedir()),
        key: registryConfig.key || null,
        swarmOptions: swarmOptionsFromConfig(config),
        routerSwarmKey: b4a.toString(keyPair.publicKey, 'hex')
      })
      await registryNode.start()
      policy = new RegistryPolicy({ registry: registryNode.registry })
      registryNode.policy = policy
    } catch (err) {
      console.error('⚠️  Address registry not started (the VPN runs without it): ' + err.message)
      if (registryNode) await registryNode.stop().catch(() => {})
      registryNode = null
      policy = null
    }
  }

  // Initialize HyperswarmRouter (before UTUNManager so we can reference it)
  const hyperswarmRouter = new HyperswarmRouter({
    gatewayIdentity,
    networkTopic: config.gateway.networkTopic || 'hyperident-network',
    swarmOptions,
    policy,
    onError: (error) => {
      console.error('❌ Hyperswarm Error:', error.message)
    },
    onConnection: (connection, isInitiator) => {
      console.log(`   Connected to peer (${isInitiator ? 'outgoing' : 'incoming'})`)
    }
  })

  // Initialize UTUNManager
  console.log('📝 Initializing UTUN Manager...')
  const utunManager = new UTUNManager({
    deviceName: config.gateway.tunDevice || 'tun0',
    gatewayIP: config.gateway.virtualIP,
    subnet: config.gateway.subnet,
    netmask: '255.255.0.0', // /16 subnet
    mtu: config.gateway.mtu || 1500,
    gatewayIdentity,

    onPacketReceived: (data) => {
      // Forward packet to P2P network
      hyperswarmRouter.forwardToP2P(data.packet, data.destIP)
    },

    onError: (error) => {
      console.error('❌ UTUN Error:', error.message)
    }
  })

  // Link router to UTUN manager
  hyperswarmRouter.utunManager = utunManager

  // Create and configure TUN device
  console.log('📝 Creating TUN device...')
  await utunManager.open()
  console.log()

  // Start Hyperswarm router and join network topic
  await hyperswarmRouter.start()
  await hyperswarmRouter.joinNetwork()

  // IP conflict detection — scan for peers announcing our IP
  console.log('📝 Scanning for IP conflicts (5 seconds)...')
  const peerIPs = await hyperswarmRouter.scanNetworkIPs(5000)
  if (peerIPs.size > 0) {
    console.log(`   Found ${peerIPs.size} peer IP(s) on the network:`)
    for (const [ip, peers] of peerIPs) {
      console.log(`     ${ip} (${peers.length} peer(s))`)
    }
  } else {
    console.log('   No peers found (may take longer for DHT discovery)')
  }

  const conflict = hyperswarmRouter.checkIPConflict(config.gateway.virtualIP)
  if (conflict.taken) {
    console.error(`\n❌ FATAL: IP ${config.gateway.virtualIP} is already in use by ${conflict.peers} peer(s)!`)
    console.error('   Choose a different virtualIP in your config or resolve the conflict.')
    await hyperswarmRouter.stop()
    await utunManager.close()
    Bare.exitCode = 1
    return
  }
  console.log(`   ✅ IP ${config.gateway.virtualIP} is available\n`)

  // Setup graceful shutdown
  let isShuttingDown = false

  const shutdown = async (exitCode = 0) => {
    if (isShuttingDown) return
    isShuttingDown = true

    console.log('\n\n🛑 Shutting down gateway...')

    try {
      await hyperswarmRouter.stop()
      console.log('✅ Hyperswarm router stopped')
    } catch (error) {
      console.error('❌ Error stopping Hyperswarm router:', error.message)
    }

    try {
      await utunManager.close()
      console.log('✅ TUN device closed')
    } catch (error) {
      console.error('❌ Error closing TUN device:', error.message)
    }

    if (registryNode) {
      try {
        await registryNode.stop()
        console.log('✅ Address registry stopped')
      } catch (error) {
        console.error('❌ Error stopping the address registry:', error.message)
      }
    }

    console.log('✅ Gateway stopped\n')
    Bare.exit(exitCode)
  }

  Bare.on('SIGINT', () => shutdown(0))
  Bare.on('SIGTERM', () => shutdown(0))

  console.log('============================================================')
  console.log('✅ Gateway is running!')
  console.log('============================================================\n')
  console.log(`Virtual IP: ${config.gateway.virtualIP}`)
  console.log(`Subnet: ${config.gateway.subnet}`)
  // The topic string is the network's secret: log only a prefix of its hash
  console.log(`Network topic hash: ${hyperswarmRouter.topic.substring(0, 8)}…`)
  console.log()
  console.log('Press Ctrl+C to stop\n')

  // INSTALLED != RUNNING from the inside (lib/release-info.js): name the
  // release this process runs, and keep status.json current.
  const manifest = readInstallManifest(INSTALL_DIR)
  console.log(describeRelease(manifest, INSTALL_DIR))
  const reportStatus = () => {
    const staged = readInstallManifest(INSTALL_DIR + '.next')
    const notice = stagedNotice(staged)
    if (notice) console.log('⚠️  ' + notice)
    const doc = statusDoc({
      pid: Bare.pid,
      startedAt,
      manifest,
      staged,
      mtu: config.gateway.mtu || 1500,
      topicHash: hyperswarmRouter.topic,
      now: new Date().toISOString()
    })
    if (policy) doc.registry = { mode: 'log-only', now: policy.snapshot(), total: { ...policy.counters } }
    writeStatus(doc)
  }
  reportStatus()

  // The registry's names as a hosts fragment for the root-owned resolver helper (lib/hosts-file.js)
  const syncHosts = async () => {
    if (!registryNode) return
    try {
      if (writeHostsIfChanged(stateDir(os.homedir()), await registryNode.registry.names())) console.log('📒 hosts.hyperlocal updated')
    } catch (err) {
      console.error('⚠️  hosts.hyperlocal not written: ' + err.message)
    }
  }
  await syncHosts()

  // Apply a staged release by exiting for systemd to restart us through the launcher (lib/release-info.js).
  // Once per staged tree: if it is still staged after the restart, say so instead of looping.
  const autoApplyFile = path.join(stateDir(os.homedir()), 'auto-apply.json')
  const maybeApplyStaged = async () => {
    let lastAttempt = null
    try { lastAttempt = JSON.parse(fsSync.readFileSync(autoApplyFile, 'utf8')).treeHash || null } catch {}
    const decision = autoApplyDecision({
      manifest,
      staged: readInstallManifest(INSTALL_DIR + '.next'),
      lastAttempt,
      enabled: config.gateway.autoApply !== false
    })
    if (!decision.apply) {
      if (decision.reason.includes('still staged')) console.error('⚠️  ' + decision.reason)
      return
    }
    console.log('🔄 ' + decision.reason + ': exiting ' + APPLY_EXIT_CODE + ' for systemd to restart through the launcher')
    try {
      fsSync.writeFileSync(autoApplyFile, JSON.stringify({ treeHash: readInstallManifest(INSTALL_DIR + '.next').treeHash, at: new Date().toISOString() }) + '\n')
    } catch (err) {
      console.error('⚠️  not applying: cannot record the attempt (' + err.message + ')')
      return
    }
    await shutdown(APPLY_EXIT_CODE)
  }

  // Log stats periodically
  setInterval(async () => {
    if (policy) await policy.refresh().catch((err) => console.error('⚠️  registry refresh: ' + err.message))
    await syncHosts()
    reportStatus()
    await maybeApplyStaged()
    const tunStats = utunManager.getStats()
    const routerStats = hyperswarmRouter.getStats()

    console.log(`[${new Date().toISOString()}] Stats:`)
    console.log(`  TUN: ${tunStats.packetsReceived} rx, ${tunStats.packetsSent} tx, ${tunStats.packetsDropped} dropped`)
    // utun's own counters (cumulative): packets the device dropped on read,
    // and writes rejected because its 32-slot queue to the kernel was full
    if (tunStats.tun) {
      console.log(`  TUN queue: ${tunStats.tun.rxDrop} rxDrop, ${tunStats.tun.txRejected} txRejected, ${tunStats.tun.txPackets} txPackets`)
    }
    console.log(`  P2P: ${routerStats.packetsFromP2P} rx, ${routerStats.packetsToP2P} tx, ${routerStats.totalConnections} connections`)
    console.log(`  Routing: ${routerStats.routingTableSize} known IPs`)
    if (policy) {
      const c = policy.counters
      const n = policy.snapshot()
      console.log(`  Registry (log-only) now: ${n.unknownPeers}/${n.connections} unknown peers, ${n.unownedAddresses} unowned addresses; total: ${c.unknownPeer} unknownPeer, ${c.unownedAnnounce} unownedAnnounce, ${c.spoofedSource} spoofedSource`)
    }
  }, 60000) // Every minute
}

/**
 * Main entry point
 */
/**
 * Scan the network for all active gateway IPs without starting the TUN.
 * Useful for finding which IPs are taken before configuring a new gateway.
 */
async function scanIPs (config) {
  console.log('🔍 Scanning Hyperident Gateway network for active IPs...')
  console.log(`   Subnet: ${config.gateway.subnet}`)
  console.log()

  // Need a minimal GatewayIdentity just for the constructor
  const gatewayIdentity = new GatewayIdentity({
    subnet: config.gateway.subnet,
    gatewayIP: config.gateway.virtualIP,
    staticMappings: config.staticMappings || {}
  })

  const hyperswarmRouter = new HyperswarmRouter({
    gatewayIdentity,
    networkTopic: config.gateway.networkTopic || 'hyperident-network',
    swarmOptions: swarmOptionsFromConfig(config),
    onError: () => {},
    onConnection: () => {}
  })

  await hyperswarmRouter.start()
  await hyperswarmRouter.joinNetwork()

  console.log('⏳ Listening for announcements (10 seconds)...\n')
  const peerIPs = await hyperswarmRouter.scanNetworkIPs(10000)

  if (peerIPs.size === 0) {
    console.log('   No peers found. The network may be empty or DHT discovery')
    console.log('   may take longer. Try running again or increasing scan duration.')
  } else {
    console.log('Active IPs on the network:')
    console.log('─────────────────────────')
    for (const [ip, peers] of peerIPs) {
      const own = ip === config.gateway.virtualIP ? ' ← (this gateway)' : ''
      console.log(`  ${ip}  (${peers.length} connection(s))${own}`)
    }
    console.log()
    console.log(`Total: ${peerIPs.size} IP(s) found`)
  }

  // Check if our configured IP is taken
  const conflict = hyperswarmRouter.checkIPConflict(config.gateway.virtualIP)
  if (conflict.taken) {
    console.log(`\n⚠️  WARNING: Your configured IP ${config.gateway.virtualIP} is already in use!`)
  } else {
    console.log(`\n✅ Your configured IP ${config.gateway.virtualIP} is available.`)
  }

  // Suggest next available IPs
  const takenIPs = new Set(peerIPs.keys())
  const suggestions = []
  for (let i = 1; i <= 10 && suggestions.length < 3; i++) {
    const candidate = `10.22.0.${i}`
    if (!takenIPs.has(candidate)) {
      suggestions.push(candidate)
    }
  }
  if (suggestions.length > 0) {
    console.log(`   Available IPs: ${suggestions.join(', ')}`)
  }

  await hyperswarmRouter.stop()
  console.log()
}

/** A hostname as a registry label: [a-z0-9-], no leading/trailing '-', at most 63. */
function hostnameLabel () {
  return os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63)
}

const short = (h) => (h ? String(h).slice(0, 12) + '…' : '-')

function printRegistry (info) {
  console.log(`Registry ${info.key}`)
  console.log(`  this node: writer ${short(info.writer)} ${info.writable ? 'writable' : 'NOT writable'}` +
    (info.device ? `, device ${short(info.device.device)} of root ${short(info.device.root)}` : ', not bound to a device'))
  console.log(`  router swarm key: ${short(info.routerSwarmKey)}`)
  if (!info.founded) { console.log('  not founded yet'); return }
  console.log(`  pool: ${info.founded.pool}`)
  console.log('  members:')
  for (const m of info.members) console.log(`    ${short(m.root)} ${m.role}`)
  console.log('  devices:')
  for (const d of info.devices) console.log(`    ${short(d.device)} root ${short(d.root)} swarm ${short(d.swarmKey)}`)
  console.log('  addresses:')
  for (const e of info.ips) {
    const names = info.names.filter((n) => n.ip === e.ip).map((n) => n.label + '.hyperlocal')
    console.log(`    ${e.ip} → device ${short(e.device)} ${names.join(' ')}`)
  }
  if (info.rejected.length) {
    console.log('  refused ops from this node:')
    for (const r of info.rejected) console.log(`    #${r.length} ${r.t}: ${r.reason}`)
  }
  if (info.checks) {
    const { now: n, total: c } = info.checks
    console.log(`  checks (log-only) now: ${n.unknownPeers}/${n.connections} unknown peers, ${n.unownedAddresses} unowned addresses`)
    console.log(`  checks (log-only) total: ${c.unknownPeer} unknownPeer, ${c.unownedAnnounce} unownedAnnounce, ${c.spoofedSource} spoofedSource`)
  }
}

/** `registry <command>`: talks to the running gateway over its control socket (lib/registry-node.js). */
async function registryCli (config) {
  const home = os.homedir()
  const state = stateDir(home)
  const cmd = argv._[1]
  // Sign "this device owns that writer core" here, with the device key; the daemon only gets the proof.
  const signedBinding = async () => {
    const cred = loadDeviceCred(home)
    const { writer } = await registryRequest(state, { cmd: 'writer' })
    return signAttested(b4a.from(writer, 'hex'), cred)
  }

  if (cmd === 'status') {
    printRegistry(await registryRequest(state, { cmd: 'info' }))
  } else if (cmd === 'found') {
    const admins = [].concat(argv.admin || [])
    printRegistry(await registryRequest(state, { cmd: 'found', bound: await signedBinding(), admins, pool: argv.pool || config.gateway.subnet }))
  } else if (cmd === 'seed') {
    const info = await registryRequest(state, { cmd: 'info' })
    const bound = info.device ? null : await signedBinding()
    printRegistry(await registryRequest(state, { cmd: 'seed', bound, ip: argv.ip || config.gateway.virtualIP, label: argv.label || hostnameLabel() }))
  } else if (cmd === 'admit' || cmd === 'expel') {
    const root = argv._[2]
    if (!root) throw new Error(`registry ${cmd} needs a root public key (hex)`)
    printRegistry(await registryRequest(state, { cmd, root: String(root), role: argv.role || 'member' }))
  } else {
    throw new Error('usage: registry status | found --admin <root> [--pool <cidr>] | seed [--ip <ip>] [--label <name>] | admit <root> [--role admin|member] | expel <root>')
  }
}

// The directory this gateway runs from. Installed by pgh it is
// <store>/apps/hyperident-gateway, with .pgh-install.json beside gateway.js and
// a staged update (if any) in <dir>.next.
const INSTALL_DIR = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(/\/$/, '')

function readInstallManifest (dir) {
  try { return JSON.parse(fsSync.readFileSync(path.join(dir, '.pgh-install.json'), 'utf8')) } catch { return null }
}

// status.json for tools comparing installed vs running; written atomically
// (tmp + rename) so a reader never sees half a document. Best-effort: a
// failure is logged, never fatal.
function writeStatus (doc) {
  const dir = stateDir(os.homedir())
  const file = path.join(dir, 'status.json')
  try {
    fsSync.mkdirSync(dir, { recursive: true })
    fsSync.writeFileSync(file + '.tmp', JSON.stringify(doc, null, 2) + '\n')
    fsSync.renameSync(file + '.tmp', file)
  } catch (err) {
    console.error('⚠️  status.json not written: ' + err.message)
  }
}

// This process's capability sets; '' where /proc is absent (not Linux), which
// capabilityRefusal treats as "cannot tell", never as "missing".
async function readProcStatus () {
  try { return await fs.readFile('/proc/self/status', 'utf8') } catch { return '' }
}

async function main() {
  try {
    // Refuse to start where the gateway cannot work, before any TUN or swarm
    // work (lib/safeguards.js). --scan-ips and `registry` create no TUN, so they run anywhere.
    const isRegistryCli = argv._[0] === 'registry'
    if (!argv['scan-ips'] && !isRegistryCli) {
      const refusal = supervisorRefusal({ PGH_APP_NAME: os.getEnv('PGH_APP_NAME') }) ||
        capabilityRefusal(await readProcStatus()) ||
        tunnelInUseRefusal((p) => fsSync.existsSync(p))
      if (refusal) {
        console.error('\n❌ ' + refusal)
        Bare.exitCode = 1
        return
      }
    }

    // Load configuration
    const config = await loadConfig(argv.config)

    if (isRegistryCli) {
      await registryCli(config)
      return
    }

    // Handle --scan-ips mode
    if (argv['scan-ips']) {
      await scanIPs(config)
      return
    }

    // Start gateway
    await startGateway(config)

  } catch (error) {
    console.error('\n❌ Fatal error:', error.message)
    console.error(error.stack)
    Bare.exitCode = 1
  }
}

// Run
main()