OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / gateway / server.js

547 lines · javascript ref:
/**
 * Standalone Hyper Gateway Server
 *
 * Runs alongside a relay node or standalone. Serves seeded Hyperdrive
 * content over HTTP for mobile clients (PearBrowser fast-path).
 *
 * Usage:
 *   node packages/core/gateway/server.js [--port 9100] [--host 127.0.0.1] [--storage ./storage] [--cors https://example.com]
 *
 * Or programmatically:
 *   const { startGateway } = await import('./server.js')
 *   const gateway = await startGateway({ port: 9100, host: '127.0.0.1', storage: './storage', corsOrigin: '*' })
 *
 * Security defaults:
 *   - binds loopback, not every interface;
 *   - POST /v1/seed is disabled unless allowDynamicSeed === true;
 *   - a non-loopback dynamic-seed listener requires a strong bearer token;
 *   - dynamic key count, request rate, body size, and open duration are bound.
 */

import { createServer } from 'http'
import { timingSafeEqual } from 'crypto'
import { isIP } from 'net'
import Hyperswarm from 'hyperswarm'
import { openCorestore } from '../core/persistence/storage-root-restore.js'
import Hyperdrive from 'hyperdrive'
import { HyperGateway } from './hyper-gateway.js'
import { RELAY_DISCOVERY_TOPIC } from '../core/constants.js'
import { readJsonBody } from '../core/relay-node/api-body.js'
import { sanitizeGatewayStats } from '../core/relay-node/api-gateway-stats.js'
import { getPostJsonContentTypeProblem } from '../core/relay-node/api-request.js'
import { writeJson } from '../core/relay-node/api-response.js'

const DEFAULT_PORT = 9100
const MAX_GATEWAY_SEED_BODY_BYTES = 4096
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_MAX_SEEDED_DRIVES = 64
const MAX_SEEDED_DRIVES = 1024
const DEFAULT_MAX_SEED_REQUESTS_PER_MINUTE = 30
const MAX_SEED_REQUESTS_PER_MINUTE = 600
const DEFAULT_SEED_OPERATION_TIMEOUT_MS = 30_000
const MAX_SEED_OPERATION_TIMEOUT_MS = 120_000
const MAX_SEED_RATE_BUCKETS = 1024
const MAX_REQUEST_HEADER_BYTES = 16 * 1024

function httpError (message, statusCode = 400, close = false) {
  const err = new Error(message)
  err.statusCode = statusCode
  err.close = close
  err.expose = true
  return err
}

export function buildGatewaySeedErrorResponse (err) {
  if (err?.expose) {
    return {
      status: err.statusCode || 400,
      payload: { error: err.message },
      close: Boolean(err.close)
    }
  }
  return {
    status: 500,
    payload: { error: 'Gateway seed failed' },
    close: false
  }
}

export function validateGatewaySeedKey (key) {
  if (typeof key !== 'string' || !/^[0-9a-f]{64}$/i.test(key)) {
    throw httpError('key must be 64 hex characters')
  }
  return key.toLowerCase()
}

export async function readGatewaySeedBody (req) {
  const contentTypeProblem = getPostJsonContentTypeProblem(req)
  if (contentTypeProblem) {
    throw httpError(contentTypeProblem.error, 400, contentTypeProblem.close)
  }
  let body
  try {
    body = await readJsonBody(req, MAX_GATEWAY_SEED_BODY_BYTES)
  } catch (err) {
    throw httpError(err.message, err.message === 'Request body too large' ? 413 : 400)
  }
  return { key: validateGatewaySeedKey(body.key) }
}

