OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / public-hive-gateway-live.test.js

465 lines · javascript ref:
import test from 'brittle'
import http from 'http'
import https from 'https'
import { createHash } from 'crypto'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import b4a from 'b4a'
import { GatewayServer } from 'p2p-hiverelay/core/relay-node/gateway-server.js'
import { encodeHiveAppKey, resolveHiveAppHost } from 'p2p-hiverelay/gateway/hive-host.js'
import { probePublicHiveGateway } from '../../scripts/lib/public-hive-gateway-preflight.mjs'

const execFileAsync = promisify(execFile)
const SUFFIX_A = 'hive-a.test'
const SUFFIX_B = 'hive-b.test'
const SOAK_REQUESTS = boundedPositiveInteger(
  process.env.HIVERELAY_PUBLIC_GATEWAY_SOAK_REQUESTS,
  24,
  10_000
)

test('live public Hive gateway - TLS, replication, isolation, and failover', async (t) => {
  const root = await mkdtemp(join(tmpdir(), 'hiverelay-public-live-'))
  const sourceStore = new Corestore(join(root, 'source'))
  const replicaStore = new Corestore(join(root, 'replica'))
  let replicationA = null
  let replicationB = null

  t.teardown(async () => {
    if (replicationA) replicationA.destroy()
    if (replicationB) replicationB.destroy()
    try { await sourceStore.close() } catch {}
    try { await replicaStore.close() } catch {}
    await rm(root, { recursive: true, force: true })
  })

  await sourceStore.ready()
  await replicaStore.ready()

  const sourceDrive = new Hyperdrive(sourceStore.namespace('public-live-source'))
  await sourceDrive.ready()
  const html = b4a.from('\ufeff<!doctype html>\r\n<script src="/assets/app.js"></script>\r\n')
  const asset = b4a.from('console.log("public-hive-live")\n')
  const large = Buffer.alloc(16 * 1024 * 1024, 0x5a)
  await sourceDrive.put('/index.html', html)
  await sourceDrive.put('/assets/app.js', asset)
  await sourceDrive.put('/large.bin', large)

  const replicaDrive = new Hyperdrive(replicaStore, sourceDrive.key)
  await replicaDrive.ready()
  replicationA = sourceStore.replicate(true)
  replicationB = replicaStore.replicate(false)
  replicationA.pipe(replicationB).pipe(replicationA)
  await replicaDrive.update({ wait: true })
  t.ok(b4a.equals(await replicaDrive.get('/index.html'), html), 'second gateway pulled exact HTML over Corestore replication')
  t.ok(b4a.equals(await replicaDrive.get('/assets/app.js'), asset), 'second gateway pulled exact asset bytes')
  t.ok(b4a.equals(await replicaDrive.get('/large.bin'), large), 'second gateway pulled the large shutdown fixture')
  replicationA.destroy()
  replicationB.destroy()
  replicationA = null
  replicationB = null

  const appKey = b4a.toString(sourceDrive.key, 'hex')
  const appLabel = encodeHiveAppKey(sourceDrive.key)
  const appHostA = `${appLabel}.${SUFFIX_A}`
  const appHostB = `${appLabel}.${SUFFIX_B}`
  const publicMetadata = {
    blind: false,
    privacyTier: 'public',
    storageClass: 'persistent',
    availabilityClass: 'always-on'
  }

  const gatewayA = await startGateway(t, sourceStore, sourceDrive, appKey, publicMetadata, SUFFIX_A)
  const gatewayB = await startGateway(t, replicaStore, replicaDrive, appKey, publicMetadata, SUFFIX_B)
  const certificateA = await createWildcardCertificate(root, SUFFIX_A, 'edge-a')
  const certificateB = await createWildcardCertificate(root, SUFFIX_B, 'edge-b')
  const edgeA = await startTlsEdge(t, gatewayA.server.address().port, certificateA, SUFFIX_A)
  const edgeB = await startTlsEdge(t, gatewayB.server.address().port, certificateB, SUFFIX_B)

  const first = await tlsRequest(edgeA.port, appHostA, '/', certificateA.cert)
  t.is(first.statusCode, 200, 'TLS edge serves the app root')
  t.is(first.tlsProtocol, 'TLSv1.3', 'live edge negotiates modern TLS')
  t.ok(first.raw.equals(html), 'TLS response preserves exact stored HTML bytes')
  t.is(first.headers['x-hive-app-key'], appKey)
  t.is(first.headers['x-hive-byte-mode'], 'exact')
  t.ok(Number(first.headers['x-hive-drive-version']) > 0, 'exact response names its immutable drive version')
  t.is(first.headers.vary, 'Host')
  t.is(first.headers.link, `<hive://${appKey}/>; rel="canonical", </.well-known/hiverelay-app.json>; rel="describedby"`)
  t.is(first.headers['cache-control'], 'no-store, max-age=0', 'Phase 1 edge cannot retain revoked mutable URLs')
  t.absent(first.headers['access-control-allow-origin'], 'app origin does not inherit compatibility CORS')

  const expectedHtmlSha256 = createHash('sha256').update(html).digest('hex')
  const evidence = await probePublicHiveGateway({
    origin: `https://${appHostA}:${edgeA.port}/`,
    appKey,
    suffix: SUFFIX_A,
    connectAddress: '127.0.0.1',
    path: '/index.html',
    expectedSha256: expectedHtmlSha256,
    expectedDriveVersion: Number(first.headers['x-hive-drive-version']),
    ca: certificateA.cert
  })
  t.is(evidence.sha256, expectedHtmlSha256, 'deployment probe records the exact publisher hash')
  t.is(evidence.tlsProtocol, 'TLSv1.3', 'deployment probe records the negotiated TLS protocol')
  t.is(evidence.connectAddress, '127.0.0.1', 'deployment probe records its pinned node address')
  t.alike(evidence.checks, {
    metadata: true,
    exactBytes: true,
    range: true,
    head: true,
    canonicalIdentity: true,
    managementIsolation: true,
    forwardedHostIsolation: true,
    unavailableAppIsolation: true,
    defaultSniRejection: true,
    sniHostBinding: true
  }, 'deployment evidence covers the public gateway isolation contract')
  await t.exception(probePublicHiveGateway({
    origin: `https://${appHostA}:${edgeA.port}/`,
    appKey,
    suffix: SUFFIX_A,
    connectAddress: '127.0.0.1',
    path: '/index.html',
    expectedSha256: expectedHtmlSha256,
    expectedDriveVersion: Number(first.headers['x-hive-drive-version']) + 1,
    ca: certificateA.cert
  }), /drive version does not match the configured immutable pin/)

  const ranged = await tlsRequest(edgeA.port, appHostA, '/assets/app.js', certificateA.cert, {
    Range: 'bytes=0-10'
  })
  t.is(ranged.statusCode, 206, 'TLS path preserves range semantics')
  t.ok(ranged.raw.equals(asset.subarray(0, 11)), 'range is cut from original asset bytes')

  const head = await tlsRequest(edgeA.port, appHostA, '/index.html', certificateA.cert, {}, 'HEAD')
  t.is(head.statusCode, 200)
  t.is(head.headers['content-length'], String(html.byteLength))
  t.is(head.raw.byteLength, 0, 'HEAD is body-free through TLS termination')

  const forwardedSibling = `${'y'.repeat(52)}.${SUFFIX_A}`
  const forwarded = await tlsRequest(edgeA.port, appHostA, '/', certificateA.cert, {
    'X-Forwarded-Host': forwardedSibling,
    'X-Forwarded-For': '198.51.100.66',
    Forwarded: 'for=198.51.100.66;host=attacker.example'
  })
  t.is(forwarded.statusCode, 200)
  t.is(forwarded.headers['x-hive-app-key'], appKey, 'forwarded Host cannot select a sibling app')
  t.absent(gatewayA._rateLimits.has('198.51.100.66'), 'strict edge overwrites attacker-supplied forwarding identity')

  const management = await tlsRequest(edgeA.port, appHostA, '/api/manage/config', certificateA.cert)
  t.is(management.statusCode, 404, 'management-looking paths remain app content lookups')
  t.absent(management.raw.toString().includes('apiKey'), 'management state is not exposed')

  const unknownHost = `${'y'.repeat(52)}.${SUFFIX_A}`
  const unknown = await tlsRequest(edgeA.port, unknownHost, '/', certificateA.cert)
  t.is(unknown.statusCode, 403, 'unapproved canonical app host fails closed')
  t.is(unknown.body.error, 'App unavailable through public Hive gateway')

  const metadata = await tlsRequest(edgeA.port, appHostA, '/.well-known/hiverelay-app.json', certificateA.cert)
  t.is(metadata.statusCode, 200)
  t.is(metadata.body.appKey, appKey)
  t.is(metadata.body.gatewayHost, appHostA)
  t.is(metadata.body.signed, false, 'transport metadata does not overclaim provenance')

  const second = await tlsRequest(edgeB.port, appHostB, '/', certificateB.cert)
  t.ok(second.raw.equals(first.raw), 'independent replicated gateway serves identical bytes')
  t.unlike(second.peerFingerprint, first.peerFingerprint, 'independent operators use different TLS private keys')

  const sniMismatch = await tlsRequest(
    edgeA.port,
    appHostA,
    '/',
    certificateA.cert,
    {},
    'GET',
    unknownHost
  )
  t.is(sniMismatch.statusCode, 421, 'strict edge rejects TLS SNI and HTTP Host mismatch')

  for (let i = 0; i < SOAK_REQUESTS; i++) {
    const useA = i % 2 === 0
    const port = useA ? edgeA.port : edgeB.port
    const host = useA ? appHostA : appHostB
    const ca = useA ? certificateA.cert : certificateB.cert
    const path = i % 3 === 0 ? '/assets/app.js' : '/index.html'
    const expected = path === '/assets/app.js' ? asset : html
    const response = await tlsRequest(port, host, path, ca)
    if (response.statusCode !== 200 || !response.raw.equals(expected)) {
      throw new Error(`soak request ${i + 1}/${SOAK_REQUESTS} returned inconsistent content`)
    }
  }
  t.pass(`${SOAK_REQUESTS} alternating TLS requests stayed byte-identical across both gateways`)

  const gatewayAPort = gatewayA.server.address().port
  gatewayA.port = gatewayAPort
  const paused = pauseTlsResponse(edgeA.port, appHostA, '/large.bin', certificateA.cert)
  await paused.started
  const stopped = await Promise.race([
    gatewayA.stop().then(() => true),
    new Promise(resolve => setTimeout(() => resolve(false), 1000))
  ])
  paused.destroy()
  t.ok(stopped, 'gateway shutdown is bounded while a TLS client pauses a large response')

  const failedOver = await requestFirstHealthy([
    { port: edgeA.port, host: appHostA, ca: certificateA.cert },
    { port: edgeB.port, host: appHostB, ca: certificateB.cert }
  ], '/')
  t.is(failedOver.port, edgeB.port, 'client fails over when the first edge has no healthy upstream')
  t.ok(failedOver.response.raw.equals(html), 'failover keeps exact application bytes')

  for (let cycle = 1; cycle <= 3; cycle++) {
    await gatewayA.start()
    t.is(gatewayA.server.address().port, gatewayAPort, `restart ${cycle} rebinds the same loopback port`)
    const restarted = await tlsRequest(edgeA.port, appHostA, '/', certificateA.cert)
    t.is(restarted.statusCode, 200, `restart ${cycle} restores HTTPS retrieval`)
    t.ok(restarted.raw.equals(html), `restart ${cycle} preserves exact bytes`)
    await gatewayA.stop()
  }
  await edgeA.stop()
})

