OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / lib / public-hive-gateway-quarantine-authority.mjs

927 lines · javascript ref:
import { Resolver } from 'node:dns/promises'
import { randomInt } from 'node:crypto'
import { createSocket } from 'node:dgram'
import { BlockList, createConnection, isIP } from 'node:net'

const MAX_NGINX_INSPECTION_BYTES = 8 * 1024 * 1024
const MAX_DNS_WIRE_BYTES = 4096
const MAX_DNS_RESOURCE_RECORDS = 128
const MAX_DNS_ROUTING_RECORDS = 8
const MAX_DNS_SERVERS = 3
const DNS_ROUTING_TYPES = Object.freeze({ SVCB: 64, HTTPS: 65 })
const HIVE_Z32_ALPHABET = 'ybndrfg8ejkmcpqxot1uwisza345h769'
const NGINX_UPSTREAM_DIRECTIVES = new Set([
  'proxy_pass',
  'fastcgi_pass',
  'uwsgi_pass',
  'scgi_pass',
  'grpc_pass'
])
const NGINX_DEFAULT_DIRECTIVES = new Set([
  'listen',
  'server_name',
  'ssl_certificate',
  'ssl_certificate_key',
  'ssl_protocols',
  'ssl_session_tickets',
  'gzip',
  'gunzip',
  'access_log',
  'error_log',
  'return'
])
const NGINX_REVIEWED_MAIN_DIRECTIVES = new Set([
  'daemon',
  'env',
  'error_log',
  'lock_file',
  'master_process',
  'pcre_jit',
  'pid',
  'thread_pool',
  'timer_resolution',
  'user',
  'worker_cpu_affinity',
  'worker_priority',
  'worker_processes',
  'worker_rlimit_core',
  'worker_rlimit_nofile',
  'working_directory'
])
const NGINX_REVIEWED_HTTP_PARENT_DIRECTIVES = new Set([
  'access_log',
  'client_body_timeout',
  'client_header_timeout',
  'default_type',
  'error_log',
  'include',
  'keepalive_requests',
  'keepalive_timeout',
  'large_client_header_buffers',
  'limit_req_zone',
  'limit_conn_zone',
  'lingering_close',
  'lingering_time',
  'lingering_timeout',
  'log_format',
  'open_file_cache',
  'open_file_cache_errors',
  'open_file_cache_min_uses',
  'open_file_cache_valid',
  'read_ahead',
  'reset_timedout_connection',
  'send_timeout',
  'sendfile',
  'server_names_hash_bucket_size',
  'server_names_hash_max_size',
  'ssl_prefer_server_ciphers',
  'ssl_protocols',
  'tcp_nodelay',
  'tcp_nopush',
  'types_hash_bucket_size',
  'types_hash_max_size'
])
const NGINX_REVIEWED_EVENTS_DIRECTIVES = new Set([
  'accept_mutex',
  'accept_mutex_delay',
  'debug_connection',
  'multi_accept',
  'use',
  'worker_connections'
])

const FORBIDDEN_IPV4 = new BlockList()
for (const [network, prefix] of [
  ['0.0.0.0', 8],
  ['10.0.0.0', 8],
  ['100.64.0.0', 10],
  ['127.0.0.0', 8],
  ['169.254.0.0', 16],
  ['172.16.0.0', 12],
  ['192.0.0.0', 24],
  ['192.0.2.0', 24],
  ['192.88.99.0', 24],
  ['192.168.0.0', 16],
  ['198.18.0.0', 15],
  ['198.51.100.0', 24],
  ['203.0.113.0', 24],
  ['224.0.0.0', 4],
  ['240.0.0.0', 4]
]) FORBIDDEN_IPV4.addSubnet(network, prefix, 'ipv4')

const FORBIDDEN_IPV6 = new BlockList()
for (const [network, prefix] of [
  ['::', 128],
  ['::1', 128],
  ['::ffff:0:0', 96],
  ['64:ff9b::', 96],
  ['100::', 64],
  ['2001:db8::', 32],
  ['2001:10::', 28],
  ['2002::', 16],
  ['fc00::', 7],
  ['fe80::', 10],
  ['ff00::', 8]
]) FORBIDDEN_IPV6.addSubnet(network, prefix, 'ipv6')

