OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / helpers / fake-tor.js

229 lines · javascript ref:
/**
 * Headless fake tor daemon for the cross-feature journey tests.
 *
 * The unit suites stub the control plane in-process via `_controlFactory`
 * (see test/unit/tor-transport.test.js). That injection point is not
 * reachable through RelayNode — the node constructs its own TorTransport
 * from operator config — so the journeys use the next-best seam: a REAL
 * loopback TCP fake that speaks the two daemon protocols the transport
 * code actually talks to:
 *
 *   1. Control port — the subset of the tor control protocol TorTransport
 *      issues (AUTHENTICATE / GETINFO / SETCONF / SETEVENTS / ADD_ONION /
 *      DEL_ONION), same reply shapes as the unit FakeControl. After
 *      SETEVENTS the fake pushes the async `650 HS_DESC UPLOADED` events
 *      the health machine needs to reach `ready` (no probe vport), which
 *      is what ungates the capability-doc privacyTransports advertisement.
 *
 *   2. SOCKS5 port — a minimal no-auth CONNECT server that emulates the
 *      daemon's hidden-service forwarding: a CONNECT to
 *      `<serviceId>.onion:<vport>` is piped to the `127.0.0.1` target the
 *      most recent ADD_ONION `Port=` mapping declared. This lets a client
 *      TorTransport (or any SOCKS client) push REAL BYTES through the same
 *      code path a tor daemon would feed — the read plane (HTTP gateway)
 *      and the peer plane (OnionPeerListener Noise XK) both included.
 *
 * Nothing here grants authority: the fake supplies reachability only, the
 * application-layer Noise handshake + per-service authorization run
 * unmodified (ONION-INV-006).
 */

import net from 'net'

const DEFAULT_SERVICE_ID = 'a'.repeat(56)
const DEFAULT_KEY_BLOB = 'ED25519-V3:' + Buffer.alloc(64, 7).toString('base64')
const DEFAULT_VERSION = '0.4.9.6'

