OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / config / public-hive-gateway-env.js

409 lines · javascript ref:
/**
 * Strict deployment inputs for the public HTTPS Hive gateway.
 *
 * Values selected here are placed on the CLI override object, so the complete
 * precedence remains:
 *
 *   gateway CLI flag > gateway environment variable > config.json > defaults
 *
 * Exact numeric strings are converted to numbers before RelayNode sees them.
 * This is important because the runtime intentionally rejects numeric strings
 * (to avoid Node's listen(path) overload) and parseInt-style partial values.
 */

import { isIP } from 'node:net'
import { normalizeHiveAppHostSuffix } from '../gateway/hive-host.js'
import {
  MAX_HIVE_APP_PUBLIC_KEYS,
  normalizeHiveAppPublicKeys,
  normalizeHiveAppPublicVersions
} from '../gateway/public-app-admission.js'

const MAX_GATEWAY_ALLOWLIST_ENTRIES = 64
const MAX_GATEWAY_IN_FLIGHT = 4096
const MAX_GATEWAY_RESPONSE_BYTES = 1024 * 1024 * 1024
const MAX_GATEWAY_EGRESS_BYTES = 1024 * 1024 * 1024 * 1024
const MAX_GATEWAY_WINDOW_MS = 60 * 60 * 1000
export const PUBLIC_T1_GATEWAY_FINITE_LIMITS = Object.freeze({
  gatewayMaxResponseBytes: 64 * 1024 * 1024,
  gatewayMaxTransformBytes: 4 * 1024 * 1024,
  gatewayEgressBytesPerWindow: 256 * 1024 * 1024,
  gatewayEgressWindowMs: 60 * 1000,
  gatewayMaxResponseLifetimeMs: 15 * 60 * 1000
})
const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key)

const INPUTS = [
  {
    key: 'gatewayHost',
    flag: 'gateway-host',
    env: 'HIVERELAY_GATEWAY_HOST',
    parse: parseBindHost
  },
  {
    key: 'gatewayPort',
    flag: 'gateway-port',
    env: 'HIVERELAY_GATEWAY_PORT',
    parse: (value, label) => parseExactInteger(value, label, 1, 65535)
  },
  {
    key: 'gatewayTrustProxy',
    flag: 'gateway-trust-proxy',
    env: 'HIVERELAY_GATEWAY_TRUST_PROXY',
    parse: parseBoolean
  },
  {
    key: 'gatewayTrustedProxyAddresses',
    flag: 'gateway-trusted-proxy-address',
    env: 'HIVERELAY_GATEWAY_TRUSTED_PROXY_ADDRESSES',
    parse: parseTrustedProxyAddresses
  },
  {
    key: 'gatewayRequireForwardedSNI',
    flag: 'gateway-require-forwarded-sni',
    env: 'HIVERELAY_GATEWAY_REQUIRE_FORWARDED_SNI',
    parse: parseBoolean
  },
  {
    key: 'gatewayCompatibilityHosts',
    flag: 'gateway-compatibility-host',
    env: 'HIVERELAY_GATEWAY_COMPATIBILITY_HOSTS',
    parse: parseCompatibilityHosts
  },
  {
    key: 'gatewayMaxInFlight',
    flag: 'gateway-max-in-flight',
    env: 'HIVERELAY_GATEWAY_MAX_IN_FLIGHT',
    parse: (value, label) => parseExactInteger(value, label, 1, MAX_GATEWAY_IN_FLIGHT)
  },
  {
    key: 'gatewayMaxInFlightPerApp',
    flag: 'gateway-max-in-flight-per-app',
    env: 'HIVERELAY_GATEWAY_MAX_IN_FLIGHT_PER_APP',
    parse: (value, label) => parseExactInteger(value, label, 1, MAX_GATEWAY_IN_FLIGHT)
  },
  {
    key: 'gatewayMaxResponseBytes',
    flag: 'gateway-max-response-bytes',
    env: 'HIVERELAY_GATEWAY_MAX_RESPONSE_BYTES',
    parse: (value, label) => parseExactInteger(value, label, 1, MAX_GATEWAY_RESPONSE_BYTES)
  },
  {
    key: 'gatewayMaxTransformBytes',
    flag: 'gateway-max-transform-bytes',
    env: 'HIVERELAY_GATEWAY_MAX_TRANSFORM_BYTES',
    parse: (value, label) => parseExactInteger(value, label, 1, MAX_GATEWAY_RESPONSE_BYTES)
  },
  {
    key: 'gatewayEgressBytesPerWindow',
    flag: 'gateway-egress-bytes-per-window',
    env: 'HIVERELAY_GATEWAY_EGRESS_BYTES_PER_WINDOW',
    parse: (value, label) => parseExactInteger(value, label, 1, MAX_GATEWAY_EGRESS_BYTES)
  },
  {
    key: 'gatewayEgressWindowMs',
    flag: 'gateway-egress-window-ms',
    env: 'HIVERELAY_GATEWAY_EGRESS_WINDOW_MS',
    parse: (value, label) => parseExactInteger(value, label, 1000, MAX_GATEWAY_WINDOW_MS)
  },
  {
    key: 'gatewayMaxResponseLifetimeMs',
    flag: 'gateway-max-response-lifetime-ms',
    env: 'HIVERELAY_GATEWAY_MAX_RESPONSE_LIFETIME_MS',
    parse: (value, label) => parseExactInteger(value, label, 1000, MAX_GATEWAY_WINDOW_MS)
  },
  {
    key: 'hiveAppHostSuffix',
    flag: 'hive-app-host-suffix',
    env: 'HIVERELAY_HIVE_APP_HOST_SUFFIX',
    parse: parseHiveAppHostSuffix
  },
  {
    key: 'hiveAppPublicKeys',
    flag: 'hive-app-public-key',
    env: 'HIVERELAY_HIVE_APP_PUBLIC_KEYS',
    parse: parseHiveAppPublicKeys
  },
  {
    key: 'hiveAppPublicVersions',
    flag: 'hive-app-public-version',
    env: 'HIVERELAY_HIVE_APP_PUBLIC_VERSIONS',
    parse: parseHiveAppPublicVersions
  }
]
const PUBLIC_GATEWAY_FLAGS = new Set(INPUTS.map(input => input.flag))

