UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

679 lines (546 loc) 21.6 kB
const misc = require('./misc'); const EventEmitter = require('./EventEmitter'); const RecordCollection = require('./RecordCollection'); const TimerContainer = require('./TimerContainer'); const StateMachine = require('./StateMachine'); let Probe = require('./Probe'); let Response = require('./Response'); const filename = require('path').basename(__filename); const debug = require('./debug')(`dnssd:${filename}`); const RType = require('./constants').RType; const ONE_SECOND = 1000; /** * Make ids, just to keep track of which responder is which in debug messages */ let counter = 0; const uniqueId = () => `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 */ class ConflictCounter { constructor() { this._count = 0; this._timer = null; } count() { return this._count; } increment() { this._count++; clearTimeout(this._timer); // reset conflict counter after 15 seconds this._timer = setTimeout(() => { this._count = 0; }, 15 * ONE_SECOND); // prevent timer from holding the process this._timer.unref(); } clear() { this._count = 0; clearTimeout(this._timer); } } /** * 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' */ const responderStates = { probing: { enter() { debug(`Now probing for: ${this._fullname}`); const onSuccess = (early) => { this.transition('responding', early); }; const onFail = () => { this.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', () => { this.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', () => { this._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() { 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() { this._stopActives(); this._timers.clear('delayed-probe'); }, }, responding: { 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(packet) { this._onProbe(packet); }, query(packet) { this._onQuery(packet); }, answer(packet) { this._onAnswer(packet); }, // stop any active announcements / responses before announcing changes update() { this._stopActives(); this._sendAnnouncement(); }, // stop any active announcements / responses before changing state 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() { debug(`Had a conflict with "${this._instance}", renaming. (${this._id})`); // Instance -> Instance (2) const oldName = this._instance; const newName = this._rename(oldName); // Instance._http._tcp.local. -> Instance (2)._http._tcp.local. const oldFull = this._fullname; const 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(() => { 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(callback) { const finish = () => { this.transition('stopped'); callback(); }; // Only send goodbyes if records were valid/probed, otherwise just stop if (this.prevState !== 'responding') finish(); else this._sendGoodbye(finish); }, exit() { this._stopActives(); }, }, // Terminal state. Cleans up any existing timers and stops listening to // interfaces. Emits any errors, like from probing timeouts. stopped: { 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 = () => 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 */ class Responder extends StateMachine { constructor(intf, records, bridgeable) { super(responderStates); this._id = uniqueId(); debug('Creating new responder (%s) using: %r', this._id, records); const uniques = [...new Set(records.filter(r => r.isUnique).map(r => 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'); this._interface = intf; this._records = records; this._bridgeable = new RecordCollection(bridgeable); // the unique name that this record set revolves around // eg: "Instance._http._tcp.local." this._fullname = uniques[0]; // the part of the name that needs to be renamed on conflicts // eg: "Instance" this._instance = misc.parse(this._fullname).instance; if (!this._instance) throw Error('No instance name found in records'); this._timers = new TimerContainer(this); this._conflicts = new ConflictCounter(); // emitter used to stop child probes & responses without having to hold // onto a reference for each one this._offswitch = new EventEmitter(); } start() { debug(`Starting responder (${this._id})`); this._addListeners(); this.transition('probing'); } // Immediately stops the responder (no goodbyes) stop() { debug(`Stopping responder (${this._id})`); this.transition('stopped'); } // Sends goodbyes before stopping 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 */ updateEach(rrtype, fn) { debug(`Updating rtype ${rrtype} records. (${this._id})`); // modify properties of each record with given update fn this._records .filter(record => record.rrtype === rrtype) .forEach(record => record.updateWith(fn)); // (update bridge list too) this._bridgeable .filter(record => record.rrtype === rrtype) .forEach(record => 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[]} */ getRecords() { return this._records; } _addListeners() { this._interface.using(this) .on('probe', packet => this.handle('probe', packet)) .on('query', packet => this.handle('query', packet)) .on('answer', packet => this.handle('answer', packet)) .once('error', err => this.transition('stopped', err)); } _removeListeners() { this._interface.removeListenersCreatedBy(this); } /** * Stop any active probes, announcements, or goodbyes (all outgoing stuff uses * the same offswitch) */ _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. */ _sendProbe(onSuccess, onFail) { debug(`Sending probes for "${this._fullname}". (${this._id})`); if (this.state === 'stopped') return debug('... already stopped!'); // only unique records need to be probed const records = this._records.filter(record => record.isUnique); // finish early if exact copies are found in the cache if (records.every(record => this._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(record => this._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) */ _sendAnnouncement(num = 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(); } _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 const records = this._records.filter(record => record.canGoodbye()); new Response.Goodbye(this._interface, this._offswitch) .add(records) .once('stopped', onComplete) .start(); } /** * "Instance" -> "Instance (2)" * "Instance (2)" -> "Instance (3)", etc. */ _rename(label) { const re = /\((\d+)\)$/; // match ' (#)' function nextSuffix(match, n) { const 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. */ _onProbe(packet) { const intf = this._interface; const name = this._fullname; const records = this._records; const multicast = []; const unicast = []; packet.questions.forEach((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. const shouldAnswer = (question.name.toUpperCase() === name.toUpperCase()); let answered = false; records.forEach((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(r => 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. */ _onQuery(packet) { const intf = this._interface; const name = this._fullname; const records = this._records; const knownAnswers = new RecordCollection(packet.answers); const multicast = []; const unicast = []; const suppressed = []; packet.questions.forEach((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. const shouldAnswer = (question.name.toUpperCase() === name.toUpperCase()); let answered = false; records.forEach((record) => { if (!record.canAnswer(question)) return; const 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(r => 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. */ _onAnswer(packet) { const records = new RecordCollection(this._records); const incoming = new RecordCollection([...packet.answers, ...packet.additionals]); // Defensively re-announce records getting TTL=0'd by other responders. const shouldFix = incoming.filter(record => record.ttl === 0).hasAny(records); if (shouldFix) { debug(`Fixing goodbyes, re-announcing records. (${this._id})`); return this._sendAnnouncement(); } const 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'); } } } module.exports = Responder;