UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

747 lines (609 loc) 26.1 kB
'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 _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; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var misc = require('./misc'); var EventEmitter = require('./EventEmitter'); var RecordCollection = require('./RecordCollection'); var TimerContainer = require('./TimerContainer'); var StateMachine = require('./StateMachine'); var Probe = require('./Probe'); var Response = require('./Response'); var filename = require('path').basename(__filename); var debug = require('./debug')('dnssd:' + filename); var RType = require('./constants').RType; var ONE_SECOND = 1000; /** * Make ids, just to keep track of which responder is which in debug messages */ var counter = 0; var uniqueId = function uniqueId() { return 'id#' + ++counter; }; /** * Responders need to keep track of repeated conflicts to save the network. If * a responder has more than 15 conflicts in a small window then the responder * should be throttled to prevent it from spamming everyone. Conflict count * gets cleared after 15s w/o any conflicts */ var ConflictCounter = function () { function ConflictCounter() { _classCallCheck(this, ConflictCounter); this._count = 0; this._timer = null; } _createClass(ConflictCounter, [{ key: 'count', value: function count() { return this._count; } }, { key: 'increment', value: function increment() { var _this = this; this._count++; clearTimeout(this._timer); // reset conflict counter after 15 seconds this._timer = setTimeout(function () { _this._count = 0; }, 15 * ONE_SECOND); // prevent timer from holding the process this._timer.unref(); } }, { key: 'clear', value: function clear() { this._count = 0; clearTimeout(this._timer); } }]); return ConflictCounter; }(); /** * Responder * @class * * A responder object takes a record set and: * - probes to see if anyone else on the network is using that name * - responds to queries (and other probes) about the record set * - renames the records whenever there is a conflict (from probes/answers) * - sends goodbye messages when stopped * * A record set will be something like A/AAAA address records for interfaces or * PTR/SRV/TXT records for a service. Each set will only have one unique name. * * Responders keeps record set names in sync across any number of interfaces, * so if the set has a conflict on any one interface it will cause it to be * renamed on all interfaces. * * Functions as a state machine with these main states: * probing -> conflict (rename) -> responding -> goodbying -> stopped (final) * * Listens to interface probe, answer, and query events. Any errors from * interfaces are bad and stops to whole thing. * * @emits 'probingComplete' when probing has completed successfully * @emits 'rename' w/ new name whenever a conflict forces a rename * @emits 'error' */ var responderStates = { probing: { enter: function enter() { var _this2 = this; debug('Now probing for: ' + this._fullname); var onSuccess = function onSuccess(early) { _this2.transition('responding', early); }; var onFail = function onFail() { _this2.transition('conflict'); }; // If the probing process takes longer than 1 minute something is wrong // and it should abort. This gets cleared when entering responding state if (!this._timers.has('timeout')) { this._timers.set('timeout', function () { _this2.transition('stopped', new Error('Could not probe within 1 min')); }, 60 * ONE_SECOND); } // If there are too many sequential conflicts, take a break before probing if (this._conflicts.count() >= 15) { debug('Too many conflicts, slowing probe down. (' + this._id + ')'); this._timers.set('delayed-probe', function () { _this2._sendProbe(onSuccess, onFail); }, 5 * ONE_SECOND); return; } this._sendProbe(onSuccess, onFail); }, // If records get updated mid-probe we need to restart the probing process update: function update() { this.states.probing.exit.call(this); this.states.probing.enter.call(this); }, // Stop any active probes, not needed anymore // Stop probes that were being throttled due to repeated conflicts exit: function exit() { this._stopActives(); this._timers.clear('delayed-probe'); } }, responding: { enter: function enter(skipAnnounce) { debug('Done probing, now responding for "' + this._fullname + '" (' + this._id + ')'); // clear probing timeout since probing was successful this._timers.clear('timeout'); // announce verified records to the network (or not) if (!skipAnnounce) this._sendAnnouncement(3);else debug('Skipping announcement. (' + this._id + ')'); // emit last this.emit('probingComplete'); }, // Only listen to these interface events in the responding state: probe: function probe(packet) { this._onProbe(packet); }, query: function query(packet) { this._onQuery(packet); }, answer: function answer(packet) { this._onAnswer(packet); }, // stop any active announcements / responses before announcing changes update: function update() { this._stopActives(); this._sendAnnouncement(); }, // stop any active announcements / responses before changing state exit: function exit() { this._stopActives(); } }, // Records get renamed on conflict, nothing else happens, no events fire. // Mostly is its own state for the convenience of having other exit & // enter handlers called. conflict: { enter: function enter() { debug('Had a conflict with "' + this._instance + '", renaming. (' + this._id + ')'); // Instance -> Instance (2) var oldName = this._instance; var newName = this._rename(oldName); // Instance._http._tcp.local. -> Instance (2)._http._tcp.local. var oldFull = this._fullname; var newFull = this._fullname.replace(oldName, newName); this._instance = newName; this._fullname = newFull; // apply rename to records (using updateWith() so records get rehashed) // (note, has to change PTR fields too) function rename(record) { record.updateWith(function () { if (record.name === oldFull) record.name = newFull; if (record.PTRDName === oldFull) record.PTRDName = newFull; }); } this._records.forEach(rename); this._bridgeable.forEach(rename); // rebuild bridge set since renames alters record hashes this._bridgeable.rebuild(); this._conflicts.increment(); this.transition('probing'); // emits the new (not yet verified) name this.emit('rename', newName); } }, // Sends TTL=0 goodbyes for all records. Uses a callback that fires once all // goodbyes have been sent. Transitions to stopped when done. goodbying: { enter: function enter(callback) { var _this3 = this; var finish = function finish() { _this3.transition('stopped'); callback(); }; // Only send goodbyes if records were valid/probed, otherwise just stop if (this.prevState !== 'responding') finish();else this._sendGoodbye(finish); }, exit: function exit() { this._stopActives(); } }, // Terminal state. Cleans up any existing timers and stops listening to // interfaces. Emits any errors, like from probing timeouts. stopped: { enter: function enter(err) { debug('Responder stopping (' + this._id + ')'); this._timers.clear(); this._conflicts.clear(); this._stopActives(); this._removeListeners(); if (err) this.emit('error', err); // override this.transition, because responder is stopped now // (shouldn't ever be a problem anyway, mostly for debugging) this.transition = function () { return debug("Responder is stopped! Can't transition."); }; } } }; /** * @constructor * * Records is an array of all records, some may be on one interface, some may * be on another interface. (Each record has an .interfaceID field that * indicates what interface it should be used on. We need this because some * record, like A/AAAA which have different rdata (addresses) for each * interface they get used on.) So the records param might look like this: * [ * 'Target.local.' A 192.168.1.10 ethernet, <-- different rdata * 'Target.local.' AAAA FF::CC::1 ethernet, * 'Target.local.' NSEC A, AAAA ethernet, * 'Target.local.' A 192.168.1.25 wifi, <-- different rdata * 'Target.local.' AAAA AA::BB::7 wifi, * 'Target.local.' NSEC A, AAAA wifi, <-- same as ethernet ok * ] * * @param {NetworkInterfaces} interface * @param {ResourceRecords[]} records * @param {ResourceRecords[]} bridgeable */ var Responder = function (_StateMachine) { _inherits(Responder, _StateMachine); function Responder(intf, records, bridgeable) { _classCallCheck(this, Responder); var _this4 = _possibleConstructorReturn(this, (Responder.__proto__ || Object.getPrototypeOf(Responder)).call(this, responderStates)); _this4._id = uniqueId(); debug('Creating new responder (%s) using: %r', _this4._id, records); var uniques = [].concat(_toConsumableArray(new Set(records.filter(function (r) { return r.isUnique; }).map(function (r) { return r.name; })))); if (!uniques.length) throw Error('No unique names in record set'); if (uniques.length > 1) throw Error('Too many unique names in record set'); _this4._interface = intf; _this4._records = records; _this4._bridgeable = new RecordCollection(bridgeable); // the unique name that this record set revolves around // eg: "Instance._http._tcp.local." _this4._fullname = uniques[0]; // the part of the name that needs to be renamed on conflicts // eg: "Instance" _this4._instance = misc.parse(_this4._fullname).instance; if (!_this4._instance) throw Error('No instance name found in records'); _this4._timers = new TimerContainer(_this4); _this4._conflicts = new ConflictCounter(); // emitter used to stop child probes & responses without having to hold // onto a reference for each one _this4._offswitch = new EventEmitter(); return _this4; } _createClass(Responder, [{ key: 'start', value: function start() { debug('Starting responder (' + this._id + ')'); this._addListeners(); this.transition('probing'); } // Immediately stops the responder (no goodbyes) }, { key: 'stop', value: function stop() { debug('Stopping responder (' + this._id + ')'); this.transition('stopped'); } // Sends goodbyes before stopping }, { key: 'goodbye', value: function goodbye(onComplete) { if (this.state === 'stopped') { debug('Responder already stopped!'); return onComplete(); } debug('Goodbying on responder (' + this._id + ')'); this.transition('goodbying', onComplete); } /** * Updates all records that match the rrtype. * // updates should only consist of updated rdata, no name changes // (which means no shared records will be changed, and no goodbyes) * @param {integer} rrtype - rrtype to be updated * @param {function} fn - function to call that does the updating */ }, { key: 'updateEach', value: function updateEach(rrtype, fn) { debug('Updating rtype ' + rrtype + ' records. (' + this._id + ')'); // modify properties of each record with given update fn this._records.filter(function (record) { return record.rrtype === rrtype; }).forEach(function (record) { return record.updateWith(fn); }); // (update bridge list too) this._bridgeable.filter(function (record) { return record.rrtype === rrtype; }).forEach(function (record) { return record.updateWith(fn); }); // rebuild bridge set since updates may have altered record hashes this._bridgeable.rebuild(); // may need to announce changes or re-probe depending on current state this.handle('update'); } /** * Get all records being used on an interface * (important because records could change with renaming) * @return {ResourceRecords[]} */ }, { key: 'getRecords', value: function getRecords() { return this._records; } }, { key: '_addListeners', value: function _addListeners() { var _this5 = this; this._interface.using(this).on('probe', function (packet) { return _this5.handle('probe', packet); }).on('query', function (packet) { return _this5.handle('query', packet); }).on('answer', function (packet) { return _this5.handle('answer', packet); }).once('error', function (err) { return _this5.transition('stopped', err); }); } }, { key: '_removeListeners', value: function _removeListeners() { this._interface.removeListenersCreatedBy(this); } /** * Stop any active probes, announcements, or goodbyes (all outgoing stuff uses * the same offswitch) */ }, { key: '_stopActives', value: function _stopActives() { debug('Sending stop signal to actives. (' + this._id + ')'); this._offswitch.emit('stop'); } /** * Probes records on each interface, call onSuccess when all probes have * completed successfully or calls onFail as soon as one probes fails. Probes * may finish early in some situations. If they do, onSuccess is called with * `true` to indicate that. */ }, { key: '_sendProbe', value: function _sendProbe(onSuccess, onFail) { var _this6 = this; debug('Sending probes for "' + this._fullname + '". (' + this._id + ')'); if (this.state === 'stopped') return debug('... already stopped!'); // only unique records need to be probed var records = this._records.filter(function (record) { return record.isUnique; }); // finish early if exact copies are found in the cache if (records.every(function (record) { return _this6._interface.cache.has(record); })) { debug('All records found in cache, skipping probe...'); return onSuccess(true); } // skip network trip if any conflicting records are found in cache if (records.some(function (record) { return _this6._interface.cache.hasConflictWith(record); })) { debug('Conflict found in cache, renaming...'); return onFail(); } new Probe(this._interface, this._offswitch).add(records).bridgeable(this._bridgeable).once('conflict', onFail).once('complete', onSuccess).start(); } /** * Send unsolicited announcements out when * - done probing * - changing rdata on a verified records (like TXTs) * - defensively correcting issues (TTL=0's, bridged records) */ }, { key: '_sendAnnouncement', value: function _sendAnnouncement() { var num = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; debug('Sending ' + num + ' announcements for "' + this._fullname + '". (' + this._id + ')'); if (this.state === 'stopped') return debug('... already stopped!'); new Response.Multicast(this._interface, this._offswitch).add(this._records).repeat(num).start(); } }, { key: '_sendGoodbye', value: function _sendGoodbye(onComplete) { debug('Sending goodbyes for "' + this._fullname + '". (' + this._id + ')'); if (this.state === 'stopped') return debug('... already stopped!'); // skip goodbyes for special record types, like the enumerator PTR var records = this._records.filter(function (record) { return record.canGoodbye(); }); new Response.Goodbye(this._interface, this._offswitch).add(records).once('stopped', onComplete).start(); } /** * "Instance" -> "Instance (2)" * "Instance (2)" -> "Instance (3)", etc. */ }, { key: '_rename', value: function _rename(label) { var re = /\((\d+)\)$/; // match ' (#)' function nextSuffix(match, n) { var next = parseInt(n, 10) + 1; return '(' + next + ')'; } return re.test(label) ? label.replace(re, nextSuffix) : label + ' (2)'; } /** * Handles incoming probes from an interface. Only ever gets used in the * `responding` state. Sends out multicast and/or unicast responses if any of * the probe records conflict with what this responder is currently using. */ }, { key: '_onProbe', value: function _onProbe(packet) { var intf = this._interface; var name = this._fullname; var records = this._records; var multicast = []; var unicast = []; packet.questions.forEach(function (question) { // check if negative responses are needed for this question, ie responder // controls the name but doesn't have rrtype XYZ record. send NSEC instead. var shouldAnswer = question.name.toUpperCase() === name.toUpperCase(); var answered = false; records.forEach(function (record) { if (!record.canAnswer(question)) return; // send as unicast if requested BUT only if the interface has not // multicast this record recently (withing 1/4 of the record's TTL) if (question.QU && intf.hasRecentlySent(record, record.ttl / 4)) { unicast.push(record); answered = true; } else { multicast.push(record); answered = true; } }); if (shouldAnswer && !answered) { multicast.push(records.find(function (r) { return r.rrtype === RType.NSEC && r.name === name; })); } }); if (multicast.length) { debug('Defending name with a multicast response. (' + this._id + ')'); new Response.Multicast(intf, this._offswitch).defensive(true).add(multicast).start(); } if (unicast.length) { debug('Defending name with a unicast response. (' + this._id + ')'); new Response.Unicast(intf, this._offswitch).respondTo(packet).defensive(true).add(unicast).start(); } } /** * Handles incoming queries from an interface. Only ever gets used in the * `responding` state. Sends out multicast and/or unicast responses if any of * the responders records match the questions. */ }, { key: '_onQuery', value: function _onQuery(packet) { var intf = this._interface; var name = this._fullname; var records = this._records; var knownAnswers = new RecordCollection(packet.answers); var multicast = []; var unicast = []; var suppressed = []; packet.questions.forEach(function (question) { // Check if negative responses are needed for this question, ie responder // controls the name but doesn't have rrtype XYZ record. send NSEC instead. var shouldAnswer = question.name.toUpperCase() === name.toUpperCase(); var answered = false; records.forEach(function (record) { if (!record.canAnswer(question)) return; var knownAnswer = knownAnswers.get(record); // suppress known answers if the answer's TTL is still above 50% if (knownAnswer && knownAnswer.ttl > record.ttl / 2) { suppressed.push(record); answered = true; // always respond via unicast to legacy queries (not from port 5353) } else if (packet.isLegacy()) { unicast.push(record); answered = true; // send as unicast if requested BUT only if the interface has not // multicast this record recently (withing 1/4 of the record's TTL) } else if (question.QU && intf.hasRecentlySent(record, record.ttl / 4)) { unicast.push(record); answered = true; // otherwise send a multicast response } else { multicast.push(record); answered = true; } }); if (shouldAnswer && !answered) { multicast.push(records.find(function (r) { return r.rrtype === RType.NSEC && r.name === name; })); } }); if (suppressed.length) { debug('Suppressing known answers (%s): %r', this._id, suppressed); } if (multicast.length) { debug('Answering question with a multicast response. (' + this._id + ')'); new Response.Multicast(intf, this._offswitch).add(multicast).start(); } if (unicast.length) { debug('Answering question with a unicast response. (' + this._id + ')'); new Response.Unicast(intf, this._offswitch).respondTo(packet).add(unicast).start(); } } /** * Handles incoming answer packets from an interface. Only ever gets used in * the `responding` state, meaning it will also have to handle packets that * originated from the responder itself as they get looped back through the * interfaces. * * The handler watches for: * - Conflicting answers, which would force the responder to re-probe * - Bad goodbyes that need to be fixed / re-announced * - Bridged packets that make the responder re-announce * * Bridged packets need special attention here because they cause problems. * (See: https://tools.ietf.org/html/rfc6762#section-14) * * Scenario: both wifi and ethernet are connected on a machine. This responder * uses A/AAAA records for each interface, but they have different addresses. * Because the interfaces are bridged, wifi packets get heard on ethernet and * vice versa. The responder would normally freak out because the wifi A/AAAA * records conflict with the ethernet A/AAAA records, causing a never ending * spiral of conflicts/probes/death. The solution is to check if records got * bridged before freaking out. The second problem is that the wifi records * will then clobber anything on the ethernet, flushing the ethernet records * from their caches (flush records get deleted in 1s, remember). To correct * this, when we detect our packets getting bridged back to us we need to * re-announce our records. This will restore the records in everyone's caches * and prevent them from getting deleted (that 1s thing). In response to the * re-announced (and bridged) ethernet records, the responder will try to * re-announce the wifi records, but this cycle will be stopped because * records are limited to being sent once ever 1 second. Its kind of a mess. * * Note, we don't need to worry about handling our own goodbye records * because there is no _onAnswer handler in the `goodbying` state. */ }, { key: '_onAnswer', value: function _onAnswer(packet) { var records = new RecordCollection(this._records); var incoming = new RecordCollection([].concat(_toConsumableArray(packet.answers), _toConsumableArray(packet.additionals))); // Defensively re-announce records getting TTL=0'd by other responders. var shouldFix = incoming.filter(function (record) { return record.ttl === 0; }).hasAny(records); if (shouldFix) { debug('Fixing goodbyes, re-announcing records. (' + this._id + ')'); return this._sendAnnouncement(); } var conflicts = records.getConflicts(incoming); if (conflicts.length) { // if the conflicts are just due to a bridged packet, re-announce instead if (this._bridgeable.hasEach(conflicts)) { debug('Bridged packet detected, re-announcing records. (' + this._id + ')'); return this._sendAnnouncement(); } // re-probe needed to verify uniqueness (doesn't rename until probing fails) debug('Found conflict on incoming records, re-probing. (' + this._id + ')'); return this.transition('probing'); } } }]); return Responder; }(StateMachine); module.exports = Responder;