OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / transports / tor / index.js

660 lines · javascript ref:
/**
 * Tor Hidden Service Transport v2 (`hiverelay.onion/1` — RA-01b)
 *
 * Provides relay-side location anonymity via a Tor v3 onion service:
 *
 * 1. **Hidden service (inbound):** persistent ED25519-V3 onion service with
 *    multi-vport forwarding, optional restricted discovery (v3 client
 *    authorization), and health-gated readiness. Peers connect without
 *    knowing the relay's real IP. When a `peerListener` (OnionPeerListener)
 *    is attached, the peer vport (19737) is forwarded to it alongside the
 *    read plane, so the Noise/Protomux peer protocol runs over the onion.
 *
 * 2. **SOCKS5 proxy (outbound):** routes outbound connections through Tor
 *    so the relay's IP is hidden from peers it connects to.
 *
 * Backward compatible with v1: when `keyFile` is not set the service is
 * ephemeral (`NEW:BEST`), exactly as before.
 *
 * Verified control-port semantics (M0 spike, 2026-07-17, tor 0.4.9.6):
 * - Persistent keys: `ADD_ONION ED25519-V3:<blob>` restores the same address.
 * - Client auth is declared AT CREATION via repeated
 *   `ClientAuthV3=<base32-x25519-pubkey>`; there is no runtime service-side
 *   roster add — roster changes rebuild the service (DEL_ONION + ADD_ONION,
 *   same key blob, same address).
 * - PoW defense is daemon-wide via SETCONF
 *   (`HiddenServicePoWDefensesEnabled/QueueRate/QueueBurst`); there are no
 *   per-service ADD_ONION PoW kwargs.
 *
 * Requirements: tor >= 0.4.9.5 (floor enforced when minDaemonVersion set),
 * ControlPort + CookieAuthentication (or HashedControlPassword).
 */

import { EventEmitter } from 'events'
import net from 'net'
import { readFile, writeFile, rename, mkdir } from 'fs/promises'
import { dirname } from 'path'
import { SocksClient } from 'socks'
import { Duplex } from 'stream'
import { OnionRosterStore, isValidClientPub } from './auth-keys.js'
import { DEFAULT_PEER_VPORT } from './peer-listener.js'

const DEFAULT_SOCKS_HOST = '127.0.0.1'
const DEFAULT_SOCKS_PORT = 9050
const DEFAULT_CONTROL_HOST = '127.0.0.1'
const DEFAULT_CONTROL_PORT = 9051
const TOR_CHECK_TIMEOUT = 5000
const KEY_BLOB_RE = /^ED25519-V3:[A-Za-z0-9+/=]+$/

export const TorHealth = Object.freeze({
  DISABLED: 'disabled',
  STARTING: 'tor-starting',
  KEY_LOADED: 'key-loaded',
  DESCRIPTOR_UPLOADED: 'descriptor-uploaded',
  READY: 'ready',
  DEGRADED: 'degraded'
})

/** Parse and compare tor version strings like "0.4.9.6", "0.4.9.5-alpha-dev". */
export function parseTorVersion (str) {
  const m = String(str).match(/(\d+)\.(\d+)\.(\d+)\.(\d+)/)
  return m ? [+m[1], +m[2], +m[3], +m[4]] : null
}

export function versionAtLeast (version, floor) {
  const v = parseTorVersion(version)
  const f = parseTorVersion(floor)
  if (!v || !f) return false
  for (let i = 0; i < 4; i++) {
    if (v[i] !== f[i]) return v[i] > f[i]
  }
  return true
}

/**
 * Minimal Tor control-port client: serializes commands, routes complete
 * replies to their waiter, and dispatches asynchronous 650 events
 * IMMEDIATELY through a persistent parser — events must never wait for the
 * next command to be readable (the health machine depends on live HS_DESC
 * events arriving between commands; M0 spike finding).
 */
