/*!
* dns.js - dns server for hsd
* Copyright (c) 2017-2018, Christopher Jeffrey (MIT License).
* https://github.com/handshake-org/hsd
*/
'use strict';
const assert = require('bsert');
const IP = require('binet');
const Logger = require('blgr');
const bns = require('bns');
const UnboundResolver = require('bns/lib/resolver/unbound');
const RecursiveResolver = require('bns/lib/resolver/recursive');
const RootResolver = require('bns/lib/resolver/root');
const secp256k1 = require('bcrypto/lib/secp256k1');
const LRU = require('blru');
const base32 = require('bcrypto/lib/encoding/base32');
const NameState = require('../covenants/namestate');
const rules = require('../covenants/rules');
const reserved = require('../covenants/reserved');
const {Resource} = require('./resource');
const key = require('./key');
const nsec = require('./nsec');
const {
DEFAULT_TTL,
TYPE_MAP_ROOT,
TYPE_MAP_EMPTY,
TYPE_MAP_NS,
TYPE_MAP_A,
TYPE_MAP_AAAA
} = require('./common');
const {
DNSServer,
hsig,
wire,
util
} = bns;
const {
Message,
Record,
ARecord,
AAAARecord,
NSRecord,
SOARecord,
types,
codes
} = wire;
/*
* Constants
*/
const RES_OPT = { inet6: false, tcp: true };
const CACHE_TTL = 30 * 60 * 1000;
/**
* RootCache
*/
class RootCache {
constructor(size) {
this.cache = new LRU(size);
}
set(name, type, msg) {
const key = toKey(name, type);
const raw = msg.compress();
this.cache.set(key, {
time: Date.now(),
raw
});
return this;
}
get(name, type) {
const key = toKey(name, type);
const item = this.cache.get(key);
if (!item)
return null;
if (Date.now() > item.time + CACHE_TTL)
return null;
return Message.decode(item.raw);
}
reset() {
this.cache.reset();
}
}
/**
* RootServer
* @extends {DNSServer}
*/
class RootServer extends DNSServer {
constructor(options) {
super(RES_OPT);
this.ra = false;
this.edns = true;
this.dnssec = true;
this.noSig0 = false;
this.icann = new RootResolver(RES_OPT);
this.logger = Logger.global;
this.key = secp256k1.privateKeyGenerate();
this.host = '127.0.0.1';
this.port = 5300;
this.lookup = null;
this.middle = null;
this.publicHost = '127.0.0.1';
// Plugins can add or remove items from
// this set before the server is opened.
this.blacklist = new Set([
'bit', // Namecoin
'eth', // ENS
'exit', // Tor
'gnu', // GNUnet (GNS)
'i2p', // Invisible Internet Project
'onion', // Tor
'tor', // OnioNS
'zkey' // GNS
]);
this.cache = new RootCache(3000);
if (options)
this.initOptions(options);
// Create SYNTH record to use for root zone NS
let ip = IP.toBuffer(this.publicHost);
if (IP.family(this.publicHost) === 4)
ip = ip.slice(12);
this.synth = `_${base32.encodeHex(ip)}._synth.`;
this.initNode();
}
initOptions(options) {
assert(options);
this.parseOptions(options);
if (options.logger != null) {
assert(typeof options.logger === 'object');
this.logger = options.logger.context('ns');
}
if (options.key != null) {
assert(Buffer.isBuffer(options.key));
assert(options.key.length === 32);
this.key = options.key;
}
if (options.host != null) {
assert(typeof options.host === 'string');
this.host = IP.normalize(options.host);
this.publicHost = this.host;
}
if (options.port != null) {
assert((options.port & 0xffff) === options.port);
assert(options.port !== 0);
this.port = options.port;
}
if (options.lookup != null) {
assert(typeof options.lookup === 'function');
this.lookup = options.lookup;
}
if (options.noSig0 != null) {
assert(typeof options.noSig0 === 'boolean');
this.noSig0 = options.noSig0;
}
if (options.publicHost != null) {
assert(typeof options.publicHost === 'string');
this.publicHost = IP.normalize(options.publicHost);
}
return this;
}
initNode() {
this.on('error', (err) => {
this.logger.error(err);
});
this.on('query', (req, res) => {
this.logMessage('\n\nDNS Request:', req);
this.logMessage('\n\nDNS Response:', res);
});
return this;
}
logMessage(prefix, msg) {
if (this.logger.level < 5)
return;
const logs = msg.toString().trim().split('\n');
this.logger.spam(prefix);
for (const log of logs)
this.logger.spam(log);
}
signSize() {
if (!this.sig0)
return 94;
return 0;
}
sign(msg, host, port) {
if (!this.noSig0)
return hsig.sign(msg, this.key);
return msg;
}
async lookupName(name) {
if (!this.lookup)
throw new Error('Tree not available.');
const hash = rules.hashName(name);
const data = await this.lookup(hash);
if (!data)
return null;
const ns = NameState.decode(data);
if (ns.data.length === 0)
return null;
return ns.data;
}
async response(req, rinfo) {
const [qs] = req.question;
const name = qs.name.toLowerCase();
const type = qs.type;
// Our root zone.
if (name === '.') {
const res = new Message();
res.aa = true;
switch (type) {
case types.ANY:
case types.NS:
res.answer.push(this.toNS());
key.signZSK(res.answer, types.NS);
if (IP.family(this.publicHost) === 4) {
res.additional.push(this.toA());
key.signZSK(res.additional, types.A);
} else {
res.additional.push(this.toAAAA());
key.signZSK(res.additional, types.AAAA);
}
break;
case types.SOA:
res.answer.push(this.toSOA());
key.signZSK(res.answer, types.SOA);
res.authority.push(this.toNS());
key.signZSK(res.authority, types.NS);
if (IP.family(this.publicHost) === 4) {
res.additional.push(this.toA());
key.signZSK(res.additional, types.A);
} else {
res.additional.push(this.toAAAA());
key.signZSK(res.additional, types.AAAA);
}
break;
case types.DNSKEY:
res.answer.push(key.ksk.deepClone());
res.answer.push(key.zsk.deepClone());
key.signKSK(res.answer, types.DNSKEY);
break;
default:
// Minimally covering NSEC proof:
res.authority.push(this.toNSEC());
key.signZSK(res.authority, types.NSEC);
res.authority.push(this.toSOA());
key.signZSK(res.authority, types.SOA);
break;
}
return res;
}
// Process the name.
const labels = util.split(name);
const tld = util.label(name, labels, -1);
// Handle reverse pointers.
if (tld === '_synth' && labels.length <= 2 && name[0] === '_') {
const res = new Message();
const rr = new Record();
res.aa = true;
rr.name = name;
rr.ttl = 21600;
// TLD '._synth' is being queried on its own, send SOA
// so recursive asks again with complete synth record.
if (labels.length === 1) {
// Empty non-terminal proof:
res.authority.push(
nsec.create(
'_synth.',
'\\000._synth.',
TYPE_MAP_EMPTY
)
);
key.signZSK(res.authority, types.NSEC);
res.authority.push(this.toSOA());
key.signZSK(res.authority, types.SOA);
return res;
}
const hash = util.label(name, labels, -2);
const ip = IP.map(base32.decodeHex(hash.substring(1)));
const synthType = IP.isIPv4(ip) ? types.A : types.AAAA;
// Query must be for the correct synth version
if (type !== synthType) {
// SYNTH4/6 proof:
const typeMap = synthType === types.A ? TYPE_MAP_A : TYPE_MAP_AAAA;
res.authority.push(nsec.create(name, '\\000.' + name, typeMap));
key.signZSK(res.authority, types.NSEC);
res.authority.push(this.toSOA());
key.signZSK(res.authority, types.SOA);
return res;
}
if (synthType === types.A) {
rr.type = types.A;
rr.data = new ARecord();
} else {
rr.type = types.AAAA;
rr.data = new AAAARecord();
}
rr.data.address = IP.toString(ip);
res.answer.push(rr);
key.signZSK(res.answer, rr.type);
return res;
}
// REFUSED for invalid names
// this simplifies NSEC proofs
// by avoiding octets like \000
// Also, this decreases load on
// the server since it avoids signing
// useless proofs for invalid TLDs
// (These requests are most
// likely bad anyways)
if (!rules.verifyName(tld)) {
const res = new Message();
res.code = codes.REFUSED;
return res;
}
// Ask the urkel tree for the name data.
const data = !this.blacklist.has(tld)
? (await this.lookupName(tld))
: null;
// Non-existent domain.
if (!data) {
const item = this.getReserved(tld);
// This name is in the existing root zone.
// Fall back to ICANN's servers if not yet
// registered on the handshake blockchain.
// This is an example of "Dynamic Fallback"
// as mentioned in the whitepaper.
if (item && item.root) {
const res = await this.icann.lookup(tld);
if (res.ad && res.code !== codes.NXDOMAIN) {
// answer must be a referral since lookup
// function always asks for NS
assert(res.code === codes.NOERROR);
assert(res.answer.length === 0);
assert(hasValidOwner(res.authority, tld));
res.ad = false;
res.question = [qs];
const secure = util.hasType(res.authority, types.DS);
// no DS referrals for TLDs
if (type === types.DS && labels.length === 1) {
const dsSet = util.extractSet(res.authority,
util.fqdn(tld), types.DS);
res.aa = true;
res.answer = dsSet;
key.signZSK(res.answer, types.DS);
res.authority = [];
res.additional = [];
if (res.answer.length === 0) {
res.authority.push(this.toSOA());
key.signZSK(res.authority, types.SOA);
}
}
// No DS we must add a minimally covering proof
if (!secure) {
// Replace any NSEC/NSEC3 records
const filterTypes = [types.NSEC, types.NSEC3];
res.authority = util.filterSet(res.authority, ...filterTypes);
const next = nsec.nextName(tld);
const rr = nsec.create(tld, next, TYPE_MAP_NS);
res.authority.push(rr);
key.signZSK(res.authority, types.NSEC);
} else {
key.signZSK(res.authority, types.DS);
}
return res;
}
}
const res = new Message();
res.code = codes.NXDOMAIN;
res.aa = true;
// Doesn't exist.
//
// We should be giving a real NSEC proof
// here, but I don't think it's possible
// with the current construction.
//
// I imagine this would only be possible
// if NSEC3 begins to support BLAKE2b for
// name hashing. Even then, it's still
// not possible for SPV nodes since they
// can't arbitrarily iterate over the tree.
//
// Instead, we give a minimally covering
// NSEC record based on rfc4470
// https://tools.ietf.org/html/rfc4470
// Proving the name doesn't exist
const prev = nsec.prevName(tld);
const next = nsec.nextName(tld);
const nameSet = [nsec.create(prev, next, TYPE_MAP_EMPTY)];
key.signZSK(nameSet, types.NSEC);
// Proving a wildcard doesn't exist
const wildcardSet = [nsec.create('!.', '+.', TYPE_MAP_EMPTY)];
key.signZSK(wildcardSet, types.NSEC);
res.authority = res.authority.concat(nameSet, wildcardSet);
res.authority.push(this.toSOA());
key.signZSK(res.authority, types.SOA);
return res;
}
// Our resolution.
const resource = Resource.decode(data);
const res = resource.toDNS(name, type);
if (res.answer.length === 0 && res.aa) {
res.authority.push(this.toSOA());
key.signZSK(res.authority, types.SOA);
}
return res;
}
async resolve(req, rinfo) {
const [qs] = req.question;
const {name, type} = qs;
const tld = util.from(name, -1);
// Plugins can insert middleware here and hijack the
// lookup for special TLDs before checking Urkel tree.
// We also pass the entire question in case a plugin
// is able to return an authoritative (non-referral) answer.
if (typeof this.middle === 'function') {
let res;
try {
res = await this.middle(tld, req, rinfo);
} catch (e) {
this.logger.warning(
'Root server middleware resolution failed for name: %s',
name
);
this.logger.debug(e.stack);
}
if (res) {
return res;
}
}
// Hit the cache first.
const cache = this.cache.get(name, type);
if (cache)
return cache;
const res = await this.response(req, rinfo);
if (!util.equal(tld, '_synth.'))
this.cache.set(name, type, res);
return res;
}
async open() {
await super.open(this.port, this.host);
this.logger.info('Root nameserver listening on port %d.', this.port);
}
getReserved(tld) {
return reserved.getByName(tld);
}
// Intended to be called by plugin.
signRRSet(rrset, type) {
key.signZSK(rrset, type);
}
resetCache() {
this.cache.reset();
}
serial() {
const date = new Date();
const y = date.getUTCFullYear() * 1e6;
const m = (date.getUTCMonth() + 1) * 1e4;
const d = date.getUTCDate() * 1e2;
const h = date.getUTCHours();
return y + m + d + h;
}
toSOA() {
const rr = new Record();
const rd = new SOARecord();
rr.name = '.';
rr.type = types.SOA;
rr.ttl = 86400;
rr.data = rd;
rd.ns = '.';
rd.mbox = '.';
rd.serial = this.serial();
rd.refresh = 1800;
rd.retry = 900;
rd.expire = 604800;
rd.minttl = DEFAULT_TTL;
return rr;
}
toNS() {
const rr = new Record();
const rd = new NSRecord();
rr.name = '.';
rr.type = types.NS;
rr.ttl = 518400;
rr.data = rd;
rd.ns = this.synth;
return rr;
}
// Glue only
toA() {
const rr = new Record();
const rd = new ARecord();
rr.name = this.synth;
rr.type = types.A;
rr.ttl = 518400;
rr.data = rd;
rd.address = this.publicHost;
return rr;
}
// Glue only
toAAAA() {
const rr = new Record();
const rd = new AAAARecord();
rr.name = this.synth;
rr.type = types.AAAA;
rr.ttl = 518400;
rr.data = rd;
rd.address = this.publicHost;
return rr;
}
toNSEC() {
const next = nsec.nextName('.');
return nsec.create('.', next, TYPE_MAP_ROOT);
}
}
/**
* RecursiveServer
* @extends {DNSServer}
*/
class RecursiveServer extends DNSServer {
constructor(options) {
super(RES_OPT);
this.ra = true;
this.edns = true;
this.dnssec = true;
this.noSig0 = false;
this.noAny = true;
this.logger = Logger.global;
this.key = secp256k1.privateKeyGenerate();
this.host = '127.0.0.1';
this.port = 5301;
this.stubHost = '127.0.0.1';
this.stubPort = 5300;
this.hns = new UnboundResolver({
inet6: false,
tcp: true,
edns: true,
dnssec: true,
minimize: true
});
if (options)
this.initOptions(options);
this.initNode();
this.hns.setStub(this.stubHost, this.stubPort, key.ds);
}
initOptions(options) {
assert(options);
this.parseOptions(options);
if (options.logger != null) {
assert(typeof options.logger === 'object');
this.logger = options.logger.context('rs');
}
if (options.key != null) {
assert(Buffer.isBuffer(options.key));
assert(options.key.length === 32);
this.key = options.key;
}
if (options.host != null) {
assert(typeof options.host === 'string');
this.host = IP.normalize(options.host);
}
if (options.port != null) {
assert((options.port & 0xffff) === options.port);
assert(options.port !== 0);
this.port = options.port;
}
if (options.stubHost != null) {
assert(typeof options.stubHost === 'string');
this.stubHost = IP.normalize(options.stubHost);
if (this.stubHost === '0.0.0.0' || this.stubHost === '::')
this.stubHost = '127.0.0.1';
}
if (options.stubPort != null) {
assert((options.stubPort & 0xffff) === options.stubPort);
assert(options.stubPort !== 0);
this.stubPort = options.stubPort;
}
if (options.noSig0 != null) {
assert(typeof options.noSig0 === 'boolean');
this.noSig0 = options.noSig0;
}
if (options.noUnbound != null) {
assert(typeof options.noUnbound === 'boolean');
if (options.noUnbound) {
this.hns = new RecursiveResolver({
inet6: false,
tcp: true,
edns: true,
dnssec: true,
minimize: true
});
}
}
return this;
}
initNode() {
this.hns.on('log', (...args) => {
this.logger.debug(...args);
});
this.on('error', (err) => {
this.logger.error(err);
});
this.on('query', (req, res) => {
this.logMessage('\n\nDNS Request:', req);
this.logMessage('\n\nDNS Response:', res);
});
return this;
}
logMessage(prefix, msg) {
if (this.logger.level < 5)
return;
const logs = msg.toString().trim().split('\n');
this.logger.spam(prefix);
for (const log of logs)
this.logger.spam(log);
}
signSize() {
if (!this.noSig0)
return 94;
return 0;
}
sign(msg, host, port) {
if (!this.noSig0)
return hsig.sign(msg, this.key);
return msg;
}
async open(...args) {
await this.hns.open();
await super.open(this.port, this.host);
this.logger.info('Recursive server listening on port %d.', this.port);
}
async close() {
await super.close();
await this.hns.close();
}
async resolve(req, rinfo) {
const [qs] = req.question;
return this.hns.resolve(qs);
}
async lookup(name, type) {
return this.hns.lookup(name, type);
}
}
/*
* Helpers
*/
function toKey(name, type) {
const labels = util.split(name);
const label = util.from(name, labels, -1);
// Ignore type if we're a referral.
if (labels.length > 1)
return label.toLowerCase();
let key = '';
key += label.toLowerCase();
key += ';';
key += type.toString(10);
return key;
}
function hasValidOwner(section, owner) {
owner = util.fqdn(owner);
for (const rr of section) {
if (rr.type === types.NS)
continue;
if (!util.equal(rr.name, owner))
return false;
}
return true;
}
/*
* Expose
*/
exports.RootServer = RootServer;
exports.RecursiveServer = RecursiveServer;