digd.js/lib/digd.js

483 lines
14 KiB
JavaScript

(function () {
'use strict';
/*
module.exports.ask = function (query, cb) {
};
*/
var NOERROR = 0;
var NXDOMAIN = 3;
var REFUSED = 5;
function getRecords(engine, qname, cb) {
var delMe = {};
var dns = require('dns');
// SECURITY XXX TODO var dig = require('dig.js/dns-request');
var count;
return engine.getRecords({ name: qname }, function (err, myRecords) {
if (err) { cb(err); return; }
function checkCount() {
var ready;
count -= 1;
ready = count <= 0;
if (!ready) {
return;
}
myRecords = myRecords.filter(function (r) {
return !delMe[r.id];
});
// There are a number of ways to interpret the wildcard rules
var hasWild = false;
var hasMatch = false;
myRecords.some(function (r) {
if (qname === r.name) {
hasMatch = true;
return true;
}
if ('*' === r.name[0]) {
hasWild = true;
}
});
if (hasMatch) {
myRecords = myRecords.filter(function (r) {
if ('*' !== r.name[0]) { return true; }
});
}
/*
// no need to filter out records if wildcard is used
else {
records = records.filter(function (r) {
if ('*' === r.name[0]) { return true; }
});
}
*/
cb(null, myRecords);
}
function getRecord(r) {
// TODO allow multiple records to be returned(?)
return function (err, addresses) {
if (err || !addresses.length) {
r.id = r.id || Math.random();
delMe[r.id] = true;
} else if (addresses.length > 1) {
r._address = addresses[Math.floor(Math.random() * addresses.length)];
} else {
r._address = addresses[0];
}
checkCount();
};
}
count = myRecords.length;
myRecords.forEach(function (r) {
if (r.aname && !r.address) {
if ('A' === r.type) {
// SECURITY XXX TODO dig.resolveJson(query, opts);
dns.resolve4(r.aname, getRecord(r));
return;
}
if ('AAAA' === r.type) {
// SECURITY XXX TODO dig.resolveJson(query, opts);
dns.resolve6(r.aname, getRecord(r));
return;
}
}
checkCount();
});
if (!myRecords.length) {
checkCount();
}
});
}
function dbToResourceRecord(r) {
return {
name: r.name
, typeName: r.typeName || r.type // NS
, className: 'IN'
, ttl: r.ttl || 300
// SOA
/*
, "primary": "ns1.yahoo.com"
, "admin": "hostmaster.yahoo-inc.com"
, "serial": 2017092539
, "refresh": 3600
, "retry": 300
, "expiration": 1814400
, "minimum": 600
*/
// A, AAAA
, address: -1 !== [ 'A', 'AAAA' ].indexOf(r.type) ? (r._address || r.address || r.value) : undefined
// CNAME, NS, PTR || TXT
, data: -1 !== [ 'CNAME', 'NS', 'PTR', 'TXT' ].indexOf(r.type) ? (r.data || r.value || r.values) : undefined
// MX, SRV
, priority: r.priority
// MX
, exchange: r.exchange
// SRV
, weight: r.weight
, port: r.port
, target: r.target
};
}
function getNs(engine, ds, results, cb) {
console.log('[DEV] getNs entered with domains', ds);
var d = ds.shift();
console.log('[DEV] trying another one', d);
if (!d) {
results.header.rcode = NXDOMAIN;
cb(null, results);
return;
}
var qn = d.id.toLowerCase();
return getRecords(engine, qn, function (err, records) {
if (err) { cb(err); return; }
records.forEach(function (r) {
if ('NS' !== r.type) {
return;
}
var ns = {
name: r.name
, typeName: r.type // NS
, className: r.class || 'IN'
, ttl: r.ttl || 300
, data: r.data || r.value || r.address
};
console.log('got NS record:');
console.log(r);
console.log(ns);
// TODO what if this NS is one of the NS?
// return SOA record instead
results.authority.push(ns);
});
if (!results.authority.length) {
return getNs(engine, ds, results, cb);
}
// d.vanityNs should only be vanity nameservers (pointing to this same server)
if (d.vanityNs || results.authority.some(function (ns) {
console.log('[debug] ns', ns);
return -1 !== engine.primaryNameservers.indexOf(ns.data.toLowerCase());
})) {
results.authority.length = 0;
results.authority.push(domainToSoa(engine.primaryNameservers, d));
results.header.rcode = NXDOMAIN;
}
cb(null, results);
return;
});
}
function domainToSoa(primaryNameservers, domain) {
var nameservers = domain.vanityNs || primaryNameservers;
var index = Math.floor(Math.random() * nameservers.length) % nameservers.length;
var nameserver = nameservers[index];
return {
name: domain.id
, typeName: 'SOA'
, className: 'IN'
, ttl: domain.ttl || 60
// nameserver -- select an NS at random if they're all in sync
, primary: nameserver
, name_server: nameserver
// admin -- email address or domain for admin
, admin: domain.admin || ('admin.' + domain.id)
, email_addr: domain.admin || ('admin.' + domain.id)
// serial -- the version, for cache-busting of secondary nameservers. suggested format: YYYYMMDDnn
, serial: domain.serial || Math.round((domain.updatedAt || domain.createdAt || 0) / 1000)
, sn: domain.serial || Math.round((domain.updatedAt || domain.createdAt || 0) / 1000)
// refresh -- only used when nameservers following the DNS NOTIFY spec talk
, refresh: domain.refresh || 1800
, ref: domain.refresh || 1800
// retry -- only used when nameservers following the DNS NOTIFY spec talk
, retry: domain.retry || 600
, ret: domain.retry || 600
// expiration -- how long other nameservers should continue when the primary goes down
, expiration: domain.expiration || 2419200
, ex: domain.expiration || 2419200
// minimum -- how long to cache a non-existent domain (also the default ttl for BIND)
, minimum: domain.minimum || 5
, nx: domain.minimum || 5
};
}
function getSoa(primaryNameservers, domain, results, cb, answerSoa) {
console.log('[DEV] getSoa entered');
if (!answerSoa) {
results.authority.push(domainToSoa(primaryNameservers, domain));
} else {
results.answer.push(domainToSoa(primaryNameservers, domain));
}
cb(null, results);
return;
}
module.exports.query = function (engine, query, cb) {
/*
var fs = require('fs');
fs.readFile(input, 'utf8', function (err, text) {
if (err) { cb(err); return; }
var records;
try {
records = JSON.parse(text);
} catch(e) { cb(e); return; }
});
*/
var qname;
if (!Array.isArray(query.question) || query.question.length < 1) {
cb(new Error("query is missing question section"));
return;
}
if (1 !== query.question.length) {
cb(new Error("query should have exactly one question (for now)"));
return;
}
if (!query.question[0] || 'string' !== typeof query.question[0].name) {
cb(new Error("query's question section should exist and have a String name property"));
return;
}
qname = query.question[0].name.toLowerCase();
var results = {
header: {
id: query.header.id // same as request
, qr: 1
, opcode: 0 // pretty much always 0 QUERY
, aa: 1 // TODO right now we assume that if we have the record, we're authoritative
// but in reality we could be hitting a cache and then recursing on a cache miss
, tc: 0
, rd: query.header.rd // duh
, ra: 0 // will be changed by cli.norecurse
, rcode: NOERROR // 0 NOERROR, 3 NXDOMAIN, 5 REFUSED
}
, question: [ query.question[0] ], answer: [], authority: [], additional: []
};
function getNsAndSoa(getNsAlso, answerSoa) {
// If the query is www.foo.delegated.example.com
// and we have been delegated delegated.example.com
// and delegated.example.com exists
// but foo.delegated.example.com does not exist
// what's the best strategy for returning the record?
//
// What does PowerDNS do in these situations?
// https://doc.powerdns.com/md/authoritative/backend-generic-mysql/
// How to optimize:
// Assume that if a record is being requested, it probably exists
// (someone has probably published it somewhere)
// If the record doesn't exist, then see if any of the domains are managed
// [ 'www.john.smithfam.net', 'john.smithfam.net', 'smithfam.net', 'net' ]
// Then if one of those exists, return the SOA record with NXDOMAIN
var qarr = qname.split('.');
var qnames = [];
while (qarr.length) {
qnames.push(qarr.join('.').toLowerCase());
qarr.shift(); // first
}
console.log('[DEV] getNsAlso?', getNsAlso);
console.log('[DEV] answerSoa?', answerSoa);
console.log('[DEV] qnames');
console.log(qnames);
return engine.getSoas({ names: qnames}, function (err, myDomains) {
console.log('[SOA] looking for', qnames, 'and proudly serving', err, myDomains);
if (err) { cb(err); return; }
// this should result in a REFUSED status
if (!myDomains.length) {
// REFUSED will have no records, so we could still recursion, if enabled
results.header.rcode = REFUSED;
cb(null, results);
return;
}
myDomains.sort(function (d1, d2) {
if (d1.id.length > d2.id.length) {
return -1;
}
if (d1.id.length < d2.id.length) {
return 1;
}
return 0;
});
//console.log('sorted domains', myDomains);
if (!getNsAlso) {
return getSoa(engine.primaryNameservers, myDomains[0], results, cb, answerSoa);
}
return getNs(engine, /*myDomains.slice(0)*/qnames.map(function (qn) { return { id: qn }; }), results, function (err, results) {
//console.log('[DEV] getNs complete');
if (err) { cb(err, results); return; }
// has NS records (or SOA record if NS records match the server itself)
if (results.authority.length) {
console.log(results); cb(null, results); return;
}
// myDomains was sorted such that the longest was first
return getSoa(engine.primaryNameservers, myDomains[0], results, cb);
});
});
}
if ('SOA' === query.question[0].typeName) {
return getNsAndSoa(false, true);
}
//console.log('[DEV] QUERY NAME', qname);
return getRecords(engine, qname, function (err, someRecords) {
var myRecords;
var nsRecords = [];
if (err) { cb(err); return; }
// There are two special cases
// NS records are returned as ANSWER for NS and ANY, and as AUTHORITY when an externally-delegated domain would return an SOA (no records)
// SOA records are returned as ANSWER for SOA and ANY, and as AUTHORITY when no records are found, but the domain is controlled here
console.log("[DEV] has", someRecords.length, "records");
// filter out NS (delegation) records, unless that is what is intended
someRecords = someRecords.filter(function (r) {
// If it's not an NS record, it's a potential result
if ('NS' !== r.type && 'NS' !== r.typeName) {
return true;
}
console.log("It's NS");
// If it's a vanity NS, it's not a valid NS for lookup
// NOTE: I think that the issue here is EXTERNAL vs INTERNAL vanity NS
// We _should_ reply for EXTERNAL vanity NS... but not when it's listed on the SOA internally?
// It's surrounding the problem of what if I do sub domain delegation to the same server.
if (-1 === engine.primaryNameservers.indexOf(r.data.toLowerCase())) {
console.log("It's a vanity NS");
return false;
}
// If the query was for NS, it's a potential result
if ('ANY' === query.question[0].typeName || 'NS' === query.question[0].typeName) {
return true;
}
nsRecords.push(r);
return false;
});
myRecords = someRecords;
// If we had an ANY query then we don't need to filter out results
if (255 !== query.question[0].type && 'ANY' !== query.question[0].typeName) {
var hasA = false;
var hasCname = false;
// We should only return the records that match the query,
// except in the case of A/AAAA in which case we should also collect the CNAME
myRecords = myRecords.filter(function (r) {
var passCnames = false;
if (!hasA && ('A' === query.question[0].typeName || 'AAAA' === query.question[0].typeName)) {
passCnames = ('CNAME' === r.type ||'CNAME' === r.typeName);
hasCname = hasCname || passCnames;
}
hasA = hasA || ('A' === r.type || 'A' === r.typeName || 'AAAA' === r.type || 'AAAA' === r.typeName);
return passCnames || ((r.type && r.type === query.question[0].type)
|| (r.type && r.type === query.question[0].typeName)
|| (r.typeName && r.typeName === query.question[0].typeName)
);
});
// A and AAAA will also return CNAME records
// but we filter out the CNAME records unless there are no A / AAAA records
if (hasA && hasCname && ('A' === query.question[0].typeName || 'AAAA' === query.question[0].typeName)) {
myRecords = myRecords.forEach(function (r) {
return 'CNAME' !== r.type && 'CNAME' !== r.typeName;
});
}
}
if (myRecords.length) {
myRecords.forEach(function (r) {
results.answer.push(dbToResourceRecord(r));
});
results.header.rcode = NOERROR;
//console.log('[DEV] ANSWER results', results);
if (255 === query.question[0].type || 'ANY' === query.question[0].typeName) {
getNsAndSoa(false, true);
return;
}
cb(null, results);
return;
}
else if (nsRecords.length) {
nsRecords.forEach(function (r) {
results.authority.push(dbToResourceRecord(r));
});
results.header.rcode = NOERROR;
//console.log('[DEV] AUTHORITY results', results);
cb(null, results);
return;
}
console.log("[DEV] Gonna get NS and SOA");
// !myRecords.length
getNsAndSoa(true);
});
};
}());