export async function collectPublicHiveGatewayQuarantineDns (contract, opts = {}) {
  const resolver = opts.resolver || new Resolver()
  const resolveRouting = opts.resolveRoutingRecords || ((hostname, rrtype) =>
    resolvePublicHiveGatewayRoutingRecords(hostname, rrtype, {
      resolver,
      servers: opts.servers,
      timeoutMs: opts.timeoutMs
    }))
  const witnessHostname = witnessHostnameFor(contract)
  const app = await resolveDnsAnswer(resolver, contract.appHostname)
  const witness = await resolveDnsAnswer(resolver, witnessHostname)
  return {
    hostname: contract.appHostname,
    witnessHostname,
    app,
    witness,
    routing: {
      app: {
        https: await resolveRouting(contract.appHostname, 'HTTPS'),
        svcb: await resolveRouting(contract.appHostname, 'SVCB')
      },
      witness: {
        https: await resolveRouting(witnessHostname, 'HTTPS'),
        svcb: await resolveRouting(witnessHostname, 'SVCB')
      }
    }
  }
}

export async function resolvePublicHiveGatewayRoutingRecords (hostname, rrtype, opts = {}) {
  const name = normalizeDnsWireHostname(hostname)
  const typeName = String(rrtype || '').toUpperCase()
  const type = DNS_ROUTING_TYPES[typeName]
  if (!type) throw new Error('operator DNS routing RR type must be HTTPS or SVCB')
  const timeoutMs = opts.timeoutMs == null ? 5000 : Number(opts.timeoutMs)
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 50 || timeoutMs > 30000) {
    throw new Error('operator DNS routing timeout must be an integer from 50 to 30000 milliseconds')
  }
  const resolver = opts.resolver || new Resolver()
  const servers = normalizeDnsServers(opts.servers || resolver.getServers?.())
  const deadline = Date.now() + timeoutMs
  const failures = []
  for (let index = 0; index < servers.length; index++) {
    const remaining = deadline - Date.now()
    if (remaining <= 0) break
    const attemptBudget = Math.max(50, Math.floor(remaining / (servers.length - index)))
    const attemptDeadline = Math.min(deadline, Date.now() + attemptBudget)
    const id = randomInt(0, 65536)
    const query = encodeDnsRoutingQuery(id, name, type)
    try {
      const udp = await exchangeDnsUdp(query, servers[index], attemptDeadline)
      let parsed = parseDnsRoutingResponse(udp, { id, name, type, allowTruncated: true })
      if (parsed.truncated) {
        const tcp = await exchangeDnsTcp(query, servers[index], attemptDeadline)
        parsed = parseDnsRoutingResponse(tcp, { id, name, type, allowTruncated: false })
      }
      return parsed.records
    } catch (err) {
      failures.push(`server ${index + 1}: ${boundedDnsError(err)}`)
    }
  }
  throw new Error(`operator DNS ${typeName} resolution failed (${failures.join('; ') || 'absolute deadline exceeded'})`)
}

export function inspectPublicHiveGatewayQuarantineDns (snapshot, contract) {
  const errors = []
  const expected = contract.expectedAddresses.map(address => requirePublicAddress(address, 'signed expected address'))
  const expectedIpv4 = expected.filter(address => isIP(address) === 4).sort(compareAddresses)
  const expectedIpv6 = expected.filter(address => isIP(address) === 6).sort(compareAddresses)
  const observed = []

  for (const [label, answer] of [['app', snapshot?.app], ['wildcard witness', snapshot?.witness]]) {
    if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
      errors.push(`${label} DNS answer is invalid`)
      continue
    }
    const ipv4 = normalizeObservedAddresses(answer.ipv4, 4, `${label} A`, errors)
    const ipv6 = normalizeObservedAddresses(answer.ipv6, 6, `${label} AAAA`, errors)
    observed.push(...ipv4, ...ipv6)
    compareAddressSets(ipv4, expectedIpv4, `${label} A`, errors)
    compareAddressSets(ipv6, expectedIpv6, `${label} AAAA`, errors)
    if (!Array.isArray(answer.cnames) || answer.cnames.length !== 0) {
      errors.push(`${label} DNS must not contain CNAME indirection`)
    }
  }

  for (const label of ['app', 'witness']) {
    const routing = snapshot?.routing?.[label]
    if (!routing || !Array.isArray(routing.https) || !Array.isArray(routing.svcb) ||
        routing.https.length !== 0 || routing.svcb.length !== 0) {
      errors.push(`${label} DNS HTTPS and SVCB RRsets must be empty`)
    }
  }

  const probeAddresses = [...new Set([...expected, ...observed])].sort(compareAddresses)
  return { ok: errors.length === 0, errors, probeAddresses }
}