export function validateStandaloneGatewayOptions (opts = {}) {
  const port = opts.port ?? DEFAULT_PORT
  if (!Number.isSafeInteger(port) || port < 0 || port > 65535) {
    throw new Error('port must be an integer from 0 to 65535')
  }
  const host = normalizeListenHost(opts.host ?? DEFAULT_HOST)
  if (!host) throw new Error('host must be a canonical IP address or localhost')

  const allowDynamicSeed = opts.allowDynamicSeed === true
  const enableDiscovery = opts.enableDiscovery !== false
  const maxSeededDrives = strictPositiveInteger(
    opts.maxSeededDrives,
    DEFAULT_MAX_SEEDED_DRIVES,
    'maxSeededDrives',
    MAX_SEEDED_DRIVES
  )
  const maxSeedRequestsPerMinute = strictPositiveInteger(
    opts.maxSeedRequestsPerMinute,
    DEFAULT_MAX_SEED_REQUESTS_PER_MINUTE,
    'maxSeedRequestsPerMinute',
    MAX_SEED_REQUESTS_PER_MINUTE
  )
  const seedOperationTimeoutMs = strictPositiveInteger(
    opts.seedOperationTimeoutMs,
    DEFAULT_SEED_OPERATION_TIMEOUT_MS,
    'seedOperationTimeoutMs',
    MAX_SEED_OPERATION_TIMEOUT_MS
  )
  const seedToken = opts.seedToken == null ? null : opts.seedToken
  if (seedToken !== null && (typeof seedToken !== 'string' || seedToken.length < 32 || seedToken.length > 4096)) {
    throw new Error('seedToken must contain 32 to 4096 characters')
  }
  if (allowDynamicSeed && !isLoopbackHost(host) && !seedToken) {
    throw new Error('non-loopback dynamic seed requires a configured seedToken')
  }

  const rawSeedKeys = opts.seedKeys ?? []
  if (!Array.isArray(rawSeedKeys)) throw new Error('seedKeys must be an array')
  const seedKeys = []
  const seen = new Set()
  for (const candidate of rawSeedKeys) {
    const key = validateGatewaySeedKey(candidate)
    if (seen.has(key)) continue
    seen.add(key)
    seedKeys.push(key)
  }
  if (seedKeys.length > maxSeededDrives) throw new Error('seedKeys exceeds maxSeededDrives')

  return {
    port,
    host,
    storagePath: opts.storage || './gateway-storage',
    corsOrigin: opts.corsOrigin || '*',
    allowDynamicSeed,
    enableDiscovery,
    seedToken,
    seedKeys,
    maxSeededDrives,
    maxSeedRequestsPerMinute,
    seedOperationTimeoutMs
  }
}

export function isStandaloneSeedRequestAuthorized (req, seedToken) {
  if (!seedToken) return true
  const distinct = req?.headersDistinct?.authorization
  if (Array.isArray(distinct) && distinct.length !== 1) return false

  let count = 0
  for (let i = 0; i < (req?.rawHeaders?.length || 0); i += 2) {
    if (String(req.rawHeaders[i]).toLowerCase() === 'authorization') count++
  }
  if (count > 1) return false

  const header = Array.isArray(distinct) ? distinct[0] : req?.headers?.authorization
  if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false
  const supplied = Buffer.from(header.slice(7))
  const expected = Buffer.from(seedToken)
  return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected)
}

function strictPositiveInteger (value, fallback, label, max) {
  if (value === undefined) return fallback
  if (!Number.isSafeInteger(value) || value < 1 || value > max) {
    throw new Error(`${label} must be an integer from 1 to ${max}`)
  }
  return value
}

function normalizeListenHost (value) {
  if (typeof value !== 'string' || !value || value !== value.trim() || value.length > 253) return null
  const host = value.toLowerCase()
  if (host === 'localhost') return host
  return isIP(host) ? host : null
}

function isLoopbackHost (value) {
  if (value === 'localhost' || value === '::1') return true
  return isIP(value) === 4 && value.startsWith('127.')
}

function withTimeout (promise, timeoutMs, context) {
  let timer = null
  return new Promise((resolve, reject) => {
    let settled = false
    const finish = (fn, value) => {
      if (settled) return
      settled = true
      if (timer) clearTimeout(timer)
      fn(value)
    }
    timer = setTimeout(() => finish(reject, new Error(`${context} timed out`)), timeoutMs)
    Promise.resolve(promise).then(
      value => finish(resolve, value),
      err => finish(reject, err)
    )
  })
}