export class TorControl extends EventEmitter {
  constructor ({ host, port }) {
    super()
    this.host = host
    this.port = port
    this.sock = null
    this._queue = Promise.resolve()
    this._pending = null // { resolve, reject, timer, lines: [] }
    this._buf = ''
  }

  connect () {
    return new Promise((resolve, reject) => {
      this.sock = net.createConnection(this.port, this.host)
      const timer = setTimeout(() => {
        this.sock.destroy()
        reject(new Error(`Tor control port not reachable at ${this.host}:${this.port}. Enable it in /etc/tor/torrc: ControlPort ${this.port}`))
      }, TOR_CHECK_TIMEOUT)
      this.sock.on('connect', () => { clearTimeout(timer); resolve() })
      this.sock.on('error', (err) => { clearTimeout(timer); reject(new Error(`Tor control port error: ${err.message}. Enable it in /etc/tor/torrc: ControlPort ${this.port}`)) })
      this.sock.on('data', (chunk) => this._onData(chunk))
      this.sock.on('close', () => {
        if (this._pending) {
          const p = this._pending
          this._pending = null
          clearTimeout(p.timer)
          p.reject(new Error('Tor control socket closed'))
        }
      })
    })
  }

  _onData (chunk) {
    this._buf += chunk.toString()
    for (;;) {
      const idx = this._buf.indexOf('\r\n')
      if (idx === -1) return
      const line = this._buf.slice(0, idx)
      this._buf = this._buf.slice(idx + 2)
      if (line.startsWith('650 ')) { this.emit('event', line.slice(4)); continue }
      if (!this._pending) continue
      this._pending.lines.push(line)
      if (/^\d{3} /.test(line)) {
        const p = this._pending
        this._pending = null
        clearTimeout(p.timer)
        const body = p.lines.join('\n').trim()
        if (/^5\d{2}/m.test(body)) p.reject(new Error(body))
        else p.resolve(body)
      }
    }
  }

  /** Send a command and wait for its reply. Commands are serialized. */
  cmd (command, timeoutMs = 15000) {
    const run = this._queue.then(() => this._send(command, timeoutMs))
    this._queue = run.catch(() => {})
    return run
  }

  _send (command, timeoutMs) {
    return new Promise((resolve, reject) => {
      if (!this.sock || this.sock.destroyed) return reject(new Error('Tor control socket closed'))
      const timer = setTimeout(() => {
        this._pending = null
        reject(new Error(`Tor control command timed out: ${command.split(' ')[0]}`))
      }, timeoutMs)
      this._pending = { resolve, reject, timer, lines: [] }
      this.sock.write(command + '\r\n')
    })
  }

  destroy () { try { this.sock && this.sock.destroy() } catch {} this.sock = null }
}