export function inspectPublicHiveGatewayQuarantineNginx (text, opts = {}) {
  const errors = []
  if (typeof text !== 'string' || Buffer.byteLength(text) < 1 ||
      Buffer.byteLength(text) > MAX_NGINX_INSPECTION_BYTES) {
    return { ok: false, errors: ['quarantine nginx dump must be a bounded non-empty string'] }
  }
  const suffix = normalizeHostSuffix(opts.suffix)
  if (!suffix) return { ok: false, errors: ['quarantine expected suffix is invalid'] }
  const parsed = parseNginxStatements(stripNginxComments(text))
  if (!parsed.ok) return { ok: false, errors: ['quarantine nginx structure could not be inspected safely'] }
  inspectReviewedNginxDirectivePosture(parsed.nodes, errors)
  if (findNginxBlocksWithContext(parsed.nodes, 'stream').length > 0) {
    errors.push('quarantine forbids nginx stream context and stream includes')
  }
  const serverRecords = findNginxBlocksWithContext(parsed.nodes, 'server')
  const foreignTlsServers = serverRecords.filter(record =>
    !isHttpServerContext(record) && directDirectives(record.block, 'listen').some(isPort443Listen))
  if (foreignTlsServers.length > 0) {
    errors.push('quarantine TLS port 443 server blocks must be in HTTP context')
  }
  const tlsBlocks = serverRecords
    .filter(isHttpServerContext)
    .map(record => record.block)
    .filter(block => directDirectives(block, 'listen').some(isPort443Listen))
  const defaults = tlsBlocks.filter(block => directDirectives(block, 'listen').some(isDefaultTls443Listen))
  if (defaults.length !== 1 || !isSafeDefaultServer(defaults[0])) {
    errors.push('quarantine requires exactly one safe TLS 421 default server')
  }
  for (const block of tlsBlocks) {
    if (directDirectives(block, 'listen').some(directive => directive.args.includes('quic'))) {
      errors.push('quarantine must not expose a QUIC listener')
    }
    if (block === defaults[0]) continue
    const names = directDirectives(block, 'server_name').flatMap(directive => directive.args)
    if (names.some(name => serverNameCanMatchHiveApp(name, suffix))) {
      errors.push('quarantine has a sibling TLS vhost capable of matching an app host')
    }
    if (!hasExactPublicTlsListeners(directDirectives(block, 'listen'), false)) {
      errors.push('quarantine sibling TLS vhosts must use only the shared wildcard 443 ssl listen tuples')
    }
    if (names.length === 0 || names.some(name => !isExplicitDisjointServerName(name, suffix))) {
      errors.push('quarantine sibling TLS vhosts require explicit server_name values disjoint from the app suffix')
    }
  }
  if (allDirectives({ children: parsed.nodes }, 'add_header').some(directive =>
    String(directive.args[0] || '').toLowerCase() === 'alt-svc' &&
    directive.args.slice(1).join(' ').toLowerCase().includes('h3'))) {
    errors.push('quarantine must not advertise HTTP/3')
  }
  return { ok: errors.length === 0, errors }
}

function inspectReviewedNginxDirectivePosture (nodes, errors) {
  const rejected = []
  const isReviewedTypesBlock = node => node.kind === 'block' &&
    node.name === 'types' &&
    node.children.every(child => child.kind === 'directive')
  const reviewedParentCompression = node => node.kind === 'directive' && (
    (node.name === 'gzip' && (sameArgs(node.args, ['on']) || sameArgs(node.args, ['off']))) ||
    (node.name === 'gunzip' && sameArgs(node.args, ['off'])))
  const inspectHttpChildren = children => {
    for (const node of children) {
      if (node.kind === 'directive') {
        if (!NGINX_REVIEWED_HTTP_PARENT_DIRECTIVES.has(node.name) && !reviewedParentCompression(node)) {
          rejected.push(node.name)
        }
      } else if (node.name !== 'server' && !isReviewedTypesBlock(node)) {
        rejected.push(`${node.name} {}`)
      }
    }
  }
  for (const node of nodes) {
    if (node.kind === 'directive') {
      if (!NGINX_REVIEWED_MAIN_DIRECTIVES.has(node.name) &&
          !NGINX_REVIEWED_HTTP_PARENT_DIRECTIVES.has(node.name) &&
          !reviewedParentCompression(node)) rejected.push(node.name)
      continue
    }
    if (node.name === 'events') {
      for (const child of node.children) {
        if (child.kind !== 'directive' || !NGINX_REVIEWED_EVENTS_DIRECTIVES.has(child.name)) {
          rejected.push(child.kind === 'block' ? `${child.name} {}` : child.name)
        }
      }
    } else if (node.name === 'http') {
      inspectHttpChildren(node.children)
    } else if (node.name !== 'server' && !isReviewedTypesBlock(node)) {
      rejected.push(`${node.name} {}`)
    }
  }
  if (rejected.length > 0) {
    errors.push(`quarantine rejects unreviewed inherited HTTP/module directives or blocks: ${[...new Set(rejected)].sort().slice(0, 8).join(', ')}`)
  }
}

