fetch-dns
Version:
A drop-in replacement of Node's 'dns' module using 'fetch' and DNS-over-HTTPS
742 lines (665 loc) • 19.4 kB
JavaScript
/** @format */
;
const _ = require("lodash");
const CACHE = require("./Cache").default;
const DEFAULT_SERVERS = require("./DefaultServers").default;
const fetch = require("cross-fetch");
const Promise = require("bluebird");
const makeDebug = require("debug");
const lookupRrtype = require("./Rrtypes").default;
const {
name: packageName,
version: packageVersion,
} = require("./package.json");
module.exports = {};
_.merge(module.exports, require("./Constants"));
/* eslint-disable no-magic-numbers */
const DEFAULT_TTL_SECONDS = 3600;
const IP_FAMILY_4 = 4;
const IP_FAMILY_6 = 6;
const IP_FAMILY_ANY = 0;
/* eslint-enable no-magic-numbers */
// DEBUG configuration
const log = makeDebug("fetch-dns");
const debug = log.extend("debug");
const error = log.extend("error");
if (debug.enabled) log.enabled = true;
if (log.enabled) error.enabled = true;
// Retrieve the methods for an object
function getMethods(obj) {
const properties = new Set();
let currentObj = obj;
do {
Object.getOwnPropertyNames(currentObj).forEach((item) =>
properties.add(item),
);
} while ((currentObj = Object.getPrototypeOf(currentObj)));
return [...properties.keys()].filter((item) => _.isFunction(obj[item]));
}
// Promote the methods of an object onto a target.
function promoteMethods(object, target) {
getMethods(object).forEach((funcName) => {
target[funcName] = _.bind(object[funcName], object);
});
}
function forceMatch(input, regex) {
return _.trim(input).match(regex) || [];
}
function isFamily(it) {
return (
_.isFinite(it) &&
(it === IP_FAMILY_4 || it === IP_FAMILY_6 || it === IP_FAMILY_ANY)
);
}
function isLookupOptions(it) {
if (_.isNil(it)) return false;
return (
(!("family" in it) || isFamily(it.family)) &&
(!("hints" in it) || _.isNumber(it.hints) || _.isNil(it.hints)) &&
(!("all" in it) || _.isBoolean(it.all) || _.isNil(it.all)) &&
(!("verbatim" in it) || _.isBoolean(it.verbatim) || _.isNil(it.verbatim))
);
}
function splitNaptr(str) {
// TODO Add validation
const [, order = 0, preference = 0, afterNumbers = ""] = forceMatch(
str,
/^(\d+)\s*(\d+)\s*(.*)$/,
);
const maybeQuotedStrRE = /("(?:\\\\"|[^"])*"|'(?:\\\\'|[^'])*'|[^\s]+)\s*(.*)$/;
const [, flags = "", afterFlags = ""] = forceMatch(
afterNumbers,
maybeQuotedStrRE,
);
const [, service = "", afterService = ""] = forceMatch(
afterFlags,
maybeQuotedStrRE,
);
const [, regexp = "", afterRegexp = ""] = forceMatch(
afterService,
maybeQuotedStrRE,
);
const [, replacement = ""] = forceMatch(afterRegexp, maybeQuotedStrRE);
const record = {
order: _.toFinite(order),
preference: _.toFinite(preference),
flags,
service,
regexp,
replacement,
};
debug("Parsed a NAPTR record's data", { data: str, record });
return record;
}
function isLookupAllOptions(it) {
return isLookupOptions(it) && it.all === true; // Yes, the "=== true" matters here for typing
}
function isLookupOneOptions(it) {
return isLookupOptions(it) && !it.all;
}
function toLookupAddress(family, hostname) {
return (address) => {
if (_.isEmpty(address)) {
throw new Error(`No IPv${family} address found for ${hostname}`);
}
return { address, family };
};
}
function isResolveOptions(it) {
if (_.isEmpty(it)) return false;
return "ttl" in it && _.isBoolean(it.ttl);
}
function isResolveWithTtlOptions(it) {
return isResolveOptions(it) && it.ttl === true;
}
class NotImplementedError extends Error {
constructor(methodName) {
super(
`'${methodName}' is not implemented in ${packageName} ${packageVersion}`,
);
this.methodName = methodName;
this.name = "NotImplementedError";
}
}
/* eslint-disable promise/no-callback-in-promise */
function callbackPromise1(promise, callback) {
const cb = _.once(callback);
Promise.resolve(promise)
.tap((result) => {
cb(null, result);
})
.catch((e) => {
cb(e);
});
}
function callbackPromise2(promise, callback) {
const cb = _.once(callback);
Promise.resolve(promise)
.tap(([t, u]) => {
cb(null, t, u);
})
.catch((e) => {
cb(e);
});
}
function lookupCallback(promise, callback) {
callbackPromise2(
promise.then(({ address, family }) => [address, family]),
callback,
);
}
/* eslint-enable promise/no-callback-in-promise */
module.exports.getDefaultServers = () => {
return _.cloneDeep(DEFAULT_SERVERS);
};
const promises = (module.exports.promises = {});
promises.Resolver = class PromiseResolver {
constructor(servers = DEFAULT_SERVERS) {
this.setServers(servers); // Ensures we don't accept empty servers
}
// The actual fetch-based DNS lookup implementations: everything is derived from here!
_doResolve(hostname, rrtype, mapper) {
const cachedResult = CACHE.check(hostname, rrtype);
if (!_.isEmpty(cachedResult)) {
debug("Retrieved cached result", { hostname, rrtype, cachedResult });
return Promise.resolve(cachedResult);
}
const server = this._pickServer();
const url = `${server}?name=${hostname}&type=${
rrtype === "ANY" ? "*" : _.toUpper(rrtype)
}`;
return Promise.resolve(
fetch(url, {
method: "GET",
headers: { Accept: "application/dns-json" },
mode: "no-cors",
keepalive: true,
}),
)
.then(async (res) => {
if (!res.ok) {
log("Result of fetching DNS record over HTTPS was not 'OK'", {
status: `${res.status} ${res.statusText}`,
hostname,
rrtype,
server,
requestUrl: url,
resultUrl: res.url,
});
return [];
}
const body = await res.json();
debug("Retrieved result", body);
const results = await Promise.map(
_.get(body, "Answer", []),
async (ans) => {
const ttl = _.isFinite(ans.TTL) ? ans.TTL : DEFAULT_TTL_SECONDS;
const result = await mapper({ ...ans, rrtype, ttl });
return { result, ttl };
},
);
CACHE.put(hostname, rrtype, results);
const toReturn = _.reject(_.map(results, "result"), _.isEmpty);
debug("Result of fetching DNS", hostname, rrtype, toReturn);
return toReturn;
})
.then(_.compact)
.tap((result) => {
if (_.isEmpty(result)) {
debug("Returning an empty result for a resolve", {
hostname,
rrtype,
result,
});
}
});
}
// A common, simple case.
_doResolveSimple(hostname, rrtype) {
const toReturn = this._doResolve(hostname, rrtype, ({ data }) => data);
if (_.isEmpty(toReturn)) {
debug("Returning an empty result for a simple resolve", {
hostname,
rrtype,
result: toReturn,
});
}
return toReturn;
}
getServers() {
const servers = this._servers;
debug("Retrieving servers", servers);
if (!servers || _.isEmpty(servers)) {
throw new Error("No servers found");
} else {
return _.cloneDeep(servers);
}
}
setServers(servers) {
if (_.isEmpty(servers)) {
throw new Error("Refusing to set empty servers for DNS");
}
log("Setting servers", servers);
this._servers = _.cloneDeep(servers);
}
_pickServer() {
const toReturn = _.sample(this.getServers());
if (_.isNil(toReturn)) {
throw new Error(`No server picked: ${JSON.stringify(this.servers)}`);
} else {
return toReturn;
}
}
lookup(hostname, familyOrOptions) {
if (_.isNil(familyOrOptions)) {
return this._lookupHostname(hostname);
} else if (isFamily(familyOrOptions)) {
return this._lookupHostnameFamily(hostname, familyOrOptions);
} else {
return this._lookupHostnameOptions(hostname, familyOrOptions);
}
}
_lookupHostname(hostname) {
return this._lookupHostnameFamily(hostname, 0);
}
_lookupHostnameFamily(hostname, family) {
if (family === IP_FAMILY_4) {
return this._lookup4(hostname);
} else if (family === IP_FAMILY_6) {
return this._lookup6(hostname);
} else {
return Promise.any([this._lookup4(hostname), this._lookup6(hostname)]);
}
}
_lookup4(hostname) {
return this.resolve4(hostname)
.then((result) => {
debug("Retrieved hostname via lookup4", hostname, result);
if (_.isArray(result)) {
return _.head(result);
} else {
return result;
}
})
.then((result) => {
if (_.isNil(result)) {
throw new Error(
`No result found when querying for 'A' record of '${hostname}'`,
);
} else {
return result;
}
})
.then(toLookupAddress(IP_FAMILY_4, hostname));
}
_lookup6(hostname) {
const mkLA = toLookupAddress(IP_FAMILY_6, hostname);
return this.resolve6(hostname)
.then((result) => {
if (_.isArray(result)) {
return _.sample(result);
} else {
return result;
}
})
.then((result) => {
if (_.isNil(result)) {
throw new Error(
`No result found when querying for 'AAAA' record of '${hostname}'`,
);
} else {
return result;
}
})
.then(mkLA);
}
_lookupHostnameOptions(hostname, options) {
// The 'verbatim' flag doesn't actually do anything because of the implementation.
// The 'hint' flag isn't supported by DoH.
const optionsFamily = _.get(options, "family", IP_FAMILY_4);
if (!isFamily(optionsFamily)) {
throw new Error(
`Could not determine desired address family (4, 6, or 0) from options: ${JSON.stringify(
options,
)}`,
);
}
if (_.isNil(options.all) || !options.all) {
return this._lookupHostnameFamily(hostname, optionsFamily);
} else {
const toLookupAddress4 = toLookupAddress(IP_FAMILY_4, hostname);
const toLookupAddress6 = toLookupAddress(IP_FAMILY_6, hostname);
if (optionsFamily === IP_FAMILY_4) {
return this.resolve4(hostname).map(toLookupAddress4);
} else if (optionsFamily === IP_FAMILY_6) {
return this.resolve6(hostname).map(toLookupAddress6);
} else if (optionsFamily === IP_FAMILY_ANY) {
return Promise.join(
this.resolve4(hostname).map(toLookupAddress4),
this.resolve6(hostname).map(toLookupAddress6),
)
.then(_.concat)
.then(_.flatten);
}
}
throw new Error(
`Unreachable code reached in '_lookupHostnameOptions(${JSON.stringify(
hostname,
)},${JSON.stringify(options)})'`,
);
}
lookupService(/*address, port*/) {
// TODO Find/create a web service exposing `getnameinfo` over HTTP
return new NotImplementedError("lookupService");
}
async resolve(hostname, rrtype) {
if (_.isEmpty(rrtype)) {
return this.resolve4(hostname);
} else {
const methodRrType = _.upperFirst(_.toLower(rrtype));
if (methodRrType === "A") {
return this.resolve4(hostname);
} else if (methodRrType === "Aaaa") {
return this.resolve6(hostname);
} else {
const f = this[`resolve${methodRrType}`];
if (_.isFunction(f)) {
return f.call(this, hostname);
} else {
throw new NotImplementedError(
`resolve(...,${JSON.stringify(rrtype)})`,
);
}
}
}
}
resolve4(hostname, options) {
const ttl = !!_.get(options, "ttl", false);
if (ttl) {
return this._doResolve(hostname, "A", (res) => ({
address: res.data,
ttl: res.ttl,
}));
} else {
return this._doResolveSimple(hostname, "A");
}
}
resolve6(hostname, options) {
const ttl = !!_.get(options, "ttl", false);
if (ttl) {
return this._doResolve(hostname, "AAAA", (res) => ({
address: res.data,
ttl: res.ttl,
}));
} else {
return this._doResolveSimple(hostname, "AAAA");
}
}
resolveAny(hostname) {
const results = this._doResolve(hostname, "*", (initialResponse) => {
if(!_.isFunction(lookupRrtype)) {
error(`lookupRrtype is not a function, but '${typeof lookupRrtype}': ${JSON.stringify(lookupRrtype)}`);
return [];
}
const rrtype = lookupRrtype(initialResponse.type);
return Promise.resolve(this.resolve(hostname, rrtype))
.catch(NotImplementedError, () => {
debug("Skipping lookup for unsupported rrtype", { rrtype, hostname });
return [];
})
.map((res) => {
if (_.isEmpty(res)) {
log("Saw an empty response", { hostname, rrtype, res });
return null;
} else if (_.isString(res)) {
return { value: res, type: rrtype };
} else {
return { ...res, type: rrtype };
}
});
});
return _.compact(_.flatten(results));
}
resolveCname(hostname) {
return this._doResolveSimple(hostname, "CNAME");
}
resolveMx(hostname) {
return this._doResolve(hostname, "MX", ({ data }) => {
const [priority, exchange] = _.split(_.trim(data), /\s+/, 2);
if (_.isEmpty(priority) || _.isEmpty(exchange)) {
log(
"Discovered an MX record with empty priority or exchange",
{ hostname },
data,
);
return [];
} else {
return { priority: _.toFinite(priority), exchange };
}
}).then(_.flatten);
}
resolveNaptr(hostname) {
return this._doResolve(hostname, "NAPTR", ({ data }) => splitNaptr(data));
}
resolveNs(hostname) {
return this._doResolveSimple(hostname, "NS");
}
resolvePtr(hostname) {
return this._doResolveSimple(hostname, "PTR");
}
resolveSoa(hostname) {
return this._doResolve(hostname, "SOA", (ans) => {
const { data } = ans;
const [
nsname,
hostmaster,
serial,
refresh,
retry,
expire,
minttl,
] = _.split(_.trim(data), /\s+/);
return {
...ans,
nsname,
hostmaster,
serial: _.toFinite(serial),
refresh: _.toFinite(refresh),
retry: _.toFinite(retry),
expire: _.toFinite(expire),
minttl: _.toFinite(minttl),
};
})
.then(_.head)
.then((result) => {
if (_.isNil(result)) {
throw new Error(
`No SOA record was able to be found for '${hostname}'`,
);
} else {
return result;
}
});
}
resolveTxt(hostname) {
return this._doResolve(hostname, "TXT", ({ data }) => [data]);
}
resolveSrv(hostname) {
return this._doResolve(hostname, "SRV", (ans) => {
const { data } = ans;
const [, , , , , priority, weight, port, name] = _.split(
_.trim(data),
/s+/,
);
return {
...ans,
priority: _.toFinite(priority),
weight: _.toFinite(weight),
port: _.toFinite(port),
name: name,
};
});
}
async reverse(ip) {
// TODO Find/create a web service that exposes reverse DNS lookups
throw new NotImplementedError("reverse");
}
};
const PROMISE_RESOLVER = new promises.Resolver(DEFAULT_SERVERS);
promoteMethods(PROMISE_RESOLVER, promises);
const Resolver = (module.exports.Resolver = class Resolver {
constructor(resolver = new promises.Resolver(DEFAULT_SERVERS)) {
this.resolver = resolver;
}
getServers() {
return this.resolver.getServers();
}
setServers(newServers) {
this.resolver.setServers(newServers);
}
lookup(hostname, familyOptionsOrCallback, callback) {
if (_.isFunction(familyOptionsOrCallback)) {
this._lookupHostname(hostname, familyOptionsOrCallback);
} else if (isFamily(familyOptionsOrCallback)) {
this._lookupFamily(hostname, familyOptionsOrCallback, callback);
} else if (isLookupAllOptions(familyOptionsOrCallback)) {
this._lookupAll(hostname, familyOptionsOrCallback, callback);
} else if (isLookupOneOptions(familyOptionsOrCallback)) {
this._lookupOne(hostname, familyOptionsOrCallback, callback);
} else {
throw new Error(
`Unknown lookup type based on args: ${JSON.stringify({
hostname,
familyOptionsOrCallback,
callback,
})}`,
);
}
}
_lookupHostname(hostname, callback) {
lookupCallback(this.resolver.lookup(hostname), callback);
}
_lookupFamily(hostname, family, callback) {
lookupCallback(this.resolver.lookup(hostname, family), callback);
}
_lookupAll(hostname, options, callback) {
callbackPromise1(this.resolver.lookup(hostname, options), callback);
}
_lookupOne(hostname, options, callback) {
lookupCallback(this.resolver.lookup(hostname, options), callback);
}
lookupService(address, port, callback) {
callbackPromise1(
this.resolver.lookupService(address, port),
(e, params) => {
if (_.isNil(e)) {
if (_.isNil(params)) {
throw new Error(`No parameters nor error provided to the callback`);
} else {
const { hostname, service } = params;
callback(null, hostname, service);
}
} else if (_.isError(e)) {
callback(e);
} else {
throw new Error(
`First argument is neither nil nor an error: ${e} (${typeof e})`,
);
}
},
);
}
resolve(hostname, rrtypeOrCallback, callback) {
if (_.isFunction(rrtypeOrCallback)) {
callbackPromise1(this.resolver.resolve(hostname), rrtypeOrCallback);
} else {
const rrtype = _.upperFirst(_.toLower(rrtypeOrCallback));
if (rrtype === "A") {
this.resolve4(hostname, callback);
} else if (rrtype === "Aaaa") {
this.resolve6(hostname, callback);
} else {
const f = this[`resolve${rrtype}`];
if (_.isFunction(f)) {
f.call(this, hostname, callback);
} else {
callback(
new NotImplementedError(
`resolve(...,${JSON.stringify(rrtypeOrCallback)})`,
),
);
}
}
}
}
resolve4(hostname, optionsOrCallback, callback) {
if (_.isFunction(optionsOrCallback)) {
this._resolve4Hostname(hostname, optionsOrCallback);
} else if (isResolveWithTtlOptions(optionsOrCallback)) {
this._resolve4Ttl(hostname, callback);
} else {
const cb = (err, recordsWithTtl) =>
callback(err, recordsWithTtl && _.map(recordsWithTtl, "address"));
this._resolve4Ttl(hostname, cb);
}
}
_resolve4Hostname(hostname, callback) {
callbackPromise1(this.resolver.resolve4(hostname), callback);
}
_resolve4Ttl(hostname, callback) {
callbackPromise1(this.resolver.resolve4(hostname, { ttl: true }), callback);
}
resolve6(hostname, optionsOrCallback, callback) {
if (_.isFunction(optionsOrCallback)) {
this._resolve6Hostname(hostname, optionsOrCallback);
} else if (isResolveWithTtlOptions(optionsOrCallback)) {
this._resolve6Ttl(hostname, callback);
} else {
const cb = (err, recordsWithTtl) =>
callback(err, recordsWithTtl && _.map(recordsWithTtl, "address"));
this._resolve6Ttl(hostname, cb);
}
}
_resolve6Hostname(hostname, callback) {
callbackPromise1(this.resolver.resolve6(hostname), callback);
}
_resolve6Ttl(hostname, callback) {
callbackPromise1(this.resolver.resolve6(hostname, { ttl: true }), callback);
}
resolveAny(hostname, callback) {
callbackPromise1(this.resolver.resolveAny(hostname), callback);
}
resolveCname(hostname, callback) {
callbackPromise1(this.resolver.resolveCname(hostname), callback);
}
resolveMx(hostname, callback) {
callbackPromise1(this.resolver.resolveMx(hostname), callback);
}
resolveNaptr(hostname, callback) {
callbackPromise1(this.resolver.resolveNaptr(hostname), callback);
}
resolveNs(hostname, callback) {
callbackPromise1(this.resolver.resolveNs(hostname), callback);
}
resolvePtr(hostname, callback) {
callbackPromise1(this.resolver.resolvePtr(hostname), callback);
}
resolveSoa(hostname, callback) {
callbackPromise1(this.resolver.resolveSoa(hostname), callback);
}
resolveSrv(hostname, callback) {
callbackPromise1(this.resolver.resolveSrv(hostname), callback);
}
resolveTxt(hostname, callback) {
callbackPromise1(this.resolver.resolveTxt(hostname), callback);
}
reverse(ip, callback) {
callbackPromise1(this.resolver.reverse(ip), callback);
}
cancel() {
this.resolver.cancel().catch((e) => log("Error while cancelling", e));
}
});
const CB_RESOLVER = new Resolver(PROMISE_RESOLVER);
promoteMethods(CB_RESOLVER, module.exports);
debug("export", module.exports);