OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / preflight-public-hive-gateway.mjs

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

import { constants } from 'fs'
import { open, rename, unlink } from 'fs/promises'
import { createHash, randomBytes } from 'crypto'
import { execFile } from 'child_process'
import { isUtf8 } from 'buffer'
import { dirname, join, resolve } from 'path'
import { promisify } from 'util'
import defaults from '../packages/core/config/default.js'
import {
  inspectPublicHiveGatewayConfig,
  inspectActivePublicHiveGatewayNginx,
  inspectPublicHiveGatewayNginx,
  normalizePublicHiveGatewayConnectAddress,
  probePublicHiveGateway,
  renderPublicHiveGatewayNginx
} from './lib/public-hive-gateway-preflight.mjs'
import {
  PUBLIC_HIVE_GATEWAY_EVIDENCE_SCHEMA
} from './lib/public-hive-gateway-evidence.mjs'

const execFileAsync = promisify(execFile)
const MAX_ACTIVE_NGINX_BYTES = 8 * 1024 * 1024

const args = parseArgs(process.argv.slice(2))
if (args.help) {
  console.log(`Usage:
  node scripts/preflight-public-hive-gateway.mjs --config <config.json> [options]

Options:
  --mode <canary|fleet>              Fleet mode fails until frozen T1 admission lands
  --public-suffix-ready              Assert PSL/separate-registrable-domain isolation
  --nginx-template <path>            Render and validate the strict nginx template
  --nginx-config <path>              Bind the exact installed include in nginx -T
  --nginx-binary <absolute-path>     Run this nginx with -T and attest active config
  --certificate <absolute-path>      Wildcard certificate path inserted in nginx config
  --certificate-key <absolute-path>  Node-local wildcard private-key path
  --nginx-output <path>              Atomically write rendered nginx config
  --probe-origin <https-origin>      Run live TLS/content/isolation probes
  --connect-address <IP>             Pin probe TCP connections to this node address
  --app-key <64-hex>                 Expected app key for the live probe
  --path </path>                     Exact content probe path (default /index.html)
  --expected-sha256 <64-hex>         Optional expected body digest
  --ca <path>                        Optional private/test CA bundle
  --release-target <vX.Y.Z>          Bind evidence to an immutable release tag
  --release-sha <40-or-64-hex>       Bind evidence to that tag's verified commit
  --evidence <path>                  Atomically write public-safe JSON evidence

HIVERELAY_API_KEY must be present. Its value is never printed or persisted.`)
  process.exit(0)
}

const release = normalizeReleaseArgs(args)
const configPath = requiredPath(args.config, '--config')
const persisted = JSON.parse(await readBoundedRegularFile(configPath, 'gateway config'))
const publicT1Preset = persisted.productProfile === 'public-t1-gateway'
  ? { mode: 'public-t1-gateway', enableRelay: false, enableSeeding: true, enableAPI: true, enableServices: false, plugins: [], requirePhysicalEnforcement: true }
  : null
const config = { ...defaults, ...(publicT1Preset || {}), ...persisted }
const mode = args.mode || 'canary'
const connectAddress = args.connectAddress == null
  ? null
  : normalizePublicHiveGatewayConnectAddress(args.connectAddress)
if (args.connectAddress != null && !connectAddress) throw new Error('--connect-address must be an explicit IP address')
if (mode === 'fleet' && !args.probeOrigin) {
  throw new Error('--mode fleet evidence requires --probe-origin')
}
if (mode === 'fleet' && !connectAddress) {
  throw new Error('--mode fleet live evidence requires --connect-address')
}
if (mode === 'fleet' && !args.nginxConfig) {
  throw new Error('--mode fleet live evidence requires --nginx-config for the exact installed edge configuration')
}
if (mode === 'fleet' && !args.nginxBinary) {
  throw new Error('--mode fleet live evidence requires --nginx-binary to attest the active parsed edge configuration')
}
const staticResult = inspectPublicHiveGatewayConfig(config, {
  mode,
  apiKeyPresent: typeof process.env.HIVERELAY_API_KEY === 'string' && process.env.HIVERELAY_API_KEY.length > 0,
  publicSuffixReady: args.publicSuffixReady === true,
  explicitConfig: persisted
})

