doh.js

import * as packet from 'dns-packet'
import * as tls from 'tls'
import DNSutils from './dnsUtils.js'
import {Writable} from 'stream'
import cryptoRandomString from 'crypto-random-string'
import fs from 'fs'
import got from 'got'

const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))

const PAD_SIZE = 128
const WF_DNS = 'application/dns-message'
const WF_JSON = 'application/dns-json'
const CLOUDFLARE_API = 'https://cloudflare-dns.com/dns-query'
const USER_AGENT = `${pkg.name} v${pkg.version}`

/**
 * Options for doing DOH lookups.
 *
 * @typedef {object} DOH_LookupOptions
 * @property {string} [name] The DNS name to look up.
 * @property {string} [rrtype='A'] The Resource Record type
 *   to retrive.
 * @property {boolean} [json=true] Retrieve a JSON response.  If false,
 *   retrieve using DNS format.
 * @property {boolean} [decode=true] Decode the response, either into JSON
 *   or an object representing the DNS format result.
 * @property {boolean} [preferPost=true] For DNS format requests, should
 *   the HTTP POST verb be used?  If false, uses GET.
 * @property {boolean} [dnssec=false] Request DNSSec records.  Currently
 *   requires `json: false`.
 * @property {string} [url=CLOUDFLARE_API] What DoH endpoint should be
 *   used?
 */

/**
 * Request DNS information over HTTPS.  The [lookup]{@link DNSoverHTTPS#lookup}
 * function provides the easiest-to-use defaults.
 */
export class DNSoverHTTPS extends DNSutils {
  /**
   * Create a DNSoverHTTPS instance.
   *
   * @param {object} opts Options for all requests.
   * @param {string} [opts.userAgent="packageName version"] User Agent for
   *   HTTP request.
   * @param {string} [opts.url="https://cloudflare-dns.com/dns-query"] Base URL
   *   for all HTTP requests.
   * @param {boolean} [opts.preferPost=true] Should POST be preferred to Get
   *   for DNS-format queries?
   * @param {string} [opts.contentType="application/dns-udpwireformat"]
   *   MIME type for POST.
   * @param {number} [opts.verbose=0] How verbose do you want your logging?
   * @param {Writable} [opts.verboseStream=process.stderr] Where to write
   *   verbose output.
   * @param {boolean} [opts.http2=false] Use http/2 if it is available.
   */
  constructor(opts = {}) {
    const {
      verbose,
      verboseStream,
      ...rest
    } = opts
    super({verbose, verboseStream})
    this.opts = {
      userAgent: DNSoverHTTPS.userAgent,
      url: DNSoverHTTPS.defaultURL,
      preferPost: true,
      contentType: WF_DNS,
      http2: false,
      ...rest,
    }

    this.hooks = (this._verbose > 0) ?
      {
        beforeRequest: [options => {
          this.verbose(1, `HTTP ${options.method} headers:`, options.headers)
          this.verbose(1, `HTTP ${options.method} URL: ${options.url.toString()}`)
        }],
      } :
      undefined

    this.verbose(1, 'DNSoverHTTPS options:', this.opts)
  }

  /**
   * @private
   * @ignore
   */
  _checkServerIdentity() {
    return {
      // This doesn't fire in nock tests.
      checkServerIdentity: (host, cert) => {
        this.verbose(3, 'CERTIFICATE:', () => DNSutils.buffersToB64(cert))
        return tls.checkServerIdentity(host, cert)
      },
    }
  }

  /**
   * Get a DNS-format response.
   *
   * @param {DOH_LookupOptions} opts Options for the request.
   * @returns {Promise<Buffer|object>} DNS result.
   */
  async getDNS(opts) {
    this.verbose(1, 'DNSoverHTTPS.getDNS options:', opts)

    const pkt = DNSutils.makePacket(opts)
    let url = opts.url || this.opts.url
    let body = pkt

    this.verbose(1, 'REQUEST:', () => packet.decode(pkt))
    this.hexDump(2, pkt)

    if (!this.opts.preferPost) {
      url += `?dns=${DNSutils.base64urlEncode(pkt)}`
      body = undefined
    }
    const response = await got(url, {
      method: this.opts.preferPost ? 'POST' : 'GET',
      headers: {
        'Content-Type': this.opts.contentType,
        'User-Agent': this.opts.userAgent,
        Accept: this.opts.contentType,
      },
      body,
      https: this._checkServerIdentity(),
      http2: this.opts.http2,
      hooks: this.hooks,
      retry: {
        limit: 0,
      },
    }).buffer()
    this.hexDump(2, response)
    this.verbose(1, 'RESPONSE:', () => packet.decode(response))

    return opts.decode ? packet.decode(response) : response
  }

  /**
   * Make a HTTPS GET request for JSON DNS.
   *
   * @param {object} opts Options for the request.
   * @param {string} [opts.name] The name to look up.
   * @param {string} [opts.rrtype="A"] The record type to look up.
   * @param {boolean} [opts.decode=true] Parse the returned JSON?
   * @param {boolean} [opts.dnssec=false] Request DNSSEC records.
   * @returns {Promise<string|object>} DNS result.
   */
  getJSON(opts) {
    this.verbose(1, 'DNSoverHTTPS.getJSON options: ', opts)

    const rrtype = opts.rrtype || 'A'
    let req = `${this.opts.url}?name=${opts.name}&type=${rrtype}`
    if (opts.dnssec) {
      req += '&do=1'
    }
    req += '&random_padding='
    req += cryptoRandomString({
      length: (Math.ceil(req.length / PAD_SIZE) * PAD_SIZE) - req.length,
      type: 'url-safe',
    })
    this.verbose(1, 'REQUEST:', req)

    const r = got(
      req, {
        headers: {
          'User-Agent': this.opts.userAgent,
          Accept: WF_JSON,
        },
        https: this._checkServerIdentity(),
        http2: this.opts.http2,
        hooks: this.hooks,
        retry: {
          limit: 0,
        },
      }
    )

    const decode = Object.prototype.hasOwnProperty.call(opts, 'decode') ?
      opts.decode :
      true
    return decode ? r.json() : r.text()
  }

  /**
   * Look up a DNS entry using DNS-over-HTTPS (DoH).
   *
   * @param {string|DOH_LookupOptions} name The DNS name to look up, or opts
   *   if this is an object.
   * @param {DOH_LookupOptions|string} [opts={}] Options for the
   *   request.  If a string is given, it will be used as the rrtype.
   * @returns {Promise<Buffer|string|object>} DNS result.
   */
  lookup(name, opts = {}) {
    const nopts = DNSutils.normalizeArgs(name, opts, {
      rrtype: 'A',
      json: true,
      decode: true,
      dnssec: false,
    })
    this.verbose(1, 'DNSoverHTTPS.lookup options:', nopts)

    return nopts.json ? this.getJSON(nopts) : this.getDNS(nopts)
  }

  // eslint-disable-next-line class-methods-use-this
  close() {
    // No-op for now
  }
}

function setStatic(c) {
  // Hide these from typescript
  c.userAgent = USER_AGENT
  c.defaultURL = CLOUDFLARE_API
}

/** @type {string} */
DNSoverHTTPS.version = pkg.version
DNSoverHTTPS.userAgent = ''
DNSoverHTTPS.defaultURL = ''
setStatic(DNSoverHTTPS)

export default DNSoverHTTPS