UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

478 lines (389 loc) 15.5 kB
let os = require('os'); const misc = require('./misc'); const validate = require('./validate'); const ServiceType = require('./ServiceType'); const EventEmitter = require('./EventEmitter'); const ResourceRecord = require('./ResourceRecord'); const QueryRecord = require('./QueryRecord'); const Packet = require('./Packet'); const sleep = require('./sleep'); let Responder = require('./Responder'); let NetworkInterface = require('./NetworkInterface'); const filename = require('path').basename(__filename); const debug = require('./debug')(`dnssd:${filename}`); const RType = require('./constants').RType; const 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, options = {}) { if (!(this instanceof Advertisement)) { return new Advertisement(type, port, options); } EventEmitter.call(this); // convert argument ServiceType to validate it (might throw) const 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() { 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(() => this._getDefaultID()) .then(() => this._advertiseHostname()) .then(() => this._advertiseService()) .catch(err => 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) { debug(`Stopping advertisement "${this._id}"...`); this.state = STATE.STOPPED; const shutdown = () => { this._hostnameResponder = null; this._serviceResponder = null; this._interface.removeListenersCreatedBy(this); this._interface.stopUsing(); sleep.removeListenersCreatedBy(this); debug('Stopped.'); callback && callback(); this.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 let numResponders = 0; if (this._serviceResponder) numResponders++; if (this._hostnameResponder) numResponders++; const 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) { // 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(() => { this._serviceResponder.updateEach(RType.TXT, (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() { 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(() => this._advertiseHostname()) .then(() => this._advertiseService()) .catch(err => this._onError(err)); }; Advertisement.prototype._getDefaultID = function() { debug(`Trying to find the default route (${this._id})`); return new Promise((resolve, reject) => { const self = this; const question = new QueryRecord({name: misc.fqdn(this.hostname, this._domain)}); const queryPacket = new Packet(); queryPacket.setQuestions([question]); // try to listen for our own query this._interface.on('query', function handler(packet) { if (packet.isLocal() && packet.equals(queryPacket)) { self._defaultAddresses = Object.values(os.networkInterfaces()).find(intf => intf.some(({ address }) => address === packet.origin.address)); if (self._defaultAddresses) { self._interface.off('query', handler); resolve(); } } }); this._interface.send(queryPacket); setTimeout(() => 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() { const interfaces = Object.values(os.networkInterfaces()); const records = this._makeAddressRecords(this._defaultAddresses); const bridgeable = [].concat(...interfaces.map(i => this._makeAddressRecords(i))); return new Promise((resolve, reject) => { const responder = new Responder(this._interface, records, bridgeable); this._hostnameResponder = responder; responder.on('rename', this._onHostRename.bind(this)); 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`); const target = misc.fqdn(hostname, this._domain); this.hostname = hostname; if (this._serviceResponder) { this._serviceResponder.updateEach(RType.SRV, (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() { const records = this._makeServiceRecords(); const responder = new Responder(this._interface, records); this._serviceResponder = responder; responder.on('rename', (instance) => { debug(`Service instance had to be renamed to "${instance}"`); this._id = misc.fqdn(instance, this.serviceName, this.protocol, 'local'); this.instanceName = instance; this.emit('instanceRenamed', instance); }); responder.once('probingComplete', () => { debug(`Probed successfully, "${this._id}" now active`); this.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) { const name = misc.fqdn(this.hostname, this._domain); const As = addresses .filter(({ family }) => family === 'IPv4') .map(({ address }) => new ResourceRecord.A({ name, address })); const AAAAs = addresses .filter(({ family }) => family === 'IPv6') .filter(({ address }) => address.substr(0, 6).toLowerCase() === 'fe80::') .map(({ address }) => new ResourceRecord.AAAA({ name, address })); const types = []; if (As.length) types.push(RType.A); if (AAAAs.length) types.push(RType.AAAA); const NSEC = new ResourceRecord.NSEC({ name : name, ttl : 120, existing: types, }); As.forEach((A) => { A.additionals = (AAAAs.length) ? [...AAAAs, NSEC] : [NSEC]; }); AAAAs.forEach((AAAA) => { AAAA.additionals = (As.length) ? [...As, NSEC] : [NSEC]; }); return [...As, ...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() { const records = []; const interfaceRecords = this._hostnameResponder.getRecords(); // enumerator : "_services._dns-sd._udp.local." // registration: "_http._tcp.local." // serviceName : "A web page._http._tcp.local." const enumerator = misc.fqdn('_services._dns-sd._udp', this._domain); const registration = misc.fqdn(this.serviceName, this.protocol, this._domain); const serviceName = misc.fqdn(this.instanceName, registration); const NSEC = new ResourceRecord.NSEC({ name : serviceName, existing: [RType.SRV, RType.TXT], }); const SRV = new ResourceRecord.SRV({ name : serviceName, target : misc.fqdn(this.hostname, this._domain), port : this.port, additionals: [NSEC, ...interfaceRecords], }); const 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, ...interfaceRecords], })); records.push(new ResourceRecord.PTR({ name : enumerator, PTRDName: registration, })); // ex: "_printer.sub._http._tcp.local." this.subtypes.forEach((subType) => { records.push(new ResourceRecord.PTR({ name : misc.fqdn(subType, '_sub', registration), PTRDName : serviceName, additionals: [SRV, TXT, NSEC, ...interfaceRecords], })); }); return records; }; module.exports = Advertisement;