let nginx = null
if (args.nginxConfig) {
  const installedPath = requiredPath(args.nginxConfig, '--nginx-config')
  const installed = await readBoundedRegularFile(installedPath, 'installed nginx config', 256 * 1024)
  const installedInspection = inspectPublicHiveGatewayNginx(installed, {
    suffix: config.hiveAppHostSuffix,
    gatewayPort: config.gatewayPort
  })
  if (args.nginxBinary) {
    const active = await readActiveNginxConfiguration(args.nginxBinary)
    const activeInspection = inspectActivePublicHiveGatewayNginx(active.text, {
      suffix: config.hiveAppHostSuffix,
      gatewayPort: config.gatewayPort,
      installedConfig: installed,
      installedPath
    })
    nginx = {
      ok: installedInspection.ok && activeInspection.ok,
      errors: [...installedInspection.errors, ...activeInspection.errors],
      source: 'active',
      sha256: createHash('sha256').update(active.bytes).digest('hex')
    }
  } else {
    nginx = {
      ...installedInspection,
      source: 'installed',
      sha256: createHash('sha256').update(installed).digest('hex')
    }
  }
} else if (args.nginxTemplate) {
  const templatePath = requiredPath(args.nginxTemplate, '--nginx-template')
  const template = await readBoundedRegularFile(templatePath, 'nginx template')
  const rendered = renderPublicHiveGatewayNginx(template, {
    suffix: config.hiveAppHostSuffix,
    gatewayPort: config.gatewayPort,
    certificate: args.certificate,
    certificateKey: args.certificateKey
  })
  nginx = {
    ...inspectPublicHiveGatewayNginx(rendered, {
      suffix: config.hiveAppHostSuffix,
      gatewayPort: config.gatewayPort
    }),
    source: 'rendered',
    sha256: createHash('sha256').update(rendered).digest('hex')
  }
  if (args.nginxOutput && staticResult.ok && nginx.ok) await writeAtomic(resolve(args.nginxOutput), rendered)
}

let probe = null
let probeError = null
if (args.probeOrigin && staticResult.ok && (!nginx || nginx.ok)) {
  try {
    const ca = args.ca ? await readBoundedRegularFile(resolve(args.ca), 'CA bundle', 1024 * 1024, null) : undefined
    const probeAppKey = String(args.appKey || staticResult.normalized?.appKeys[0] || '').toLowerCase()
    probe = await probePublicHiveGateway({
      origin: args.probeOrigin,
      appKey: probeAppKey,
      suffix: config.hiveAppHostSuffix,
      connectAddress,
      path: args.path || '/index.html',
      expectedSha256: args.expectedSha256,
      expectedDriveVersion: staticResult.normalized?.appVersions?.[probeAppKey],
      ca
    })
  } catch (err) {
    probeError = err.message
  }
}

const ok = staticResult.ok && (!nginx || nginx.ok) && !probeError && (!args.probeOrigin || !!probe)
const evidence = {
  schema: PUBLIC_HIVE_GATEWAY_EVIDENCE_SCHEMA,
  status: ok ? 'pass' : 'fail',
  checkedAt: new Date().toISOString(),
  mode,
  admissionProfile: staticResult.normalized?.admissionProfile || null,
  release,
  config: {
    suffix: staticResult.normalized?.suffix || null,
    appKeyCount: staticResult.normalized?.appKeys.length || 0,
    apiHost: config.apiHost,
    apiPort: config.apiPort,
    gatewayHost: config.gatewayHost,
    gatewayPort: config.gatewayPort,
    connectAddress,
    publicSuffixReady: args.publicSuffixReady === true,
    custodyEnabled: config.custody?.enabled === true,
    physicalEnforcementRequired: staticResult.normalized?.physicalEnforcementRequired === true,
    finiteProductionPolicy: staticResult.normalized?.finiteProductionPolicy || null
  },
  static: {
    ok: staticResult.ok,
    errors: staticResult.errors,
    warnings: staticResult.warnings
  },
  nginx,
  probe,
  probeError
}

if (args.evidence) await writeAtomic(resolve(args.evidence), JSON.stringify(evidence, null, 2) + '\n')
console.log(JSON.stringify(evidence, null, 2))
if (!ok) process.exitCode = 1

