OverviewFilesCommitsIssuesDocsReleasesPeers

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

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

import { constants } from 'node:fs'
import { lstat, open, realpath, rename, unlink } from 'node:fs/promises'
import { createHash, randomBytes } from 'node:crypto'
import { execFile } from 'node:child_process'
import { isUtf8 } from 'node:buffer'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { promisify } from 'node:util'
import defaults from '../packages/core/config/default.js'
import {
  assertOperatorContractMatchesCohort,
  cohortEntryForRelay,
  normalizePublicHiveGatewayReleaseManifest
} from './lib/public-hive-gateway-release-manifest.mjs'
import {
  PUBLIC_HIVE_GATEWAY_DEPLOYMENT_PROFILE,
  PUBLIC_HIVE_GATEWAY_OPS_EVIDENCE_SCHEMA,
  collectPublicHiveGatewayDnsSnapshot,
  collectPublicHiveGatewayTlsSnapshot,
  inspectPublicHiveGatewayBaseEvidence,
  inspectPublicHiveGatewayCertificate,
  inspectPublicHiveGatewayDnsSnapshot,
  inspectPublicHiveGatewayOpsConfig,
  inspectPublicHiveGatewaySocketSnapshot,
  inspectPublicHiveGatewayTlsSnapshot,
  normalizePublicHiveGatewayOpsContract,
  sha256PublicHiveGatewayOpsContract
} from './lib/public-hive-gateway-ops.mjs'

const execFileAsync = promisify(execFile)
const args = parseArgs(process.argv.slice(2))

if (args.help) {
  console.log(`Usage:
  node scripts/preflight-public-hive-gateway-ops.mjs --mode <rehearsal|fleet> \\
    --contract <operator-contract.json> --config <config.json> \\
    --gateway-evidence <preflight-live.json> --certificate <fullchain.pem> \\
    --certificate-key <privkey.pem> --release-sha <verified-tag-commit> [sources] [options]

Required sources:
  --dns-live                         Resolve and pin live wildcard A/AAAA records
  --dns-snapshot <path>              Rehearsal-only DNS fixture instead of live DNS
  --ss-binary <absolute-path>        Capture live TCP and UDP listener state
  --socket-snapshot <path>           Rehearsal-only listener fixture
  --tls-snapshot <path>              Required with --dns-snapshot; rehearsal only

Options:
  --certificate-root <absolute-dir>  Allow certbot-style symlinks only inside this root
  --release-manifest <path>          Signed-tag cohort manifest binding (required fleet)
  --ca <path>                        Rehearsal-only private CA for live TLS endpoint checks
  --timeout-ms <1000..30000>         Per-endpoint TLS timeout (default 5000)
  --evidence <path>                  Atomically write public-safe ops evidence

Fleet mode forbids fixtures/private CAs, requires live DNS/TLS and socket
inspection, and also requires the merged runtime config to expose the exact
compiled public-t1-gateway product profile. Ops evidence never claims blind
G2/G3, storage-class, or finite-production-policy proof.`)
  process.exit(0)
}

const mode = args.mode
if (mode !== 'rehearsal' && mode !== 'fleet') throw new Error('--mode must be rehearsal or fleet')
validateSourceArgs(args, mode)
const timeoutMs = args.timeoutMs == null ? 5000 : parseBoundedInteger(args.timeoutMs, '--timeout-ms', 1000, 30000)
const contractBuffer = await readRegularFile(requiredPath(args.contract, '--contract'), 'operator readiness contract', 256 * 1024)
const contract = normalizePublicHiveGatewayOpsContract(parseJson(contractBuffer, 'operator readiness contract'))
const releaseSha = normalizeReleaseSha(args.releaseSha)
const operatorContractSha256 = sha256PublicHiveGatewayOpsContract(contract)
let releaseManifestBuffer = null
let manifestBinding = null
if (args.releaseManifest) {
  releaseManifestBuffer = await readRegularFile(
    requiredPath(args.releaseManifest, '--release-manifest'),
    'public gateway release manifest',
    2 * 1024 * 1024
  )
  const manifest = normalizePublicHiveGatewayReleaseManifest(
    parseJson(releaseManifestBuffer, 'public gateway release manifest'),
    { releaseTarget: contract.release.target, requirePublicT1: true }
  )
  const entry = cohortEntryForRelay(manifest, contract.relay)
  manifestBinding = assertOperatorContractMatchesCohort(contract, manifest, entry)
  if (manifestBinding.digest !== operatorContractSha256) throw new Error('operator contract canonical digest changed during manifest binding')
}
const configBuffer = await readRegularFile(requiredPath(args.config, '--config'), 'operator gateway config', 1024 * 1024)
const persistedConfig = parseJson(configBuffer, 'operator gateway config')
const config = { ...defaults, ...persistedConfig }
const gatewayEvidenceBuffer = await readRegularFile(requiredPath(args.gatewayEvidence, '--gateway-evidence'), 'operator base gateway evidence', 2 * 1024 * 1024)
const gatewayEvidence = parseJson(gatewayEvidenceBuffer, 'operator base gateway evidence')

