UNPKG

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,642 lines (1,452 loc) 79.8 kB
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 pAny = require('p-any'); 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 }); } // Node.js v24+ adds a 'type' property to certain DNS record objects (MX, CAA, SRV, NAPTR) // We need to match this behavior for compatibility // See: https://github.com/nodejs/node/blob/main/doc/changelogs/CHANGELOG_V24.md const NODE_MAJOR_VERSION = Number.parseInt( process.versions.node.split('.')[0], 10 ); // HTTPS and SVCB record types are not yet supported by dns-packet // We map them to UNKNOWN_65 and UNKNOWN_64 respectively // See: https://github.com/mafintosh/dns-packet/pull/104 const HTTPS_SVCB_TYPE_MAP = { HTTPS: 'UNKNOWN_65', SVCB: 'UNKNOWN_64' }; // <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', 'UNKNOWN_64', 'UNKNOWN_65', '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, // whether to resolve all servers in parallel and use first successful result parallelResolution: false, // 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 (this.options.parallelResolution === undefined) this.options.parallelResolution = false; if (typeof this.options.parallelResolution !== 'boolean') throw new Error('parallelResolution must be a boolean'); // 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'); // errno -3007 is for invalid hostnames (like ".") 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) { // For lookup, if any error is ENOTFOUND, return ENOTFOUND // For .localhost subdomains, BADNAME, and ENODATA errors, return ENOTFOUND // This matches c-ares behavior for lookup let errorCode = errors[0].code; const hasNotFound = errors.some((e) => e.code === dns.NOTFOUND); if ( hasNotFound || errorCode === dns.BADNAME || errorCode === dns.NODATA || lower.endsWith('.localhost') || lower.endsWith('.localhost.') ) { errorCode = dns.NOTFOUND; } const err = this.constructor.createError(name, '', errorCode); // remap and perform syscall err.syscall = 'getaddrinfo'; err.message = err.message.replace('query', 'getaddrinfo'); // errno -3008 is the standard ENOTFOUND errno 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; // Include all hosts (c-ares includes all hosts from /etc/hosts) for (const host of rule.hosts) { 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() { // Normalize IPv6 addresses to match native dns.Resolver behavior // e.g., '[::0]' -> '::' but '[2001:db8::1]:8080' stays as-is return [...this.options.servers].map((server) => { // Check if it's a bracketed IPv6 address if (server.startsWith('[')) { // Extract the IPv6 address and optional port const match = server.match(/^\[([^\]]+)](?::(\d+))?$/); if (match) { const ipv6 = match[1]; const port = match[2]; // Normalize the IPv6 address using ipaddr.js try { const parsed = ipaddr.parse(ipv6); const normalized = parsed.toString(); // If there's a port, keep the bracket format like native DNS does // Otherwise just return the normalized address return port ? `[${normalized}]:${port}` : normalized; } catch { // If parsing fails, return as-is return server; } } } return server; }); } // // 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('ETIMEOUT'); }, timeout); const response = await this.request(url, options); clearTimeout(t); return response; } // <https://github.com/hildjj/dohdec/tree/main/pkg/dohdec> async #query(name, rrtype = 'A', ecsSubnet, abortController, dnssec) { if (!dohdec) await pWaitFor(() => Boolean(dohdec)); debug('query', { name, nameToASCII: toASCII(name), rrtype, ecsSubnet, dnssec, 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, // When dnssec is true, set the AD flag in the query and the DO // (DNSSEC OK) flag in the EDNS0 OPT record so the upstream resolver // returns DNSSEC validation status via the AD flag in the response. // <https://datatracker.ietf.org/doc/html/rfc3225> // <https://datatracker.ietf.org/doc/html/rfc4035#section-3.2.1> dnssec }); try { // mirror the behavior as noted in built-in DNS // <https://github.com/nodejs/node/issues/33353#issuecomment-627259827> let buffer; const errors = []; const servers = [...this.options.servers]; const parseBody = async (body) => { // <https://sindresorhus.com/blog/goodbye-nodejs-buffer> if (Buffer.isBuffer(body)) return body; if (typeof body.arrayBuffer === 'function') return Buffer.from(await body.arrayBuffer()); if (isStream(body)) return getStream.buffer(body); const err = new TypeError('Unsupported body type'); err.body = body; throw err; }; const getRequestAbortController = (parallelAbortController) => { if (!parallelAbortController) return { requestAbortController: abortController, cleanupAbortController() {} }; const requestAbortController = new AbortController(); const parentSignal = abortController?.signal; const parallelSignal = parallelAbortController.signal; const onAbort = () => { if (!requestAbortController.signal.aborted) requestAbortController.abort(parentSignal.reason); }; const onParallelAbort = () => { if (!requestAbortController.signal.aborted) requestAbortController.abort(parallelSignal.reason); }; if (parentSignal?.aborted) onAbort(); else if (parentSignal) parentSignal.addEventListener('abort', onAbort, { once: true }); if (parallelSignal.aborted) onParallelAbort(); else parallelSignal.addEventListener('abort', onParallelAbort, { once: true }); return { requestAbortController, cleanupAbortController() { parentSignal?.removeEventListener('abort', onAbort); parallelSignal.removeEventListener('abort', onParallelAbort); } }; }; const addServerErrors = (server, ipErrors) => { if (ipErrors.length === 0) return; // 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); }; const throwOnNotFound = (err) => { if (err.code === dns.NOTFOUND) throw err; }; const queryServer = async (server, parallelAbortController) => { const ipErrors = []; let lastError; for (let i = 0; i < this.options.tries; i++) { const { requestAbortController, cleanupAbortController } = getRequestAbortController(parallelAbortController); try { // eslint-disable-next-line no-await-in-loop const response = await this.#request( pkt, server, requestAbortController, this.options.timeout * 2 ** i ); if (response) { const { body, headers } = response; const statusCode = response.status || response.statusCode; debug('response', { statusCode, headers }); if (body && statusCode >= 200 && statusCode < 300) return parseBody(body); // <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); lastError = err; // // NOTE: if NOTFOUND error occurs then don't attempt further requests // <https://nodejs.org/api/dns.html#dnssetserversservers> // throwOnNotFound(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; } finally { cleanupAbortController(); } } addServerErrors(server, ipErrors); throw lastError || new Error(`No response from ${server}`); }; if (this.options.parallelResolution) { const parallelAbortController = new AbortController(); try { buffer = await pAny( servers.map((server) => queryServer(server, parallelAbortController) ) ); } catch (err) { if (errors.length > 0) throw this.constructor.combineErrors(errors); throw err; } finally { if (!parallelAbortController.signal.aborted) parallelAbortController.abort('CANCELLED'); } } else { // NOTE: we would have used `p-map-series` but it did not support abort/break for (const server of servers) { try { // eslint-disable-next-line no-await-in-loop buffer = await queryServer(server); break; } catch (err) { throwOnNotFound(err); debug(err); } } } if (!buffer) { if (errors.length > 0) throw this.constructor.combineErrors(errors); // if no errors and no response // that must indicate that it was aborted // Check if this was a timeout abort (reason will be 'ETIMEOUT') const abortCode = abortController?.signal?.reason === 'ETIMEOUT' ? dns.TIMEOUT : dns.CANCELLED; throw this.constructor.createError(name, rrtype, abortCode); } // 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; // Check if this was a timeout abort (reason will be 'ETIMEOUT') // or if the error name is AbortError with a numeric code (undici behavior) let errorCode = _err.code; if ( (_err.name === 'AbortError' || typeof _err.code === 'number') && abortController?.signal?.reason === 'ETIMEOUT' ) { errorCode = 'ETIMEOUT'; } const err = this.constructor.createError( name, rrtype, errorCode, _err.errno ); // then map it to dns.CONNREFUSED // preserve original error and stack trace err.error = _err; // throwing here saves indentation below throw err; } } // #createAbortController and #releaseAbortController manage all AbortController instances created by this resolver // - to support cancel() and // - to avoid keeping references in this.abortControllers after a query is finished (which would create a memory leak) #createAbortController() { const abortController = new AbortController(); this.abortControllers.add(abortController); return abortController; } #releaseAbortController(abortController) { try { this.abortControllers.delete(abortController); } catch (err) { this.options.logger.debug(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 = this.#createAbortController(); try { 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; } } finally { this.#releaseAbortController(abortController); } }; } // <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> let mustReleaseAbortController = false; if (!abortController) { abortController = this.#createAbortController(); mustReleaseAbortController = true; try { // <https://github.com/nodejs/undici/pull/1910/commits/7615308a92d3c8c90081fb99c55ab8bd59212396> setMaxListeners( getEventListeners(abortController.signal, 'abort').length + this.constructor.ANY_TYPES.length, abortController.signal ); } catch (err) { this.#releaseAbortController(abortController); throw err; } } 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; } finally { if (mustReleaseAbortController) { this.#releaseAbortController(abortController); } } } 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