dns-discovery
Version:
Discovery peers in a distributed system using regular dns and multicast dns.
736 lines (619 loc) • 19.7 kB
JavaScript
var dns = require('dns-socket')
var events = require('events')
var util = require('util')
var crypto = require('crypto')
var network = require('network-address')
var multicast = require('multicast-dns')
var debug = require('debug')('dns-discovery')
var store = require('./store')
var IPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}.\d{1,3}$/
var PORT = /^\d{1,5}$/
const TYPE_LOOKUP = 1
const TYPE_ANNOUNCE = 2
const TYPE_UNANNOUNCE = 3
module.exports = DNSDiscovery
function DNSDiscovery (opts) {
if (!(this instanceof DNSDiscovery)) return new DNSDiscovery(opts)
if (!opts) opts = {}
events.EventEmitter.call(this)
var self = this
this.socket = dns(opts)
this.servers = [].concat(opts.servers || opts.server || []).map(parseAddr)
this._sockets = []
this._onsocket(this.socket)
this.multicast = opts.multicast !== false ? (isMulticaster(opts.multicast) ? opts.multicast : multicast()) : null
if (this.multicast) {
this.multicast.on('query', onmulticastquery)
this.multicast.on('response', onmulticastresponse)
this.multicast.on('error', onerror)
}
this._loopback = !!opts.loopback
this._listening = false
this._id = crypto.randomBytes(32).toString('base64')
this._domain = opts.domain || 'dns-discovery.local'
this._pushDomain = 'push.' + this._domain
this._tokens = new Array(this.servers.length)
this._tokensAge = []
this._secrets = [
crypto.randomBytes(32),
crypto.randomBytes(32)
]
while (this._tokensAge.length < this._tokens.length) this._tokensAge.push(0)
this._interval = setInterval(rotateSecrets, 5 * 60 * 1000)
if (this._interval.unref) this._interval.unref()
this._ttl = opts.ttl || 0
this._tick = 1
var push = opts.push || {}
if (!push.ttl) push.ttl = opts.ttl || 60
if (!push.limit) push.limit = opts.limit
this._domainStore = store(opts)
this._pushStore = store(push)
function rotateSecrets () {
self._rotateSecrets()
}
function onerror (err) {
debug('Error', err)
self.emit('error', err)
}
function onmulticastquery (message, rinfo) {
debug(
'MDNS query %s:%s %dQ %dA +%d',
rinfo.address, rinfo.port,
message.questions.length,
message.answers.length,
message.additionals.length
)
self.emit('traffic', 'in:multicastquery', {message: message, peer: rinfo})
self._onmulticastquery(message, rinfo.port, rinfo.address)
}
function onmulticastresponse (message, rinfo) {
debug(
'MDNS response %s:%s %dA +%d',
rinfo.address, rinfo.port,
message.answers.length,
message.additionals.length
)
self.emit('traffic', 'in:multicastresponse', {message: message, peer: rinfo})
self._onmulticastresponse(message, rinfo.port, rinfo.address)
}
}
util.inherits(DNSDiscovery, events.EventEmitter)
DNSDiscovery.prototype.toJSON = function () {
return this._domainStore.toJSON()
}
DNSDiscovery.prototype._onsocket = function (socket) {
var self = this
this._sockets.push(socket)
socket.on('query', onquery)
socket.on('error', onerror)
function onerror (err) {
debug('Error', err)
self.emit('error', err)
}
function onquery (message, port, host) {
debug(
'DNS query %s:%s %dQ %dA +%d',
host, port,
message.questions.length,
message.answers.length,
message.additionals.length
)
self.emit('traffic', 'in:query', {message: message, peer: {port: port, host: host}})
self._onquery(message, port, host, socket)
}
}
DNSDiscovery.prototype._rotateSecrets = function () {
if (this._listening) {
debug('Rotating secrets')
this._secrets.shift()
this._secrets.push(crypto.randomBytes(32))
}
for (var i = 0; i < this._tokensAge.length; i++) {
if (this._tokensAge[i] < this._tick) {
this._tokens[i] = null
this._tokensAge[i] = 0
}
}
this.emit('secrets-rotated')
this._tick++
}
DNSDiscovery.prototype._onmulticastquery = function (query, port, host) {
var reply = {questions: query.questions, answers: []}
var i = 0
for (i = 0; i < query.questions.length; i++) {
this._onquestion(query.questions[i], port, host, reply.answers, true)
}
for (i = 0; i < query.answers.length; i++) {
this._onanswer(query.answers[i], port, host, null)
}
for (i = 0; i < query.additionals.length; i++) {
this._onanswer(query.additionals[i], port, host, null)
}
if (reply.answers.length) {
this.emit('traffic', 'out:multicastresponse', {message: reply})
this.multicast.response(reply, {port: port})
}
}
DNSDiscovery.prototype._onmulticastresponse = function (response, port, host) {
var i = 0
for (i = 0; i < response.answers.length; i++) {
this._onanswer(response.answers[i], port, host, null)
}
for (i = 0; i < response.additionals.length; i++) {
this._onanswer(response.additionals[i], port, host, null)
}
}
DNSDiscovery.prototype._onanswer = function (answer, port, host, socket) {
var domain = parseDomain(answer.name)
var id = parseId(answer.name, domain)
if (!id) {
debug('Invalid ID in answer, discarding', { name: answer.name, domain: domain, host: host, port: port })
return
}
if (answer.type === 'SRV') {
if (!IPv4.test(answer.data.target)) return
var peer = {
port: answer.data.port || port,
host: answer.data.target === '0.0.0.0' ? host : answer.data.target
}
debug('Announce received via SRV', id, peer.host + ':' + 'peer.port')
this.emit('peer', id, peer)
return
}
if (answer.type === 'TXT') {
try {
var data = decodeTxt(answer.data)
} catch (err) {
return
}
var tokenMatch = data.token === hash(this._secrets[1], host)
if (!tokenMatch || this._loopback) {
// not an echo
this._parsePeers(id, data, host)
}
if (!this._listening) {
return
}
// We are in server mode now. Add the record to the cache
if (!tokenMatch) {
// check if old token matches
if (data.token !== hash(this._secrets[0], host)) {
debug('Invalid token in TXT answer, discarding')
return
}
}
if (PORT.test(data.announce)) {
var announce = Number(data.announce) || port
debug('Announce received via TXT', id, host + ':' + announce)
this.emit('peer', id, {port: announce, host: host})
if (this._domainStore.add(id, announce, host) && socket) {
this._push(id, announce, host, socket)
}
}
if (PORT.test(data.unannounce)) {
var unannounce = Number(data.unannounce) || port
this._domainStore.remove(id, unannounce, host)
debug('Un-announce received via TXT', id, host + ':' + unannounce)
}
if (data.subscribe) {
debug('Subscribe-to-push received via TXT', id, host + ':' + port)
this._pushStore.add(id, port, host)
} else {
debug('Unsubscribe-from-push received via TXT', id, host + ':' + port)
this._pushStore.remove(id, port, host)
}
}
}
DNSDiscovery.prototype._push = function (id, port, host, socket) {
var subs = this._pushStore.get(id, 16)
var query = {
additionals: [{
type: 'SRV',
name: id + '.' + this._domain,
ttl: this._ttl,
data: {
port: port,
target: host
}
}]
}
if (subs.length) debug('Pushing announcement to', subs.length, 'subscribers')
for (var i = 0; i < subs.length; i++) {
var peer = subs[i]
var tid = socket.query(query, peer.port, peer.host)
socket.setRetries(tid, 2)
}
}
DNSDiscovery.prototype._onquestion = function (query, port, host, answers, multicast) {
var domain = parseDomain(query.name)
if (domain !== this._domain) return
if (query.type === 'TXT' && domain === query.name) {
debug('Replying state-info via TXT to %s:%s', host, port)
answers.push({
type: 'TXT',
name: query.name,
ttl: this._ttl,
data: encodeTxt({
token: hash(this._secrets[1], host),
host: host,
port: '' + port
})
})
return
}
var id = parseId(query.name, domain)
if (!id) {
debug('Invalid ID in question, discarding', { name: query.name, domain: domain, host: host, port: port })
return
}
if (query.type === 'TXT') {
var buf = toBuffer(this._domainStore.get(id, 100))
var token = hash(this._secrets[1], host)
if (multicast && !buf.length) return // just an optimization
debug('Replying known peers via TXT to', host + ':' + port)
answers.push({
type: 'TXT',
name: query.name,
ttl: this._ttl,
data: encodeTxt(buf.length ? {
token: token,
peers: buf.toString('base64')
} : {
token: token
})
})
return
}
var peers = this._domainStore.get(id, 10)
debug('Replying announce via', query.type, ' to', host + ':' + port)
for (var i = 0; i < peers.length; i++) {
var peer = peers[i]
if (query.type === 'A') {
answers.push({
type: 'A',
name: query.name,
ttl: this._ttl,
data: peer.host === '0.0.0.0' ? network() : peer.host
})
}
if (query.type === 'SRV') {
answers.push({
type: 'SRV',
name: query.name,
ttl: this._ttl,
data: {
port: peer.port,
target: peer.host
}
})
}
}
}
DNSDiscovery.prototype._onquery = function (query, port, host, socket) {
var reply = {questions: query.questions, answers: []}
var i = 0
for (i = 0; i < query.questions.length; i++) {
this._onquestion(query.questions[i], port, host, reply.answers)
}
for (i = 0; i < query.answers.length; i++) {
this._onanswer(query.answers[i], port, host, socket)
}
for (i = 0; i < query.additionals.length; i++) {
this._onanswer(query.additionals[i], port, host, socket)
}
socket.response(query, reply, port, host)
// note: emit 'traffic' after calling .response() because socket.response() modifies `reply`
this.emit('traffic', 'out:response', {message: reply, peer: {port: port, host: host}})
}
DNSDiscovery.prototype._probeAndSend = function (type, i, id, port, cb) {
var self = this
this._probe(i, 0, function (err) {
if (err) return cb(err)
self._send(type, i, id, port, cb)
})
}
DNSDiscovery.prototype._send = function (type, i, id, port, cb) {
var s = this.servers[i]
var token = this._tokens[i]
var data = null
switch (type) {
case TYPE_LOOKUP:
data = {subscribe: true, token: token}
break
case TYPE_ANNOUNCE:
data = {subscribe: true, token: token, announce: '' + port}
break
case TYPE_UNANNOUNCE:
data = {token: token, unannounce: '' + port}
break
}
var query = {
index: i,
questions: [{
type: 'TXT',
name: id + '.' + this._domain
}],
additionals: [{
type: 'TXT',
name: id + '.' + this._domain,
ttl: this._ttl,
data: encodeTxt(data)
}]
}
this.socket.query(query, s.port, s.host, cb)
this.emit('traffic', 'out:query', {message: query, peer: s})
}
DNSDiscovery.prototype.lookup = function (id, opts, cb) {
debug('lookup()', id)
this._visit(TYPE_LOOKUP, id, 0, opts, cb)
}
DNSDiscovery.prototype.announce = function (id, port, opts, cb) {
debug('announce()', id)
this._visit(TYPE_ANNOUNCE, id, port, opts, cb)
}
DNSDiscovery.prototype.unannounce = function (id, port, opts, cb) {
debug('unannounce()', id)
this._visit(TYPE_UNANNOUNCE, id, port, opts, cb)
}
DNSDiscovery.prototype._visit = function (type, id, port, opts, cb) {
if (typeof opts === 'function') return this._visit(type, id, port, null, opts)
if (typeof port === 'function') return this._visit(type, id, 0, port)
if (!cb) cb = noop
if (Buffer.isBuffer(id)) id = id.toString('hex')
if (!opts) opts = {}
var self = this
var missing = this.servers.length
var success = false
if (opts.server !== false) {
var publicPort = opts.publicPort || (opts.impliedPort ? 0 : port)
for (var i = 0; i < this.servers.length; i++) {
if (this._tokens[i]) this._send(type, i, id, publicPort, done)
else this._probeAndSend(type, i, id, publicPort, done)
}
}
if (type === TYPE_ANNOUNCE) this._domainStore.add(id, port, '0.0.0.0')
if (type === TYPE_UNANNOUNCE) this._domainStore.remove(id, port, '0.0.0.0')
if (opts.multicast !== false && this.multicast) {
if (type !== TYPE_UNANNOUNCE) {
missing++
var message = {
questions: [{
type: 'TXT',
name: id + '.' + this._domain
}]
}
this.multicast.query(message, done)
this.emit('traffic', 'out:multicastquery', {message: message})
}
}
if (!missing) {
missing++
process.nextTick(done)
}
function done (_, res, q, _port, _host) {
if (res) {
success = true
self.emit('traffic', 'in:response', {message: res, peer: {host: _host, port: _port}})
try {
var data = res.answers.length && decodeTxt(res.answers[0].data)
} catch (err) {
// do nothing
}
if (data) self._parseData(id, data, q.index, _host)
if (type === TYPE_ANNOUNCE) self.emit('announced', id, {port: port})
if (type === TYPE_UNANNOUNCE) self.emit('unannounced', id, {port: port})
}
if (!--missing) cb(success ? null : new Error('Query failed'))
}
}
DNSDiscovery.prototype._parsePeers = function (id, data, host) {
try {
var buf = Buffer.from(data.peers, 'base64')
} catch (err) {
return
}
for (var i = 0; i < buf.length; i += 6) {
var peer = decodePeer(buf, i)
if (!peer) continue
if (peer.host === '0.0.0.0') peer.host = host
this.emit('peer', id, peer)
}
}
DNSDiscovery.prototype._parseData = function (id, data, index, host) {
if (data.token) {
this._tokens[index] = data.token
this._tokensAge[index] = this._tick
}
if (data && data.peers && id) this._parsePeers(id, data, host)
}
DNSDiscovery.prototype.whoami = function (cb) {
var missing = this.servers.length
var prevData = null
var prevHost = null
var called = false
if (this.servers.length) {
for (var i = 0; i < this.servers.length; i++) this._probe(i, 2, done)
} else {
debug('whoami() failed - no servers to ping')
missing = 1
process.nextTick(done)
}
function done (_, data, port, host) {
if (data) {
if (!called && IPv4.test(data.host) && PORT.test(data.port)) {
if (prevHost && prevHost !== host) {
called = true
if (prevData.host === data.host && prevData.port === data.port) {
cb(null, {port: Number(data.port), host: data.host})
} else if (prevData.host === data.host) {
cb(null, {port: 0, host: data.host})
} else {
cb(new Error('Inconsistent remote port/host'))
}
}
prevData = data
prevHost = host
}
}
if (--missing || called) {
if (!called) {
debug('whoami() probe got response; waiting for a confirmation from %d other(s)', missing)
}
return
}
if (data) cb(null, {port: 0, host: data.host})
else cb(new Error('Probe failed'))
}
}
DNSDiscovery.prototype._probe = function (i, retries, cb) {
var self = this
var s = this.servers[i]
var q = {
questions: [{
type: 'TXT',
name: this._domain
}]
}
debug('probing %s:%d', s.host, s.port)
var first = true
var result = null
var id = this.socket.query(q, s.port, s.host, done)
if (retries) this.socket.setRetries(id, retries)
function done (_, res, query, port, host) {
if (res) {
self.emit('traffic', 'in:response', {message: res, peer: {host: host, port: port}})
try {
var data = res.answers.length && decodeTxt(res.answers[0].data)
} catch (err) {
// do nothing
}
if (data && data.token) {
self._parseData(null, data, i, host)
result = data
}
}
if (result) {
if (!first) {
s.port = port
s.secondaryPort = 0
} else {
s.secondaryPort = 0
}
debug('probe of %s:%d succeeded', host, port)
return cb(null, result, port, host)
}
if (!first || !s.secondaryPort) {
debug('probe of %s:%d failed', host, port)
return cb(new Error('Probe failed'))
}
first = false
debug('retrying probe of %s at secondary port %d', host, s.secondaryPort)
id = self.socket.query(q, s.secondaryPort, s.host, done)
if (retries) self.socket.setRetries(id, retries)
}
}
DNSDiscovery.prototype.destroy = function (onclose) {
debug('destroy()')
if (onclose) this.once('close', onclose)
var self = this
var missing = this._sockets.length
clearInterval(this._interval)
if (this.multicast) this.multicast.destroy(onmulticastclose)
else onmulticastclose()
function onmulticastclose () {
for (var i = 0; i < self._sockets.length; i++) {
self._sockets[i].destroy(onsocketclose)
}
}
function onsocketclose () {
if (!--missing) self.emit('close')
}
}
DNSDiscovery.prototype.listen = function (ports, onlistening) {
if (onlistening) this.once('listening', onlistening)
if (this._listening) throw new Error('Server is already listening')
this._listening = true
if (!ports) ports = [53, 5300]
if (!Array.isArray(ports)) ports = [ports]
debug('Listening on port(s)', ports.join(', '))
var self = this
var missing = ports.length
for (var i = 0; i < ports.length; i++) {
var socket = dns()
socket.bind(ports[i], onbind)
this._onsocket(socket)
}
function onbind () {
if (!--missing) self.emit('listening')
}
}
function noop () {}
function parseAddr (addr) {
if (addr.indexOf(':') === -1) addr += ':5300,53'
var match = addr.match(/^([^:]+)(?::(\d{1,5})(?:,(\d{1,5}))?)?$/)
if (!match) throw new Error('Could not parse ' + addr)
return {
port: Number(match[2] || 53),
secondaryPort: Number(match[3] || 0),
host: match[1]
}
}
function hash (secret, host) {
return crypto.createHash('sha256').update(secret).update(host).digest('base64')
}
function parseId (name, domain) {
if (!domain || name.length === domain.length) return null
return name.slice(0, -domain.length - 1)
}
function parseDomain (name) {
var i = name.lastIndexOf('.')
if (i === -1) return null
i = name.lastIndexOf('.', i - 1)
return i === -1 ? name : name.slice(i + 1)
}
function toBuffer (peers) {
var buf = Buffer.alloc(peers.length * 6)
for (var i = 0; i < peers.length; i++) {
if (!peers[i].buffer) peers[i].buffer = encodePeer(peers[i])
peers[i].buffer.copy(buf, i * 6)
}
return buf
}
function encodePeer (peer) {
var buf = Buffer.alloc(6)
var parts = peer.host.split('.')
buf[0] = Number(parts[0] || 0)
buf[1] = Number(parts[1] || 0)
buf[2] = Number(parts[2] || 0)
buf[3] = Number(parts[3] || 0)
buf.writeUInt16BE(peer.port || 0, 4)
return buf
}
function decodePeer (buf, offset) {
if (buf.length - offset < 6) return null
var host = buf[offset++] + '.' + buf[offset++] + '.' + buf[offset++] + '.' + buf[offset++]
var port = buf.readUInt16BE(offset)
offset += 2
return {port: port, host: host}
}
function decodeTxt (bufs) {
var data = {}
for (var i = 0; i < bufs.length; i++) {
var buf = bufs[i]
var j = buf.indexOf(61) // '='
if (j === -1) data[buf.toString()] = true
else data[buf.slice(0, j).toString()] = buf.slice(j + 1).toString()
}
return data
}
function encodeTxt (data) {
var keys = Object.keys(data)
var bufs = []
for (var i = 0; i < keys.length; i++) {
bufs.push(Buffer.from(keys[i] + '=' + data[keys[i]]))
}
return bufs
}
function isMulticaster (m) {
return typeof m === 'object' && m && typeof m.query === 'function'
}