Created
April 26, 2018 16:26
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'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