dnssd
Version:
Bonjour/Avahi-like service discovery in pure JavaScript
563 lines (461 loc) • 18.8 kB
JavaScript
'use strict';
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
var misc = require('./misc');
var EventEmitter = require('./EventEmitter');
var QueryRecord = require('./QueryRecord');
var Query = require('./Query');
var TimerContainer = require('./TimerContainer');
var StateMachine = require('./StateMachine');
var filename = require('path').basename(__filename);
var debug = require('./debug')('dnssd:' + filename);
var RType = require('./constants').RType;
/**
* Service Resolver
*
* In order to actually use a service discovered on the network, you need to
* know the address of the service, the port its on, and any TXT data.
* ServiceResponder takes a description of a service and any initial known
* records and tries to find the missing pieces.
*
* ServiceResolver is a state machine with 3 states: unresolved, resolved, and
* stopped. The resolver will stay active as long as knowledge about the
* service is needed. The resolve will check for updates as service records go
* stale and will notify if records expire and the service goes down.
*
*/
var resovlverStates = {
unresolved: {
enter: function enter() {
var _this = this;
debug('Service is unresolved');
// Give resolver 10s to query and resolve. If it can't find
// all the records it needs in 10s then something is probably wrong
this._timers.set('timeout', function () {
debug('Resolver timed out.');
_this.transition('stopped');
}, 10 * 1000);
this._queryForMissing();
},
incomingRecords: function incomingRecords(records) {
var wasUpdated = this._processRecords(records);
if (this.isResolved()) this.transition('resolved');else if (wasUpdated) this._queryForMissing();
},
reissue: function reissue(record) {
this._batchReissue(record);
},
exit: function exit() {
this._cancelQueries();
this._timers.clear('timeout');
}
},
resolved: {
enter: function enter() {
debug('Service is resolved');
this.emit('resolved');
},
incomingRecords: function incomingRecords(records) {
var wasUpdated = this._processRecords(records);
if (!this.isResolved()) this.transition('unresolved');else if (wasUpdated) this.emit('updated');
},
reissue: function reissue(record) {
this._batchReissue(record);
},
exit: function exit() {
this._cancelQueries();
}
},
stopped: {
enter: function enter() {
debug('Stopping resolver "' + this.fullname + '"');
this._cancelQueries();
this._removeListeners();
this.emit('down');
// override this.transition, because resolver is down now
// (shouldn't be a problem anyway, more for debugging)
this.transition = function () {
return debug("Service is down! Can't transition.");
};
}
}
};
/**
* Creates a new ServiceResolver
* @class
*
* Fullname is the string describing the service to resolve, like:
* 'Instance (2)._http._tcp.local.'
*
* @emits 'resovled'
* @emits 'updated'
* @emits 'down'
*
* @param {string} fullname
* @param {Networkinterfaces} intf
* @return {ServiceResolver}
*/
var ServiceResolver = function (_StateMachine) {
_inherits(ServiceResolver, _StateMachine);
function ServiceResolver(fullname, intf) {
_classCallCheck(this, ServiceResolver);
debug('Creating new resolver for "' + fullname + '"');
var _this2 = _possibleConstructorReturn(this, (ServiceResolver.__proto__ || Object.getPrototypeOf(ServiceResolver)).call(this, resovlverStates));
_this2.fullname = fullname;
_this2._interface = intf;
var parts = misc.parse(fullname);
_this2.instance = parts.instance;
_this2.serviceType = parts.service;
_this2.protocol = parts.protocol;
_this2.domain = parts.domain;
// e.g. _http._tcp.local.
_this2.ptrname = misc.fqdn(_this2.serviceType, _this2.protocol, _this2.domain);
// info required for resolution
_this2.addresses = [];
_this2.target = null;
_this2.port = null;
_this2.txt = null;
_this2.txtRaw = null;
// keep one consistent service object so they resolved services can be
// compared by object reference or kept in a set/map
_this2._service = {};
// dirty flag to track changes to service info. gets reset to false before
// each incoming answer packet is checked.
_this2._changed = false;
// offswitch used to communicate with & stop child queries instead of
// holding onto a reference for each one
_this2._offswitch = new EventEmitter();
_this2._batch = [];
_this2._timers = new TimerContainer(_this2);
return _this2;
}
/**
* Starts the resolver and parses optional starting records
* @param {ResourceRecords[]} records
*/
_createClass(ServiceResolver, [{
key: 'start',
value: function start(records) {
debug('Starting resolver');
this._addListeners();
if (records) {
debug.verbose('Adding initial records: %r', records);
this._processRecords(records);
}
this.isResolved() ? this.transition('resolved') : this.transition('unresolved');
}
}, {
key: 'stop',
value: function stop() {
debug('Stopping resolver');
this.transition('stopped');
}
/**
* Returns the service that has been resolved. Always returns the same obj
* reference so they can be included in sets/maps or be compared however.
*
* addresses/txt/txtRaw are all cloned so any accidental changes to them
* won't cause problems within the resolver.
*
* Ex: {
* fullname : 'Instance (2)._http._tcp.local.',
* name : 'Instance (2)',
* type : {name: 'http', protocol: 'tcp'},
* domain : 'local',
* host : 'target.local.',
* port : 8888,
* addresses: ['192.168.1.1', '::1'],
* txt : {key: 'value'},
* txtRaw : {key: <Buffer 76 61 6c 75 65>},
* }
*
* @return {object}
*/
}, {
key: 'service',
value: function service() {
// remove any leading underscores
var serviceType = this.serviceType.replace(/^_/, '');
var protocol = this.protocol.replace(/^_/, '');
// re-assign/update properties
this._service.fullname = this.fullname;
this._service.name = this.instance;
this._service.type = { name: serviceType, protocol: protocol };
this._service.domain = this.domain;
this._service.host = this.target;
this._service.port = this.port;
this._service.addresses = this.addresses.slice();
this._service.txt = this.txt ? Object.assign({}, this.txt) : {};
this._service.txtRaw = this.txtRaw ? Object.assign({}, this.txtRaw) : {};
// always return same obj
return this._service;
}
}, {
key: 'isResolved',
value: function isResolved() {
return !!this.addresses.length && !!this.target && !!this.port && !!this.txtRaw;
}
/**
* Listen to new answers coming to the interfaces. Do stuff when interface
* caches report that a record needs to be refreshed or when it expires.
* Stop on interface errors.
*/
}, {
key: '_addListeners',
value: function _addListeners() {
var _this3 = this;
this._interface.using(this).on('answer', this._onAnswer).once('error', function (err) {
return _this3.transition('stopped', err);
});
this._interface.cache.using(this).on('reissue', this._onReissue).on('expired', this._onExpired);
}
}, {
key: '_removeListeners',
value: function _removeListeners() {
this._interface.removeListenersCreatedBy(this);
this._interface.cache.removeListenersCreatedBy(this);
}
}, {
key: '_onAnswer',
value: function _onAnswer(packet) {
this.handle('incomingRecords', [].concat(_toConsumableArray(packet.answers), _toConsumableArray(packet.additionals)));
}
/**
* As cached records go stale they need to be refreshed. The cache will ask
* for updates to records as they reach 80% 85% 90% and 95% of their TTLs.
* This listens to all reissue events from the cache and checks if the record
* is relevant to this resolver. If it is, the fsm will handle it based on
* what state its currently in.
*
* If the SRV record needs to be updated the PTR is queried too. Some dumb
* responders seem more likely to answer the PTR question.
*/
}, {
key: '_onReissue',
value: function _onReissue(record) {
var isRelevant = record.matches({ name: this.fullname }) || record.matches({ name: this.ptrname, PTRDName: this.fullname }) || record.matches({ name: this.target });
var isSRV = record.matches({ rrtype: RType.SRV, name: this.fullname });
if (isRelevant) {
this.handle('reissue', record);
}
if (isSRV) {
this.handle('reissue', { name: this.ptrname, rrtype: RType.PTR });
}
}
/**
* Check records as they expire from the cache. This how the resolver learns
* that a service has died instead of from goodbye records with TTL=0's.
* Goodbye's only tell the cache to purge the records in 1s and the resolver
* should ignore those.
*/
}, {
key: '_onExpired',
value: function _onExpired(record) {
// PTR/SRV: transition to stopped, service is down
var isDown = record.matches({ rrtype: RType.SRV, name: this.fullname }) || record.matches({ rrtype: RType.PTR, name: this.ptrname, PTRDName: this.fullname });
// A/AAAA: remove address & transition to unresolved if none are left
var isAddress = record.matches({ rrtype: RType.A, name: this.target }) || record.matches({ rrtype: RType.AAAA, name: this.target });
// TXT: remove txt & transition to unresolved
var isTXT = record.matches({ rrtype: RType.TXT, name: this.fullname });
if (isDown) {
debug('Service expired, resolver going down. (%s)', record);
this.transition('stopped');
}
if (isAddress) {
debug('Address record expired, removing. (%s)', record);
this.addresses = this.addresses.filter(function (add) {
return add !== record.address;
});
if (!this.addresses.length) this.transition('unresolved');
}
if (isTXT) {
debug('TXT record expired, removing. (%s)', record);
this.txt = null;
this.txtRaw = null;
this.transition('unresolved');
}
}
/**
* Checks incoming records for changes or updates. Returns true if anything
* happened.
*
* @param {ResourceRecord[]} incoming
* @return {boolean}
*/
}, {
key: '_processRecords',
value: function _processRecords(incoming) {
var _this4 = this;
// reset changes flag before checking records
this._changed = false;
// Ignore TTL 0 records. Get expiration events from the caches instead
var records = incoming.filter(function (record) {
return record.ttl > 0;
});
if (!records.length) return false;
var findOne = function findOne(params) {
return records.find(function (record) {
return record.matches(params);
});
};
var findAll = function findAll(params) {
return records.filter(function (record) {
return record.matches(params);
});
};
// SRV/TXT before A/AAAA, since they contain the target for A/AAAA records
var SRV = findOne({ rrtype: RType.SRV, name: this.fullname });
var TXT = findOne({ rrtype: RType.TXT, name: this.fullname });
if (SRV) this._processSRV(SRV);
if (TXT) this._processTXT(TXT);
if (!this.target) return this._changed;
var As = findAll({ rrtype: RType.A, name: this.target });
var AAAAs = findAll({ rrtype: RType.AAAA, name: this.target });
if (As.length) As.forEach(function (A) {
return _this4._processAddress(A);
});
if (AAAAs.length) AAAAs.forEach(function (AAAA) {
return _this4._processAddress(AAAA);
});
return this._changed;
}
}, {
key: '_processSRV',
value: function _processSRV(record) {
if (this.port !== record.port) {
this.port = record.port;
this._changed = true;
}
// if the target changes the addresses are no longer valid
if (this.target !== record.target) {
this.target = record.target;
this.addresses = [];
this._changed = true;
}
}
}, {
key: '_processTXT',
value: function _processTXT(record) {
if (!misc.equals(this.txtRaw, record.txtRaw)) {
this.txtRaw = record.txtRaw;
this.txt = record.txt;
this._changed = true;
}
}
}, {
key: '_processAddress',
value: function _processAddress(record) {
if (this.addresses.indexOf(record.address) === -1) {
this.addresses.push(record.address);
this._changed = true;
}
}
/**
* Tries to get info that is missing and needed for the service to resolve.
* Checks the interface caches first and then sends out queries for whatever
* is still missing.
*/
}, {
key: '_queryForMissing',
value: function _queryForMissing() {
debug('Getting missing records');
var questions = [];
// get missing SRV
if (!this.target) questions.push({ name: this.fullname, qtype: RType.SRV });
// get missing TXT
if (!this.txtRaw) questions.push({ name: this.fullname, qtype: RType.TXT });
// get missing A/AAAA
if (this.target && !this.addresses.length) {
questions.push({ name: this.target, qtype: RType.A });
questions.push({ name: this.target, qtype: RType.AAAA });
}
// check interface caches for answers first
this._checkCache(questions);
// send out queries for what is still unanswered
// (_checkCache may have removed all/some questions from the list)
if (questions.length) this._sendQueries(questions);
}
/**
* Checks the cache for missing records. Tells the fsm to handle new records
* if it finds anything
*/
}, {
key: '_checkCache',
value: function _checkCache(questions) {
var _this5 = this;
debug('Checking cache for needed records');
var answers = [];
// check cache for answers to each question
questions.forEach(function (question, index) {
var results = _this5._interface.cache.find(new QueryRecord(question));
if (results && results.length) {
// remove answered questions from list
questions.splice(index, 1);
answers.push.apply(answers, _toConsumableArray(results));
}
});
// process any found records
answers.length && this.handle('incomingRecords', answers);
}
/**
* Sends queries out on each interface for needed records. Queries are
* continuous, they keep asking until they get the records or until they
* are stopped by the resolver with `this._cancelQueries()`.
*/
}, {
key: '_sendQueries',
value: function _sendQueries(questions) {
debug('Sending queries for needed records');
// stop any existing queries, they might be stale now
this._cancelQueries();
// no 'answer' event handler here because this resolver is already
// listening to the interface 'answer' event
new Query(this._interface, this._offswitch).ignoreCache(true).add(questions).start();
}
/**
* Reissue events from the cache are slightly randomized for each record's TTL
* (80-82%, 85-87% of the TTL, etc) so reissue queries are batched here to
* prevent a bunch of outgoing queries from being sent back to back 10ms apart.
*/
}, {
key: '_batchReissue',
value: function _batchReissue(record) {
var _this6 = this;
debug('Batching record for reissue %s', record);
this._batch.push(record);
if (!this._timers.has('batch')) {
this._timers.setLazy('batch', function () {
_this6._sendReissueQuery(_this6._batch);
_this6._batch = [];
}, 1 * 1000);
}
}
/**
* Asks for updates to records. Only sends one query out (non-continuous).
*/
}, {
key: '_sendReissueQuery',
value: function _sendReissueQuery(records) {
debug('Reissuing query for cached records: %r', records);
var questions = records.map(function (_ref) {
var name = _ref.name,
rrtype = _ref.rrtype;
return { name: name, qtype: rrtype };
});
new Query(this._interface, this._offswitch).continuous(false) // only send query once, don't need repeats
.ignoreCache(true) // ignore cache, trying to renew this record
.add(questions).start();
}
}, {
key: '_cancelQueries',
value: function _cancelQueries() {
debug('Sending stop signal to active queries & canceling batched');
this._offswitch.emit('stop');
this._timers.clear('batch');
}
}]);
return ServiceResolver;
}(StateMachine);
module.exports = ServiceResolver;