export class TorTransport extends EventEmitter {
  constructor (opts = {}) {
    super()
    this.socksHost = opts.socksHost || DEFAULT_SOCKS_HOST
    this.socksPort = opts.socksPort || DEFAULT_SOCKS_PORT
    this.controlHost = opts.controlHost || DEFAULT_CONTROL_HOST
    this.controlPort = opts.controlPort || DEFAULT_CONTROL_PORT
    this.controlPassword = opts.controlPassword || null
    this.cookieAuthFile = opts.cookieAuthFile || '/var/lib/tor/control_auth_cookie'

    // v2 options — all optional; absence preserves v1 behavior
    this.keyFile = opts.keyFile || null // persistent ED25519-V3 key custody
    this.minDaemonVersion = opts.minDaemonVersion || null // fail-closed floor
    this.vports = Array.isArray(opts.vports) ? opts.vports : null // [{ vport, targetHost?, targetPort }]
    this.clientAuthKeys = Array.isArray(opts.clientAuthKeys) ? [...opts.clientAuthKeys] : [] // base32 x25519 pubkeys
    this.rosterFile = opts.rosterFile || null // persisted roster (survives restarts); operator-private
    this._roster = this.rosterFile ? new OnionRosterStore(this.rosterFile) : null
    this.endpointKeyId = opts.endpointKeyId || null // signed-advertisement key id (rotation)
    this.startedAtMs = null
    this.pow = opts.pow || null // { enabled, queueRate?, queueBurst? } — daemon-wide SETCONF
    this.healthOpts = Object.assign({ probeIntervalMs: 900000, probeFailLimit: 3, minDescriptorUploads: 2, probeVport: null }, opts.health || {})
    this.maxStreams = opts.maxStreams || null

    // Peer protocol plane: a bound OnionPeerListener the peer vport forwards
    // to. Inbound peer connections surface through the same 'connection'
    // event as outbound SOCKS streams — the relay's one handler serves both.
    this.peerListener = opts.peerListener || null
    this.peerVport = opts.peerVport || DEFAULT_PEER_VPORT
    if (this.peerListener) {
      this.peerListener.on('connection', (stream, info) => this.emit('connection', stream, info))
    }

    // legacy: single localPort → vport 80
    this.localPort = opts.localPort || null

    this.onionAddress = null
    this.serviceId = null
    this.daemonVersion = null
    this.health = TorHealth.DISABLED
    this.running = false
    this._control = null
    this._connections = new Set()
    this._socksConnect = opts._socksConnectFactory || SocksClient.createConnection.bind(SocksClient)
    this._probeTimer = null
    this._probeFails = 0
    this._descriptorUploads = 0
    this._controlFactory = opts._controlFactory || null // test injection
  }

  _makeControl () {
    if (this._controlFactory) return this._controlFactory()
    return new TorControl({ host: this.controlHost, port: this.controlPort })
  }

  _effectiveVports () {
    const vports = (this.vports && this.vports.length)
      ? [...this.vports]
      : this.localPort
        ? [{ vport: 80, targetHost: '127.0.0.1', targetPort: this.localPort }]
        : []
    // Forward the peer vport to the bound peer listener so the hidden
    // service carries Noise/Protomux next to the read plane. An explicit
    // vports entry for the same vport wins (operator override).
    if (this.peerListener && !vports.some((v) => v.vport === this.peerVport)) {
      vports.push({ vport: this.peerVport, targetHost: this.peerListener.host, targetPort: this.peerListener.port })
    }
    return vports
  }

  async start () {
    if (this.running) return
    this._setHealth(TorHealth.STARTING)

    await this._checkTorRunning()

    // Bind the peer protocol endpoint before the hidden service is created
    // so the peer vport forwards to the live port (ephemeral binds included).
    if (this.peerListener && !this.peerListener.running) await this.peerListener.start()

    const vports = this._effectiveVports()
    if (vports.length || this.minDaemonVersion || this.pow) {
      this._control = this._makeControl()
      await this._control.connect()
      await this._controlAuth()
      this._control.on('event', (line) => this._onControlEvent(line))

      if (this.minDaemonVersion) {
        await this._checkDaemonVersion()
      }
      if (this.pow && this.pow.enabled) {
        await this._applyPow()
      }
      if (vports.length) {
        await this._createHiddenService(vports)
      }
    } else if (this.localPort) {
      await this._createHiddenService(this.localPort) // unreachable; kept for clarity
    }

    this.running = true
    this.startedAtMs = Date.now()
    this.emit('started', {
      socksPort: this.socksPort,
      onionAddress: this.onionAddress,
      health: this.health,
      daemonVersion: this.daemonVersion
    })
  }

  async stop () {
    if (!this.running) return
    this.running = false
    this.startedAtMs = null
    this._setHealth(TorHealth.DISABLED)
    if (this._probeTimer) { clearInterval(this._probeTimer); this._probeTimer = null }

    for (const conn of this._connections) conn.destroy()
    this._connections.clear()

    if (this._control) { this._control.destroy(); this._control = null }
    if (this.peerListener) await this.peerListener.stop()
    this._descriptorUploads = 0
    this.emit('stopped')
  }