const allowedCertificateRoot = args.certificateRoot ? requiredAbsolutePath(args.certificateRoot, '--certificate-root') : null
const certificateFile = await readPemPath(requiredPath(args.certificate, '--certificate'), 'operator fullchain', 1024 * 1024, {
  allowedRoot: allowedCertificateRoot,
  privateFile: false,
  requireRootOwner: mode === 'fleet'
})
const keyFile = await readPemPath(requiredPath(args.certificateKey, '--certificate-key'), 'operator private key', 128 * 1024, {
  allowedRoot: allowedCertificateRoot,
  privateFile: true,
  requireRootOwner: mode === 'fleet'
})
if (certificateFile.identity.dev === keyFile.identity.dev && certificateFile.identity.ino === keyFile.identity.ino) {
  throw new Error('operator certificate and private key must be distinct files')
}

const now = Date.now()
const configCheck = inspectPublicHiveGatewayOpsConfig(config, contract, mode, { explicitConfig: persistedConfig })
const certificateCheck = inspectPublicHiveGatewayCertificate(
  certificateFile.buffer.toString('utf8'),
  keyFile.buffer.toString('utf8'),
  contract,
  { now }
)
const gatewayCheck = inspectPublicHiveGatewayBaseEvidence(gatewayEvidence, config, contract, mode, { now, releaseSha })

let dnsSource
let dnsSnapshot
let dnsCollectionError = null
try {
  if (args.dnsLive) {
    dnsSource = 'live'
    dnsSnapshot = await collectPublicHiveGatewayDnsSnapshot(contract, { now })
  } else {
    dnsSource = 'fixture'
    dnsSnapshot = parseJson(
      await readRegularFile(requiredPath(args.dnsSnapshot, '--dns-snapshot'), 'operator DNS snapshot', 256 * 1024),
      'operator DNS snapshot'
    )
  }
} catch (err) {
  dnsCollectionError = err.message
}
const dnsCheck = dnsSnapshot
  ? captureInspection(() => inspectPublicHiveGatewayDnsSnapshot(dnsSnapshot, contract, { now }))
  : failedInspection(dnsCollectionError || 'operator DNS snapshot unavailable')

let tlsSource
let tlsSnapshot
let tlsCollectionError = null
try {
  if (args.dnsLive) {
    tlsSource = 'live'
    const ca = args.ca
      ? (await readRegularFile(requiredPath(args.ca, '--ca'), 'operator private CA', 1024 * 1024)).toString('utf8')
      : undefined
    tlsSnapshot = await collectPublicHiveGatewayTlsSnapshot(contract, {
      now,
      ca,
      timeoutMs,
      baseProbe: gatewayCheck.ok ? gatewayEvidence.probe : null
    })
  } else {
    tlsSource = 'fixture'
    tlsSnapshot = parseJson(
      await readRegularFile(requiredPath(args.tlsSnapshot, '--tls-snapshot'), 'operator TLS snapshot', 512 * 1024),
      'operator TLS snapshot'
    )
  }
} catch (err) {
  tlsCollectionError = err.message
}
const tlsCheck = tlsSnapshot && certificateCheck.identity
  ? captureInspection(() => inspectPublicHiveGatewayTlsSnapshot(tlsSnapshot, contract, certificateCheck.identity, { now }))
  : failedInspection(tlsCollectionError || 'operator TLS certificate identity unavailable')