function normalizeObservedAddresses (value, family, label, errors) {
  if (!Array.isArray(value) || value.length > 16) {
    errors.push(`${label} RRset must be a bounded array`)
    return []
  }
  const normalized = []
  for (const address of value) {
    try {
      const canonical = requirePublicAddress(address, `${label} address`)
      if (isIP(canonical) !== family) throw new Error(`${label} address has the wrong family`)
      normalized.push(canonical)
    } catch (err) {
      errors.push(err.message)
    }
  }
  if (new Set(normalized).size !== normalized.length) errors.push(`${label} RRset contains duplicates`)
  return [...new Set(normalized)].sort(compareAddresses)
}

async function resolveDnsAnswer (resolver, hostname) {
  const [ipv4, ipv6, cnames] = await Promise.all([
    resolveOptional(() => resolver.resolve4(hostname)),
    resolveOptional(() => resolver.resolve6(hostname)),
    resolveOptional(() => resolver.resolveCname(hostname))
  ])
  if (ipv4.length > 16 || ipv6.length > 16 || cnames.length > 8) {
    throw new Error('quarantine DNS response exceeds its RRset bound')
  }
  return { ipv4, ipv6, cnames }
}

async function resolveOptional (operation) {
  try {
    return await operation()
  } catch (err) {
    if (['ENODATA', 'ENOTFOUND', 'NOTFOUND'].includes(err?.code)) return []
    throw new Error(`quarantine DNS resolution failed: ${err?.code || err?.message || 'unknown error'}`)
  }
}

function normalizeDnsWireHostname (value) {
  if (typeof value !== 'string' || value !== value.trim() || value.length < 1 || value.length > 253) {
    throw new Error('operator DNS routing hostname is invalid')
  }
  const name = value.toLowerCase().replace(/\.$/, '')
  const labels = name.split('.')
  if (labels.some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
    throw new Error('operator DNS routing hostname is invalid')
  }
  return name
}

function normalizeDnsServers (values) {
  if (!Array.isArray(values) || values.length < 1 || values.length > MAX_DNS_SERVERS) {
    throw new Error(`operator DNS routing requires 1 to ${MAX_DNS_SERVERS} system resolver servers`)
  }
  const servers = values.map((value, index) => parseDnsServer(value, index))
  if (new Set(servers.map(server => `${server.family}|${server.address}|${server.port}`)).size !== servers.length) {
    throw new Error('operator DNS routing resolver server list contains duplicates')
  }
  return servers
}

function parseDnsServer (value, index) {
  const text = String(value || '')
  if (text !== text.trim() || text.length < 1 || text.length > 256 || /[\r\n\0]/.test(text)) {
    throw new Error(`operator DNS routing resolver server[${index}] is invalid`)
  }
  let address = text
  let port = 53
  if (isIP(address) === 0) {
    const bracketed = /^\[([^\]]+)](?::([0-9]{1,5}))?$/.exec(text)
    const ipv4Port = /^([^:]+):([0-9]{1,5})$/.exec(text)
    if (bracketed && isIP(bracketed[1]) === 6) {
      address = bracketed[1]
      if (bracketed[2]) port = Number(bracketed[2])
    } else if (ipv4Port && isIP(ipv4Port[1]) === 4) {
      address = ipv4Port[1]
      port = Number(ipv4Port[2])
    } else {
      throw new Error(`operator DNS routing resolver server[${index}] is invalid`)
    }
  }
  if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
    throw new Error(`operator DNS routing resolver server[${index}] port is invalid`)
  }
  return { address, port, family: isIP(address) }
}

function encodeDnsRoutingQuery (id, hostname, type) {
  const name = encodeDnsName(hostname)
  const query = Buffer.allocUnsafe(12 + name.length + 4)
  query.writeUInt16BE(id, 0)
  query.writeUInt16BE(0x0100, 2)
  query.writeUInt16BE(1, 4)
  query.fill(0, 6, 12)
  name.copy(query, 12)
  query.writeUInt16BE(type, 12 + name.length)
  query.writeUInt16BE(1, 14 + name.length)
  return query
}

function encodeDnsName (hostname) {
  const parts = []
  for (const label of hostname.split('.')) {
    const bytes = Buffer.from(label, 'ascii')
    if (bytes.length < 1 || bytes.length > 63) throw new Error('operator DNS routing label is invalid')
    parts.push(Buffer.from([bytes.length]), bytes)
  }
  parts.push(Buffer.from([0]))
  return Buffer.concat(parts)
}

function exchangeDnsUdp (query, server, deadline) {
  return new Promise((resolve, reject) => {
    const socket = createSocket(server.family === 6 ? 'udp6' : 'udp4')
    let settled = false
    let timer = null
    const finish = (err, value) => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      try { socket.close() } catch {}
      if (err) reject(err)
      else resolve(value)
    }
    const remaining = deadline - Date.now()
    if (remaining <= 0) return finish(new Error('UDP query exceeded its absolute deadline'))
    timer = setTimeout(() => finish(new Error('UDP query exceeded its absolute deadline')), remaining)
    socket.once('error', err => finish(new Error(`UDP transport failed: ${boundedDnsError(err)}`)))
    socket.once('message', message => {
      if (message.length < 12 || message.length > MAX_DNS_WIRE_BYTES) {
        return finish(new Error('UDP response is outside its wire-size bound'))
      }
      finish(null, message)
    })
    socket.connect(server.port, server.address, () => {
      socket.send(query, err => {
        if (err) finish(new Error(`UDP send failed: ${boundedDnsError(err)}`))
      })
    })
  })
}

function exchangeDnsTcp (query, server, deadline) {
  return new Promise((resolve, reject) => {
    const socket = createConnection({ host: server.address, port: server.port, family: server.family })
    let settled = false
    let received = Buffer.alloc(0)
    let expectedLength = null
    let timer = null
    const finish = (err, value) => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      socket.destroy()
      if (err) reject(err)
      else resolve(value)
    }
    const remaining = deadline - Date.now()
    if (remaining <= 0) return finish(new Error('TCP retry exceeded its absolute deadline'))
    timer = setTimeout(() => finish(new Error('TCP retry exceeded its absolute deadline')), remaining)
    socket.setNoDelay(true)
    socket.once('error', err => finish(new Error(`TCP transport failed: ${boundedDnsError(err)}`)))
    socket.once('end', () => finish(new Error('TCP response ended before one complete DNS frame')))
    socket.on('data', chunk => {
      if (settled) return
      received = Buffer.concat([received, chunk])
      if (received.length > MAX_DNS_WIRE_BYTES + 2) {
        return finish(new Error('TCP response exceeds its wire-size bound'))
      }
      if (expectedLength === null && received.length >= 2) {
        expectedLength = received.readUInt16BE(0)
        if (expectedLength < 12 || expectedLength > MAX_DNS_WIRE_BYTES) {
          return finish(new Error('TCP response frame length is invalid'))
        }
      }
      if (expectedLength !== null && received.length >= expectedLength + 2) {
        if (received.length !== expectedLength + 2) {
          return finish(new Error('TCP response contains trailing data beyond one DNS frame'))
        }
        finish(null, received.subarray(2))
      }
    })
    socket.once('connect', () => {
      const frame = Buffer.allocUnsafe(query.length + 2)
      frame.writeUInt16BE(query.length, 0)
      query.copy(frame, 2)
      socket.write(frame, err => {
        if (err) finish(new Error(`TCP send failed: ${boundedDnsError(err)}`))
      })
    })
  })
}

function parseDnsRoutingResponse (message, expected) {
  if (!Buffer.isBuffer(message) || message.length < 12 || message.length > MAX_DNS_WIRE_BYTES) {
    throw new Error('DNS response is outside its wire-size bound')
  }
  const id = message.readUInt16BE(0)
  const flags = message.readUInt16BE(2)
  const qdcount = message.readUInt16BE(4)
  const ancount = message.readUInt16BE(6)
  const nscount = message.readUInt16BE(8)
  const arcount = message.readUInt16BE(10)
  if (id !== expected.id) throw new Error('DNS response query ID mismatch')
  if ((flags & 0x8000) === 0 || (flags & 0x7800) !== 0 || (flags & 0x0040) !== 0) {
    throw new Error('DNS response has invalid QR, opcode, or reserved flags')
  }
  if ((flags & 0x000f) !== 0) throw new Error(`DNS response returned RCODE ${flags & 0x000f}`)
  if (qdcount !== 1) throw new Error('DNS response must contain exactly one question')
  if (ancount + nscount + arcount > MAX_DNS_RESOURCE_RECORDS) {
    throw new Error('DNS response exceeds its resource-record bound')
  }
  const question = decodeDnsName(message, 12)
  if (question.nextOffset + 4 > message.length) throw new Error('DNS response question is truncated')
  const qtype = message.readUInt16BE(question.nextOffset)
  const qclass = message.readUInt16BE(question.nextOffset + 2)
  let offset = question.nextOffset + 4
  if (question.name !== expected.name || qtype !== expected.type || qclass !== 1) {
    throw new Error('DNS response question does not match the exact query')
  }
  if ((flags & 0x0200) !== 0) {
    if (!expected.allowTruncated) throw new Error('DNS TCP response remains truncated')
    return { truncated: true, records: [] }
  }

  const records = []
  for (let index = 0; index < ancount; index++) {
    const rr = decodeDnsResourceRecord(message, offset)
    offset = rr.nextOffset
    if (rr.type === 5) throw new Error('DNS routing response contains CNAME alias ambiguity')
    if (rr.type !== expected.type || rr.class !== 1 || rr.name !== expected.name) {
      throw new Error('DNS routing answer contains an unexpected owner, class, or RR type')
    }
    records.push(decodeSvcbRecord(message, rr, expected.type))
    if (records.length > MAX_DNS_ROUTING_RECORDS) throw new Error('DNS routing RRset exceeds its record bound')
  }
  for (let index = 0; index < nscount; index++) {
    const rr = decodeDnsResourceRecord(message, offset)
    offset = rr.nextOffset
    if (rr.type === 5) throw new Error('DNS routing response contains CNAME alias ambiguity')
  }
  for (let index = 0; index < arcount; index++) {
    const rr = decodeDnsResourceRecord(message, offset)
    offset = rr.nextOffset
    if (rr.type === 5) throw new Error('DNS routing response contains CNAME alias ambiguity')
    if (rr.type === 41 && (rr.ttl >>> 24) !== 0) throw new Error('DNS response contains a nonzero extended RCODE')
  }
  if (offset !== message.length) throw new Error('DNS response contains trailing or unparsed bytes')
  if (records.filter(record => record.priority === 0).length > 0 && records.length !== 1) {
    throw new Error('DNS routing RRset mixes AliasMode with other records')
  }
  return { truncated: false, records }
}