function parseArgs (argv) {
  const out = {}
  const boolean = new Set(['help', 'public-suffix-ready'])
  const values = new Set([
    'config',
    'mode',
    'nginx-template',
    'nginx-config',
    'nginx-binary',
    'certificate',
    'certificate-key',
    'nginx-output',
    'probe-origin',
    'connect-address',
    'app-key',
    'path',
    'expected-sha256',
    'ca',
    'release-target',
    'release-sha',
    'evidence'
  ])
  const seen = new Set()
  for (let i = 0; i < argv.length; i++) {
    const raw = argv[i]
    if (raw === '-h') {
      if (seen.has('help')) throw new Error('Duplicate option: --help')
      seen.add('help')
      out.help = true
      continue
    }
    if (!raw.startsWith('--')) throw new Error(`Unexpected argument: ${raw}`)
    const rawName = raw.slice(2)
    if (!boolean.has(rawName) && !values.has(rawName)) throw new Error(`Unknown option: --${rawName}`)
    if (seen.has(rawName)) throw new Error(`Duplicate option: --${rawName}`)
    seen.add(rawName)
    const name = camel(rawName)
    if (boolean.has(rawName)) { out[name] = true; continue }
    const value = argv[++i]
    if (!value || value.startsWith('--')) throw new Error(`Missing value for ${raw}`)
    out[name] = value
  }
  if (out.nginxOutput && !out.nginxTemplate) throw new Error('--nginx-output requires --nginx-template')
  if (out.nginxConfig && out.nginxTemplate) throw new Error('--nginx-config and --nginx-template are mutually exclusive')
  if (out.nginxBinary && !out.nginxConfig) throw new Error('--nginx-binary requires --nginx-config')
  if ((out.certificate || out.certificateKey) && !out.nginxTemplate) {
    throw new Error('--certificate and --certificate-key require --nginx-template')
  }
  if (out.nginxTemplate && (!out.certificate || !out.certificateKey)) {
    throw new Error('--nginx-template requires --certificate and --certificate-key')
  }
  if (!out.probeOrigin && (out.appKey || out.path || out.expectedSha256 || out.ca || out.connectAddress)) {
    throw new Error('--app-key, --path, --expected-sha256, --ca, and --connect-address require --probe-origin')
  }
  if (Boolean(out.releaseTarget) !== Boolean(out.releaseSha)) {
    throw new Error('--release-target and --release-sha must be provided together')
  }
  return out
}

async function readActiveNginxConfiguration (binaryValue) {
  const binary = safeExecutablePath(binaryValue, '--nginx-binary')
  let result
  try {
    result = await execFileAsync(binary, ['-T'], {
      encoding: 'buffer',
      timeout: 10_000,
      maxBuffer: MAX_ACTIVE_NGINX_BYTES
    })
  } catch (err) {
    const detail = err?.killed ? 'timed out' : 'failed its configuration test'
    throw new Error(`active nginx ${detail}`)
  }
  const active = result.stdout
  if (!Buffer.isBuffer(active) || active.length === 0 || active.length > MAX_ACTIVE_NGINX_BYTES || !isUtf8(active)) {
    throw new Error('active nginx -T output must be a bounded non-empty configuration')
  }
  return { bytes: active, text: active.toString('utf8') }
}

function safeExecutablePath (value, label) {
  if (typeof value !== 'string' || !value.startsWith('/') || value.length > 4096 || /[\r\n\0]/.test(value)) {
    throw new Error(`${label} must be a bounded absolute path`)
  }
  return value
}

function camel (value) {
  return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}

function requiredPath (value, flag) {
  if (!value) throw new Error(`${flag} is required`)
  return resolve(value)
}

function normalizeReleaseArgs (args) {
  if (!args.releaseTarget) return null
  if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(args.releaseTarget)) {
    throw new Error('--release-target must be a release tag like v1.2.3')
  }
  if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(args.releaseSha)) {
    throw new Error('--release-sha must be 40 or 64 hex characters')
  }
  return { target: args.releaseTarget, sha: args.releaseSha.toLowerCase() }
}