/**
 * Apply strict public-gateway flags and environment variables to the mutable
 * CLI override object. Existing overrides beat environment values; an explicit
 * gateway flag beats both.
 */
export function applyPublicHiveGatewayEnv (cliOverrides = {}, argv = {}, env = {}) {
  if (!cliOverrides || typeof cliOverrides !== 'object' || Array.isArray(cliOverrides)) {
    throw new TypeError('public Hive gateway CLI overrides must be an object')
  }
  if (!argv || typeof argv !== 'object' || Array.isArray(argv)) {
    throw new TypeError('public Hive gateway argv must be an object')
  }
  if (!env || typeof env !== 'object' || Array.isArray(env)) {
    throw new TypeError('public Hive gateway env must be an object')
  }
  for (const key of Object.keys(argv)) {
    if ((key.startsWith('gateway-') || key.startsWith('hive-app-')) && !PUBLIC_GATEWAY_FLAGS.has(key)) {
      throw new Error(`Unknown public Hive gateway option: --${key}`)
    }
  }

  for (const input of INPUTS) {
    if (hasOwn(argv, input.flag)) {
      cliOverrides[input.key] = input.parse(argv[input.flag], `--${input.flag}`)
      continue
    }
    if (!hasOwn(cliOverrides, input.key) && hasOwn(env, input.env)) {
      cliOverrides[input.key] = input.parse(env[input.env], input.env)
    }
  }

  const max = cliOverrides.gatewayMaxInFlight
  const perApp = cliOverrides.gatewayMaxInFlightPerApp
  if (Number.isSafeInteger(max) && Number.isSafeInteger(perApp)) {
    assertPublicHiveGatewayConcurrency(cliOverrides)
  }
  const maxResponseBytes = cliOverrides.gatewayMaxResponseBytes
  const maxTransformBytes = cliOverrides.gatewayMaxTransformBytes
  if (Number.isSafeInteger(maxResponseBytes) && Number.isSafeInteger(maxTransformBytes) && maxTransformBytes > maxResponseBytes) {
    throw new Error('Invalid public Hive gateway finite policy: gatewayMaxTransformBytes must not exceed gatewayMaxResponseBytes')
  }
  if (hasOwn(cliOverrides, 'hiveAppPublicKeys') && hasOwn(cliOverrides, 'hiveAppPublicVersions')) {
    assertPublicHiveGatewayVersionPins(cliOverrides)
  }

  return cliOverrides
}

/**
 * Validate the merged concurrency values after config.json and defaults have
 * been applied. Checking only the raw deployment inputs would miss a single
 * env override that conflicts with the persisted/default value on the other
 * side of the relation.
 */