  /**
   * Create a SOCKS5 connection through Tor to a .onion address or IP.
   * Returns a Duplex stream compatible with Hyperswarm connections.
   */
  async connect (host, port) {
    if (!this.running) throw new Error('Tor transport not running')

    const { socket } = await SocksClient.createConnection({
      proxy: { host: this.socksHost, port: this.socksPort, type: 5 },
      command: 'connect',
      destination: { host, port },
      timeout: 30000
    })

    const stream = new TorStream(socket)
    this._connections.add(stream)
    stream.on('close', () => this._connections.delete(stream))

    this.emit('connection', stream, {
      type: 'tor',
      remoteAddress: host,
      remotePort: port,
      isOnion: host.endsWith('.onion')
    })

    return stream
  }

  // ---------- client-auth roster (restricted discovery) ----------

  /**
   * Rebuild the service with an updated authorized-client set.
   * Tor exposes no runtime service-side roster add; the service keeps its
   * key blob and address, so this is a brief intro-point churn, not an
   * identity change. ONION-INV: address MUST stay identical after rebuild.
   */
  async _rebuildWithRoster (keys) {
    if (!this.keyFile) throw new Error('client-auth roster requires persistent keyFile mode')
    const blob = await this._loadOrThrowKey()
    await this._control.cmd('DEL_ONION ' + this.serviceId)
    const addressBefore = this.onionAddress
    this._descriptorUploads = 0
    await this._addOnion(blob, keys)
    if (this.onionAddress !== addressBefore) {
      throw new Error('onion address changed across roster rebuild — refusing to continue')
    }
  }

  async addAuthClient (pubB32, { name = null, expiresAtMs = null } = {}) {
    if (!isValidClientPub(pubB32)) throw new Error('invalid x25519 client public key (base32, 52 chars)')
    if (this._roster) {
      if (!this._roster.loaded) await this._roster.load()
      this._roster.add(pubB32, { name, expiresAtMs })
      this._roster.purge()
      await this._roster.save()
      this.clientAuthKeys = this._roster.activeKeys()
    } else if (!this.clientAuthKeys.includes(pubB32)) {
      this.clientAuthKeys = [...this.clientAuthKeys, pubB32]
    } else {
      return this.clientAuthKeys
    }
    if (this.onionAddress) await this._rebuildWithRoster(this.clientAuthKeys)
    this.emit('roster-changed', { added: pubB32, size: this.clientAuthKeys.length })
    return this.clientAuthKeys
  }

  async removeAuthClient (pubB32) {
    let changed = true
    if (this._roster) {
      if (!this._roster.loaded) await this._roster.load()
      changed = this._roster.revoke(pubB32)
      this._roster.purge()
      await this._roster.save()
      this.clientAuthKeys = this._roster.activeKeys()
    } else {
      const keys = this.clientAuthKeys.filter((k) => k !== pubB32)
      changed = keys.length !== this.clientAuthKeys.length
      this.clientAuthKeys = keys
    }
    if (changed && this.onionAddress) await this._rebuildWithRoster(this.clientAuthKeys)
    if (changed) this.emit('roster-changed', { removed: pubB32, size: this.clientAuthKeys.length })
    return this.clientAuthKeys
  }

  listAuthClients () { return [...this.clientAuthKeys] }

  // ---------- health ----------

  _setHealth (state) {
    if (this.health === state) return
    this.health = state
    this.emit('health', state)
  }

  _onControlEvent (line) {
    if (line.startsWith('HS_DESC UPLOADED') && this.serviceId && line.includes(this.serviceId)) {
      this._descriptorUploads++
      if (this.health === TorHealth.KEY_LOADED && this._descriptorUploads >= this.healthOpts.minDescriptorUploads) {
        this._setHealth(TorHealth.DESCRIPTOR_UPLOADED)
        this._probeNow()
      }
    }
  }