async function readBoundedRegularFile (path, label, maxBytes = 1024 * 1024, encoding = 'utf8') {
  let handle
  try {
    handle = await openNoFollow(path)
    const before = await handle.stat({ bigint: true })
    assertBoundedRegularFile(before, maxBytes, label)

    const buffer = Buffer.allocUnsafe(maxBytes + 1)
    let length = 0
    while (length < buffer.length) {
      const result = await handle.read(buffer, length, buffer.length - length, length)
      if (result.bytesRead === 0) break
      length += result.bytesRead
    }
    if (length > maxBytes) throw new Error(`${label} must be a bounded regular file`)

    const after = await handle.stat({ bigint: true })
    if (!sameFileSnapshot(before, after) || BigInt(length) !== after.size) {
      throw new Error(`${label} changed while it was being read`)
    }
    await assertPathIdentity(path, after, label)

    const contents = buffer.subarray(0, length)
    if (encoding === 'utf8' && !isUtf8(contents)) throw new Error(`${label} must be valid UTF-8`)
    return encoding == null ? contents : contents.toString(encoding)
  } catch (err) {
    if (isNoFollowError(err)) throw new Error(`${label} must be a bounded regular file`)
    throw err
  } finally {
    await handle?.close()
  }
}

async function writeAtomic (path, contents) {
  const directory = dirname(path)
  const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents)
  let temp = null
  let handle = null
  let tempOwned = false
  let renamed = false
  let failure = null

  try {
    for (let attempt = 0; attempt < 8; attempt++) {
      temp = join(directory, `.hiverelay-gateway-${process.pid}-${randomBytes(16).toString('hex')}.tmp`)
      try {
        handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600)
        tempOwned = true
        break
      } catch (err) {
        if (err.code !== 'EEXIST') throw err
      }
    }
    if (!handle) throw new Error(`Unable to allocate an exclusive temporary file for ${path}`)

    await handle.writeFile(buffer)
    await handle.sync()
    const written = await handle.stat({ bigint: true })
    if (!written.isFile() || written.nlink !== 1n || written.size !== BigInt(buffer.length)) {
      throw new Error(`Atomic output verification failed for ${path}`)
    }

    await assertPathIdentity(temp, written, 'atomic output')
    await rename(temp, path)
    renamed = true
    await syncDirectory(directory)
  } catch (err) {
    failure = err
  }

  try {
    await handle?.close()
  } catch (err) {
    if (!failure) failure = err
  }
  if (tempOwned && temp && !renamed) {
    try {
      await unlink(temp)
    } catch (err) {
      if (err.code !== 'ENOENT' && !failure) failure = err
    }
  }
  if (failure) throw failure
}

function noFollowFlag () {
  if (typeof constants.O_NOFOLLOW !== 'number') throw new Error('This platform cannot safely open no-follow paths')
  return constants.O_NOFOLLOW
}

async function openNoFollow (path) {
  return open(path, constants.O_RDONLY | noFollowFlag())
}

function assertBoundedRegularFile (info, maxBytes, label) {
  if (!info.isFile() || info.size < 0n || info.size > BigInt(maxBytes)) {
    throw new Error(`${label} must be a bounded regular file`)
  }
}

function sameFileIdentity (left, right) {
  return left.dev === right.dev && left.ino === right.ino
}

function sameFileSnapshot (left, right) {
  return sameFileIdentity(left, right) &&
    left.mode === right.mode &&
    left.nlink === right.nlink &&
    left.size === right.size &&
    left.mtimeNs === right.mtimeNs &&
    left.ctimeNs === right.ctimeNs
}

async function assertPathIdentity (path, expected, label) {
  let current
  try {
    current = await openNoFollow(path)
    const info = await current.stat({ bigint: true })
    if (!sameFileSnapshot(expected, info)) throw new Error(`${label} path changed while it was being inspected`)
  } catch (err) {
    if (isNoFollowError(err) || err.code === 'ENOENT') {
      throw new Error(`${label} path changed while it was being inspected`)
    }
    throw err
  } finally {
    await current?.close()
  }
}

function isNoFollowError (err) {
  return err?.code === 'ELOOP' || err?.code === 'EMLINK'
}

async function syncDirectory (directory) {
  let handle
  try {
    handle = await open(directory, constants.O_RDONLY | (constants.O_DIRECTORY || 0))
    await handle.sync()
  } catch (err) {
    if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EISDIR'].includes(err.code)) throw err
  } finally {
    await handle?.close()
  }
}