Skip to content

Instantly share code, notes, and snippets.

@NiklasGollenstede
Created April 26, 2018 16:26
Show Gist options
  • Save NiklasGollenstede/2eeafe3802c5fb50af4119c8d233c7b4 to your computer and use it in GitHub Desktop.
Save NiklasGollenstede/2eeafe3802c5fb50af4119c8d233c7b4 to your computer and use it in GitHub Desktop.
A non caching DNS forwarder that sends requests encrypted through TLS or HTTP/2
'use strict'; /* globals Buffer, */
/**
* This is a non-caching DNS resolver/proxy/forwarder (Trusted Recursive Resolver).
* It accepts DNS requests over UDN and TCP and forwards them via HTTPSv2 or TLS.
* Configuration is done via environment variables:
*/
/// Port and Host that both inbound UDP and TCP will accept DNS requests on.
const PORT = process.env.PORT || 53535;
const HOST = process.env.HOST || '127.0.0.1';
/**
* Addresses of the upstream resolvers to use. Apace separated list of either:
* * 'https://<host>/<path>' URLs to use for the HTTPSv2 dns-udpwireformat resolver
* * '[tls:]host:port' addresses to use for the TLS resolver
*/
const RESOLVER = process.env.RESOLVER || 'https://1.0.0.1/dns-query https://1.1.1.1/dns-query tls:1.0.0.1:853 tls:1.1.1.1:853';
/**
* All this forwarder needs to understand about the DNS message format (from RFC1035#4):
* [A message starts with] A 16 bit identifier assigned by the program that generates any kind of query.
*
* Messages sent using UDP [and] TCP connections use udp port 53 (decimal).
*
* Messages carried by UDP are restricted to 512 bytes (not counting the IPor UDP headers).
* Longer messages are truncated and the TC bit [0x16] is set in the header.
*
* [When sending via TCP/TLS] The message is prefixed with a two byte length field
* which gives the message length, excluding the two byte length field.
*/
/**
* Related:
* * https://github.com/pforemski/dingo
* * forwards as json over https/2, but doesn't really interpret packets correctly
*/
const UDP = require('dgram'), TCP = require('net');
const TLS = require('tls'), HTTP2 = require('http2');
const empty = Buffer.alloc(0);
const timeoutError = Object.freeze(Object.assign(new Error('timeout'), { stack: 'Error: timeout', }));
const disconnectError = Object.freeze(Object.assign(new Error('timeout'), { stack: 'Error: disconnected', }));
// inbound UDP
const udp = UDP.createSocket('udp4');
udp.on('message', async (request, { address, port, }) => logTime(async () => {
let reply; try { reply = (await resolver.resolve(request)); }
catch (error) { console.error(error); return; }
if (reply.length > 512) { // TODO: test this
reply[2] = reply[2] & 0b01; // TC bit
reply = reply.slice(0, 512); // TODO: is this correct?
}
udp.send(reply, 0, reply.length, port, address);
}, 'UDP lookup in'));
udp.bind(PORT, HOST);
// inbound TCP
const tcp = TCP.createServer(socket => { accMsgs(socket, async request => logTime(async () => {
let reply; try { reply = (await resolver.resolve(request)); }
catch (error) { socket.destroy(); console.error(error); return; }
!socket.destroyed && writeMsg(socket, reply);
}, 'TCP lookup in')); });
tcp.listen(PORT, HOST);
/**
* Base Resolver interface class
* @property {natural} timeout Time in ms after which the resolve attempt fails with an error.
*/
class Resolver {
constructor(timeout) {
this.timeout = timeout || 2500;
}
/**
* Takes a RFC1035 DNS request and resolves it.
* @param {Buffer} request Buffer containing the DNS request.
* @param {natural} timeout Optional overwrite for `.timeout` for this request.
* @return {Buffer} The DNS response.
*/
async resolve(/*request, timeout*/) {
throw new Error('Not implemented');
}
}
/**
* Connects to an upstream resolver via TLS.
* The resolver must provide a valid certificate.
* @property {natural} port Port of th upstream resolver.
* @property {string} host Hostname/IP of th upstream resolver.
*
*/
class TlsResolver extends Resolver {
constructor(port, host, timeout) {
super(timeout || 2500);
this.port = port; this.host = host;
this._requests = new Map/*<id,{request,resolve,failed,timeout}>*/;
this._tcp = null;
this._resolved = this._resolved.bind(this);
}
resolve(request, timeout) { return new Promise((resolve, failed) => {
const id = request.readUInt16BE();
const dup = this._requests.get(id); if (dup) {
if (dup.request.compare(request)) { throw new Error('Duplicate id'); }
// via unreliable UDP, clients tend to retry with the exact same package
// but there is no need to do that via TCP, just wait for the first reply
const good = dup.resolve, bad = dup.failed;
dup.resolve = value => { good(value); resolve(value); };
dup.failed = error => { bad(error); failed(error); };
// does not extend the original timeout
}
timeout = setTimeout(() => {
failed(timeoutError); this._requests.delete(id);
}, timeout || this.timeout);
this._requests.set(id, { request, resolve, failed, timeout, });
// console.log('requesting', id);
this._connect().then(tls => writeMsg(tls, request), failed);
}); }
_resolved(reply) {
const id = reply.readUInt16BE(), ctx = this._requests.get(id);
// console.log('got reply', id);
if (!ctx) { console.info('unrequested reply', id); return; }
clearTimeout(ctx.timeout); ctx.resolve(reply);
}
// use the same connection as long as it stays open
_connect() { return this._tcp || (this._tcp = new Promise((connected, failed) => {
console.log(`connecting to ${this.host}:${this.port}`);
const onError = error => { this._tcp = null; failed(error); };
const tls = TLS.connect(this.port, this.host, { /* TODO: options? */ }, () => {
// console.log('connected');
tls.removeListener('error', onError); connected(tls);
}).on('end', () => {
console.log('disconnected');
this._tcp = null; failed(disconnectError);
this._requests.forEach(ctx => { // kill all issued requests
clearTimeout(ctx.timeout); ctx.failed(disconnectError);
}); this._requests.clear();
}).once('error', onError); accMsgs(tls, this._resolved);
})); }
}
/**
* Connects to an upstream resolver via HTTPS version 2
* and POSTs requests directly as `application/dns-udpwireformat`.
* The resolver must provide a valid certificate.
* @property {string} authority protocol + host to connect to.
* @property {string} path Path for lookup.
*
*/
class H2Resolver extends Resolver {
constructor(authority, path, timeout) {
super(timeout || 2500);
this.authority = authority; this.path = path;
this.__client = null;
}
resolve(request, timeout) { return new Promise((done, failed) => {
timeout = setTimeout(() => {
failed(timeoutError); req = reply = null;
}, timeout || this.timeout);
let reply = null, req = this._client.request({
':path': this.path, ':method': 'POST',
'accept': 'application/dns-udpwireformat',
'content-type': 'application/dns-udpwireformat',
'content-length': request.length,
})
.on('error', failed).on('response', (/*headers, flags*/) => { })
.on('data', data => req && (reply = reply ? Buffer.concat([ reply, data, ]) : data)) // TODO: limit size
.on('end', () => { req && done(reply); });
req.end(request);
}); }
// use the same connection as long as it stays open
get _client() {
if (this.__client) { return this.__client; }
const client = HTTP2.connect(this.authority)
.on('socketError', error => console.error(error)).on('error', error => console.error(error))
.on('close', () => { this.__client = null; console.log('disconnected'); });
// console.log('connected');
return (this.__client = client);
}
}
/**
* Uses multiple resolvers as fallovers.
* @property {[Resolver]} resolvers The underlying H2 and Tls resolvers to use.
*/
class MultiResolver extends Resolver {
/**
* Constructs the underlying resolvers from address strings.
* @param {[string]} addresses Array of either 'https://' URLs or 'host:port' strings.
*/
constructor(addresses, timeout) {
super(timeout || 5000);
this.resolvers = addresses.map(address => {
const useH2 = (/^(https:\/\/.*?)(\/.*)$/).exec(address);
if (useH2) { return new H2Resolver(useH2[1], useH2[2]); }
const useTls = (/^(?:tls:)([a-z0-9._-]+):(\d+)$/).exec(address);
if (useTls) { return new TlsResolver(useTls[2], useTls[1]); }
throw new TypeError(`"${address}" is not a recognized resolver address`);
});
this.resolvers.forEach((r, i) => (r.index = i));
this._sort = null;
}
/**
* Goes through all `.resolvers` until a request succeeds or the timeout is reached.
* Failed resolvers will be put to the back of the queue for a while.
*/
async resolve(request, timeout) {
const by = Date.now() + (timeout || this.timeout);
let error; for (let i = 0, end = this.resolvers.length; i < end; ++i) {
const left = by - Date.now(); if (left < 100) { throw error || timeoutError; }
const resolver = this.resolvers[i];
try { return (await resolver.resolve(request, Math.min(left, resolver.timeout))); }
catch (e) {
error = e; --end; --i; console.error(error);
this.resolvers.push(this.resolvers.shift());
console.log(this.resolvers.map(_=>_.index));
this._sort && clearTimeout(this._sort);
this._sort = setTimeout(() => {
console.log('sort');
this.resolvers.sort((a, b) => a.index - b.index); this._sort = null;
}, (timeout || this.timeout) * 20);
}
} throw (error || new Error('no resolver'));
}
}
const resolver = new MultiResolver(RESOLVER.split(' '));
// helpers
/// reads the first two bytes of a TCP connection as size, then reads that many bytes and calls onMsg with them
function accMsgs(socket, onMsg) {
let expect = null, buffer = empty;
socket.on('data', function onData(data) {
data !== empty && (buffer = buffer === empty ? data : Buffer.concat([ buffer, data, ]));
if (expect == null) {
if (buffer.length < 2) { return; }
expect = buffer.readUInt16BE(0); // might want to exit if expect <= 0
buffer = buffer.length === 2 ? empty : buffer.slice(2);
}
if (buffer.length < expect) { return; }
onMsg(buffer.length === expect ? buffer : Buffer.from(buffer.slice(0, expect)));
buffer = buffer.length === expect ? empty : Buffer.from(buffer.slice(expect));
expect = null; onData(empty); // may have another message in buffer
});
}
// writes a two-byte size plus data to a TCP connection
function writeMsg(socket, data) {
const length = data.length;
const tupel = Buffer.from([ data.length << 8 & 0xff, data.length & 0xff, ]);
socket.write(Buffer.concat([ tupel, data, ], 2 + length));
}
async function logTime(fn, message = 'took') {
const start = process.hrtime();
const value = (await fn());
const diff = process.hrtime(start);
console.log(message, (diff[0] * 1e3 + diff[1] / 1e6).toFixed(2) +'ms');
return value;
}
module.exports = { udp, tcp, resolver, };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment