OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / tor-bulk.test.js

203 lines · javascript ref:
/**
 * RA-05 — bulk-over-onion validation against a LIVE tor daemon.
 *
 * Verifies that the TorTransport v2 (persistent key, onion service) can carry
 * real bulk bytes end-to-end with integrity, and measures throughput to
 * calibrate the RA-05 gate (100 MB median <= 10 min on a 10 Mbps uplink).
 *
 * NOT part of the default suite — requires a tor binary and network access:
 *   HIVERELAY_TOR_TEST=1   enable the 5 MB integrity+throughput test
 *   HIVERELAY_TOR_BULK=1   additionally run the 100 MB measurement
 *   HIVERELAY_TOR_ASSERT_GATE=1  make the 100 MB timing a hard gate (<= 10 min)
 *   TOR_BIN=/path/to/tor   override tor binary resolution
 */
import test from 'brittle'
import { spawn, execSync } from 'child_process'
import http from 'http'
import fs from 'fs'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import { TorTransport } from 'p2p-hiverelay/transports/tor/index.js'

const ENABLED = !!process.env.HIVERELAY_TOR_TEST
const BULK = !!process.env.HIVERELAY_TOR_BULK
const ASSERT_GATE = !!process.env.HIVERELAY_TOR_ASSERT_GATE

// The socks package emits an unhandled 'error' event alongside its promise
// rejection on proxy-connect timeout (onEstablishedTimeout → closeSocket →
// emit('error')). The rejected promise already drives the retry loop below;
// the duplicate emitter error is the SAME condition and would otherwise kill
// the process mid-retry. Swallow exactly that signature; anything else crashes.
process.on('uncaughtException', (err) => {
  if (err && err.name === 'SocksClientError' && /Proxy connection timed out/.test(err.message)) return
  throw err
})

// pid+counter-derived ports so parallel runs and leftover daemons never
// collide (a stale tor holding the port silently stalls bootstrap); the
// counter also separates successive tests inside one file.
let portNonce = 0
function nextPorts () {
  portNonce += 1
  const base = 19000 + ((process.pid + portNonce * 37) % 900)
  return { socks: base, control: base + 1 }
}
const GATE_MS = 10 * 60 * 1000

function resolveTorBin () {
  if (process.env.TOR_BIN) return process.env.TOR_BIN
  try { return execSync('which tor', { encoding: 'utf8' }).trim() } catch { return null }
}

function startTor (dataDir) {
  fs.mkdirSync(dataDir, { recursive: true })
  const ports = nextPorts()
  const lines = [
    'DataDirectory ' + dataDir,
    'SocksPort ' + ports.socks,
    'ControlPort ' + ports.control,
    'CookieAuthentication 1',
    'Log notice file ' + path.join(dataDir, 'tor.log')
  ]
  // Optional calibrated run: HIVERELAY_TOR_BANDWIDTH_RATE=1250KB pins the
  // daemon's relay rate (e.g. to the 10 Mbps uplink the RA-05 gate is
  // calibrated against) so the measurement is comparable across machines.
  if (process.env.HIVERELAY_TOR_BANDWIDTH_RATE) lines.push('BandwidthRate ' + process.env.HIVERELAY_TOR_BANDWIDTH_RATE)
  fs.writeFileSync(path.join(dataDir, 'torrc'), lines.join('\n'))
  const proc = spawn(resolveTorBin(), ['-f', path.join(dataDir, 'torrc')], { stdio: 'ignore' })
  return { proc, socksPort: ports.socks, controlPort: ports.control }
}

async function waitBootstrap (dataDir, timeoutMs = Number(process.env.HIVERELAY_TOR_BOOTSTRAP_TIMEOUT_MS) || 360000) {
  const logFile = path.join(dataDir, 'tor.log')
  const t0 = Date.now()
  for (;;) {
    try { if (fs.readFileSync(logFile, 'utf8').includes('Bootstrapped 100%')) return Date.now() - t0 } catch {}
    if (Date.now() - t0 > timeoutMs) throw new Error('tor bootstrap timeout')
    await new Promise((resolve) => setTimeout(resolve, 500))
  }
}

function blobServer (t, blobs) {
  const server = http.createServer((req, res) => {
    const blob = blobs[req.url.slice(1)]
    if (!blob) { res.writeHead(404); res.end(); return }
    res.writeHead(200, { 'content-length': blob.length, 'content-type': 'application/octet-stream' })
    res.end(blob)
  })
  return new Promise((resolve) => {
    server.listen(0, '127.0.0.1', () => {
      t.teardown(() => { try { server.close() } catch {} })
      resolve(server.address().port)
    })
  })
}

function httpGetStream (stream, host, pathOut) {
  return new Promise((resolve, reject) => {
    const chunks = []
    stream.on('data', (d) => chunks.push(d))
    stream.on('end', () => resolve(Buffer.concat(chunks)))
    stream.on('error', reject)
    stream.write(`GET ${pathOut} HTTP/1.0\r\nHost: ${host}\r\nConnection: close\r\n\r\n`)
  })
}

function stripHttp (buf) {
  const idx = buf.indexOf('\r\n\r\n')
  return buf.subarray(idx + 4)
}

async function onionFetch (t, sizeBytes, { keyFile, cookieFile, socksPort, controlPort }) {
  const blob = crypto.randomBytes(sizeBytes)
  const expected = crypto.createHash('sha256').update(blob).digest('hex')
  const port = await blobServer(t, { blob })
  const transport = new TorTransport({
    socksPort,
    controlPort,
    cookieAuthFile: cookieFile,
    keyFile,
    localPort: port,
    minDaemonVersion: '0.4.9.5'
  })
  t.teardown(async () => { try { await transport.stop() } catch {} })
  await transport.start()
  t.ok(transport.onionAddress, 'onion service created')
  t.ok(transport.onionAddress.endsWith('.onion'))

  // address must persist across restart (persistent custody, ONION-INV-001)
  const firstAddress = transport.onionAddress
  await transport.stop()
  const transport2 = new TorTransport({
    socksPort,
    controlPort,
    cookieAuthFile: cookieFile,
    keyFile,
    localPort: port
  })
  t.teardown(async () => { try { await transport2.stop() } catch {} })
  await transport2.start()
  t.is(transport2.onionAddress, firstAddress, 'persistent address survives restart')

  const t0 = Date.now()
  // A freshly created onion's descriptor takes real time to upload to HSDirs
  // and become fetchable by this daemon — connect can fail/timeout inside the
  // propagation window (the transport's steady-state 30 s connect timeout is
  // correct). Retry through it; only the successful fetch is timed.
  let stream = null
  const deadline = t0 + 4 * 60 * 1000
  for (;;) {
    try {
      stream = await transport2.connect(firstAddress, 80)
      break
    } catch (err) {
      if (Date.now() > deadline) throw err
      await new Promise((resolve) => setTimeout(resolve, 5000))
    }
  }
  const raw = await httpGetStream(stream, firstAddress, '/blob')
  const ms = Date.now() - t0
  const body = stripHttp(raw)
  const got = crypto.createHash('sha256').update(body).digest('hex')
  t.is(got, expected, 'byte integrity through onion')
  const mbps = +(body.length * 8 / 1e6 / (ms / 1000)).toFixed(2)
  return { ms, bytes: body.length, mbps }
}

const it = ENABLED ? test : test.skip
const itBulk = ENABLED && BULK ? test : test.skip

it('onion bulk fetch — 5 MB integrity + throughput (live tor)', { timeout: 900000 }, async (t) => {
  if (!resolveTorBin()) { t.comment('no tor binary found — skipping'); return }
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiverelay-tor-bulk-'))
  t.teardown(() => { try { fs.rmSync(dataDir, { recursive: true, force: true }) } catch {} })
  const { proc, socksPort, controlPort } = startTor(dataDir)
  t.teardown(() => { proc.kill('SIGTERM') })
  await waitBootstrap(dataDir)
  const keyFile = path.join(dataDir, 'hs-key.blob')
  const cookieFile = path.join(dataDir, 'control_auth_cookie')

  const r = await onionFetch(t, 5 * 1024 * 1024, { keyFile, cookieFile, socksPort, controlPort })
  t.comment(`5MB over onion: ${r.ms}ms (${r.mbps} Mbps)`)
  t.ok(r.bytes === 5 * 1024 * 1024)
})

itBulk('onion bulk fetch — 100 MB gate measurement (live tor)', { timeout: 1500000 }, async (t) => {
  if (!resolveTorBin()) { t.comment('no tor binary found — skipping'); return }
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiverelay-tor-bulk100-'))
  t.teardown(() => { try { fs.rmSync(dataDir, { recursive: true, force: true }) } catch {} })
  const { proc, socksPort, controlPort } = startTor(dataDir)
  t.teardown(() => { proc.kill('SIGTERM') })
  await waitBootstrap(dataDir)
  const keyFile = path.join(dataDir, 'hs-key.blob')
  const cookieFile = path.join(dataDir, 'control_auth_cookie')

  const r = await onionFetch(t, 100 * 1024 * 1024, { keyFile, cookieFile, socksPort, controlPort })
  t.comment(`100MB over onion: ${r.ms}ms (${r.mbps} Mbps) — RA-05 gate is ${GATE_MS}ms`)
  t.ok(r.bytes === 100 * 1024 * 1024)
  if (ASSERT_GATE) {
    t.ok(r.ms <= GATE_MS, `RA-05 gate: 100MB ${r.ms}ms <= ${GATE_MS}ms`)
  }
})