UNPKG

dns-over-http-resolver

Version:
266 lines (224 loc) 7.81 kB
import QuickLRU from 'quick-lru' import debug from 'weald' import * as utils from './utils.js' import type { DNSJSON } from './utils' const log = Object.assign(debug('dns-over-http-resolver'), { error: debug('dns-over-http-resolver:error') }) export interface Request { (resource: string, signal: AbortSignal): Promise<DNSJSON> } interface ResolverOptions { maxCache?: number request?: Request } /** * DNS over HTTP resolver. * Uses a list of servers to resolve DNS records with HTTP requests. */ class Resolver { private readonly _cache: QuickLRU<string, string[]> private readonly _TXTcache: QuickLRU<string, string[][]> private _servers: string[] private readonly _request: Request private _abortControllers: AbortController[] /** * @class * @param {object} [options] * @param {number} [options.maxCache = 100] - maximum number of cached dns records * @param {Request} [options.request] - function to return DNSJSON */ constructor (options: ResolverOptions = {}) { this._cache = new QuickLRU({ maxSize: options?.maxCache ?? 100 }) this._TXTcache = new QuickLRU({ maxSize: options?.maxCache ?? 100 }) this._servers = [ 'https://cloudflare-dns.com/dns-query', 'https://dns.google/resolve' ] this._request = options.request ?? utils.request this._abortControllers = [] } /** * Cancel all outstanding DNS queries made by this resolver. Any outstanding * requests will be aborted and promises rejected. */ cancel (): void { this._abortControllers.forEach(controller => { controller.abort() }) } /** * Get an array of the IP addresses currently configured for DNS resolution. * These addresses are formatted according to RFC 5952. It can include a custom port. */ getServers (): string[] { return this._servers } /** * Get a shuffled array of the IP addresses currently configured for DNS resolution. * These addresses are formatted according to RFC 5952. It can include a custom port. */ _getShuffledServers (): string[] { const newServers = [...this._servers] for (let i = newServers.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * i) const temp = newServers[i] newServers[i] = newServers[j] newServers[j] = temp } return newServers } /** * Sets the IP address and port of servers to be used when performing DNS resolution. * * @param {string[]} servers - array of RFC 5952 formatted addresses. */ setServers (servers: string[]): void { this._servers = servers } /** * Uses the DNS protocol to resolve the given host name into the appropriate DNS record * * @param {string} hostname - host name to resolve * @param {string} [rrType = 'A'] - resource record type */ async resolve (hostname: string, rrType: 'TXT'): Promise<string[][]> async resolve (hostname: string, rrType: 'A' | 'AAAA'): Promise<string[]> async resolve (hostname: string): Promise<string[]> async resolve (hostname: string, rrType: string = 'A'): Promise<string[] | string[][]> { switch (rrType) { case 'A': return this.resolve4(hostname) case 'AAAA': return this.resolve6(hostname) case 'TXT': return this.resolveTxt(hostname) default: throw new Error(`${rrType} is not supported`) } } /** * Uses the DNS protocol to resolve the given host name into IPv4 addresses * * @param {string} hostname - host name to resolve */ async resolve4 (hostname: string): Promise<string[]> { const recordType = 'A' const cached = this._cache.get(utils.getCacheKey(hostname, recordType)) if (cached != null) { return cached } let aborted = false for (const server of this._getShuffledServers()) { const controller = new AbortController() this._abortControllers.push(controller) try { const response = await this._request(utils.buildResource( server, hostname, recordType ), controller.signal) const data = response.Answer.map(a => a.data) const ttl = Math.min(...response.Answer.map(a => a.TTL)) this._cache.set(utils.getCacheKey(hostname, recordType), data, { maxAge: ttl }) return data } catch (err) { if (controller.signal.aborted) { aborted = true } log.error(`${server} could not resolve ${hostname} record ${recordType}`) } finally { this._abortControllers = this._abortControllers.filter(c => c !== controller) } } if (aborted) { throw Object.assign(new Error('queryA ECANCELLED'), { code: 'ECANCELLED' }) } throw new Error(`Could not resolve ${hostname} record ${recordType}`) } /** * Uses the DNS protocol to resolve the given host name into IPv6 addresses * * @param {string} hostname - host name to resolve */ async resolve6 (hostname: string): Promise<string[]> { const recordType = 'AAAA' const cached = this._cache.get(utils.getCacheKey(hostname, recordType)) if (cached != null) { return cached } let aborted = false for (const server of this._getShuffledServers()) { const controller = new AbortController() this._abortControllers.push(controller) try { const response = await this._request(utils.buildResource( server, hostname, recordType ), controller.signal) const data = response.Answer.map(a => a.data) const ttl = Math.min(...response.Answer.map(a => a.TTL)) this._cache.set(utils.getCacheKey(hostname, recordType), data, { maxAge: ttl }) return data } catch (err) { if (controller.signal.aborted) { aborted = true } log.error(`${server} could not resolve ${hostname} record ${recordType}`) } finally { this._abortControllers = this._abortControllers.filter(c => c !== controller) } } if (aborted) { throw Object.assign(new Error('queryAaaa ECANCELLED'), { code: 'ECANCELLED' }) } throw new Error(`Could not resolve ${hostname} record ${recordType}`) } /** * Uses the DNS protocol to resolve the given host name into a Text record * * @param {string} hostname - host name to resolve */ async resolveTxt (hostname: string): Promise<string[][]> { const recordType = 'TXT' const cached = this._TXTcache.get(utils.getCacheKey(hostname, recordType)) if (cached != null) { return cached } let aborted = false for (const server of this._getShuffledServers()) { const controller = new AbortController() this._abortControllers.push(controller) try { const response = await this._request(utils.buildResource( server, hostname, recordType ), controller.signal) const data = response.Answer.map(a => [a.data.replace(/['"]+/g, '')]) const ttl = Math.min(...response.Answer.map(a => a.TTL)) this._TXTcache.set(utils.getCacheKey(hostname, recordType), data, { maxAge: ttl }) return data } catch (err) { if (controller.signal.aborted) { aborted = true } log.error(`${server} could not resolve ${hostname} record ${recordType}`) } finally { this._abortControllers = this._abortControllers.filter(c => c !== controller) } } if (aborted) { throw Object.assign(new Error('queryTxt ECANCELLED'), { code: 'ECANCELLED' }) } throw new Error(`Could not resolve ${hostname} record ${recordType}`) } clearCache (): void { this._cache.clear() this._TXTcache.clear() } } export default Resolver