  async _startHealthLoop () {
    if (this._probeTimer) clearInterval(this._probeTimer)
    this._probeFails = 0
    this._probeTimer = setInterval(() => this._probeNow(), this.healthOpts.probeIntervalMs)
    this._probeTimer.unref && this._probeTimer.unref()
  }

  /** Self-probe: SOCKS-connect back to our own onion through the network. */
  async _probeNow () {
    const vport = this.healthOpts.probeVport
    if (!vport || !this.onionAddress) {
      // No probe surface configured: descriptor uploads are the readiness signal.
      if (this.health === TorHealth.DESCRIPTOR_UPLOADED) this._setHealth(TorHealth.READY)
      return
    }
    try {
      const { socket } = await this._socksConnect({
        proxy: { host: this.socksHost, port: this.socksPort, type: 5 },
        command: 'connect',
        destination: { host: this.onionAddress, port: vport },
        timeout: 60000
      })
      socket.destroy()
      this._probeFails = 0
      if (this._restrictedDiscoveryConfigured()) {
        // Positive reachability is not enough under restricted discovery: the
        // tor daemon runs FAIL-OPEN when every roster key is invalid, which
        // would silently turn a "restricted" service into an open one while we
        // still advertise it as restricted. Probe the negative side too.
        const failOpen = await this._probeNegative(vport)
        if (failOpen) {
          this._setHealth(TorHealth.DEGRADED)
          this.emit('health-degraded', { reason: 'restricted-discovery-fail-open' })
          return
        }
      }
      this._setHealth(TorHealth.READY)
    } catch {
      this._probeFails++
      if (this._probeFails >= this.healthOpts.probeFailLimit) this._setHealth(TorHealth.DEGRADED)
    }
  }

  _restrictedDiscoveryConfigured () {
    return this.clientAuthKeys.length > 0 || (this._roster && this._roster.keys.size > 0)
  }

  /**
   * Negative probe: a client presenting a credential that is NOT in the roster
   * must be refused. Installs a deliberately bogus client credential, attempts
   * the connection, then removes it (best-effort, even on failure — it must
   * never linger in the daemon's client-auth set). Returns true when the
   * connection succeeded, i.e. the service accepted an unauthorized client
   * (the fail-open state: roster present but ineffective).
   */
  async _probeNegative (vport) {
    const bogusPub = 'a'.repeat(43) + 'x'
    const bogusPriv = 'a'.repeat(43) + 'y'
    const addCmd = `ONION_CLIENT_AUTH_ADD ${this.serviceId} x25519:${bogusPriv} Flags=Temporary`
    const removeCmd = `ONION_CLIENT_AUTH_REMOVE ${this.serviceId} x25519:${bogusPub}`
    try {
      await this._control.cmd(addCmd)
    } catch {
      return false // daemon refuses to run the probe at all — treat as restricted holding
    }
    let accepted = false
    try {
      const { socket } = await this._socksConnect({
        proxy: { host: this.socksHost, port: this.socksPort, type: 5 },
        command: 'connect',
        destination: { host: this.onionAddress, port: vport },
        timeout: 60000
      })
      socket.destroy()
      accepted = true
    } catch {
      accepted = false
    }
    try { await this._control.cmd(removeCmd) } catch {}
    return accepted
  }

  // ---------- daemon setup ----------

  async _checkDaemonVersion () {
    const resp = await this._control.cmd('GETINFO version')
    this.daemonVersion = (resp.split('=')[1] || '').trim()
    if (!versionAtLeast(this.daemonVersion, this.minDaemonVersion)) {
      throw new Error(`tor daemon ${this.daemonVersion} below floor ${this.minDaemonVersion} — refusing to start (fail closed)`)
    }
  }