let socketSource
let socketText
let socketCollectionError = null
try {
  if (args.ssBinary) {
    socketSource = 'live'
    socketText = await collectSocketSnapshot(args.ssBinary)
  } else {
    socketSource = 'fixture'
    socketText = (await readRegularFile(
      requiredPath(args.socketSnapshot, '--socket-snapshot'),
      'operator socket snapshot',
      1024 * 1024
    )).toString('utf8')
  }
} catch (err) {
  socketCollectionError = err.message
}
const socketCheck = socketText
  ? captureInspection(() => inspectPublicHiveGatewaySocketSnapshot(socketText, config))
  : failedInspection(socketCollectionError || 'operator socket snapshot unavailable')

const checks = {
  contract: true,
  config: configCheck.ok,
  certificate: certificateCheck.ok,
  dns: dnsCheck.ok,
  tls: tlsCheck.ok,
  sockets: socketCheck.ok,
  gateway: gatewayCheck.ok
}
const errors = [
  ...configCheck.errors,
  ...certificateCheck.errors,
  ...dnsCheck.errors,
  ...tlsCheck.errors,
  ...socketCheck.errors,
  ...gatewayCheck.errors
]
const ok = Object.values(checks).every(Boolean) && errors.length === 0
const evidence = {
  schema: PUBLIC_HIVE_GATEWAY_OPS_EVIDENCE_SCHEMA,
  status: ok ? 'pass' : 'fail',
  checkedAt: new Date(now).toISOString(),
  mode,
  deploymentProfile: PUBLIC_HIVE_GATEWAY_DEPLOYMENT_PROFILE,
  physicalEnforcementRequired: configCheck.result?.physicalEnforcementRequired === true,
  operator: {
    relay: contract.relay,
    operatorId: contract.operatorId,
    registrableDomain: contract.registrableDomain,
    apiHostname: contract.apiHostname,
    suffix: contract.suffix,
    publicSuffixReady: contract.publicSuffixReady
  },
  gateway: gatewayCheck.result || {
    releaseTarget: contract.release.target,
    releaseSha,
    origin: contract.origin,
    connectAddress: contract.expectedConnectAddress,
    appKey: contract.appKey,
    contentSha256: contract.release.expectedContentSha256,
    driveVersion: contract.release.expectedDriveVersion,
    physicalEnforcementRequired: configCheck.result?.physicalEnforcementRequired === true,
    peerFingerprint256: contract.certificateFingerprint256,
    nginxSha256: contract.release.expectedNginxSha256
  },
  finiteProductionPolicy: {
    ...contract.finiteProductionPolicy,
    configured: configCheck.result?.finiteProductionPolicy?.configured || null,
    contractBound: true,
    signedReleaseBound: mode === 'fleet' && manifestBinding?.digest === operatorContractSha256
  },
  certificate: certificateCheck.identity,
  dns: {
    source: dnsSource || null,
    expectedAddresses: contract.expectedAddresses,
    addressFamilyPolicy: contract.addressFamilyPolicy,
    observed: dnsCheck.result
  },
  tls: {
    source: tlsSource || null,
    observed: tlsCheck.result
  },
  sockets: {
    source: socketSource || null,
    observed: socketCheck.result
  },
  sourceDigests: {
    contractFileSha256: sha256(contractBuffer),
    operatorContractSha256,
    configSha256: sha256(configBuffer),
    gatewayEvidenceSha256: sha256(gatewayEvidenceBuffer),
    releaseManifestSha256: releaseManifestBuffer ? sha256(releaseManifestBuffer) : null
  },
  checks,
  claims: {
    provesCurrentDnsAnswers: dnsSource === 'live' && dnsCheck.ok,
    provesCurrentWebPkiTls: tlsSource === 'live' && tlsCheck.ok,
    provesCurrentLoopbackSockets: socketSource === 'live' && socketCheck.ok,
    forbidsT2Exposure: true,
    forbidsUnknownExposure: true,
    attestsFiniteProductionPolicyValues: configCheck.ok,
    attestsPhysicalEnforcementRequirement: configCheck.result?.physicalEnforcementRequired === true,
    provesActivePhysicalEnforcement: false,
    provesBlindG2: false,
    provesBlindG3: false,
    provesFiniteProductionPolicyBehavior: false,
    provesOperatorControl: false
  },
  externalGates: [
    ...(mode === 'fleet' && manifestBinding?.digest === operatorContractSha256
      ? []
      : ['signed release binding for the operator contract digest and finite-production-policy values']),
    'authoritative compiled public-t1-gateway substrate/profile evidence',
    'negative T2 and unknown storage-class exposure conformance',
    'finite-production-policy response/egress/lifetime conformance evidence from the exact release',
    'active exclusive OS quota provider lease and fresh settlement attestation; the config boolean proves only the fail-closed requirement',
    'blind G2 storage-classification and G3 signing-foundation evidence',
    'authenticated proof of DNS, certificate-key, and operator organizational control'
  ],
  errors
}

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