function decodeDnsResourceRecord (message, offset) {
  const owner = decodeDnsName(message, offset)
  if (owner.nextOffset + 10 > message.length) throw new Error('DNS resource record header is truncated')
  const type = message.readUInt16BE(owner.nextOffset)
  const rrclass = message.readUInt16BE(owner.nextOffset + 2)
  const ttl = message.readUInt32BE(owner.nextOffset + 4)
  const length = message.readUInt16BE(owner.nextOffset + 8)
  const rdataOffset = owner.nextOffset + 10
  const nextOffset = rdataOffset + length
  if (nextOffset > message.length) throw new Error('DNS resource record data is truncated')
  return { name: owner.name, type, class: rrclass, ttl, rdataOffset, rdataEnd: nextOffset, nextOffset }
}

function decodeSvcbRecord (message, rr, type) {
  if (rr.rdataEnd - rr.rdataOffset < 3) throw new Error('DNS HTTPS/SVCB record data is truncated')
  const priority = message.readUInt16BE(rr.rdataOffset)
  const target = decodeDnsName(message, rr.rdataOffset + 2, { allowCompression: false, limit: rr.rdataEnd })
  let offset = target.nextOffset
  let previousKey = -1
  const parameterKeys = []
  while (offset < rr.rdataEnd) {
    if (offset + 4 > rr.rdataEnd) throw new Error('DNS HTTPS/SVCB parameter header is truncated')
    const key = message.readUInt16BE(offset)
    const length = message.readUInt16BE(offset + 2)
    offset += 4
    if (key <= previousKey) throw new Error('DNS HTTPS/SVCB parameters are not strictly ordered')
    if (offset + length > rr.rdataEnd) throw new Error('DNS HTTPS/SVCB parameter value is truncated')
    parameterKeys.push(key)
    previousKey = key
    offset += length
  }
  if (priority === 0 && !target.name) throw new Error('DNS HTTPS/SVCB AliasMode target must not be root')
  return {
    type: type === DNS_ROUTING_TYPES.HTTPS ? 'HTTPS' : 'SVCB',
    priority,
    targetName: target.name || '.',
    ttl: rr.ttl,
    parameterKeys
  }
}

function decodeDnsName (message, offset, opts = {}, depth = 0, visited = new Set()) {
  const allowCompression = opts.allowCompression !== false
  const limit = opts.limit == null ? message.length : opts.limit
  if (!Number.isSafeInteger(offset) || offset < 0 || offset >= limit || depth > 16 || visited.has(offset)) {
    throw new Error('DNS name encoding is invalid or cyclic')
  }
  visited.add(offset)
  const labels = []
  let cursor = offset
  let nextOffset = null
  while (true) {
    if (cursor >= limit) throw new Error('DNS name is truncated')
    const length = message[cursor]
    if ((length & 0xc0) === 0xc0) {
      if (!allowCompression || cursor + 1 >= limit) throw new Error('DNS name uses forbidden or truncated compression')
      const pointer = ((length & 0x3f) << 8) | message[cursor + 1]
      if (pointer >= message.length) throw new Error('DNS name compression pointer is outside the message')
      if (nextOffset === null) nextOffset = cursor + 2
      const suffix = decodeDnsName(message, pointer, {}, depth + 1, visited)
      if (suffix.name) labels.push(...suffix.name.split('.'))
      break
    }
    if ((length & 0xc0) !== 0 || length > 63) throw new Error('DNS name label length is invalid')
    cursor++
    if (length === 0) {
      if (nextOffset === null) nextOffset = cursor
      break
    }
    if (cursor + length > limit) throw new Error('DNS name label is truncated')
    const label = message.subarray(cursor, cursor + length).toString('ascii')
    if (!/^[A-Za-z0-9_-]+$/.test(label)) throw new Error('DNS name contains an unsupported label')
    labels.push(label.toLowerCase())
    cursor += length
    if (labels.join('.').length > 253) throw new Error('DNS name exceeds its expansion bound')
  }
  return { name: labels.join('.'), nextOffset }
}