  async _applyPow () {
    const parts = ['HiddenServicePoWDefensesEnabled=1']
    if (this.pow.queueRate) parts.push(`HiddenServicePoWQueueRate=${this.pow.queueRate}`)
    if (this.pow.queueBurst) parts.push(`HiddenServicePoWQueueBurst=${this.pow.queueBurst}`)
    // PoW is daemon-wide via SETCONF (no per-service ADD_ONION kwargs exist),
    // and C-tor REJECTS it with "no preceding HiddenServiceDir directive" when
    // no filesystem hidden service is configured (M0 finding, tor 0.4.9.6):
    // PoW on a control-port-only deployment requires at least one
    // HiddenServiceDir in torrc/SETCONF. Surface that failure loudly.
    try {
      await this._control.cmd('SETCONF ' + parts.join(' '))
    } catch (err) {
      throw new Error(`tor PoW SETCONF failed (${err.message.slice(0, 120)}) — PoW defense requires a HiddenServiceDir-configured service; control-port-only services cannot use it`)
    }
  }

  async _createHiddenService (vportsOrLegacy) {
    const vports = Array.isArray(vportsOrLegacy)
      ? vportsOrLegacy
      : [{ vport: 80, targetHost: '127.0.0.1', targetPort: vportsOrLegacy }]

    // Ensure we have a control connection even in legacy mode
    if (!this._control) {
      this._control = this._makeControl()
      await this._control.connect()
      await this._controlAuth()
      this._control.on('event', (line) => this._onControlEvent(line))
    }

    if (this.keyFile) {
      const blob = await this._loadOrCreateKey()
      if (this._roster) {
        await this._roster.load()
        this._roster.purge()
        const union = new Set([...this.clientAuthKeys, ...this._roster.activeKeys()])
        this.clientAuthKeys = [...union]
      }
      await this._addOnion(blob, this.clientAuthKeys, vports)
    } else {
      await this._addOnion(null, this.clientAuthKeys, vports)
    }

    this._setHealth(TorHealth.KEY_LOADED)
    await this._startHealthLoop()

    // Subscribe to descriptor events (needed for descriptor-uploaded health)
    try { await this._control.cmd('SETEVENTS HS_DESC') } catch {}

    this.emit('hidden-service', { onionAddress: this.onionAddress, vports, health: this.health })
  }

  async _addOnion (keyBlob, clientKeys = [], vports = null) {
    const effective = vports || this._effectiveVports()
    const portArgs = effective.map((v) => `Port=${v.vport},${v.targetHost || '127.0.0.1'}:${v.targetPort}`).join(' ')
    // v3 client authorization requires BOTH the V3Auth flag (auth type) and
    // per-client ClientAuthV3 keys — keys without the flag fail with
    // "512 No auth type specified" (M0 finding, tor 0.4.9.6).
    const flags = clientKeys.length ? ' Flags=V3Auth' : ''
    const authArgs = clientKeys.map((k) => `ClientAuthV3=${k}`).join(' ')
    const keyArg = keyBlob || (this.keyFile ? 'NEW:ED25519-V3' : 'NEW:BEST')
    const maxStreams = this.maxStreams ? ` MaxStreams=${this.maxStreams}` : ''
    const cmd = [`ADD_ONION ${keyArg}${flags}`, portArgs, authArgs].filter(Boolean).join(' ') + maxStreams

    const response = await this._control.cmd(cmd)
    for (const line of response.split('\n')) {
      if (line.startsWith('250-ServiceID=')) {
        this.serviceId = line.split('=')[1].trim()
        this.onionAddress = this.serviceId + '.onion'
      }
      if (!keyBlob && line.startsWith('250-PrivateKey=')) {
        await this._storeKey(line.slice('250-PrivateKey='.length).trim())
      }
    }
    if (!this.onionAddress) throw new Error('Failed to create hidden service — no ServiceID in response')
  }