export async function startGateway (opts = {}) {
  const config = validateStandaloneGatewayOptions(opts)
  const {
    port,
    host,
    storagePath,
    seedKeys,
    corsOrigin,
    allowDynamicSeed,
    enableDiscovery,
    seedToken,
    maxSeededDrives,
    maxSeedRequestsPerMinute,
    seedOperationTimeoutMs
  } = config

  const store = openCorestore(storagePath)
  let swarm = null
  let gateway = null
  let server = null
  let closing = false
  let closed = false
  let closePromise = null
  const seededDrives = new Map()
  const seededApps = new Map()
  const seedOpenPromises = new Map()
  const seedRateLimits = new Map()
  const activeSockets = new Set()

  const closeResources = async () => {
    if (closed) return
    if (closePromise) return closePromise
    closing = true
    const pending = (async () => {
      if (server) {
        const current = server
        for (const socket of activeSockets) {
          try { socket.destroy() } catch {}
        }
        if (typeof current.closeAllConnections === 'function') {
          try { current.closeAllConnections() } catch {}
        }
        if (current.listening) await new Promise(resolve => current.close(resolve))
        server = null
      }
      activeSockets.clear()
      if (gateway) {
        try { await gateway.close() } catch {}
      }
      if (seedOpenPromises.size > 0) {
        await Promise.allSettled([...seedOpenPromises.values()])
        seedOpenPromises.clear()
      }
      await Promise.allSettled([...seededDrives.values()].map(async drive => {
        try { await withTimeout(drive.close(), seedOperationTimeoutMs, 'drive.close()') } catch {}
      }))
      seededDrives.clear()
      seededApps.clear()
      seedRateLimits.clear()
      if (swarm) {
        try { await withTimeout(swarm.destroy(), seedOperationTimeoutMs, 'swarm.destroy()') } catch {}
        swarm = null
      }
      try { await withTimeout(store.close(), seedOperationTimeoutMs, 'Corestore.close()') } catch {}
    })()
    closePromise = pending
    try { await pending } finally { closed = true; closePromise = null }
  }

  try {
    await withTimeout(store.ready(), seedOperationTimeoutMs, 'Corestore.ready()')

    swarm = new Hyperswarm()
    swarm.on('connection', (conn) => store.replicate(conn))
    if (enableDiscovery) swarm.join(RELAY_DISCOVERY_TOPIC, { server: true, client: false })

    const openSeedDrive = async (key) => {
      const existing = seededDrives.get(key)
      if (existing) return { drive: existing, alreadySeeded: true }
      const inFlight = seedOpenPromises.get(key)
      if (inFlight) return inFlight
      if (seededDrives.size + seedOpenPromises.size >= maxSeededDrives) {
        throw httpError('Seeded drive limit reached', 429)
      }

      const pending = (async () => {
        let drive = null
        let joined = false
        try {
          drive = new Hyperdrive(store.session(), Buffer.from(key, 'hex'))
          await withTimeout(drive.ready(), seedOperationTimeoutMs, 'drive.ready()')
          if (closing) throw new Error('Gateway is closing')
          swarm.join(drive.discoveryKey, { server: true, client: true })
          joined = true
          await withTimeout(swarm.flush(), seedOperationTimeoutMs, 'swarm.flush()')
          if (closing) throw new Error('Gateway is closing')
          seededDrives.set(key, drive)
          seededApps.set(key, {
            drive,
            blind: false,
            privacyTier: 'public',
            storageClass: 'persistent',
            availabilityClass: 'always-on'
          })
          return { drive, alreadySeeded: false }
        } catch (err) {
          if (joined && drive) {
            try { await swarm.leave(drive.discoveryKey) } catch {}
          }
          if (drive) {
            try { await withTimeout(drive.close(), seedOperationTimeoutMs, 'failed drive.close()') } catch {}
          }
          throw err
        }
      })()
      seedOpenPromises.set(key, pending)
      pending.finally(() => {
        if (seedOpenPromises.get(key) === pending) seedOpenPromises.delete(key)
      }).catch(() => {})
      return pending
    }

    for (const keyHex of seedKeys) {
      await openSeedDrive(keyHex)
      console.log(`  Seeding: ${keyHex.slice(0, 16)}...`)
    }
    if (enableDiscovery || seedKeys.length > 0) {
      await withTimeout(swarm.flush(), seedOperationTimeoutMs, 'initial swarm.flush()')
    }

    // Share the already-open Corestore and seeded drives. The old standalone
    // path accidentally built a second Corestore/Hyperswarm inside
    // HyperGateway and ignored its own seededDrives map.
    const nodeProxy = { store, swarm, seededDrives, seededApps }
    gateway = new HyperGateway(nodeProxy, { store })

    const checkSeedRate = (address) => {
      const now = Date.now()
      let entry = seedRateLimits.get(address)
      if (!entry || now > entry.resetAt) {
        if (!entry && seedRateLimits.size >= MAX_SEED_RATE_BUCKETS) {
          for (const [key, value] of seedRateLimits) {
            if (!value || now > value.resetAt) seedRateLimits.delete(key)
          }
          if (seedRateLimits.size >= MAX_SEED_RATE_BUCKETS) return false
        }
        entry = { count: 0, resetAt: now + 60_000 }
        seedRateLimits.set(address, entry)
      }
      if (entry.count >= maxSeedRequestsPerMinute) return false
      entry.count++
      return true
    }

    const handleRequest = async (req, res) => {
      if (closing) {
        res.shouldKeepAlive = false
        writeJson(res, { error: 'Gateway shutting down' }, 503, { Connection: 'close', 'Retry-After': '1' })
        return
      }
      if (typeof req.url !== 'string' || !req.url.startsWith('/') || req.url.startsWith('//')) {
        res.shouldKeepAlive = false
        writeJson(res, { error: 'Invalid request target' }, 400, { Connection: 'close' })
        return
      }

      const url = new URL(req.url, `http://localhost:${port}`)
      const origin = req.headers.origin
      if (corsOrigin === '*') {
        res.setHeader('Access-Control-Allow-Origin', '*')
      } else if (origin && origin === corsOrigin) {
        res.setHeader('Access-Control-Allow-Origin', corsOrigin)
        res.setHeader('Vary', 'Origin')
      }
      // Mutating seed management is intentionally not browser-CORS-enabled.
      res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')

      if (req.method === 'OPTIONS') {
        res.writeHead(204)
        res.end()
        return
      }

      if ((req.method === 'GET' || req.method === 'HEAD') && url.pathname === '/health') {
        writeJson(res, {
          ok: true,
          type: 'hiverelay-gateway',
          drives: seededDrives.size,
          dynamicSeedEnabled: allowDynamicSeed,
          ...sanitizeGatewayStats(gateway.getStats())
        })
        return
      }

      if (url.pathname.startsWith('/v1/hyper/')) {
        return gateway.handle(req, res)
      }

      if (req.method === 'POST' && url.pathname === '/v1/seed') {
        if (!allowDynamicSeed) {
          writeJson(res, { error: 'Not found' }, 404)
          return
        }
        const address = req.socket.remoteAddress || 'unknown'
        if (!checkSeedRate(address)) {
          writeJson(res, { error: 'Too many seed requests' }, 429, { 'Retry-After': '60' })
          return
        }
        if (!isStandaloneSeedRequestAuthorized(req, seedToken)) {
          writeJson(res, { error: 'Unauthorized' }, 401, {
            'WWW-Authenticate': 'Bearer realm="hiverelay-standalone-seed"'
          })
          return
        }
        try {
          const { key } = await readGatewaySeedBody(req)
          const result = await openSeedDrive(key)
          writeJson(res, { ok: true, seeding: key, alreadySeeded: result.alreadySeeded })
        } catch (err) {
          const result = buildGatewaySeedErrorResponse(err)
          if (result.close) res.shouldKeepAlive = false
          writeJson(res, result.payload, result.status, result.close ? { Connection: 'close' } : null)
        }
        return
      }

      writeJson(res, { error: 'Not found' }, 404)
    }

    server = createServer({
      maxHeaderSize: MAX_REQUEST_HEADER_BYTES,
      insecureHTTPParser: false,
      requireHostHeader: true,
      joinDuplicateHeaders: false
    }, (req, res) => {
      handleRequest(req, res).catch(() => {
        if (res.destroyed || res.writableEnded) return
        if (res.headersSent) {
          res.destroy()
          return
        }
        writeJson(res, { error: 'Internal error' }, 500)
      })
    })
    server.headersTimeout = 10_000
    server.requestTimeout = 60_000
    server.keepAliveTimeout = 5_000
    server.maxRequestsPerSocket = 100
    server.maxHeadersCount = 64
    server.maxConnections = 512
    server.on('connection', socket => {
      activeSockets.add(socket)
      socket.on('close', () => activeSockets.delete(socket))
    })

    await new Promise((resolve, reject) => {
      const onError = err => reject(err)
      server.once('error', onError)
      server.listen(port, host, () => {
        server.removeListener('error', onError)
        resolve()
      })
    })
    server.on('error', err => {
      gateway.emit('server-error', { error: err?.message || String(err) })
    })

    const actualPort = server.address().port
    console.log(`\n  HiveRelay Gateway running on http://${host}:${actualPort}`)
    console.log(`  Seeding ${seededDrives.size} drives`)
    console.log(`  Dynamic seed endpoint: ${allowDynamicSeed ? 'explicitly enabled' : 'disabled'}`)
    console.log(`  Fetch: GET http://${host}:${actualPort}/v1/hyper/{KEY}/{path}\n`)

    return {
      server,
      gateway,
      store,
      swarm,
      seededDrives,
      config: {
        host,
        allowDynamicSeed,
        maxSeededDrives,
        maxSeedRequestsPerMinute,
        seedOperationTimeoutMs,
        seedTokenRequired: Boolean(seedToken)
      },
      close: closeResources
    }
  } catch (err) {
    await closeResources()
    throw err
  }
}

