dns-over-http-resolver
Version:
DNS over HTTP resolver
266 lines (224 loc) • 7.81 kB
text/typescript
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