export function assertPublicHiveGatewayConcurrency (config = {}) {
  const max = config.gatewayMaxInFlight
  const perApp = config.gatewayMaxInFlightPerApp
  if (!Number.isSafeInteger(max) || max < 1 || max > MAX_GATEWAY_IN_FLIGHT) {
    throw new Error(`Invalid gatewayMaxInFlight: expected an integer from 1 to ${MAX_GATEWAY_IN_FLIGHT}`)
  }
  if (!Number.isSafeInteger(perApp) || perApp < 1 || perApp > MAX_GATEWAY_IN_FLIGHT) {
    throw new Error(`Invalid gatewayMaxInFlightPerApp: expected an integer from 1 to ${MAX_GATEWAY_IN_FLIGHT}`)
  }
  if (perApp > max) {
    throw new Error('Invalid public Hive gateway concurrency: gatewayMaxInFlightPerApp must not exceed gatewayMaxInFlight')
  }
  return config
}

export function assertPublicHiveGatewayVersionPins (config = {}) {
  const keys = normalizeHiveAppPublicKeys(config.hiveAppPublicKeys ?? [])
  const versions = normalizeHiveAppPublicVersions(config.hiveAppPublicVersions ?? {})
  if (!keys) throw new Error('Invalid hiveAppPublicKeys: expected 64-character hexadecimal app keys')
  if (!versions) throw new Error('Invalid hiveAppPublicVersions: expected an object mapping app keys to non-negative safe integer versions')
  for (const key of versions.keys()) {
    if (!keys.has(key)) throw new Error('Invalid hiveAppPublicVersions: version pins must not name keys outside hiveAppPublicKeys')
  }
  return config
}

export function assertPublicHiveGatewayFiniteLimits (config = {}) {
  const fields = [
    ['gatewayMaxResponseBytes', 1, MAX_GATEWAY_RESPONSE_BYTES],
    ['gatewayMaxTransformBytes', 1, MAX_GATEWAY_RESPONSE_BYTES],
    ['gatewayEgressBytesPerWindow', 1, MAX_GATEWAY_EGRESS_BYTES],
    ['gatewayEgressWindowMs', 1000, MAX_GATEWAY_WINDOW_MS],
    ['gatewayMaxResponseLifetimeMs', 1000, MAX_GATEWAY_WINDOW_MS]
  ]
  for (const [field, minimum, maximum] of fields) {
    const value = config[field]
    if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
      throw new Error(`Invalid ${field}: expected an integer from ${minimum} to ${maximum}`)
    }
  }
  if (config.gatewayMaxTransformBytes > config.gatewayMaxResponseBytes) {
    throw new Error('Invalid public Hive gateway finite policy: gatewayMaxTransformBytes must not exceed gatewayMaxResponseBytes')
  }
  if (config.productProfile === 'public-t1-gateway') {
    for (const [field, expected] of Object.entries(PUBLIC_T1_GATEWAY_FINITE_LIMITS)) {
      if (config[field] !== expected) {
        throw new Error(`Invalid public-t1-gateway finite policy: ${field} must equal ${expected}`)
      }
    }
  }
  return config
}

function parseExactInteger (value, label, min, max) {
  let candidate = value
  if (typeof candidate === 'string') {
    if (!/^(?:0|[1-9][0-9]*)$/.test(candidate)) {
      throw new Error(`Invalid ${label}: expected an integer from ${min} to ${max}`)
    }
    candidate = Number(candidate)
  }
  if (!Number.isSafeInteger(candidate) || candidate < min || candidate > max) {
    throw new Error(`Invalid ${label}: expected an integer from ${min} to ${max}`)
  }
  return candidate
}

function parseBoolean (value, label) {
  if (typeof value === 'boolean') return value
  // minimist coerces --flag=1 / --flag=0 to numbers unless the option is
  // declared as a string; accept only those two exact numeric spellings.
  if (value === 1) return true
  if (value === 0) return false
  if (typeof value === 'string') {
    const candidate = value.trim().toLowerCase()
    if (candidate === 'true' || candidate === '1') return true
    if (candidate === 'false' || candidate === '0') return false
  }
  throw new Error(`Invalid ${label}: expected true, false, 1, or 0`)
}

function parseBindHost (value, label) {
  if (typeof value !== 'string' || value !== value.trim() || value.length === 0 || value.length > 253) {
    throw new Error(`Invalid ${label}: expected an IP address or DNS hostname without a port`)
  }
  const host = value.toLowerCase()
  if (isIP(host)) return host
  if (host.includes('..') || !host.split('.').every(candidate => DNS_LABEL.test(candidate))) {
    throw new Error(`Invalid ${label}: expected an IP address or DNS hostname without a port`)
  }
  return host
}