async function startGateway (t, store, drive, appKey, metadata, suffix) {
  const pinnedVersion = drive.version
  const node = {
    config: {
      gatewayPublicOnlyPrivacyTier: true,
      hiveAppPublicKeys: [appKey],
      hiveAppPublicVersions: { [appKey]: pinnedVersion }
    },
    store,
    seededApps: new Map([[appKey, {
      drive,
      ...metadata,
      storageProvedDriveVersion: pinnedVersion
    }]])
  }
  node.appLifecycle = {
    acquireDriveReadLease (key) {
      const seededDrive = node.seededApps.get(key)?.drive
      if (!seededDrive || seededDrive.closed || seededDrive.closing) return null
      return { drive: seededDrive, release () {} }
    }
  }
  const gateway = new GatewayServer(node, {
    gatewayPort: 0,
    gatewayHost: '127.0.0.1',
    hiveAppHostSuffix: suffix,
    hiveAppPublicKeys: [appKey],
    hiveAppPublicVersions: { [appKey]: pinnedVersion },
    trustProxy: true,
    trustedProxyAddresses: ['127.0.0.1'],
    requireForwardedSNI: true
  })
  await gateway.start()
  t.teardown(async () => {
    try { await gateway.stop() } catch {}
  })
  return gateway
}

async function startTlsEdge (t, upstreamPort, certificate, suffix) {
  const sockets = new Set()
  const server = https.createServer({
    key: certificate.key,
    cert: certificate.cert,
    minVersion: 'TLSv1.2'
  }, (req, res) => {
    const resolvedHost = resolveHiveAppHost(req.headers.host, suffix)
    const sni = String(req.socket.servername || '').toLowerCase()
    if (resolvedHost.kind !== 'app' || sni !== resolvedHost.host) {
      res.writeHead(421, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
      res.end('{"error":"Misdirected request"}\n')
      return
    }

    const headers = { ...req.headers, host: req.headers.host }
    delete headers.connection
    for (const name of Object.keys(headers)) {
      if (name === 'forwarded' || name.startsWith('x-forwarded-')) delete headers[name]
    }
    headers['x-forwarded-for'] = normalizeLoopbackAddress(req.socket.remoteAddress)
    headers['x-hive-forwarded-sni'] = sni
    const upstream = http.request({
      hostname: '127.0.0.1',
      port: upstreamPort,
      method: req.method,
      path: req.url,
      headers
    }, (upstreamResponse) => {
      upstreamResponse.on('error', () => {
        if (!res.destroyed) res.destroy()
      })
      res.writeHead(upstreamResponse.statusCode, upstreamResponse.headers)
      upstreamResponse.pipe(res)
    })
    upstream.on('error', () => {
      if (!res.headersSent) res.writeHead(502)
      res.end()
    })
    upstream.setTimeout(3000, () => upstream.destroy(new Error('upstream timeout')))
    req.pipe(upstream)
  })
  server.on('connection', socket => {
    sockets.add(socket)
    socket.on('close', () => sockets.delete(socket))
  })
  await new Promise((resolve, reject) => {
    server.once('error', reject)
    server.listen(0, '127.0.0.1', resolve)
  })
  let stopped = false
  const stop = async () => {
    if (stopped) return
    stopped = true
    for (const socket of sockets) socket.destroy()
    await new Promise(resolve => server.close(resolve))
  }
  t.teardown(stop)
  return { port: server.address().port, stop }
}

async function createWildcardCertificate (root, suffix, name) {
  const configPath = join(root, `${name}-openssl.cnf`)
  const keyPath = join(root, `${name}.key`)
  const certPath = join(root, `${name}.crt`)
  await writeFile(configPath, [
    '[req]',
    'prompt = no',
    'distinguished_name = dn',
    'x509_extensions = extensions',
    '[dn]',
    `CN = *.${suffix}`,
    '[extensions]',
    'basicConstraints = critical,CA:FALSE',
    'keyUsage = critical,digitalSignature,keyEncipherment',
    'extendedKeyUsage = serverAuth',
    `subjectAltName = DNS:*.${suffix}`,
    ''
  ].join('\n'))
  await execFileAsync('openssl', [
    'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
    '-config', configPath,
    '-keyout', keyPath,
    '-out', certPath
  ])
  return {
    key: await readFile(keyPath),
    cert: await readFile(certPath)
  }
}

function tlsRequest (port, host, path, ca, headers = {}, method = 'GET', servername = host) {
  return new Promise((resolve, reject) => {
    const req = https.request({
      hostname: host,
      port,
      path,
      method,
      ca,
      servername,
      family: 4,
      lookup: (_hostname, _opts, callback) => callback(null, '127.0.0.1', 4),
      headers: { Host: host, Connection: 'close', ...headers },
      agent: false,
      timeout: 3000
    }, (res) => {
      const chunks = []
      res.on('data', chunk => chunks.push(chunk))
      res.on('end', () => {
        const raw = Buffer.concat(chunks)
        let body = raw
        try { body = JSON.parse(raw.toString('utf8')) } catch {}
        resolve({
          statusCode: res.statusCode,
          headers: res.headers,
          raw,
          body,
          tlsProtocol: res.socket.getProtocol(),
          peerFingerprint: res.socket.getPeerCertificate().fingerprint256
        })
      })
    })
    req.on('timeout', () => req.destroy(new Error('TLS request timed out')))
    req.on('error', reject)
    req.end()
  })
}

function pauseTlsResponse (port, host, path, ca) {
  let response = null
  let settled = false
  let resolveStarted
  let rejectStarted
  const started = new Promise((resolve, reject) => {
    resolveStarted = resolve
    rejectStarted = reject
  })
  const req = https.request({
    hostname: host,
    port,
    path,
    method: 'GET',
    ca,
    servername: host,
    family: 4,
    lookup: (_hostname, _opts, callback) => callback(null, '127.0.0.1', 4),
    headers: { Host: host, Connection: 'close' },
    agent: false,
    timeout: 3000
  }, res => {
    response = res
    res.on('error', () => {})
    res.once('data', () => {
      res.pause()
      settled = true
      resolveStarted()
    })
  })
  req.on('timeout', () => req.destroy(new Error('paused TLS response timed out')))
  req.on('error', err => {
    if (!settled) rejectStarted(err)
  })
  req.end()
  return {
    started,
    destroy () {
      response?.destroy()
      req.destroy()
    }
  }
}

async function requestFirstHealthy (candidates, path) {
  let lastError = null
  for (const candidate of candidates) {
    try {
      const response = await tlsRequest(candidate.port, candidate.host, path, candidate.ca)
      if (response.statusCode >= 200 && response.statusCode < 300) return { port: candidate.port, response }
    } catch (err) {
      lastError = err
    }
  }
  throw lastError || new Error('no healthy public Hive gateway')
}

function normalizeLoopbackAddress (value) {
  return value === '::ffff:127.0.0.1' ? '127.0.0.1' : String(value || '')
}

function boundedPositiveInteger (value, fallback, max) {
  if (value == null || value === '') return fallback
  if (!/^[1-9][0-9]*$/.test(value)) throw new Error('HIVERELAY_PUBLIC_GATEWAY_SOAK_REQUESTS must be a positive integer')
  const number = Number(value)
  if (!Number.isSafeInteger(number) || number > max) {
    throw new Error(`HIVERELAY_PUBLIC_GATEWAY_SOAK_REQUESTS must not exceed ${max}`)
  }
  return number
}