// CLI mode
if (import.meta.url === `file://${process.argv[1]}`) {
  const args = process.argv.slice(2)
  const port = args.includes('--port') ? Number(args[args.indexOf('--port') + 1]) : DEFAULT_PORT
  const host = args.includes('--host') ? args[args.indexOf('--host') + 1] : DEFAULT_HOST
  const storage = args.includes('--storage') ? args[args.indexOf('--storage') + 1] : './gateway-storage'
  const corsOrigin = args.includes('--cors') ? args[args.indexOf('--cors') + 1] : '*'
  const allowDynamicSeed = args.includes('--allow-dynamic-seed')
  const seedToken = process.env.HIVERELAY_GATEWAY_SEED_TOKEN || null
  const maxSeededDrives = args.includes('--max-seeded-drives')
    ? Number(args[args.indexOf('--max-seeded-drives') + 1])
    : DEFAULT_MAX_SEEDED_DRIVES
  const maxSeedRequestsPerMinute = args.includes('--max-seed-requests-per-minute')
    ? Number(args[args.indexOf('--max-seed-requests-per-minute') + 1])
    : DEFAULT_MAX_SEED_REQUESTS_PER_MINUTE
  const seedKeys = args.filter(a => /^[a-f0-9]{64}$/i.test(a))

  startGateway({
    port,
    host,
    storage,
    seedKeys,
    corsOrigin,
    allowDynamicSeed,
    seedToken,
    maxSeededDrives,
    maxSeedRequestsPerMinute
  }).then((gw) => {
    let stopping = false
    const stop = async () => {
      if (stopping) return
      stopping = true
      console.log('\n  Shutting down...')
      await gw.close()
      process.exit(0)
    }
    process.once('SIGINT', stop)
    process.once('SIGTERM', stop)
  }).catch(err => {
    console.error('Failed:', err.message)
    process.exit(1)
  })
}