index.js 7 KB
'use strict'

const dgram = require('dgram')
const util = require('util')
const packet = require('dns-packet')
const events = require('events')

module.exports = DNS

function DNS (opts) {
  if (!(this instanceof DNS)) {
    return new DNS(opts)
  }
  if (!opts) {
    opts = {}
  }

  events.EventEmitter.call(this)

  const self = this

  this.retries = opts.retries !== undefined ? opts.retries : 5
  this.timeout = opts.timeout || 7500
  this.timeoutChecks = opts.timeoutChecks || (this.timeout / 10)
  this.destroyed = false
  this.inflight = 0
  this.maxQueries = opts.maxQueries || 10000
  this.maxRedirects = opts.maxRedirects || 0
  this.socket = opts.socket || dgram.createSocket('udp4')
  this._id = Math.ceil(Math.random() * this.maxQueries)
  this._queries = new Array(this.maxQueries).fill(null)
  this._interval = null

  this.socket.on('error', onerror)
  this.socket.on('message', onmessage)
  if (isListening(this.socket)) onlistening()
  else this.socket.on('listening', onlistening)
  this.socket.on('close', onclose)

  function onerror (err) {
    if (err.code === 'EACCES' || err.code === 'EADDRINUSE') {
      self.emit('error', err)
    } else {
      self.emit('warning', err)
    }
  }

  function onmessage (message, rinfo) {
    self._onmessage(message, rinfo)
  }

  function ontimeoutCheck () {
    self._ontimeoutCheck()
  }

  function onlistening () {
    self._interval = setInterval(ontimeoutCheck, self.timeoutChecks)
    self.emit('listening')
  }

  function onclose () {
    self.emit('close')
  }
}

util.inherits(DNS, events.EventEmitter)

DNS.RECURSION_DESIRED = DNS.prototype.RECURSION_DESIRED = packet.RECURSION_DESIRED
DNS.RECURSION_AVAILABLE = DNS.prototype.RECURSION_AVAILABLE = packet.RECURSION_AVAILABLE
DNS.TRUNCATED_RESPONSE = DNS.prototype.TRUNCATED_RESPONSE = packet.TRUNCATED_RESPONSE
DNS.AUTHORITATIVE_ANSWER = DNS.prototype.AUTHORITATIVE_ANSWER = packet.AUTHORITATIVE_ANSWER
DNS.AUTHENTIC_DATA = DNS.prototype.AUTHENTIC_DATA = packet.AUTHENTIC_DATA
DNS.CHECKING_DISABLED = DNS.prototype.CHECKING_DISABLED = packet.CHECKING_DISABLED

DNS.prototype.address = function () {
  return this.socket.address()
}

DNS.prototype.bind = function (...args) {
  const onlistening = args.length > 0 && args[args.length - 1]
  if (typeof onlistening === 'function') {
    this.once('listening', onlistening)
    this.socket.bind(...args.slice(0, -1))
  } else {
    this.socket.bind(...args)
  }
}

DNS.prototype.destroy = function (onclose) {
  if (onclose) {
    this.once('close', onclose)
  }
  if (this.destroyed) {
    return
  }
  this.destroyed = true
  clearInterval(this._interval)
  this.socket.close()

  for (let i = 0; i < this.maxQueries; i++) {
    const q = this._queries[i]
    if (q) {
      q.callback(new Error('Socket destroyed'))
      this._queries[i] = null
    }
  }
  this.inflight = 0
}

DNS.prototype._ontimeoutCheck = function () {
  const now = Date.now()
  for (let i = 0; i < this.maxQueries; i++) {
    const q = this._queries[i]

    if ((!q) || (now - q.firstTry < (q.tries + 1) * this.timeout)) {
      continue
    }

    if (q.tries > this.retries) {
      this._queries[i] = null
      this.inflight--
      this.emit('timeout', q.query, q.port, q.host)
      q.callback(new Error('Query timed out'))
      continue
    }
    q.tries++
    this.socket.send(q.buffer, 0, q.buffer.length, q.port, Array.isArray(q.host) ? q.host[Math.floor(q.host.length * Math.random())] : q.host || '127.0.0.1')
  }
}

