import * as packet from 'dns-packet'
import * as rcodes from 'dns-packet/rcodes.js'
import {Buffer} from 'buffer'
import {EventEmitter} from 'events'
import {Writable} from 'stream'
import ip from 'ip'
import net from 'net'
import url from 'url'
import util from 'util'
const PAD_SIZE = 128
// Extracted from node source
function stylizeWithColor(str, styleType) {
const style = util.inspect.styles[styleType]
if (style !== undefined) {
const color = util.inspect.colors[style]
return `\u001b[${color[0]}m${str}\u001b[${color[1]}m`
}
return str
}
function styleStream(stream, str, styleType) {
stream.write(stream.isTTY ? stylizeWithColor(str, styleType) : str)
}
function printableString(stream, buf) {
// Intent: each byte that is "printable" takes up one grapheme, and everything
// else is replaced with '.'
for (const x of buf) {
if ((x < 0x20) ||
((x > 0x7e) && (x < 0xa1)) ||
(x === 0xad)) {
stream.write('.')
} else {
styleStream(stream, String.fromCharCode(x), 'string')
}
}
return buf.length
}
export class DNSutils extends EventEmitter {
/**
* Creates an instance of DNSutils.
*
* @param {object} [opts={}] Options.
* @param {number} [opts.verbose=0] How verbose do you want your logging?
* @param {Writable} [opts.verboseStream=process.stderr] Where to write
* verbose output.
*/
constructor(opts = {}) {
super()
if (opts.verbose && (typeof opts.verbose !== 'number')) {
throw new Error('Bad verbose level')
}
this._verbose = opts.verbose || 0
this.verboseStream = opts.verboseStream || process.stderr
}
/**
* Output verbose logging information, if this.verbose is true.
*
* @param {number} level Print at this verbosity level or higher.
* @param {any[]} args Same as onsole.log parameters.
* @returns {boolean} True if output was written.
*/
verbose(level, ...args) {
if (this._verbose >= level) {
// Defer expensive processing
args = args.map(a => ((typeof a === 'function') ? a() : a))
this.verboseStream.write(util.formatWithOptions({
// Really, process.stderr is a tty.WriteStream, but this will work
// fine in practice since isTTY will be undefined on other streams.
// @ts-ignore TS2339
colors: this.verboseStream.isTTY,
depth: Infinity,
sorted: true,
}, ...args))
this.verboseStream.write('\n')
return true
}
return false
}
/**
* Dump a nice hex representation of the given buffer to verboseStream,
* if verbose is true.
*
* @param {number} level Print at this verbosity level or higher.
* @param {Buffer} buf The buffer to dump.
* @returns {boolean} True if output was written.
*/
hexDump(level, buf) {
if (this._verbose < level) {
return false
}
if (buf.length > 0) {
let offset = 0
for (const byte of buf.slice(0, buf.length)) {
// eslint-disable-next-line multiline-comment-style, indent
/*
00000000 7b 0a 20 20 22 6e 61 6d 65 22 3a 20 22 64 6f 68 |{. "name": "doh|
*/
if ((offset % 16) === 0) {
if (offset !== 0) {
this.verboseStream.write(' |')
printableString(this.verboseStream, buf.slice(offset - 16, offset))
this.verboseStream.write('|\n')
}
styleStream(this.verboseStream, offset.toString(16).padStart(8, '0'), 'undefined')
}
if ((offset % 8) === 0) {
this.verboseStream.write(' ')
}
this.verboseStream.write(' ')
this.verboseStream.write(byte.toString(16).padStart(2, '0'))
offset++
}
let left = offset % 16
if (left === 0) {
left = 16
} else {
let undone = 3 * (16 - left)
if (left <= 8) {
undone++
}
this.verboseStream.write(' '.padStart(undone, ' '))
}
const start = offset > 16 ? offset - left : 0
this.verboseStream.write(' |')
printableString(this.verboseStream, buf.slice(start, offset))
this.verboseStream.write('|\n')
}
styleStream(this.verboseStream, buf.length.toString(16).padStart(8, '0'), 'undefined')
this.verboseStream.write('\n')
return true
}
/**
* Encode a DNS query packet to a buffer.
*
* @param {object} opts Options for the query.
* @param {number} [opts.id=0] ID for the query. SHOULD be 0 for DOH.
* @param {string} [opts.name] The name to look up.
* @param {string} [opts.rrtype="A"] The record type to look up.
* @param {boolean} [opts.dnssec=false] Request DNSSec information?
* @param {string} [opts.ecsSubnet] Subnet to use for ECS.
* @param {number} [opts.ecs] Number of ECS bits. Defaults to 24 or 56
* (IPv4/IPv6).
* @param {boolean} [opts.stream=false] Encode for streaming, with the packet
* prefixed by a 2-byte big-endian integer of the number of bytes in the
* packet.
* @returns {Buffer} The encoded packet.
*/
static makePacket(opts) {
const dns = {
type: 'query',
id: opts.id || 0,
flags: packet.RECURSION_DESIRED,
questions: [{
type: opts.rrtype || 'A',
class: 'IN',
name: opts.name,
}],
additionals: [{
name: '.',
type: 'OPT',
// @ts-ignore TS2339: types not up to date
udpPayloadSize: 4096,
flags: 0,
options: [],
}],
}
if (opts.dnssec) {
dns.flags |= packet.AUTHENTIC_DATA
// @ts-ignore TS2339: types not up to date
dns.additionals[0].flags |= packet.DNSSEC_OK
}
if (opts.ecs != null || net.isIP(opts.ecsSubnet) !== 0) {
// https://tools.ietf.org/html/rfc7871#section-11.1
const prefix = (opts.ecsSubnet && net.isIPv4(opts.ecsSubnet)) ? 24 : 56
// @ts-ignore TS2339: types not up to date
dns.additionals[0].options.push({
code: 'CLIENT_SUBNET',
ip: opts.ecsSubnet || '0.0.0.0',
sourcePrefixLength: (opts.ecs == null) ? prefix : opts.ecs,
})
}
const unpadded = packet.encodingLength(dns)
// @ts-ignore TS2339: types not up to date
dns.additionals[0].options.push({
code: 'PADDING',
// Next pad size, minus what we already have, minus another 4 bytes for
// the option header
length: (Math.ceil(unpadded / PAD_SIZE) * PAD_SIZE) - unpadded - 4,
})
if (opts.stream) {
// @ts-ignore TS2339: types not up to date
return packet.streamEncode(dns)
}
return packet.encode(dns)
}
/**
* @typedef {object} LookupOptions
* @property {string} [name] Name to look up.
* @property {string} [rrtype] The Resource Record type to retrive.
* @property {number} [id] The 2-byte unsigned integer for the request.
* For DOH, should be 0 or undefined.
* @property {boolean} [json] Force JSON lookups for DOH. Ignored for DOT.
* @property {boolean} [stream=false] Encode for streaming, with the packet
* prefixed by a 2-byte big-endian integer of the number of bytes in the
* packet.
*/
/**
* Normalize parameters into the lookup functions.
*
* @param {string|LookupOptions} [name] If string, lookup this name,
* otherwise it is options. Has precedence over opts.name if string.
* @param {string|LookupOptions} [opts] If string, rrtype.
* Otherwise options.
* @param {object} [defaults] Defaults options.
* @returns {LookupOptions} Normalized options, including punycode∑d
* options.name and upper-case options.rrtype.
* @throws {Error} Invalid type for name.
*/
static normalizeArgs(name, opts, defaults) {
/** @type {LookupOptions} */
let nopts = {}
if (name != null) {
switch (typeof name) {
case 'object':
nopts = name
break
case 'string':
nopts.name = name
break
default:
throw new Error('Invalid type for name')
}
}
if (opts != null) {
switch (typeof opts) {
case 'object':
nopts = {...opts, ...nopts}
break
case 'string':
nopts = {...nopts, rrtype: opts}
break
default:
throw new Error('Invalid type for opts')
}
}
return {
...defaults,
...nopts,
name: url.domainToASCII(nopts.name),
rrtype: (nopts.rrtype || 'A').toUpperCase(),
}
}
/**
* See [RFC 4648]{@link https://tools.ietf.org/html/rfc4648#section-5}.
*
* @param {Buffer} buf Buffer to encode.
* @returns {string} The base64url string.
*/
static base64urlEncode(buf) {
const s = buf.toString('base64')
return s.replace(/[=+/]/g, c => ({
'=': '',
'+': '-',
'/': '_',
})[c])
}
/**
* Recursively traverse an object, turning all of its properties that have
* Buffer values into base64 representations of the buffer.
*
* @param {any} o The object to traverse.
* @param {WeakSet<object>} [circular] WeakMap to prevent circular
* dependencies.
* @returns {any} The converted object.
*/
static buffersToB64(o, circular = null) {
if (!circular) {
circular = new WeakSet()
}
if (o && (typeof o === 'object')) {
if (circular.has(o)) {
return '[Circular reference]'
}
circular.add(o)
if (Buffer.isBuffer(o)) {
return o.toString('base64')
} else if (Array.isArray(o)) {
return o.map(v => this.buffersToB64(v, circular))
}
return Object.entries(o).reduce((prev, [k, v]) => {
prev[k] = this.buffersToB64(v, circular)
return prev
}, {})
}
return o
}
/**
* Calculate the reverse name to look up for an IP address.
*
* @param {string} addr The IPv[46] address to reverse.
* @returns {string} Address ending in .in-addr.arpa or .ip6.arpa.
* @throws {Error} Invalid IP Address.
*/
static reverse(addr) {
const buf = ip.toBuffer(addr)
Array.prototype.reverse.call(buf)
if (buf.length === 4) {
// IPv4
return `${Array.from(buf).join('.')}.in-addr.arpa`
}
// IPv6
const bytes = Array.from(
buf,
b => `${(b & 0xf).toString(16)}.${(b >> 4).toString(16)}`
).join('.')
return `${bytes}.ip6.arpa`
}
}
export class DNSError extends Error {
constructor(er, pkt) {
super(`DNS error: ${er}`)
this.packet = pkt
this.code = `dns.${er}`
}
static getError(pkt) {
if (Object.prototype.hasOwnProperty.call(pkt, 'rcode')) {
if (pkt.rcode !== 'NOERROR') {
return new DNSError(pkt.rcode, pkt)
}
} else if (Object.prototype.hasOwnProperty.call(pkt, 'Status')) {
if (pkt.Status !== 0) {
return new DNSError(rcodes.toString(pkt.Status), pkt)
}
}
return null
}
}
export default DNSutils