tangerine
Version:
Tangerine is the best Node.js drop-in replacement for dns.promises.Resolver using DNS over HTTPS ("DoH") via undici with built-in retries, timeouts, smart server rotation, AbortControllers, and caching support for multiple backends (with TTL and purge sup
1,657 lines (1,470 loc) • 62.6 kB
JavaScript
const dns = require('node:dns');
const http = require('node:http');
const os = require('node:os');
const process = require('node:process');
const { Buffer } = require('node:buffer');
const { debuglog } = require('node:util');
const { getEventListeners, setMaxListeners } = require('node:events');
const { isIP, isIPv4, isIPv6 } = require('node:net');
const { toASCII } = require('punycode/');
const autoBind = require('auto-bind');
const getStream = require('get-stream');
const hostile = require('hostile');
const ipaddr = require('ipaddr.js');
const isStream = require('is-stream');
const mergeOptions = require('merge-options');
const pMap = require('p-map');
const pWaitFor = require('p-wait-for');
const packet = require('dns-packet');
const semver = require('semver');
const { getService } = require('port-numbers');
const pkg = require('./package.json');
const debug = debuglog('tangerine');
// dynamically import dohdec
let dohdec;
// eslint-disable-next-line unicorn/prefer-top-level-await
import('dohdec').then((obj) => {
dohdec = obj;
});
// dynamically import private-ip
let isPrivateIP;
// eslint-disable-next-line unicorn/prefer-top-level-await
import('private-ip').then((obj) => {
isPrivateIP = obj.default;
});
const HOSTFILE = hostile
.get(true)
.map((s) => (Array.isArray(s) ? s.join(' ') : s))
.join('\n');
const HOSTS = [];
const hosts = hostile.get();
for (const line of hosts) {
const [ip, str] = line;
const hosts = str.split(' ');
HOSTS.push({ ip, hosts });
}
// <https://github.com/szmarczak/cacheable-lookup/pull/76>
class Tangerine extends dns.promises.Resolver {
static HOSTFILE = HOSTFILE;
static HOSTS = HOSTS;
static isValidPort(port) {
return Number.isSafeInteger(port) && port >= 0 && port <= 65535;
}
static CTYPE_BY_VALUE = {
1: 'PKIX',
2: 'SPKI',
3: 'PGP',
4: 'IPKIX',
5: 'ISPKI',
6: 'IPGP',
7: 'ACPKIX',
8: 'IACPKIX',
253: 'URI',
254: 'OID'
};
static getAddrConfigTypes() {
const networkInterfaces = os.networkInterfaces();
let hasIPv4 = false;
let hasIPv6 = false;
for (const key of Object.keys(networkInterfaces)) {
for (const obj of networkInterfaces[key]) {
if (!obj.internal) {
if (obj.family === 'IPv4') {
hasIPv4 = true;
} else if (obj.family === 'IPv6') {
hasIPv6 = true;
}
}
}
}
if (hasIPv4 && hasIPv6) return 0;
if (hasIPv4) return 4;
if (hasIPv6) return 6;
// NOTE: should this be an edge case where we return empty results (?)
return 0;
}
// <https://github.com/mafintosh/dns-packet/blob/master/examples/doh.js>
static getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
//
// NOTE: we can most likely move to AggregateError instead
//
static combineErrors(errors) {
let err;
if (errors.length === 1) {
err = errors[0];
} else {
err = new Error(
[...new Set(errors.map((e) => e.message).filter(Boolean))].join('; ')
);
err.stack = [...new Set(errors.map((e) => e.stack).filter(Boolean))].join(
'\n\n'
);
// if all errors had `name` and they were all the same then preserve it
if (
errors[0].name !== undefined &&
errors.every((e) => e.name === errors[0].name)
)
err.name = errors[0].name;
// if all errors had `code` and they were all the same then preserve it
if (
errors[0].code !== undefined &&
errors.every((e) => e.code === errors[0].code)
)
err.code = errors[0].code;
// if all errors had `errno` and they were all the same then preserve it
if (
errors[0].errno !== undefined &&
errors.every((e) => e.errno === errors[0].errno)
)
err.errno = errors[0].errno;
// preserve original errors
err.errors = errors;
}
return err;
}
static CODES = new Set([
dns.ADDRGETNETWORKPARAMS,
dns.BADFAMILY,
dns.BADFLAGS,
dns.BADHINTS,
dns.BADNAME,
dns.BADQUERY,
dns.BADRESP,
dns.BADSTR,
dns.CANCELLED,
dns.CONNREFUSED,
dns.DESTRUCTION,
dns.EOF,
dns.FILE,
dns.FORMERR,
dns.LOADIPHLPAPI,
dns.NODATA,
dns.NOMEM,
dns.NONAME,
dns.NOTFOUND,
dns.NOTIMP,
dns.NOTINITIALIZED,
dns.REFUSED,
dns.SERVFAIL,
dns.TIMEOUT,
'EINVAL'
]);
static DNS_TYPES = new Set([
'A',
'AAAA',
'CAA',
'CNAME',
'MX',
'NAPTR',
'NS',
'PTR',
'SOA',
'SRV',
'TXT'
]);
// <https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4>
static TYPES = new Set([
'A',
'A6',
'AAAA',
'AFSDB',
'AMTRELAY',
'APL',
'ATMA',
'AVC',
'AXFR',
'CAA',
'CDNSKEY',
'CDS',
'CERT',
'CNAME',
'CSYNC',
'DHCID',
'DLV',
'DNAME',
'DNSKEY',
'DOA',
'DS',
'EID',
'EUI48',
'EUI64',
'GID',
'GPOS',
'HINFO',
'HIP',
'HTTPS',
'IPSECKEY',
'ISDN',
'IXFR',
'KEY',
'KX',
'L32',
'L64',
'LOC',
'LP',
'MAILA',
'MAILB',
'MB',
'MD',
'MF',
'MG',
'MINFO',
'MR',
'MX',
'NAPTR',
'NID',
'NIMLOC',
'NINFO',
'NS',
'NSAP',
'NSAP-PTR',
'NSEC',
'NSEC3',
'NSEC3PARAM',
'NULL',
'NXT',
'OPENPGPKEY',
'OPT',
'PTR',
'PX',
'RKEY',
'RP',
'RRSIG',
'RT',
'Reserved',
'SIG',
'SINK',
'SMIMEA',
'SOA',
'SPF',
'SRV',
'SSHFP',
'SVCB',
'TA',
'TALINK',
'TKEY',
'TLSA',
'TSIG',
'TXT',
'UID',
'UINFO',
'UNSPEC',
'URI',
'WKS',
'X25',
'ZONEMD'
]);
static ANY_TYPES = [
'A',
'AAAA',
'CNAME',
'MX',
'NAPTR',
'NS',
'PTR',
'SOA',
'SRV',
'TXT'
];
static NETWORK_ERROR_CODES = new Set([
'ENETDOWN',
'ENETRESET',
'ECONNRESET',
'EADDRINUSE',
'ECONNREFUSED',
'ENETUNREACH'
]);
static RETRY_STATUS_CODES = new Set([
408, 413, 429, 500, 502, 503, 504, 521, 522, 524
]);
static RETRY_ERROR_CODES = new Set([
'ETIMEOUT',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ECONNREFUSED',
'EPIPE',
// NOTE: dns behavior does not retry on ENOTFOUND
// <https://nodejs.org/api/dns.html#dnssetserversservers>
// 'ENOTFOUND',
'ENETUNREACH',
'EAI_AGAIN'
]);
// sourced from node, superagent, got, axios, and fetch
// <https://github.com/nodejs/node/issues/14554>
// <https://github.com/nodejs/node/issues/38361#issuecomment-1046151452>
// <https://github.com/axios/axios/blob/bdf493cf8b84eb3e3440e72d5725ba0f138e0451/lib/cancel/CanceledError.js#L17>
static ABORT_ERROR_CODES = new Set([
'ABORT_ERR',
'ECONNABORTED',
'ERR_CANCELED',
'ECANCELLED',
'ERR_ABORTED',
'UND_ERR_ABORTED'
]);
static getSysCall(rrtype) {
return `query${rrtype.slice(0, 1).toUpperCase()}${rrtype
.slice(1)
.toLowerCase()}`;
}
// <https://github.com/EduardoRuizM/native-dnssec-dns/blob/main/lib/client.js#L350>
static createError(name, rrtype, code = dns.BADRESP, errno) {
const syscall = this.getSysCall(rrtype);
if (this.ABORT_ERROR_CODES.has(code)) code = dns.CANCELLED;
else if (this.NETWORK_ERROR_CODES.has(code)) code = dns.CONNREFUSED;
else if (this.RETRY_ERROR_CODES.has(code)) code = dns.TIMEOUT;
else if (!this.CODES.has(code)) code = dns.BADRESP;
const err = new Error(`${syscall} ${code} ${name}`);
err.hostname = name;
err.syscall = syscall;
err.code = code;
err.errno = errno || undefined;
return err;
}
constructor(options = {}, request = require('undici').request) {
const timeout =
options.timeout && options.timeout !== -1 ? options.timeout : 5000;
const tries = options.tries || 4;
super({
timeout,
tries
});
if (typeof request !== 'function')
throw new Error(
'Request option must be a function (e.g. `undici.request` or `got`)'
);
this.request = request;
this.options = mergeOptions(
{
// <https://github.com/nodejs/node/issues/33353#issuecomment-627259827>
// > For posterity: there's a 75 second timeout.
// > Local testing with a blackholed DNS server shows that c-ares internally
// > retries four times (with 5, 10, 20 and 40 second timeouts)
// > before giving up with an ARES_ETIMEDOUT error.
timeout,
tries,
// dns servers will optionally retry in series
// and servers that error will get shifted to the end of list
servers: new Set(['1.1.1.1', '1.0.0.1']),
requestOptions: {
method: 'GET',
headers: {
'content-type': 'application/dns-message',
'user-agent': `${pkg.name}/${pkg.version}`,
accept: 'application/dns-message'
}
},
//
// NOTE: we set the default to "get" since it is faster from `benchmark` results
//
// http protocol to be used
protocol: 'https',
//
// NOTE: this value was changed from ipv4first to verbatim in v17.0.0
// and this feature was added in v14.8.0 and v16.4.0
// <https://nodejs.org/api/dns.html#dnspromisessetdefaultresultorderorder>
dnsOrder: semver.gte(process.version, 'v17.0.0')
? 'verbatim'
: 'ipv4first',
// https://github.com/cabinjs/cabin
// https://github.com/cabinjs/axe
logger: false,
// default id generator
// (e.g. set to a synchronous or async function such as `() => Tangerine.getRandomInt(1, 65534)`)
id: 0,
// concurrency for `resolveAny` (defaults to # of CPU's)
concurrency: os.cpus().length,
// ipv4 and ipv6 default addresses (from dns defaults)
ipv4: '0.0.0.0',
ipv6: '::0',
ipv4Port: undefined,
ipv6Port: undefined,
// cache mapping (e.g. txt -> Map/keyv/redis instance) - see below
cache: new Map(),
// <https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl/>
defaultTTLSeconds: 300,
maxTTLSeconds: 86400,
// default is to support ioredis
// setCacheArgs(key, result) {
setCacheArgs() {
// also you have access to `result.expires` which is is ms since epoch
// (can be converted to Date via `new Date(result.expires)`)
// return ['PX', Math.round(result.ttl * 1000)];
return [];
},
// whether to do 1:1 HTTP -> DNS error mapping
returnHTTPErrors: false,
// whether to smart rotate and bump-to-end servers that have issues
smartRotate: true,
// fallback if status code was not found in http.STATUS_CODES
defaultHTTPErrorMessage: 'Unsuccessful HTTP response'
},
options
);
// timeout must be >= 0
if (!Number.isFinite(this.options.timeout) || this.options.timeout < 0)
throw new Error('Timeout must be >= 0');
// tries must be >= 1
if (!Number.isFinite(this.options.tries) || this.options.tries < 1)
throw new Error('Tries must be >= 1');
// request option method must be either GET or POST
if (
!['get', 'post'].includes(
this.options.requestOptions.method.toLowerCase()
)
)
throw new Error('Request options method must be either GET or POST');
// perform validation by re-using `setServers` method
this.setServers([...this.options.servers]);
if (
!(this.options.servers instanceof Set) ||
this.options.servers.size === 0
)
throw new Error(
'Servers must be an Array or Set with at least one server'
);
if (!['http', 'https'].includes(this.options.protocol))
throw new Error('Protocol must be http or https');
if (!['verbatim', 'ipv4first'].includes(this.options.dnsOrder))
throw new Error('DNS order must be either verbatim or ipv4first');
// if `cache: false` then caching is disabled
// but note that this doesn't disable `got` dnsCache which is separate
// so to turn that off, you need to supply `dnsCache: undefined` in `got` object (?)
if (this.options.cache === true) this.options.cache = new Map();
// convert `false` logger option into noop
// <https://github.com/breejs/bree/issues/147>
if (this.options.logger === false)
this.options.logger = {
/* istanbul ignore next */
info() {},
/* istanbul ignore next */
warn() {},
/* istanbul ignore next */
error() {}
};
// manage set of abort controllers
this.abortControllers = new Set();
//
// NOTE: bind methods so we don't have to programmatically call `.bind`
// (e.g. `getDmarcRecord(name, resolver.resolve.bind(resolver))`)
// (alternative to `autoBind(this)` is `this[method] = this[method].bind(this)`)
//
autoBind(this);
}
setLocalAddress(ipv4, ipv6) {
// ipv4 = default => '0.0.0.0'
// ipv6 = default => '::0'
if (ipv4) {
if (typeof ipv4 !== 'string') {
const err = new TypeError(
'The "ipv4" argument must be of type string.'
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
// if port specified then split it apart
let port;
if (ipv4.includes(':')) [ipv4, port] = ipv4.split(':');
if (!isIPv4(ipv4)) {
const err = new TypeError('Invalid IP address.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
// not sure if there's a built-in way with Node.js to do this (?)
if (port) {
port = Number(port);
// <https://github.com/leecjson/node-is-valid-port/blob/2da250b23e0d83bcfc042b44fa7cabdea1984a73/index.js#L3-L7>
if (!this.constructor.isValidPort(port)) {
const err = new TypeError('Invalid port.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
}
this.options.ipv4 = ipv4;
this.options.ipv4Port = port;
}
if (ipv6) {
if (typeof ipv6 !== 'string') {
const err = new TypeError(
'The "ipv6" argument must be of type string.'
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
// if port specified then split it apart
let port;
// if it starts with `[` then we can assume it's encoded as `[IPv6]` or `[IPv6]:PORT`
if (ipv6.startsWith('[')) {
const lastIndex = ipv6.lastIndexOf(']');
port = ipv6.slice(lastIndex + 2);
ipv6 = ipv6.slice(1, lastIndex);
}
// not sure if there's a built-in way with Node.js to do this (?)
if (port) {
port = Number(port);
// <https://github.com/leecjson/node-is-valid-port/blob/2da250b23e0d83bcfc042b44fa7cabdea1984a73/index.js#L3-L7>
if (!(Number.isSafeInteger(port) && port >= 0 && port <= 65535)) {
const err = new TypeError('Invalid port.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
}
if (!isIPv6(ipv6)) {
const err = new TypeError('Invalid IP address.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
this.options.ipv6 = ipv6;
this.options.ipv6Port = port;
}
}
// eslint-disable-next-line complexity
async lookup(name, options = {}) {
// validate name
if (typeof name !== 'string') {
const err = new TypeError('The "name" argument must be of type string.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
// if options is an integer, it must be 4 or 6
if (typeof options === 'number') {
if (options !== 0 && options !== 4 && options !== 6) {
const err = new TypeError(
`The argument 'family' must be one of: 0, 4, 6. Received ${options}`
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
options = { family: options };
} else if (
options?.family !== undefined &&
![0, 4, 6, 'IPv4', 'IPv6'].includes(options.family)
) {
// validate family
const err = new TypeError(
`The argument 'family' must be one of: 0, 4, 6. Received ${options.family}`
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (options?.family === 'IPv4') options.family = 4;
else if (options?.family === 'IPv6') options.family = 6;
if (typeof options.family !== 'number') options.family = 0;
// validate hints
// eslint-disable-next-line no-bitwise
if ((options?.hints & ~(dns.ADDRCONFIG | dns.ALL | dns.V4MAPPED)) !== 0) {
const err = new TypeError(
`The argument 'hints' is invalid. Received ${options.hints}`
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (name === '.') {
const err = this.constructor.createError(name, '', dns.NOTFOUND);
// remap and perform syscall
err.syscall = 'getaddrinfo';
err.message = err.message.replace('query', 'getaddrinfo');
err.errno = -3008; // <-- ?
// err.errno = -3007;
throw err;
}
// purge cache support
let purgeCache;
if (options?.purgeCache) {
purgeCache = true;
delete options.purgeCache;
}
if (options.hints) {
switch (options.hints) {
case dns.ADDRCONFIG: {
options.family = this.constructor.getAddrConfigTypes();
break;
}
// eslint-disable-next-line no-bitwise
case dns.ADDRCONFIG | dns.V4MAPPED: {
options.family = this.constructor.getAddrConfigTypes();
break;
}
// eslint-disable-next-line no-bitwise
case dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL: {
options.family = this.constructor.getAddrConfigTypes();
break;
}
default: {
break;
}
}
}
// <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares_getaddrinfo.c#L407>
// <https://www.rfc-editor.org/rfc/rfc6761.html#section-6.3>
//
// > 'localhost and any domains falling within .localhost'
//
// if no system loopback match, then revert to the default
// <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares__addrinfo_localhost.c#L224-L229>
// - IPv4 = '127.0.0.1"
// - IPv6 = "::1"
//
let resolve4;
let resolve6;
const lower = name.toLowerCase();
for (const rule of this.constructor.HOSTS) {
if (rule.hosts.every((h) => h.toLowerCase() !== lower)) continue;
const type = isIP(rule.ip);
if (!resolve4 && type === 4) {
if (!Array.isArray(resolve4)) resolve4 = [rule.ip];
else if (!resolve4.includes(rule.ip)) resolve4.push([rule.ip]);
} else if (!resolve6 && type === 6) {
if (!Array.isArray(resolve6)) resolve6 = [rule.ip];
else if (!resolve6.includes(rule.ip)) resolve6.push(rule.ip);
}
}
// safeguard (matches c-ares)
if (lower === 'localhost' || lower === 'localhost.') {
resolve4 ||= ['127.0.0.1'];
resolve6 ||= ['::1'];
}
if (isIPv4(name)) {
resolve4 = [name];
resolve6 = [];
} else if (isIPv6(name)) {
resolve6 = [name];
resolve4 = [];
}
// resolve the first A or AAAA record (conditionally)
const results = await Promise.all(
[
Array.isArray(resolve4)
? Promise.resolve(resolve4)
: this.resolve4(name, { purgeCache, noThrowOnNODATA: true }),
Array.isArray(resolve6)
? Promise.resolve(resolve6)
: this.resolve6(name, { purgeCache, noThrowOnNODATA: true })
].map((p) => p.catch((err) => err))
);
const errors = [];
let answers = [];
for (const result of results) {
if (result instanceof Error) {
errors.push(result);
} else {
answers.push(result);
}
}
if (
answers.length === 0 &&
errors.length > 0 &&
errors.every((e) => e.code === errors[0].code)
) {
const err = this.constructor.createError(
name,
'',
errors[0].code === dns.BADNAME ? dns.NOTFOUND : errors[0].code
);
// remap and perform syscall
err.syscall = 'getaddrinfo';
err.message = err.message.replace('query', 'getaddrinfo');
err.errno = -3008;
throw err;
}
// default node behavior seems to return IPv4 by default always regardless
if (answers.length > 0)
answers =
answers[0].length > 0 &&
(options.family === undefined || options.family === 0)
? answers[0]
: answers.flat();
// if no results then throw ENODATA
if (answers.length === 0) {
const err = this.constructor.createError(name, '', dns.NODATA);
// remap and perform syscall
err.syscall = 'getaddrinfo';
err.message = err.message.replace('query', 'getaddrinfo');
err.errno = -3008;
throw err;
}
// respect options from dns module
// <https://nodejs.org/api/dns.html#dnspromiseslookuphostname-options>
// - [x] `family` (4, 6, or 0, default is 0)
// - [x] `hints` multiple flags may be passed by bitwise OR'ing values
// - [x] `all` (iff true, then return all results, otherwise single result)
// - [x] `verbatim` - if `true` then return as-is, otherwise use dns order
//
// <https://nodejs.org/api/dns.html#supported-getaddrinfo-flags>
//
// dns.ADDRCONFIG:
// Limits returned address types to the types of non-loopback addresses configured on the system.
// For example, IPv4 addresses are only returned if the current system has at least one IPv4 address configured.
// dns.V4MAPPED:
// If the IPv6 family was specified, but no IPv6 addresses were found, then return IPv4 mapped IPv6 addresses.
// It is not supported on some operating systems (e.g. FreeBSD 10.1).
// dns.ALL:
// If dns.V4MAPPED is specified, return resolved IPv6 addresses as well as IPv4 mapped IPv6 addresses.
//
if (options.hints) {
switch (options.hints) {
case dns.V4MAPPED: {
if (options.family === 6 && !answers.some((answer) => isIPv6(answer)))
answers = answers.map((answer) =>
ipaddr.parse(answer).toIPv4MappedAddress().toString()
);
break;
}
case dns.ALL: {
options.all = true;
break;
}
// eslint-disable-next-line no-bitwise
case dns.ADDRCONFIG | dns.V4MAPPED: {
if (options.family === 6 && !answers.some((answer) => isIPv6(answer)))
answers = answers.map((answer) =>
ipaddr.parse(answer).toIPv4MappedAddress().toString()
);
break;
}
// eslint-disable-next-line no-bitwise
case dns.V4MAPPED | dns.ALL: {
if (options.family === 6 && !answers.some((answer) => isIPv6(answer)))
answers = answers.map((answer) =>
ipaddr.parse(answer).toIPv4MappedAddress().toString()
);
options.all = true;
break;
}
// eslint-disable-next-line no-bitwise
case dns.ADDRCONFIG | dns.V4MAPPED | dns.ALL: {
if (options.family === 6 && !answers.some((answer) => isIPv6(answer)))
answers = answers.map((answer) =>
ipaddr.parse(answer).toIPv4MappedAddress().toString()
);
options.all = true;
break;
}
default: {
break;
}
}
}
if (options.family === 4)
answers = answers.filter((answer) => isIPv4(answer));
else if (options.family === 6)
answers = answers.filter((answer) => isIPv6(answer));
//
// respect sort order from `setDefaultResultOrder` method
//
// NOTE: we need to optimize this sort logic at some point
//
if (options.verbatim !== true && this.options.dnsOrder === 'ipv4first') {
answers = answers.sort((a, b) => {
const aFamily = isIP(a);
const bFamily = isIP(b);
if (aFamily < bFamily) return -1;
if (aFamily > bFamily) return 1;
return 0;
});
}
return options.all === true
? answers.map((answer) => ({
address: answer,
family: isIP(answer)
}))
: { address: answers[0], family: isIP(answers[0]) };
}
// <https://man7.org/linux/man-pages/man3/getnameinfo.3.html>
async lookupService(address, port, abortController, purgeCache = false) {
if (!address || !port) {
const err = new TypeError(
'The "address" and "port" arguments must be specified.'
);
err.code = 'ERR_MISSING_ARGS';
throw err;
}
if (!isIP(address)) {
const err = new TypeError(
`The argument 'address' is invalid. Received '${address}'`
);
err.code = 'ERR_INVALID_ARG_VALUE';
throw err;
}
if (!this.constructor.isValidPort(port)) {
const err = new TypeError(
`Port should be >= 0 and < 65536. Received ${port}.`
);
err.code = 'ERR_SOCKET_BAD_PORT';
throw err;
}
const { name } = getService(port);
// reverse lookup
try {
const [hostname] = await this.reverse(
address,
abortController,
purgeCache
);
return { hostname, service: name };
} catch (err) {
err.syscall = 'getnameinfo';
throw err;
}
}
async reverse(ip, abortController, purgeCache = false) {
// basically reverse the IP and then perform PTR lookup
if (typeof ip !== 'string') {
const err = new TypeError('The "ip" argument must be of type string.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (!isIP(ip)) {
const err = this.constructor.createError(ip, '', 'EINVAL');
err.message = `getHostByAddr EINVAL ${err.hostname}`;
err.syscall = 'getHostByAddr';
err.errno = -22;
if (!ip) delete err.hostname;
throw err;
}
// edge case where localhost IP returns matches
if (!isPrivateIP) await pWaitFor(() => Boolean(isPrivateIP));
const answers = new Set();
let match = false;
for (const rule of this.constructor.HOSTS) {
if (rule.ip === ip) {
match = true;
for (const host of rule.hosts.slice(1)) {
answers.add(host);
}
}
}
if (answers.size > 0 || match) return [...answers];
// NOTE: we can prob remove this (?)
// if (ip === '::1' || ip === '127.0.0.1') return [];
// reverse the IP address
if (!dohdec) await pWaitFor(() => Boolean(dohdec));
const name = dohdec.DNSoverHTTPS.reverse(ip);
// perform resolvePTR
try {
const answers = await this.resolve(
name,
'PTR',
{ purgeCache },
abortController
);
return answers;
} catch (err) {
// remap syscall
err.syscall = 'getHostByAddr';
err.message = `${err.syscall} ${err.code} ${ip}`;
err.hostname = ip;
throw err;
}
}
//
// NOTE: we support an `options.ecsSubnet` property (e.g. in addition to `ttl`)
//
resolve4(name, options, abortController) {
return this.resolve(name, 'A', options, abortController);
}
resolve6(name, options, abortController) {
return this.resolve(name, 'AAAA', options, abortController);
}
resolveCaa(name, options, abortController) {
return this.resolve(name, 'CAA', options, abortController);
}
resolveCname(name, options, abortController) {
return this.resolve(name, 'CNAME', options, abortController);
}
resolveMx(name, options, abortController) {
return this.resolve(name, 'MX', options, abortController);
}
resolveNaptr(name, options, abortController) {
return this.resolve(name, 'NAPTR', options, abortController);
}
resolveNs(name, options, abortController) {
return this.resolve(name, 'NS', options, abortController);
}
resolvePtr(name, options, abortController) {
return this.resolve(name, 'PTR', options, abortController);
}
resolveSoa(name, options, abortController) {
return this.resolve(name, 'SOA', options, abortController);
}
resolveSrv(name, options, abortController) {
return this.resolve(name, 'SRV', options, abortController);
}
resolveTxt(name, options, abortController) {
return this.resolve(name, 'TXT', options, abortController);
}
resolveCert(name, options, abortController) {
return this.resolve(name, 'CERT', options, abortController);
}
// NOTE: parse this properly according to spec (see below default case)
resolveTlsa(name, options, abortController) {
return this.resolve(name, 'TLSA', options, abortController);
}
// 1:1 mapping with node's official dns.promises API
// (this means it's a drop-in replacement for `dns`)
// <https://github.com/nodejs/node/blob/9bbde3d7baef584f14569ef79f116e9d288c7aaa/lib/internal/dns/utils.js#L87-L95>
getServers() {
return [...this.options.servers];
}
//
// NOTE: we attempted to set up streams with `got` however the retry usage
// was too confusing and the documentation was lacking, misleading, or incredibly complex
// <https://github.com/sindresorhus/got/issues/2226>
//
async #request(pkt, server, abortController, timeout = this.options.timeout) {
// safeguard in case aborted
abortController?.signal?.throwIfAborted();
let localAddress;
let localPort;
let url = `${this.options.protocol}://${server}/dns-query`;
if (isIPv4(new URL(url).hostname)) {
localAddress = this.options.ipv4;
if (this.options.ipv4LocalPort) localPort = this.options.ipv4LocalPort;
} else {
localAddress = this.options.ipv6;
if (this.options.ipv6LocalPort) localPort = this.options.ipv6LocalPort;
}
const options = {
...this.options.requestOptions,
signal: abortController.signal
};
if (localAddress !== '0.0.0.0') options.localAddress = localAddress;
if (localPort) options.localPort = localPort;
// <https://github.com/hildjj/dohdec/blob/43564118c40f2127af871bdb4d40f615409d4b9c/pkg/dohdec/lib/doh.js#L117-L120>
if (this.options.requestOptions.method.toLowerCase() === 'get') {
if (!dohdec) await pWaitFor(() => Boolean(dohdec));
// safeguard in case aborted
abortController?.signal?.throwIfAborted();
url += `?dns=${dohdec.DNSoverHTTPS.base64urlEncode(pkt)}`;
} else {
options.body = pkt;
}
debug('request', { url, options });
const t = setTimeout(() => {
if (!abortController?.signal?.aborted) abortController.abort();
}, timeout);
const response = await this.request(url, options);
clearTimeout(t);
return response;
}
// <https://github.com/hildjj/dohdec/tree/main/pkg/dohdec>
// eslint-disable-next-line complexity
async #query(name, rrtype = 'A', ecsSubnet, abortController) {
if (!dohdec) await pWaitFor(() => Boolean(dohdec));
debug('query', {
name,
nameToASCII: toASCII(name),
rrtype,
ecsSubnet,
abortController
});
// <https://github.com/hildjj/dohdec/blob/43564118c40f2127af871bdb4d40f615409d4b9c/pkg/dohdec/lib/dnsUtils.js#L161>
const pkt = dohdec.DNSoverHTTPS.makePacket({
id:
typeof this.options.id === 'function'
? await this.options.id()
: this.options.id,
rrtype,
// mirrors dns module behavior
name: toASCII(name),
// <https://github.com/mafintosh/dns-packet/pull/47#issuecomment-1435818437>
ecsSubnet
});
try {
// mirror the behavior as noted in built-in DNS
// <https://github.com/nodejs/node/issues/33353#issuecomment-627259827>
let buffer;
const errors = [];
// NOTE: we would have used `p-map-series` but it did not support abort/break
const servers = [...this.options.servers];
for (const server of servers) {
const ipErrors = [];
for (let i = 0; i < this.options.tries; i++) {
try {
// <https://github.com/sindresorhus/p-map-series/blob/bc1b9f5e19ed62363bff3d7dc5ecc1fd820ccb51/index.js#L1-L11>
// eslint-disable-next-line no-await-in-loop
const response = await this.#request(
pkt,
server,
abortController,
this.options.timeout * 2 ** i
);
// if aborted signal then returns early
// eslint-disable-next-line max-depth
if (response) {
const { body, headers } = response;
const statusCode = response.status || response.statusCode;
debug('response', { statusCode, headers });
// eslint-disable-next-line max-depth
if (body && statusCode >= 200 && statusCode < 300) {
// <https://sindresorhus.com/blog/goodbye-nodejs-buffer>
// eslint-disable-next-line max-depth
if (Buffer.isBuffer(body)) buffer = body;
else if (typeof body.arrayBuffer === 'function')
// eslint-disable-next-line no-await-in-loop
buffer = Buffer.from(await body.arrayBuffer());
// eslint-disable-next-line no-await-in-loop
else if (isStream(body)) buffer = await getStream.buffer(body);
else {
const err = new TypeError('Unsupported body type');
err.body = body;
throw err;
}
break;
}
// <https://github.com/nodejs/undici/issues/3353>
if (
!abortController?.signal?.aborted &&
body &&
typeof body.dump === 'function'
)
// eslint-disable-next-line no-await-in-loop
await body.dump();
// <https://github.com/nodejs/undici/blob/00dfd0bd41e73782452aecb728395f354585ca94/lib/core/errors.js#L47-L58>
const message =
http.STATUS_CODES[statusCode] ||
this.options.defaultHTTPErrorMessage;
const err = new Error(message);
err.body = body;
err.status = statusCode;
err.statusCode = statusCode;
err.headers = headers;
throw err;
}
} catch (err) {
debug(err);
//
// NOTE: if NOTFOUND error occurs then don't attempt further requests
// <https://nodejs.org/api/dns.html#dnssetserversservers>
//
if (err.code === dns.NOTFOUND) throw err;
if (err.status >= 429) ipErrors.push(err);
// break out of the loop if status code was not retryable
if (
!(
err.statusCode &&
this.constructor.RETRY_STATUS_CODES.has(err.statusCode)
) &&
!(err.code && this.constructor.RETRY_ERROR_CODES.has(err.code))
)
break;
}
}
// break out if we had a response
if (buffer) break;
if (ipErrors.length > 0) {
// if the `server` had all errors, then remove it and add to end
// (this ensures we don't keep retrying servers that keep timing out)
// (which improves upon default c-ares behavior)
if (this.options.servers.size > 1 && this.options.smartRotate) {
const err = this.constructor.combineErrors([
new Error('Rotating DNS servers due to issues'),
...ipErrors
]);
this.options.logger.error(err, { server });
this.options.servers.delete(server);
this.options.servers.add(server);
}
errors.push(...ipErrors);
}
}
if (!buffer) {
if (errors.length > 0) throw this.constructor.combineErrors(errors);
// if no errors and no response
// that must indicate that it was aborted
throw this.constructor.createError(name, rrtype, dns.CANCELLED);
}
// without logging an error here, one might not know
// that one or more dns servers have persistent issues
if (errors.length > 0)
this.options.logger.error(this.constructor.combineErrors(errors));
//
// NOTE: dns-packet does not yet support Uint8Array
// (however undici does have body.arrayBuffer() method)
//
// https://github.com/mafintosh/dns-packet/issues/72
return packet.decode(buffer);
} catch (_err) {
debug(_err, { name, rrtype, ecsSubnet });
if (this.options.returnHTTPErrors) throw _err;
const err = this.constructor.createError(
name,
rrtype,
_err.code,
_err.errno
);
// then map it to dns.CONNREFUSED
// preserve original error and stack trace
err.error = _err;
// throwing here saves indentation below
throw err;
}
}
// Cancel all outstanding DNS queries made by this resolver
// NOTE: callbacks not currently called with ECANCELLED (prob need to alter got options)
// (instead they are called with "ABORT_ERR"; see ABORT_ERROR_CODES)
cancel() {
for (const abortController of this.abortControllers) {
if (!abortController.signal.aborted) {
try {
abortController.abort('Cancel invoked');
} catch (err) {
this.options.logger.debug(err);
}
}
}
}
#resolveByType(name, options = {}, parentAbortController) {
return async (type) => {
const abortController = new AbortController();
this.abortControllers.add(abortController);
abortController.signal.addEventListener(
'abort',
() => {
this.abortControllers.delete(abortController);
},
{ once: true }
);
parentAbortController.signal.addEventListener(
'abort',
() => {
try {
abortController.abort('Parent abort controller aborted');
} catch (err) {
this.options.logger.debug(err);
}
},
{ once: true }
);
// wrap with try/catch because ENODATA shouldn't cause errors
try {
switch (type) {
case 'A': {
const result = await this.resolve4(
name,
{ ...options, ttl: true },
abortController
);
return result.map((r) => ({ type, ...r }));
}
case 'AAAA': {
const result = await this.resolve6(
name,
{ ...options, ttl: true },
abortController
);
return result.map((r) => ({ type, ...r }));
}
case 'CNAME': {
const result = await this.resolveCname(
name,
options,
abortController
);
return result.map((value) => ({ type, value }));
}
case 'MX': {
const result = await this.resolveMx(name, options, abortController);
return result.map((r) => ({ type, ...r }));
}
case 'NAPTR': {
const result = await this.resolveNaptr(
name,
options,
abortController
);
return result.map((value) => ({ type, value }));
}
case 'NS': {
const result = await this.resolveNs(name, options, abortController);
return result.map((value) => ({ type, value }));
}
case 'PTR': {
const result = await this.resolvePtr(
name,
options,
abortController
);
return result.map((value) => ({ type, value }));
}
case 'SOA': {
const result = await this.resolveSoa(
name,
options,
abortController
);
return { type, ...result };
}
case 'SRV': {
const result = await this.resolveSrv(
name,
options,
abortController
);
return result.map((value) => ({ type, value }));
}
case 'TXT': {
const result = await this.resolveTxt(
name,
options,
abortController
);
return result.map((entries) => ({ type, entries }));
}
default: {
break;
}
}
} catch (err) {
debug(err);
if (err.code === dns.NODATA) return;
throw err;
}
};
}
// <https://nodejs.org/api/dns.html#dnspromisesresolveanyhostname>
async resolveAny(name, options = {}, abortController) {
if (typeof name !== 'string') {
const err = new TypeError('The "name" argument must be of type string.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
// <https://gist.github.com/andrewcourtice/ef1b8f14935b409cfe94901558ba5594#file-task-ts-L37>
// <https://github.com/nodejs/undici/blob/0badd390ad5aa531a66aacee54da664468aa1577/lib/api/api-fetch/request.js#L280-L295>
// <https://github.com/nodejs/node/issues/40849>
if (!abortController) {
abortController = new AbortController();
this.abortControllers.add(abortController);
abortController.signal.addEventListener(
'abort',
() => {
this.abortControllers.delete(abortController);
},
{ once: true }
);
// <https://github.com/nodejs/undici/pull/1910/commits/7615308a92d3c8c90081fb99c55ab8bd59212396>
setMaxListeners(
getEventListeners(abortController.signal, 'abort').length +
this.constructor.ANY_TYPES.length,
abortController.signal
);
}
try {
const results = await pMap(
this.constructor.ANY_TYPES,
this.#resolveByType(name, options, abortController),
// <https://developers.cloudflare.com/fundamentals/api/reference/limits/>
{
concurrency: this.options.concurrency,
signal: abortController.signal
}
);
return results.flat().filter(Boolean);
} catch (err) {
err.syscall = 'queryAny';
err.message = `queryAny ${err.code} ${name}`;
throw err;
}
}
setDefaultResultOrder(dnsOrder) {
if (dnsOrder !== 'ipv4first' && dnsOrder !== 'verbatim') {
const err = new TypeError(
"The argument 'dnsOrder' must be one of: 'verbatim', 'ipv4first'."
);
err.code = 'ERR_INVALID_ARG_VALUE';
throw err;
}
this.options.dnsOrder = dnsOrder;
}
setServers(servers) {
if (!Array.isArray(servers) || servers.length === 0) {
const err = new TypeError(
'The "name" argument must be an instance of Array.'
);
err.code = 'ERR_INVALID_ARG_TYPE';
}
//
// NOTE: every address must be ipv4 or ipv6 (use `new URL` to parse and check)
// servers [ string ] - array of RFC 5952 formatted addresses
//
// <https://github.com/nodejs/node/blob/9bbde3d7baef584f14569ef79f116e9d288c7aaa/lib/internal/dns/utils.js#L87-L95>
this.options.servers = new Set(servers);
}
// eslint-disable-next-line max-params
spoofPacket(name, rrtype, answers = [], json = false, expires = 30000) {
if (typeof name !== 'string') {
const err = new TypeError('The "name" argument must be of type string.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (typeof rrtype !== 'string') {
const err = new TypeError(
'The "rrtype" argument must be of type string.'
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (!this.constructor.TYPES.has(rrtype)) {
const err = new TypeError("The argument 'rrtype' is invalid.");
err.code = 'ERR_INVALID_ARG_VALUE';
throw err;
}
if (!Array.isArray(answers)) {
const err = new TypeError("The argument 'answers' is invalid.");
err.code = 'ERR_INVALID_ARG_VALUE';
throw err;
}
const obj = {
id: 0,
type: 'response',
flags: 384,
flag_qr: true,
opcode: 'QUERY',
flag_aa: false,
flag_tc: false,
flag_rd: true,
flag_ra: true,
flag_z: false,
flag_ad: false,
flag_cd: false,
rcode: 'NOERROR',
questions: [{ name, type: rrtype, class: 'IN' }],
answers: answers.map((answer) => ({
name,
type: rrtype,
ttl: 300,
class: 'IN',
flush: false,
data: rrtype === 'TXT' ? [answer] : answer
})),
authorities: [],
additionals: [
{
name: '.',
type: 'OPT',
udpPayloadSize: 1232,
extendedRcode: 0,
ednsVersion: 0,
flags: 0,
flag_do: false,
options: [Array]
}
],
ttl: 300,
expires:
expires instanceof Date ? expires.getTime() : Date.now() + expires
};
return json ? JSON.stringify(obj) : obj;
}
// eslint-disable-next-line complexity
async resolve(name, rrtype = 'A', options = {}, abortController) {
if (typeof name !== 'string') {
const err = new TypeError('The "name" argument must be of type string.');
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (typeof rrtype !== 'string') {
const err = new TypeError(
'The "rrtype" argument must be of type string.'
);
err.code = 'ERR_INVALID_ARG_TYPE';
throw err;
}
if (!this.constructor.TYPES.has(rrtype)) {
const err = new TypeError("The argument 'rrtype' is invalid.");
err.code = 'ERR_INVALID_ARG_VALUE';
throw err;
}
// edge case where c-ares detects "." as start of string
// <https://github.com/c-ares/c-ares/blob/38b30bc922c21faa156939bde15ea35332c30e08/src/lib/ares_getaddrinfo.c#L829>
if (name !== '.' && (name.startsWith('.') || name.includes('..')))
throw this.constructor.createError(name, rrtype, dns.BADNAME);
// purge cache support
let purgeCache;
if (options?.purgeCache) {
purgeCache = true;
delete options.purgeCache;
}
// ecsSubnet support
let ecsSubnet;
if (options?.ecsSubnet) {
ecsSubnet = options.ecsSubnet;
delete options.ecsSubnet;
}
const key = (
ecsSubnet ? `${rrtype}:${ecsSubnet}:${name}` : `${rrtype}:${name}`
).toLowerCase();
let result;
let data;
if (this.options.cache && !purgeCache) {
//
// NOTE: we store `result.ttl` which was the lowest TTL determined
// (this saves us from duplicating the same `...sort().filter(Number.isFinite)` logic)
//
data = await this.options.cache.get(key);
//
// if it's not an object then assume that
// the cache implementation does not have JSON.parse built-in
// and so we should try to parse it on our own (user-friendly)
//
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch {}
}
// safeguard in case cache pollution
if (data && typeof data === 'object') {
debug('cache retrieved', key);
const now = Date.now();
// safeguard in case cache pollution
if (
!Number.isFinite(data.expires) ||
data.expires < now ||
!Number.isFinite(data.ttl) ||
data.ttl < 1
) {
debug('cache expired', key);
data = undefined;
} else if (options?.ttl) {
// clone the data so that we don't mutate cache (e.g. if it's in-memory)
// <https://nodejs.org/api/globals.html#structuredclonevalue-options>
// <https://github.com/ungap/structured-clone>
data = structuredClone(data);
// returns ms -> s conversion
const ttl = Math.round((data.expires - now) / 1000);
const diff = data.ttl - ttl;
for (let i = 0; i < data.answers.length; i++) {
// eslint-disable-next-line max-depth
if (typeof data.answers[i].ttl === 'number') {
// subtract ttl from answer
data.answers[i].ttl = Math.round(data.answers[i].ttl - diff);
// eslint-disable-next-line max-depth
if (data.answers[i].ttl <= 0) {
debug('answer cache expired', key);
data = undefined;
break;
}
}
}
}
// will only use cache if it's still set after parsing ttl
result = data;
} else {
data = undefined;
}
}
//
// <https://nodejs.org/api/dns.html#dnspromisesresolvehostname-rrtype>
//
// // <https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/#return-codes>
// HTTP Status Meaning
// 400 DNS query not specified or too small.
// 413 DNS query is larger than maximum allowed DNS message size.
// 415 Unsupported content type.
// 504 Resolver timeout while waiting for the query response.
//
// <https://developers.google.com/speed/public-dns/docs/doh#errors>
// 400 Bad Request
// - Problems parsing the GET parameters, or an invalid DNS request message. For bad GET parameters, the HTTP body should explain the error. Most invalid DNS messages get a 200 OK with a FORMERR; the HTTP error is returned for garbled messages with no Question section, a QR flag indicating a reply, or other nonsensical flag combinations with binary DNS parse errors