function parseArgs (argv) {
  const out = {}
  const booleans = new Set(['help', 'dns-live'])
  const values = new Set([
    'mode',
    'contract',
    'config',
    'gateway-evidence',
    'release-sha',
    'release-manifest',
    'certificate',
    'certificate-key',
    'certificate-root',
    'dns-snapshot',
    'tls-snapshot',
    'ss-binary',
    'socket-snapshot',
    'ca',
    'timeout-ms',
    'evidence'
  ])
  const seen = new Set()
  for (let index = 0; index < argv.length; index++) {
    const raw = argv[index]
    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 (!booleans.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 (booleans.has(rawName)) {
      out[name] = true
      continue
    }
    const value = argv[++index]
    if (!value || value.startsWith('--')) throw new Error(`Missing value for ${raw}`)
    out[name] = value
  }
  return out
}

function validateSourceArgs (values, selectedMode) {
  if (Boolean(values.dnsLive) === Boolean(values.dnsSnapshot)) {
    throw new Error('provide exactly one of --dns-live or --dns-snapshot')
  }
  if (values.dnsLive && values.tlsSnapshot) throw new Error('--tls-snapshot is valid only with --dns-snapshot')
  if (values.dnsSnapshot && !values.tlsSnapshot) throw new Error('--dns-snapshot requires --tls-snapshot')
  if (Boolean(values.ssBinary) === Boolean(values.socketSnapshot)) {
    throw new Error('provide exactly one of --ss-binary or --socket-snapshot')
  }
  if (values.ca && !values.dnsLive) throw new Error('--ca requires --dns-live')
  if (selectedMode === 'fleet' && (!values.dnsLive || !values.ssBinary || !values.releaseManifest || values.ca)) {
    throw new Error('fleet mode requires --dns-live, --ss-binary, and --release-manifest and forbids fixture/private-CA inputs')
  }
}

async function collectSocketSnapshot (binaryValue) {
  const binary = requiredAbsolutePath(binaryValue, '--ss-binary')
  let tcp
  let udp
  try {
    tcp = await execFileAsync(binary, ['-H', '-lnt'], {
      encoding: 'buffer',
      timeout: 10_000,
      maxBuffer: 1024 * 1024,
      env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C', LC_ALL: 'C' }
    })
    udp = await execFileAsync(binary, ['-H', '-lnu'], {
      encoding: 'buffer',
      timeout: 10_000,
      maxBuffer: 1024 * 1024,
      env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C', LC_ALL: 'C' }
    })
  } catch (err) {
    throw new Error(`live socket inspection failed: ${err?.killed ? 'timed out' : 'ss command failed'}`)
  }
  if (!Buffer.isBuffer(tcp.stdout) || tcp.stdout.length < 1 || !isUtf8(tcp.stdout) ||
      !Buffer.isBuffer(udp.stdout) || !isUtf8(udp.stdout)) {
    throw new Error('live socket inspection must produce bounded UTF-8 ss output')
  }
  return [
    ...tcp.stdout.toString('utf8').split(/\r?\n/).filter(Boolean).map(line => `tcp ${line}`),
    ...udp.stdout.toString('utf8').split(/\r?\n/).filter(Boolean).map(line => `udp ${line}`)
  ].join('\n')
}

async function readRegularFile (path, label, maxBytes) {
  const opened = await readOpenedFile(path, label, maxBytes)
  return opened.buffer
}

async function readPemPath (path, label, maxBytes, opts) {
  const absolute = resolve(path)
  let selected = absolute
  const initial = await lstat(absolute, { bigint: true })
  if (initial.isSymbolicLink()) {
    if (!opts.allowedRoot) throw new Error(`${label} symlink requires --certificate-root`)
    const allowedRoot = await realpath(opts.allowedRoot)
    selected = await realpath(absolute)
    if (!isWithin(allowedRoot, selected)) throw new Error(`${label} symlink must resolve inside --certificate-root`)
  } else if (!initial.isFile()) {
    throw new Error(`${label} must be a regular file or an explicitly rooted certificate symlink`)
  }
  const opened = await readOpenedFile(selected, label, maxBytes)
  if (initial.isSymbolicLink() && await realpath(absolute) !== selected) throw new Error(`${label} symlink changed while it was being read`)
  if (opened.identity.nlink !== 1n) throw new Error(`${label} must have exactly one hard link`)
  if (opts.privateFile && (opened.identity.mode & 0o077n) !== 0n) throw new Error(`${label} must not be group/world accessible`)
  if (opts.requireRootOwner && opened.identity.uid !== 0n) throw new Error(`${label} must be root-owned in fleet mode`)
  return opened
}

async function readOpenedFile (path, label, maxBytes) {
  let handle
  try {
    if (typeof constants.O_NOFOLLOW !== 'number') throw new Error('safe no-follow opens are unavailable')
    handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
    const before = await handle.stat({ bigint: true })
    if (!before.isFile() || before.size < 1n || before.size > BigInt(maxBytes)) {
      throw new Error(`${label} must be a bounded non-empty regular file`)
    }
    const buffer = Buffer.allocUnsafe(maxBytes + 1)
    let length = 0
    while (length < buffer.length) {
      const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length)
      if (bytesRead === 0) break
      length += bytesRead
    }
    const after = await handle.stat({ bigint: true })
    if (length > maxBytes || BigInt(length) !== after.size || !sameSnapshot(before, after)) {
      throw new Error(`${label} changed or exceeded its size bound while being read`)
    }
    return { buffer: buffer.subarray(0, length), identity: after }
  } catch (err) {
    if (err?.message?.startsWith(label)) throw err
    throw new Error(`${label} must be a readable non-symlink regular file`)
  } finally {
    await handle?.close()
  }
}

