UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

508 lines (423 loc) 16.3 kB
'use strict'; 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); } } var os = require('os'); var misc = require('./misc'); var validate = require('./validate'); var ServiceType = require('./ServiceType'); var EventEmitter = require('./EventEmitter'); var ResourceRecord = require('./ResourceRecord'); var QueryRecord = require('./QueryRecord'); var Packet = require('./Packet'); var sleep = require('./sleep'); var Responder = require('./Responder'); var NetworkInterface = require('./NetworkInterface'); var filename = require('path').basename(__filename); var debug = require('./debug')('dnssd:' + filename); var RType = require('./constants').RType; var STATE = { STOPPED: 'stopped', STARTED: 'started' }; /** * Creates a new Advertisement * * @emits 'error' * @emits 'stopped' when the advertisement is stopped * @emits 'instanceRenamed' when the service instance is renamed * @emits 'hostRenamed' when the hostname has to be renamed * * @param {ServiceType|Object|String|Array} type - type of service to advertise * @param {Number} port - port to advertise * * @param {Object} [options] * @param {Object} options.name - instance name * @param {Object} options.host - hostname to use * @param {Object} options.txt - TXT record * @param {Object} options.subtypes - subtypes to register * @param {Object} options.interface - interface name or address to use */ function Advertisement(type, port) { var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; if (!(this instanceof Advertisement)) { return new Advertisement(type, port, options); } EventEmitter.call(this); // convert argument ServiceType to validate it (might throw) var serviceType = !(type instanceof ServiceType) ? new ServiceType(type) : type; // validate other inputs (throws on invalid) validate.port(port); if (options.txt) validate.txt(options.txt); if (options.name) validate.label(options.name, 'Instance'); if (options.host) validate.label(options.host, 'Hostname'); this.serviceName = serviceType.name; this.protocol = serviceType.protocol; this.subtypes = options.subtypes ? options.subtypes : serviceType.subtypes; this.port = port; this.instanceName = options.name || misc.hostname(); this.hostname = options.host || misc.hostname(); this.txt = options.txt || {}; // Domain notes: // 1- link-local only, so this is the only possible value // 2- "_domain" used instead of "domain" because "domain" is an instance var // in older versions of EventEmitter. Using "domain" messes up `this.emit()` this._domain = 'local'; this._id = misc.fqdn(this.instanceName, this.serviceName, this.protocol, 'local'); debug('Creating new advertisement for "' + this._id + '" on ' + port); this.state = STATE.STOPPED; this._interface = NetworkInterface.get(options.interface); this._defaultAddresses = null; this._hostnameResponder = null; this._serviceResponder = null; } Advertisement.prototype = Object.create(EventEmitter.prototype); Advertisement.prototype.constructor = Advertisement; /** * Starts advertisement * * In order: * - bind interface to multicast port * - make records and advertise this.hostname * - make records and advertise service * * If the given hostname is already taken by someone else (not including * bonjour/avahi on the same machine), the hostname is automatically renamed * following the pattern: * Name -> Name (2) * * Services aren't advertised until the hostname has been properly advertised * because a service needs a host. Service instance names (this.instanceName) * have to be unique and get renamed automatically the same way. * * @return {this} */ Advertisement.prototype.start = function () { var _this = this; if (this.state === STATE.STARTED) { debug('Advertisement already started!'); return this; } debug('Starting advertisement "' + this._id + '"'); this.state = STATE.STARTED; // restart probing process when waking from sleep sleep.using(this).on('wake', this._restart); // treat interface errors as fatal this._interface.using(this).once('error', this._onError); this._interface.bind().then(function () { return _this._getDefaultID(); }).then(function () { return _this._advertiseHostname(); }).then(function () { return _this._advertiseService(); }).catch(function (err) { return _this._onError(err); }); return this; }; /** * Stops advertisement * * Advertisement can do either a clean stop or a forced stop. A clean stop will * send goodbye records out so others will know the service is going down. This * takes ~1s. Forced goodbyes shut everything down immediately w/o goodbyes. * * `this._shutdown()` will deregister the advertisement. If the advertisement was * the only thing using the interface it will shut down too. * * @emits 'stopped' * * @param {Boolean} [forceImmediate] */ Advertisement.prototype.stop = function (forceImmediate, callback) { var _this2 = this; debug('Stopping advertisement "' + this._id + '"...'); this.state = STATE.STOPPED; var shutdown = function shutdown() { _this2._hostnameResponder = null; _this2._serviceResponder = null; _this2._interface.removeListenersCreatedBy(_this2); _this2._interface.stopUsing(); sleep.removeListenersCreatedBy(_this2); debug('Stopped.'); callback && callback(); _this2.emit('stopped'); }; // If doing a clean stop, responders need to send goodbyes before turning off // the interface. Depending on when the advertisment was stopped, it could // have one, two, or no active responders that need to send goodbyes var numResponders = 0; if (this._serviceResponder) numResponders++; if (this._hostnameResponder) numResponders++; var done = misc.after_n(shutdown, numResponders); // immediate shutdown (forced or if there aren't any active responders) // or wait for goodbyes on a clean shutdown if (forceImmediate || !numResponders) { this._serviceResponder && this._serviceResponder.stop(); this._hostnameResponder && this._hostnameResponder.stop(); shutdown(); } else { this._serviceResponder && this._serviceResponder.goodbye(done); this._hostnameResponder && this._hostnameResponder.goodbye(done); } }; /** * Updates the adverts TXT record * @param {object} txtObj */ Advertisement.prototype.updateTXT = function (txtObj) { var _this3 = this; // validates txt first, will throw validation errors on bad input validate.txt(txtObj); // make sure responder handles network requests in event loop before updating // (otherwise could have unintended record conflicts) setImmediate(function () { _this3._serviceResponder.updateEach(RType.TXT, function (record) { record.txtRaw = misc.makeRawTXT(txtObj); record.txt = misc.makeReadableTXT(txtObj); }); }); }; /** * Error handler. Does immediate shutdown * @emits 'error' */ Advertisement.prototype._onError = function (err) { debug('Error on "' + this._id + '", shutting down. Got: \n' + err); this.stop(true); // stop immediately this.emit('error', err); }; Advertisement.prototype._restart = function () { var _this4 = this; if (this.state !== STATE.STARTED) return debug('Not yet started, skipping'); debug('Waking from sleep, restarting "' + this._id + '"'); // stop responders if they exist this._serviceResponder && this._serviceResponder.stop(); this._hostnameResponder && this._hostnameResponder.stop(); this._hostnameResponder = null; this._serviceResponder = null; // need to check if active interface has changed this._getDefaultID().then(function () { return _this4._advertiseHostname(); }).then(function () { return _this4._advertiseService(); }).catch(function (err) { return _this4._onError(err); }); }; Advertisement.prototype._getDefaultID = function () { var _this5 = this; debug('Trying to find the default route (' + this._id + ')'); return new Promise(function (resolve, reject) { var self = _this5; var question = new QueryRecord({ name: misc.fqdn(_this5.hostname, _this5._domain) }); var queryPacket = new Packet(); queryPacket.setQuestions([question]); // try to listen for our own query _this5._interface.on('query', function handler(packet) { if (packet.isLocal() && packet.equals(queryPacket)) { self._defaultAddresses = Object.values(os.networkInterfaces()).find(function (intf) { return intf.some(function (_ref) { var address = _ref.address; return address === packet.origin.address; }); }); if (self._defaultAddresses) { self._interface.off('query', handler); resolve(); } } }); _this5._interface.send(queryPacket); setTimeout(function () { return reject(new Error('Timed out getting default route')); }, 500); }); }; /** * Advertise the same hostname * * A new responder is created for this task. A responder is a state machine * that will talk to the network to do advertising. Its responsible for a * single record set from `_makeAddressRecords` and automatically renames * them if conflicts are found. * * Returns a promise that resolves when a hostname has been authoritatively * advertised. Rejects on fatal errors only. * * @return {Promise} */ Advertisement.prototype._advertiseHostname = function () { var _ref2, _this6 = this; var interfaces = Object.values(os.networkInterfaces()); var records = this._makeAddressRecords(this._defaultAddresses); var bridgeable = (_ref2 = []).concat.apply(_ref2, _toConsumableArray(interfaces.map(function (i) { return _this6._makeAddressRecords(i); }))); return new Promise(function (resolve, reject) { var responder = new Responder(_this6._interface, records, bridgeable); _this6._hostnameResponder = responder; responder.on('rename', _this6._onHostRename.bind(_this6)); responder.once('probingComplete', resolve); responder.once('error', reject); responder.start(); }); }; /** * Handles rename events from the interface hostname responder. * * If a conflict was been found with a proposed hostname, the responder will * rename and probe again. This event fires *after* the rename but *before* * probing, so the name here isn't guaranteed yet. * * The hostname responder will update its A/AAAA record set with the new name * when it does the renaming. The service responder will need to update the * hostname in its SRV record. * * @emits 'hostRenamed' * * @param {String} hostname - the new current hostname */ Advertisement.prototype._onHostRename = function (hostname) { debug('Hostname renamed to "' + hostname + '" on interface records'); var target = misc.fqdn(hostname, this._domain); this.hostname = hostname; if (this._serviceResponder) { this._serviceResponder.updateEach(RType.SRV, function (record) { record.target = target; }); } this.emit('hostRenamed', target); }; /** * Advertises the service * * A new responder is created for this task also. The responder will manage * the record set from `_makeServiceRecords` and automatically rename them * if conflicts are found. * * The responder will keeps advertising/responding until `advertisement.stop()` * tells it to stop. * * @emits 'instanceRenamed' when the service instance is renamed */ Advertisement.prototype._advertiseService = function () { var _this7 = this; var records = this._makeServiceRecords(); var responder = new Responder(this._interface, records); this._serviceResponder = responder; responder.on('rename', function (instance) { debug('Service instance had to be renamed to "' + instance + '"'); _this7._id = misc.fqdn(instance, _this7.serviceName, _this7.protocol, 'local'); _this7.instanceName = instance; _this7.emit('instanceRenamed', instance); }); responder.once('probingComplete', function () { debug('Probed successfully, "' + _this7._id + '" now active'); _this7.emit('active'); }); responder.once('error', this._onError.bind(this)); responder.start(); }; /** * Make the A/AAAA records that will be used on an interface. * * Each interface will have its own A/AAAA records generated because the * IPv4/IPv6 addresses will be different on each interface. * * NSEC records are created to show which records are available with this name. * This lets others know if an AAAA doesn't exist, for example. * (See 8.2.4 Negative Responses or whatever) * * @param {NetworkInterface} intf * @return {ResourceRecords[]} */ Advertisement.prototype._makeAddressRecords = function (addresses) { var name = misc.fqdn(this.hostname, this._domain); var As = addresses.filter(function (_ref3) { var family = _ref3.family; return family === 'IPv4'; }).map(function (_ref4) { var address = _ref4.address; return new ResourceRecord.A({ name: name, address: address }); }); var AAAAs = addresses.filter(function (_ref5) { var family = _ref5.family; return family === 'IPv6'; }).filter(function (_ref6) { var address = _ref6.address; return address.substr(0, 6).toLowerCase() === 'fe80::'; }).map(function (_ref7) { var address = _ref7.address; return new ResourceRecord.AAAA({ name: name, address: address }); }); var types = []; if (As.length) types.push(RType.A); if (AAAAs.length) types.push(RType.AAAA); var NSEC = new ResourceRecord.NSEC({ name: name, ttl: 120, existing: types }); As.forEach(function (A) { A.additionals = AAAAs.length ? [].concat(_toConsumableArray(AAAAs), [NSEC]) : [NSEC]; }); AAAAs.forEach(function (AAAA) { AAAA.additionals = As.length ? [].concat(_toConsumableArray(As), [NSEC]) : [NSEC]; }); return [].concat(_toConsumableArray(As), _toConsumableArray(AAAAs), [NSEC]); }; /** * Make the SRV/TXT/PTR records that will be used on an interface. * * Each interface will have its own SRV/TXT/PTR records generated because * these records are dependent on the A/AAAA hostname records, which are * different for each hostname. * * NSEC records are created to show which records are available with this name. * * @return {ResourceRecords[]} */ Advertisement.prototype._makeServiceRecords = function () { var records = []; var interfaceRecords = this._hostnameResponder.getRecords(); // enumerator : "_services._dns-sd._udp.local." // registration: "_http._tcp.local." // serviceName : "A web page._http._tcp.local." var enumerator = misc.fqdn('_services._dns-sd._udp', this._domain); var registration = misc.fqdn(this.serviceName, this.protocol, this._domain); var serviceName = misc.fqdn(this.instanceName, registration); var NSEC = new ResourceRecord.NSEC({ name: serviceName, existing: [RType.SRV, RType.TXT] }); var SRV = new ResourceRecord.SRV({ name: serviceName, target: misc.fqdn(this.hostname, this._domain), port: this.port, additionals: [NSEC].concat(_toConsumableArray(interfaceRecords)) }); var TXT = new ResourceRecord.TXT({ name: serviceName, additionals: [NSEC], txt: this.txt }); records.push(SRV); records.push(TXT); records.push(NSEC); records.push(new ResourceRecord.PTR({ name: registration, PTRDName: serviceName, additionals: [SRV, TXT, NSEC].concat(_toConsumableArray(interfaceRecords)) })); records.push(new ResourceRecord.PTR({ name: enumerator, PTRDName: registration })); // ex: "_printer.sub._http._tcp.local." this.subtypes.forEach(function (subType) { records.push(new ResourceRecord.PTR({ name: misc.fqdn(subType, '_sub', registration), PTRDName: serviceName, additionals: [SRV, TXT, NSEC].concat(_toConsumableArray(interfaceRecords)) })); }); return records; }; module.exports = Advertisement;