function parseTrustedProxyAddresses (value, label) {
  const candidates = parseCsvList(value, label, MAX_GATEWAY_ALLOWLIST_ENTRIES)
  const addresses = []
  const seen = new Set()
  for (const candidate of candidates) {
    let address = candidate.toLowerCase()
    if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) {
      address = address.slice(7)
    }
    if (address.length > 64 || !isIP(address)) {
      throw new Error(`Invalid ${label}: entries must be IP addresses`)
    }
    if (!seen.has(address)) {
      seen.add(address)
      addresses.push(address)
    }
  }
  return addresses
}

function parseCompatibilityHosts (value, label) {
  const candidates = parseCsvList(value, label, MAX_GATEWAY_ALLOWLIST_ENTRIES)
  const hosts = []
  const seen = new Set()
  for (const candidate of candidates) {
    const host = normalizeCompatibilityHost(candidate)
    if (!host) {
      throw new Error(`Invalid ${label}: entries must be canonical IP addresses or DNS hostnames`)
    }
    if (!seen.has(host)) {
      seen.add(host)
      hosts.push(host)
    }
  }
  return hosts
}

function normalizeCompatibilityHost (value) {
  if (value.startsWith('[')) {
    const match = /^\[([0-9a-f:.]+)\](?::([0-9]{1,5}))?$/i.exec(value)
    if (!match || isIP(match[1]) !== 6 || !validOptionalPort(match[2])) return null
    return `[${match[1].toLowerCase()}]`
  }

  const match = /^([a-z0-9.-]+)(?::([0-9]{1,5}))?$/i.exec(value)
  if (!match || !validOptionalPort(match[2])) return null
  let host = match[1].toLowerCase()
  if (host.endsWith('.')) host = host.slice(0, -1)
  if (!host || host.includes('..')) return null
  if (!isIP(host) && !host.split('.').every(candidate => DNS_LABEL.test(candidate))) return null
  return host
}

function validOptionalPort (value) {
  if (value === undefined) return true
  return /^(?:[1-9][0-9]{0,4})$/.test(value) && Number(value) <= 65535
}

function parseHiveAppHostSuffix (value, label) {
  if (typeof value !== 'string' || value !== value.trim()) {
    throw new Error(`Invalid ${label}: expected a DNS suffix such as hive.example.com`)
  }
  const suffix = normalizeHiveAppHostSuffix(value)
  if (!suffix) throw new Error(`Invalid ${label}: expected a DNS suffix such as hive.example.com`)
  return suffix
}

function parseHiveAppPublicKeys (value, label) {
  const candidates = parseCsvList(value, label, MAX_HIVE_APP_PUBLIC_KEYS)
  const normalized = normalizeHiveAppPublicKeys(candidates)
  if (!normalized) {
    throw new Error(`Invalid ${label}: entries must be 64-character hexadecimal app keys`)
  }
  return [...normalized]
}

function parseHiveAppPublicVersions (value, label) {
  let candidate = null
  if (typeof value === 'string' && value.trim().startsWith('{')) {
    try {
      candidate = JSON.parse(value)
    } catch {
      throw new Error(`Invalid ${label}: expected JSON or key=version entries`)
    }
  } else {
    const entries = parseCsvList(value, label, MAX_HIVE_APP_PUBLIC_KEYS)
    candidate = {}
    for (const entry of entries) {
      const match = /^([0-9a-fA-F]{64})=(0|[1-9][0-9]*)$/.exec(entry)
      if (!match) throw new Error(`Invalid ${label}: expected key=version entries`)
      const version = Number(match[2])
      if (!Number.isSafeInteger(version)) throw new Error(`Invalid ${label}: versions must be safe integers`)
      const key = match[1].toLowerCase()
      if (Object.prototype.hasOwnProperty.call(candidate, key)) {
        throw new Error(`Invalid ${label}: duplicate app key`)
      }
      candidate[key] = version
    }
  }
  const normalized = normalizeHiveAppPublicVersions(candidate)
  if (!normalized) {
    throw new Error(`Invalid ${label}: expected app keys mapped to non-negative safe integer versions`)
  }
  return Object.fromEntries(normalized)
}

function parseCsvList (value, label, maxEntries) {
  const rawValues = Array.isArray(value) ? value : [value]
  if (rawValues.length === 0) throw new Error(`Invalid ${label}: expected at least one entry`)

  const entries = []
  for (const raw of rawValues) {
    if (typeof raw !== 'string') throw new Error(`Invalid ${label}: expected a comma-separated list`)
    const parts = raw.split(',')
    for (const part of parts) {
      const entry = part.trim()
      if (!entry) throw new Error(`Invalid ${label}: list entries must not be empty`)
      entries.push(entry)
      if (entries.length > maxEntries) {
        throw new Error(`Invalid ${label}: expected no more than ${maxEntries} entries`)
      }
    }
  }
  return entries
}