UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

514 lines (403 loc) 14.7 kB
const misc = require('./misc'); const EventEmitter = require('./EventEmitter'); const QueryRecord = require('./QueryRecord'); let Query = require('./Query'); const TimerContainer = require('./TimerContainer'); const StateMachine = require('./StateMachine'); const filename = require('path').basename(__filename); const debug = require('./debug')(`dnssd:${filename}`); const 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. * */ const resovlverStates = { unresolved: { enter() { 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', () => { debug('Resolver timed out.'); this.transition('stopped'); }, 10 * 1000); this._queryForMissing(); }, incomingRecords(records) { const wasUpdated = this._processRecords(records); if (this.isResolved()) this.transition('resolved'); else if (wasUpdated) this._queryForMissing(); }, reissue(record) { this._batchReissue(record); }, exit() { this._cancelQueries(); this._timers.clear('timeout'); }, }, resolved: { enter() { debug('Service is resolved'); this.emit('resolved'); }, incomingRecords(records) { const wasUpdated = this._processRecords(records); if (!this.isResolved()) this.transition('unresolved'); else if (wasUpdated) this.emit('updated'); }, reissue(record) { this._batchReissue(record); }, exit() { this._cancelQueries(); }, }, stopped: { 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 = () => 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} */ class ServiceResolver extends StateMachine { constructor(fullname, intf) { debug(`Creating new resolver for "${fullname}"`); super(resovlverStates); this.fullname = fullname; this._interface = intf; const parts = misc.parse(fullname); this.instance = parts.instance; this.serviceType = parts.service; this.protocol = parts.protocol; this.domain = parts.domain; // e.g. _http._tcp.local. this.ptrname = misc.fqdn(this.serviceType, this.protocol, this.domain); // info required for resolution this.addresses = []; this.target = null; this.port = null; this.txt = null; this.txtRaw = null; // keep one consistent service object so they resolved services can be // compared by object reference or kept in a set/map this._service = {}; // dirty flag to track changes to service info. gets reset to false before // each incoming answer packet is checked. this._changed = false; // offswitch used to communicate with & stop child queries instead of // holding onto a reference for each one this._offswitch = new EventEmitter(); this._batch = []; this._timers = new TimerContainer(this); } /** * Starts the resolver and parses optional starting records * @param {ResourceRecords[]} records */ 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'); } 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} */ service() { // remove any leading underscores const serviceType = this.serviceType.replace(/^_/, ''); const protocol = this.protocol.replace(/^_/, ''); // re-assign/update properties this._service.fullname = this.fullname; this._service.name = this.instance; this._service.type = { name: serviceType, 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; } 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. */ _addListeners() { this._interface.using(this) .on('answer', this._onAnswer) .once('error', err => this.transition('stopped', err)); this._interface.cache.using(this) .on('reissue', this._onReissue) .on('expired', this._onExpired); } _removeListeners() { this._interface.removeListenersCreatedBy(this); this._interface.cache.removeListenersCreatedBy(this); } _onAnswer(packet) { this.handle('incomingRecords', [...packet.answers, ...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. */ _onReissue(record) { const isRelevant = record.matches({ name: this.fullname }) || record.matches({ name: this.ptrname, PTRDName: this.fullname }) || record.matches({ name: this.target }); const 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. */ _onExpired(record) { // PTR/SRV: transition to stopped, service is down const 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 const isAddress = record.matches({ rrtype: RType.A, name: this.target }) || record.matches({ rrtype: RType.AAAA, name: this.target }); // TXT: remove txt & transition to unresolved const 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(add => 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} */ _processRecords(incoming) { // reset changes flag before checking records this._changed = false; // Ignore TTL 0 records. Get expiration events from the caches instead const records = incoming.filter(record => record.ttl > 0); if (!records.length) return false; const findOne = params => records.find(record => record.matches(params)); const findAll = params => records.filter(record => record.matches(params)); // SRV/TXT before A/AAAA, since they contain the target for A/AAAA records const SRV = findOne({ rrtype: RType.SRV, name: this.fullname }); const TXT = findOne({ rrtype: RType.TXT, name: this.fullname }); if (SRV) this._processSRV(SRV); if (TXT) this._processTXT(TXT); if (!this.target) return this._changed; const As = findAll({ rrtype: RType.A, name: this.target }); const AAAAs = findAll({ rrtype: RType.AAAA, name: this.target }); if (As.length) As.forEach(A => this._processAddress(A)); if (AAAAs.length) AAAAs.forEach(AAAA => this._processAddress(AAAA)); return this._changed; } _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; } } _processTXT(record) { if (!misc.equals(this.txtRaw, record.txtRaw)) { this.txtRaw = record.txtRaw; this.txt = record.txt; this._changed = true; } } _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. */ _queryForMissing() { debug('Getting missing records'); const 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 */ _checkCache(questions) { debug('Checking cache for needed records'); const answers = []; // check cache for answers to each question questions.forEach((question, index) => { const results = this._interface.cache.find(new QueryRecord(question)); if (results && results.length) { // remove answered questions from list questions.splice(index, 1); answers.push(...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()`. */ _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. */ _batchReissue(record) { debug('Batching record for reissue %s', record); this._batch.push(record); if (!this._timers.has('batch')) { this._timers.setLazy('batch', () => { this._sendReissueQuery(this._batch); this._batch = []; }, 1 * 1000); } } /** * Asks for updates to records. Only sends one query out (non-continuous). */ _sendReissueQuery(records) { debug('Reissuing query for cached records: %r', records); const questions = records.map(({ name, rrtype }) => ({ 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(); } _cancelQueries() { debug('Sending stop signal to active queries & canceling batched'); this._offswitch.emit('stop'); this._timers.clear('batch'); } } module.exports = ServiceResolver;