export async function startFakeTorDaemon (t, opts = {}) {
  const serviceId = opts.serviceId || DEFAULT_SERVICE_ID
  const keyBlob = opts.keyBlob || DEFAULT_KEY_BLOB
  const version = opts.version || DEFAULT_VERSION

  const commands = []
  // vport → { host, targetPort } from the most recent ADD_ONION.
  const portMap = new Map()
  // Every socket the daemon touches, so close() never blocks on open
  // connections (server.close() alone waits for them).
  const sockets = new Set()
  let controlSock = null
  let serviceLive = false

  function track (sock) {
    sockets.add(sock)
    sock.on('close', () => sockets.delete(sock))
    sock.on('error', () => {})
    return sock
  }

  function emitDescriptorUploads (n = 2) {
    // Mirror the daemon's async 650 events: with minDescriptorUploads = 2
    // and no probe vport these drive TorTransport health KEY_LOADED →
    // DESCRIPTOR_UPLOADED → READY (the privacyTransports gate).
    let sent = 0
    const tick = () => {
      if (!controlSock || controlSock.destroyed || !serviceLive) return
      try { controlSock.write('650 HS_DESC UPLOADED ' + serviceId + ' fakeupload' + sent + '\r\n') } catch { return }
      sent++
      if (sent < n) setTimeout(tick, 15)
    }
    setTimeout(tick, 15)
  }

  function handleControlLine (sock, line) {
    commands.push(line)
    const head = line.split(' ')[0]
    if (head === 'AUTHENTICATE') return sock.write('250 OK\r\n')
    if (head === 'GETINFO') return sock.write('250-version=' + version + '\r\n250 OK\r\n')
    if (head === 'SETCONF') return sock.write('250 OK\r\n')
    if (head === 'SETEVENTS') {
      sock.write('250 OK\r\n')
      emitDescriptorUploads(2)
      return
    }
    if (head === 'DEL_ONION') {
      serviceLive = false
      return sock.write('250 OK\r\n')
    }
    if (head === 'ADD_ONION') {
      portMap.clear()
      for (const token of line.split(' ')) {
        if (!token.startsWith('Port=')) continue
        const [vport, target] = token.slice('Port='.length).split(',')
        const split = target.lastIndexOf(':')
        portMap.set(Number(vport), {
          host: target.slice(0, split),
          targetPort: Number(target.slice(split + 1))
        })
      }
      serviceLive = true
      const lines = ['250-ServiceID=' + serviceId]
      if (line.startsWith('ADD_ONION NEW:')) lines.push('250-PrivateKey=' + keyBlob)
      lines.push('250 OK')
      return sock.write(lines.join('\r\n') + '\r\n')
    }
    sock.write('250 OK\r\n')
  }

  const controlServer = net.createServer((sock) => {
    track(sock)
    controlSock = sock
    let buf = ''
    sock.on('data', (chunk) => {
      buf += chunk.toString()
      for (;;) {
        const idx = buf.indexOf('\r\n')
        if (idx === -1) break
        const line = buf.slice(0, idx)
        buf = buf.slice(idx + 2)
        try {
          handleControlLine(sock, line)
        } catch {
          try { sock.write('513 fake control error\r\n') } catch {}
        }
      }
    })
  })

  // ─── SOCKS5 (no-auth CONNECT) with .onion → vport-target routing ─────
  const socksServer = net.createServer((sock) => {
    track(sock)
    let buf = Buffer.alloc(0)
    let phase = 'greeting'
    sock.on('data', (chunk) => {
      if (phase === 'piped') return // stray data after piping starts is forwarded by pipe()
      buf = Buffer.concat([buf, chunk])
      try {
        if (phase === 'greeting') {
          if (buf.length < 2) return
          const nmethods = buf[1]
          if (buf.length < 2 + nmethods) return
          buf = buf.subarray(2 + nmethods)
          phase = 'request'
          sock.write(Buffer.from([0x05, 0x00])) // select no-auth
        }
        if (phase !== 'request') return
        if (buf.length < 4) return
        if (buf[0] !== 0x05 || buf[1] !== 0x01) { // CONNECT only
          sock.write(socksReply(0x07)) // command not supported
          sock.end()
          return
        }
        const atyp = buf[3]
        let host = null
        let addrEnd = -1
        if (atyp === 0x01) { // IPv4
          if (buf.length < 4 + 4 + 2) return
          host = [...buf.subarray(4, 8)].join('.')
          addrEnd = 8
        } else if (atyp === 0x03) { // domain
          const len = buf[4]
          if (buf.length < 5 + len + 2) return
          host = buf.subarray(5, 5 + len).toString('ascii')
          addrEnd = 5 + len
        } else {
          sock.write(socksReply(0x08)) // address type not supported
          sock.end()
          return
        }
        const port = buf.readUInt16BE(addrEnd)
        phase = 'piped'

        // The hidden-service forwarding model: only our service's .onion
        // resolves, and only on vports the latest ADD_ONION declared.
        const route = host === serviceId + '.onion' ? portMap.get(port) || null : null
        if (!route) {
          sock.write(socksReply(0x04)) // host unreachable
          sock.end()
          return
        }
        const upstream = track(net.createConnection(route.targetPort, route.host))
        upstream.on('error', () => { try { sock.destroy() } catch {} })
        upstream.on('connect', () => {
          sock.write(socksReply(0x00))
          sock.pipe(upstream)
          upstream.pipe(sock)
        })
      } catch {
        try { sock.destroy() } catch {}
      }
    })
  })

  await Promise.all([
    new Promise((resolve, reject) => {
      controlServer.once('error', reject)
      controlServer.listen(0, '127.0.0.1', resolve)
    }),
    new Promise((resolve, reject) => {
      socksServer.once('error', reject)
      socksServer.listen(0, '127.0.0.1', resolve)
    })
  ])

  const daemon = {
    serviceId,
    onionAddress: serviceId + '.onion',
    keyBlob,
    version,
    controlPort: controlServer.address().port,
    socksPort: socksServer.address().port,
    commands,
    portMap,
    addOnionCommands: () => commands.filter((c) => c.startsWith('ADD_ONION')),
    emitDescriptorUploads,
    close: async () => {
      // Destroy every live connection first — server.close() waits for them.
      for (const sock of [...sockets]) { try { sock.destroy() } catch {} }
      for (const server of [controlServer, socksServer]) {
        try { await new Promise((resolve) => server.close(resolve)) } catch {}
      }
    }
  }
  t.teardown(() => daemon.close())
  return daemon
}

function socksReply (code) {
  return Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
}