DNS.prototype._shouldRedirect = function (q, result) {
  // no redirects, no query, more than 1 questions, has any A record answer
  if (this.maxRedirects <= 0 || (!q) || (q.query.questions.length !== 1) || result.answers.filter(e => e.type === 'A').length > 0) {
    return false
  }

  // no more redirects left
  if (q.redirects > this.maxRedirects) {
    return false
  }

  const cnameresults = result.answers.filter(e => e.type === 'CNAME')
  if (cnameresults.length === 0) {
    return false
  }

  const id = this._getNextEmptyId()
  if (id === -1) {
    q.callback(new Error('Query array is full!'))
    return true
  }

  // replace current query with a new one
  q.query = {
    id: id + 1,
    flags: packet.RECURSION_DESIRED,
    questions: [{
      type: 'A',
      name: cnameresults[0].data
    }]
  }
  q.redirects++
  q.firstTry = Date.now()
  q.tries = 0
  q.buffer = packet.encode(q.query)
  this._queries[id] = q
  this.socket.send(q.buffer, 0, q.buffer.length, q.port, Array.isArray(q.host) ? q.host[Math.floor(q.host.length * Math.random())] : q.host || '127.0.0.1')
  return true
}

DNS.prototype._onmessage = function (buffer, rinfo) {
  let message

  try {
    message = packet.decode(buffer)
  } catch (err) {
    this.emit('warning', err)
    return
  }

  if (message.type === 'response' && message.id) {
    const q = this._queries[message.id - 1]
    if (q) {
      this._queries[message.id - 1] = null
      this.inflight--

      if (!this._shouldRedirect(q, message)) {
        q.callback(null, message)
      }
    }
  }

  this.emit(message.type, message, rinfo.port, rinfo.address)
}

DNS.prototype.unref = function () {
  this.socket.unref()
}

DNS.prototype.ref = function () {
  this.socket.ref()
}

DNS.prototype.response = function (query, response, port, host) {
  if (this.destroyed) {
    return
  }

  response.type = 'response'
  response.id = query.id
  const buffer = packet.encode(response)
  this.socket.send(buffer, 0, buffer.length, port, host)
}

DNS.prototype.cancel = function (id) {
  const q = this._queries[id]
  if (!q) return

  this._queries[id] = null
  this.inflight--
  q.callback(new Error('Query cancelled'))
}

DNS.prototype.setRetries = function (id, retries) {
  const q = this._queries[id]
  if (!q) return
  q.firstTry = q.firstTry - this.timeout * (retries - q.retries)
  q.retries = this.retries - retries
}

DNS.prototype._getNextEmptyId = function () {
  // try to find the next unused id
  let id = -1
  for (let idtries = this.maxQueries; idtries > 0; idtries--) {
    const normalizedId = (this._id + idtries) % this.maxQueries
    if (this._queries[normalizedId] === null) {
      id = normalizedId
      this._id = (normalizedId + 1) % this.maxQueries
      break
    }
  }
  return id
}

DNS.prototype.query = function (query, port, host, cb) {
  if (this.destroyed) {
    cb(new Error('Socket destroyed'))
    return 0
  }

  this.inflight++
  query.type = 'query'
  query.flags = typeof query.flags === 'number' ? query.flags : DNS.RECURSION_DESIRED

  const id = this._getNextEmptyId()
  if (id === -1) {
    cb(new Error('Query array is full!'))
    return 0
  }

  query.id = id + 1
  const buffer = packet.encode(query)

  this._queries[id] = {
    callback: cb || noop,
    redirects: 0,
    firstTry: Date.now(),
    query: query,
    tries: 0,
    buffer: buffer,
    port: port,
    host: host
  }
  this.socket.send(buffer, 0, buffer.length, port, Array.isArray(host) ? host[Math.floor(host.length * Math.random())] : host || '127.0.0.1')
  return id
}

function noop () {
}

function isListening (socket) {
  try {
    return socket.address().port !== 0
  } catch (err) {
    return false
  }
}