function boundedDnsError (err) {
  return String(err?.message || err || 'unknown error').replace(/[\r\n\0]/g, ' ').slice(0, 240)
}

function compareAddressSets (actual, expected, label, errors) {
  if (JSON.stringify(actual) !== JSON.stringify(expected)) {
    errors.push(`${label} RRset does not exactly match signed expected addresses`)
  }
}

function requirePublicAddress (value, label) {
  const address = canonicalAddress(value, label)
  if ((isIP(address) === 4 && FORBIDDEN_IPV4.check(address, 'ipv4')) ||
      (isIP(address) === 6 && FORBIDDEN_IPV6.check(address, 'ipv6'))) {
    throw new Error(`${label} must be a globally routable unicast address`)
  }
  return address
}

function canonicalAddress (value, label) {
  if (typeof value !== 'string' || value !== value.trim() || isIP(value) === 0) {
    throw new Error(`${label} must be an IP address`)
  }
  const normalized = isIP(value) === 6
    ? new URL(`http://[${value}]/`).hostname.slice(1, -1)
    : value
  if (normalized !== value.toLowerCase()) throw new Error(`${label} must use canonical IP spelling`)
  return normalized
}

function compareAddresses (left, right) {
  return isIP(left) - isIP(right) || left.localeCompare(right)
}

function witnessHostnameFor (contract) {
  const witnessKey = contract.appKey === '0'.repeat(64) ? 'f'.repeat(64) : '0'.repeat(64)
  return `${encodeHiveAppKey(Buffer.from(witnessKey, 'hex'))}.${contract.suffix}`
}

function encodeHiveAppKey (bytes) {
  let output = ''
  let accumulator = 0
  let bits = 0
  for (const byte of bytes) {
    accumulator = (accumulator << 8) | byte
    bits += 8
    while (bits >= 5) {
      bits -= 5
      output += HIVE_Z32_ALPHABET[(accumulator >>> bits) & 31]
      accumulator &= bits === 0 ? 0 : (1 << bits) - 1
    }
  }
  if (bits > 0) output += HIVE_Z32_ALPHABET[(accumulator << (5 - bits)) & 31]
  return output
}