async function writeAtomic (path, buffer) {
  const directory = dirname(path)
  let temporary = null
  let handle = null
  let renamed = false
  try {
    for (let attempt = 0; attempt < 8; attempt++) {
      temporary = join(directory, `.hiverelay-ops-${process.pid}-${randomBytes(16).toString('hex')}.tmp`)
      try {
        handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
        break
      } catch (err) {
        if (err.code !== 'EEXIST') throw err
      }
    }
    if (!handle) throw new Error(`unable to allocate an exclusive temporary output for ${path}`)
    await handle.writeFile(buffer)
    await handle.sync()
    await handle.close()
    handle = null
    await rename(temporary, path)
    renamed = true
  } finally {
    await handle?.close()
    if (temporary && !renamed) await unlink(temporary).catch(() => {})
  }
}

function parseJson (buffer, label) {
  if (!isUtf8(buffer)) throw new Error(`${label} must be valid UTF-8 JSON`)
  let value
  try { value = JSON.parse(buffer.toString('utf8')) } catch { throw new Error(`${label} must be valid JSON`) }
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must contain a JSON object`)
  return value
}

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

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

function isWithin (root, candidate) {
  const relation = relative(root, candidate)
  return relation !== '' && relation !== '..' && !relation.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) && !isAbsolute(relation)
}

function sameSnapshot (left, right) {
  return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode &&
    left.nlink === right.nlink && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs
}

function parseBoundedInteger (value, label, minimum, maximum) {
  if (!/^(?:0|[1-9][0-9]*)$/.test(value || '')) throw new Error(`${label} must be an integer`)
  const parsed = Number(value)
  if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
    throw new Error(`${label} must be between ${minimum} and ${maximum}`)
  }
  return parsed
}

function normalizeReleaseSha (value) {
  const normalized = String(value || '').toLowerCase()
  if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(normalized)) {
    throw new Error('--release-sha must be the verified 40- or 64-hex tagged commit')
  }
  return normalized
}

function captureInspection (operation) {
  try { return operation() } catch (err) { return failedInspection(err.message) }
}

function failedInspection (message) {
  return { ok: false, errors: [message], result: null }
}

function sha256 (buffer) {
  return createHash('sha256').update(buffer).digest('hex')
}

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