UNPKG

dohdec

Version:

DNS over HTTPS and DNS over TLS

413 lines (388 loc) 12.4 kB
import * as optioncodes from 'dns-packet/optioncodes.js'; import * as packet from 'dns-packet'; import * as rcodes from 'dns-packet/rcodes.js'; import {Buffer} from 'node:buffer'; import {EventEmitter} from 'node:events'; import assert from 'node:assert'; import ip from 'ip-address'; import net from 'node:net'; import url from 'node:url'; import util from 'node:util'; const PAD_SIZE = 128; /** * @typedef {object} LookupOptions * @property {string} [name] Name to look up. * @property {packet.RecordType} [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} [decode=true] Decode the response, either into JSON * or an object representing the DNS format result. * @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. * @property {boolean} [dnssec=false] Request DNSSec records. Currently * requires `json: false`. * @property {boolean} [dnssecCheckingDisabled=false] Disable DNSSEC */ /** * @typedef {import('stream').Writable & {isTTY?: boolean}} Writable */ /** * Extracted from node source. * Only exported for testing. * * @param {string} str * @param {util.Style} styleType * @returns {string} * @private */ export function stylizeWithColor(str, styleType) { const style = util.inspect.styles[styleType]; if (style !== undefined) { const color = util.inspect.colors[style]; assert(color, style); return `\u001b[${color[0]}m${str}\u001b[${color[1]}m`; } return str; } /** * @param {Writable} stream * @param {string} str * @param {util.Style} styleType * @private */ function styleStream(stream, str, styleType) { stream.write(stream.isTTY ? stylizeWithColor(str, styleType) : str); } /** * Exported for testing only * @param {Writable} stream * @param {Buffer} buf * @returns {number} */ export 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 { /** @type {Writable} */ verboseStream; /** * 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 @stylistic/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 {string} [opts.name] The name to look up. * @param {number} [opts.id=0] ID for the query. SHOULD be 0 for DOH. * @param {packet.RecordType} [opts.rrtype="A"] The record type to look up. * @param {boolean} [opts.dnssec=false] Request DNSSec information? * @param {boolean} [opts.dnssecCheckingDisabled=false] Disable DNSSec * validation? * @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) { if (!opts?.name) { throw new TypeError('Name is required'); } /** @type {packet.OptAnswer} */ const opt = { name: '.', type: 'OPT', udpPayloadSize: 4096, extendedRcode: 0, flags: 0, flag_do: false, // Setting here has no effect ednsVersion: 0, options: [], }; /** @type {packet.Packet} */ const dns = { type: 'query', id: opts.id || 0, flags: packet.RECURSION_DESIRED, questions: [{ type: opts.rrtype || 'A', class: 'IN', name: opts.name, }], additionals: [opt], }; assert(dns.flags !== undefined); if (opts.dnssec) { dns.flags |= packet.AUTHENTIC_DATA; opt.flags |= packet.DNSSEC_OK; } if (opts.dnssecCheckingDisabled) { dns.flags |= packet.CHECKING_DISABLED; } if ( (opts.ecs != null) || (opts.ecsSubnet && (net.isIP(opts.ecsSubnet) !== 0)) ) { // https://tools.ietf.org/html/rfc7871#section-11.1 const prefix = (opts.ecsSubnet && net.isIPv4(opts.ecsSubnet)) ? 24 : 56; opt.options.push({ code: optioncodes.toCode('CLIENT_SUBNET'), ip: opts.ecsSubnet || '0.0.0.0', sourcePrefixLength: (opts.ecs == null) ? prefix : opts.ecs, }); } const unpadded = packet.encodingLength(dns); opt.options.push({ code: optioncodes.toCode('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) { return packet.streamEncode(dns); } return packet.encode(dns); } /** * 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 = Object.create(null); 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: /** @type {packet.RecordType} */(opts)}; break; default: throw new Error('Invalid type for opts'); } } assert(nopts.name, 'Name required'); return { ...defaults, ...nopts, name: url.domainToASCII(nopts.name), rrtype: /** @type {packet.RecordType} */((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'); // @ts-expect-error This isn't worth getting type-safe 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 = undefined) { 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]) => { // @ts-expect-error Object.create(null) messes up output 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 ai = net.isIPv4(addr) ? new ip.Address4(addr) : new ip.Address6(addr); return ai.reverseForm(); } } export class DNSError extends Error { /** * Create a DNS Error that wraps another error. * @param {Error} er * @param {packet.Packet} pkt */ constructor(er, pkt) { super(`DNS error: ${er}`, {cause: er}); this.packet = pkt; this.code = `dns.${er}`; } /** * Factory to extract DNS error from packet, if one exists. * * @param {Record<string,any>} pkt * @returns {DNSError|undefined} */ static getError(pkt) { if ((typeof pkt !== 'object') || !pkt) { throw new TypeError('Invalid packet'); } 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 undefined; } } export default DNSutils;