function normalizeHostSuffix (value) {
  if (typeof value !== 'string' || value !== value.trim().toLowerCase() || value.length > 200) return null
  const labels = value.replace(/\.$/, '').split('.')
  if (labels.length < 2 || labels.some(label =>
    !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) return null
  return labels.join('.')
}

function stripNginxComments (text) {
  let output = ''
  let quote = null
  let escaped = false
  for (let i = 0; i < text.length; i++) {
    const character = text[i]
    if (escaped) {
      output += character
      escaped = false
      continue
    }
    if (character === '\\') {
      output += character
      escaped = true
      continue
    }
    if (quote) {
      output += character
      if (character === quote) quote = null
      continue
    }
    if (character === '"' || character === "'") {
      quote = character
      output += character
      continue
    }
    if (character === '#') {
      while (i + 1 < text.length && text[i + 1] !== '\n') i++
      continue
    }
    output += character
  }
  return output
}

function parseNginxStatements (text) {
  const nodes = []
  const stack = [nodes]
  let tokens = []
  let token = ''
  let quote = null
  let ok = true
  const pushToken = () => {
    if (token.length > 0) tokens.push(token)
    token = ''
  }
  for (let i = 0; i < text.length; i++) {
    const character = text[i]
    if (quote) {
      token += character
      if (character === '\\' && i + 1 < text.length) token += text[++i]
      else if (character === quote) quote = null
      continue
    }
    if (character === '"' || character === "'") {
      quote = character
      token += character
      continue
    }
    if (character === '\\') {
      token += character
      if (i + 1 < text.length) token += text[++i]
      else ok = false
      continue
    }
    if (/\s/.test(character)) {
      pushToken()
      continue
    }
    if (character !== ';' && character !== '{' && character !== '}') {
      token += character
      continue
    }
    pushToken()
    if (character === ';') {
      if (tokens.length === 0) ok = false
      else stack.at(-1).push({ kind: 'directive', name: tokens[0], args: tokens.slice(1) })
      tokens = []
      continue
    }
    if (character === '{') {
      if (tokens.length === 0) {
        ok = false
      } else {
        const block = { kind: 'block', name: tokens[0], args: tokens.slice(1), children: [] }
        stack.at(-1).push(block)
        stack.push(block.children)
      }
      tokens = []
      continue
    }
    if (tokens.length > 0 || stack.length === 1) ok = false
    else stack.pop()
    tokens = []
  }
  pushToken()
  if (quote || tokens.length > 0 || stack.length !== 1) ok = false
  return { ok, nodes }
}

function findNginxBlocksWithContext (nodes, name, ancestors = []) {
  const matches = []
  for (const node of nodes) {
    if (node.kind !== 'block') continue
    if (node.name === name) matches.push({ block: node, ancestors })
    matches.push(...findNginxBlocksWithContext(node.children, name, [...ancestors, node]))
  }
  return matches
}

function isHttpServerContext (record) {
  return record.ancestors.length === 0 || record.ancestors.at(-1).name === 'http'
}

function directDirectives (block, name = null) {
  if (!block) return []
  return block.children.filter(node => node.kind === 'directive' && (name === null || node.name === name))
}

function allDirectives (block, name = null) {
  if (!block) return []
  const matches = []
  for (const node of block.children) {
    if (node.kind === 'directive' && (name === null || node.name === name)) matches.push(node)
    if (node.kind === 'block') matches.push(...allDirectives(node, name))
  }
  return matches
}

function sameArgs (actual, expected) {
  return actual.length === expected.length && actual.every((value, index) => value === expected[index])
}

function isPort443Listen (directive) {
  const endpoint = directive.args[0] || ''
  return endpoint === '443' || /:443$/.test(endpoint)
}

function isDefaultTls443Listen (directive) {
  return isPort443Listen(directive) && directive.args.includes('default_server')
}

function hasExactPublicTlsListeners (listens, defaultServer) {
  const option = defaultServer ? ['ssl', 'default_server'] : ['ssl']
  const expected = [['443', ...option], ['[::]:443', ...option]]
  return listens.length === expected.length && expected.every(args =>
    listens.some(directive => sameArgs(directive.args, args)))
}

function isSafeDefaultServer (block) {
  const directives = directDirectives(block)
  const names = directDirectives(block, 'server_name')
  const returns = directDirectives(block, 'return')
  const hasNestedBlock = block.children.some(node => node.kind === 'block')
  const upstreams = allDirectives(block).filter(directive => NGINX_UPSTREAM_DIRECTIVES.has(directive.name))
  return hasExactPublicTlsListeners(directDirectives(block, 'listen'), true) &&
    names.length === 1 && sameArgs(names[0].args, ['_']) &&
    returns.length === 1 && sameArgs(returns[0].args, ['421']) &&
    hasSingleDirective(block, 'ssl_certificate') &&
    hasSingleDirective(block, 'ssl_certificate_key') &&
    hasSingleDirective(block, 'ssl_protocols', ['TLSv1.2', 'TLSv1.3']) &&
    hasSingleDirective(block, 'ssl_session_tickets', ['off']) &&
    hasSingleDirective(block, 'gzip', ['off']) &&
    hasSingleDirective(block, 'gunzip', ['off']) &&
    hasSingleDirective(block, 'access_log', ['off']) &&
    hasSingleDirective(block, 'error_log', ['stderr', 'crit']) &&
    directives.every(directive => NGINX_DEFAULT_DIRECTIVES.has(directive.name)) &&
    !hasNestedBlock && upstreams.length === 0
}

function hasSingleDirective (block, name, args = null) {
  const directives = directDirectives(block, name)
  return directives.length === 1 && directives[0].args.length === (args?.length || 1) &&
    (args === null || sameArgs(directives[0].args, args))
}

function serverNameCanMatchHiveApp (rawName, suffix) {
  const name = unquote(rawName).toLowerCase().replace(/\.$/, '')
  if (!name || name === '_') return false
  if (name.startsWith('~')) return true
  if (name.startsWith('*.')) {
    const base = name.slice(2)
    return suffix === base || suffix.endsWith(`.${base}`)
  }
  if (name.startsWith('.')) {
    const base = name.slice(1)
    return suffix === base || suffix.endsWith(`.${base}`)
  }
  if (name.includes('*')) return true
  if (!name.endsWith(`.${suffix}`)) return false
  const label = name.slice(0, -(suffix.length + 1))
  return /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(label)
}

function isExplicitDisjointServerName (rawName, suffix) {
  const name = unquote(rawName).toLowerCase().replace(/\.$/, '')
  if (!name || name === '_' || name.startsWith('~') || name.includes('*')) return false
  if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/.test(name)) return false
  return !serverNameCanMatchHiveApp(name, suffix)
}

function unquote (value) {
  let name = String(value || '').trim()
  if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith("'") && name.endsWith("'"))) {
    name = name.slice(1, -1)
  }
  return name
}