  async _loadOrThrowKey () {
    const blob = (await readFile(this.keyFile, 'utf8')).trim()
    if (!KEY_BLOB_RE.test(blob)) {
      throw new Error(`corrupt tor key file at ${this.keyFile} — refusing silent re-identity; restore from backup or delete to mint a new endpoint`)
    }
    return blob
  }

  async _loadOrCreateKey () {
    try {
      return await this._loadOrThrowKey()
    } catch (err) {
      if (err.code === 'ENOENT') return null // first boot: mint below
      throw err
    }
  }

  async _storeKey (blob) {
    if (!this.keyFile || !KEY_BLOB_RE.test(blob)) return
    await mkdir(dirname(this.keyFile), { recursive: true })
    const tmp = this.keyFile + '.tmp'
    await writeFile(tmp, blob + '\n', { mode: 0o600 })
    await rename(tmp, this.keyFile)
  }

  // ---------- shared v1 helpers ----------

  async _checkTorRunning () {
    return new Promise((resolve, reject) => {
      const sock = net.createConnection(this.socksPort, this.socksHost)
      const timer = setTimeout(() => {
        sock.destroy()
        reject(new Error(`Tor SOCKS proxy not reachable at ${this.socksHost}:${this.socksPort}. Make sure Tor is running: sudo systemctl start tor`))
      }, TOR_CHECK_TIMEOUT)
      sock.on('connect', () => { clearTimeout(timer); sock.destroy(); resolve() })
      sock.on('error', (err) => {
        clearTimeout(timer)
        reject(new Error(`Tor SOCKS proxy not reachable at ${this.socksHost}:${this.socksPort}: ${err.message}. Make sure Tor is running: sudo systemctl start tor`))
      })
    })
  }

  async _controlAuth () {
    try {
      const cookie = await readFile(this.cookieAuthFile)
      const response = await this._control.cmd(`AUTHENTICATE ${cookie.toString('hex')}`)
      if (response.startsWith('250')) return
    } catch {}
    if (this.controlPassword) {
      const escapedPassword = this.controlPassword.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
      const response = await this._control.cmd(`AUTHENTICATE "${escapedPassword}"`)
      if (response.startsWith('250')) return
      throw new Error('Tor control authentication failed with password')
    }
    const response = await this._control.cmd('AUTHENTICATE')
    if (response.startsWith('250')) return
    throw new Error('Tor control authentication failed. Configure in /etc/tor/torrc: CookieAuthentication 1 or HashedControlPassword')
  }

  getInfo () {
    return {
      running: this.running,
      health: this.health,
      daemonVersion: this.daemonVersion,
      socksProxy: `${this.socksHost}:${this.socksPort}`,
      onionAddress: this.onionAddress,
      vports: this._effectiveVports().map((v) => v.vport),
      authClients: this.clientAuthKeys.length,
      pow: this.pow ? !!this.pow.enabled : false,
      persistent: !!this.keyFile,
      descriptorUploads: this._descriptorUploads,
      activeConnections: this._connections.size
    }
  }
}

/**
 * Wraps a SOCKS5-established TCP socket into a Duplex stream
 * compatible with the Hyperswarm connection interface.
 */
class TorStream extends Duplex {
  constructor (socket, opts = {}) {
    super({ ...opts, allowHalfOpen: false })
    this.socket = socket
    socket.on('data', (data) => { if (!this.push(data)) socket.pause() })
    socket.on('end', () => this.push(null))
    socket.on('close', () => this.destroy())
    socket.on('error', (err) => this.destroy(err))
  }

  _write (chunk, encoding, cb) {
    if (this.socket.destroyed) { cb(new Error('Socket is destroyed')); return }
    this.socket.write(chunk, encoding, cb)
  }

  _read () {
    if (this.socket && !this.socket.destroyed) this.socket.resume()
  }

  _destroy (err, cb) {
    if (!this.socket.destroyed) this.socket.destroy()
    cb(err)
  }

  get remoteHost () { return this.socket.remoteAddress }
  get remotePort () { return this.socket.remotePort }
}