UNPKG

dnssd

Version:

Bonjour/Avahi-like service discovery in pure JavaScript

739 lines (578 loc) 18.8 kB
const hash = require('./hash'); const misc = require('./misc'); const BufferWrapper = require('./BufferWrapper'); const filename = require('path').basename(__filename); const debug = require('./debug')(`dnssd:${filename}`); const RClass = require('./constants').RClass; const RType = require('./constants').RType; const RNums = require('./constants').RNums; /** * Create/parse resource records * @class * * Create a specific ResourceRecord (AAAA): * > const record = new ResourceRecord.AAAA({name: 'Target.local.', address: '::1'}); * * Parse a ResourceRecord from a buffer (a wrapped buffer): * > const record = ResourceRecord.fromBuffer(wrapper); * */ class ResourceRecord { constructor(fields) { if (this.constructor === ResourceRecord) throw new Error('Abstract only!'); if (!fields || !fields.name) throw new Error('Record must have a name'); this.name = fields.name; this.rrtype = fields.rrtype || RType[this.constructor.name]; this.rrclass = fields.rrclass || RClass.IN; if ('ttl' in fields) this.ttl = fields.ttl; if ('isUnique' in fields) this.isUnique = fields.isUnique; this.additionals = fields.additionals || []; } /** * Parse a record from a buffer. Starts reading the wrapped buffer at w/e * position its at when fromBuffer is called. * * @param {BufferWrapper} wrapper * @return {ResourceRecord} */ static fromBuffer(wrapper) { const name = wrapper.readFQDN(); const rrtype = wrapper.readUInt16BE(); const rrclass = wrapper.readUInt16BE(); const ttl = wrapper.readUInt32BE(); // top-bit in rrclass is reused as the cache-flush bit const fields = { name, rrtype, rrclass : rrclass & ~0x8000, isUnique: !!(rrclass & 0x8000), ttl, }; if (rrtype === RType.A) return new ResourceRecord.A(fields, wrapper); if (rrtype === RType.PTR) return new ResourceRecord.PTR(fields, wrapper); if (rrtype === RType.TXT) return new ResourceRecord.TXT(fields, wrapper); if (rrtype === RType.AAAA) return new ResourceRecord.AAAA(fields, wrapper); if (rrtype === RType.SRV) return new ResourceRecord.SRV(fields, wrapper); if (rrtype === RType.NSEC) return new ResourceRecord.NSEC(fields, wrapper); return new ResourceRecord.Unknown(fields, wrapper); } /** * Makes a couple hashes of record properties so records can get compared * easier. */ _makehashes() { // a hash for name/rrtype/rrclass (records like PTRs might share name/type // but have different rdata) this.namehash = hash(this.name, this.rrtype, this.rrclass); // hash for comparing rdata this.rdatahash = this._hashRData(); // a unique hash for a given name/type/class *AND* rdata this.hash = hash(this.namehash, this.rdatahash); } /** * Writes the record to a wrapped buffer at the wrapper's current position. * @param {BufferWrapper} wrapper */ writeTo(wrapper) { const classField = (this.isUnique) ? this.rrclass | 0x8000 : this.rrclass; // record info wrapper.writeFQDN(this.name); wrapper.writeUInt16BE(this.rrtype); wrapper.writeUInt16BE(classField); wrapper.writeUInt32BE(this.ttl); // leave UInt16BE gap to write rdataLen const rdataLenPos = wrapper.tell(); wrapper.skip(2); // record specific rdata this._writeRData(wrapper); // go back and add rdata length const rdataLen = wrapper.tell() - rdataLenPos - 2; wrapper.buffer.writeUInt16BE(rdataLen, rdataLenPos); } /** * Checks if this record conflicts with another. Records conflict if they * 1) are both unique (shared record sets can't conflict) * 2) have the same name/type/class * 3) but have different rdata * * @param {ResourceRecord} record * @return {boolean} */ conflictsWith(record) { const hasConflict = (this.isUnique && record.isUnique) && (this.namehash === record.namehash) && (this.rdatahash !== record.rdatahash); if (hasConflict) { debug('Found conflict: \nRecord: %s\nIncoming: %s', this, record); } return hasConflict; } /** * Checks if this record can answer the question. Record names are compared * case insensitively. * * @param {QueryRecord} question * @return {boolean} */ canAnswer(question) { return (this.rrclass === question.qclass || question.qclass === RClass.ANY) && (this.rrtype === question.qtype || question.qtype === RType.ANY) && (this.name.toUpperCase() === question.name.toUpperCase()); } /** * Records are equal if name/type/class and rdata are the same */ equals(record) { return (this.hash === record.hash); } /** * Determines which record is lexicographically later. Used to determine * which probe wins when two competing probes are sent at the same time. * (see https://tools.ietf.org/html/rfc6762#section-8.2) * * means comparing, in order, * - rrclass * - rrtype * - rdata, byte by byte * * Rdata has to be written to a buffer first and then compared. * The cache flush bit has to be excluded as well when comparing * rrclass. * * 1 = this record comes later than the other record * -1 = this record comes earlier than the other record * 0 = records are equal * * @param {ResourceRecord} record * @return {number} */ compare(record) { if (this.equals(record)) return 0; if (this.rrclass > record.rrclass) return 1; if (this.rrclass < record.rrclass) return -1; if (this.rrtype > record.rrtype) return 1; if (this.rrtype < record.rrtype) return -1; // make buffers out of em so we can compare byte by byte // this also prevents data from being name compressed, since // we are only writing a single rdata, and nothing else const rdata_1 = new BufferWrapper(); const rdata_2 = new BufferWrapper(); this._writeRData(rdata_1); record._writeRData(rdata_2); return rdata_1.unwrap().compare(rdata_2.unwrap()); } /** * Test if a record matches some properties. String values are compared * case insensitively. * * Ex: * > const isMatch = record.matches({name: 'test.', priority: 12}) * * @param {object} properties * @return {boolean} */ matches(properties) { return Object.keys(properties) .map(key => [key, properties[key]]) .every(([key, value]) => { return (typeof this[key] === 'string' && typeof value === 'string') ? this[key].toUpperCase() === value.toUpperCase() : misc.equals(this[key], value); }); } /** * Returns a clone of the record, making a new object */ clone() { const type = this.constructor.name; const fields = this; return new ResourceRecord[type](fields); } /** * If anything changes on a record it needs to be re-hashed. Otherwise * all the comparisons won't work with the new changes. * * Bad: record.target = 'new.local.'; * Good: record.updateWith(() => {record.target = 'new.local.'}); * */ updateWith(fn) { // give record to updater function to modify fn(this); // rehash in case name/rdata changed this._makehashes(); } /** * Records with reserved names shouldn't be goodbye'd * * _services._dns-sd._udp.<domain>. * b._dns-sd._udp.<domain>. * db._dns-sd._udp.<domain>. * r._dns-sd._udp.<domain>. * dr._dns-sd._udp.<domain>. * lb._dns-sd._udp.<domain>. */ canGoodbye() { const name = this.name.toLowerCase(); return (name.indexOf('._dns-sd._udp.') === -1); } /** * Breaks up the record into an array of parts. Used in misc.alignRecords * so stuff can get printed nicely in columns. Only ever used in debugging. */ toParts() { const parts = []; const type = (this.constructor.name === 'Unknown') ? this.rrtype : this.constructor.name; const ttl = (this.ttl === 0) ? misc.color(this.ttl, 'red') : String(this.ttl); parts.push(this.name); parts.push((this.ttl === 0) ? misc.color(type, 'red') : misc.color(type, 'blue')); parts.push(ttl); parts.push(String(this._getRDataStr())); if (this.isUnique) parts.push(misc.color('(flush)', 'grey')); return parts; } toString() { return this.toParts().join(' '); } } /** * A record (IPv4 address) */ class A extends ResourceRecord { /** * @param {object} fields * @param {BufferWrapper} [wrapper] - only used by the .fromBuffer method */ constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 120, isUnique: true }); // rdata: this.address = fields.address || ''; if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const _len = wrapper.readUInt16BE(); const n1 = wrapper.readUInt8(); const n2 = wrapper.readUInt8(); const n3 = wrapper.readUInt8(); const n4 = wrapper.readUInt8(); this.address = `${n1}.${n2}.${n3}.${n4}`; } _writeRData(wrapper) { this.address.split('.').forEach((str) => { const n = parseInt(str, 10); wrapper.writeUInt8(n); }); } _hashRData() { return hash(this.address); } _getRDataStr() { return this.address; } } ResourceRecord.A = A; /** * PTR record */ class PTR extends ResourceRecord { constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 4500, isUnique: false }); // rdata: this.PTRDName = fields.PTRDName || ''; if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const _len = wrapper.readUInt16BE(); this.PTRDName = wrapper.readFQDN(); } _writeRData(wrapper) { wrapper.writeFQDN(this.PTRDName); } _hashRData() { return hash(this.PTRDName); } _getRDataStr() { return this.PTRDName; } } ResourceRecord.PTR = PTR; /** * TXT record * * key/value conventions: * - Key present with value * 'key=value' -> {key: value} * * - Key present, _empty_ value: * 'key=' -> {key: null} * * - Key present, but no value: * 'key' -> {key: true} * * Important note: keys are case insensitive */ class TXT extends ResourceRecord { constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 4500, isUnique: true }); // rdata: this.txtRaw = misc.makeRawTXT(fields.txt || {}); this.txt = misc.makeReadableTXT(fields.txt || {}); if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const rdataLength = wrapper.readUInt16BE(); const end = wrapper.tell() + rdataLength; let len; // read each key: value pair while (wrapper.tell() < end && (len = wrapper.readUInt8())) { let key = ''; let chr, value; while (len-- > 0 && (chr = wrapper.readString(1)) !== '=') { key += chr; } if (len > 0) value = wrapper.read(len); else if (chr === '=') value = null; else value = true; this.txtRaw[key] = value; this.txt[key] = (Buffer.isBuffer(value)) ? value.toString() : value; } } _writeRData(wrapper) { // need to at least put a 0 byte if no txt data if (!Object.keys(this.txtRaw).length) { return wrapper.writeUInt8(0); } // value is either true, null, or a buffer Object.keys(this.txtRaw).forEach((key) => { const value = this.txtRaw[key]; const str = (value === true) ? key : key + '='; let len = Buffer.byteLength(str); if (Buffer.isBuffer(value)) len += value.length; wrapper.writeUInt8(len); wrapper.writeString(str); if (Buffer.isBuffer(value)) wrapper.add(value); }); } _hashRData() { return hash(this.txtRaw); } _getRDataStr() { return misc.truncate(JSON.stringify(this.txt), 30); } } ResourceRecord.TXT = TXT; /** * AAAA record (IPv6 address) */ class AAAA extends ResourceRecord { constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 120, isUnique: true }); // rdata: this.address = fields.address || ''; if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const _len = wrapper.readUInt16BE(); const raw = wrapper.read(16); const parts = []; for (let i = 0; i < raw.length; i += 2) { parts.push(raw.readUInt16BE(i).toString(16)); } this.address = parts.join(':') .replace(/(^|:)0(:0)*:0(:|$)/, '$1::$3') .replace(/:{3,4}/, '::'); } _writeRData(wrapper) { function expandIPv6(str) { let ip = str; // replace ipv4 address if any const ipv4_match = ip.match(/(.*:)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$)/); if (ipv4_match) { ip = ipv4_match[1]; const ipv4 = ipv4_match[2].match(/[0-9]+/g); for (let i = 0; i < 4; i++) { ipv4[i] = parseInt(ipv4[i], 10).toString(16); } ip += ipv4[0] + ipv4[1] + ':' + ipv4[2] + ipv4[3]; } // take care of leading and trailing :: ip = ip.replace(/^:|:$/g, ''); const ipv6 = ip.split(':'); for (let i = 0; i < ipv6.length; i++) { // normalize grouped zeros :: if (ipv6[i] === '') { ipv6[i] = new Array(9 - ipv6.length).fill(0).join(':'); } } return ipv6.join(':'); } expandIPv6(this.address).split(':').forEach((str) => { const u16 = parseInt(str, 16); wrapper.writeUInt16BE(u16); }); } _hashRData() { return hash(this.address); } _getRDataStr() { return this.address; } } ResourceRecord.AAAA = AAAA; /** * SRV record */ class SRV extends ResourceRecord { constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 120, isUnique: true }); // rdata: this.target = fields.target || ''; this.port = fields.port || 0; this.priority = fields.priority || 0; this.weight = fields.weight || 0; if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const _len = wrapper.readUInt16BE(); this.priority = wrapper.readUInt16BE(); this.weight = wrapper.readUInt16BE(); this.port = wrapper.readUInt16BE(); this.target = wrapper.readFQDN(); } _writeRData(wrapper) { wrapper.writeUInt16BE(this.priority); wrapper.writeUInt16BE(this.weight); wrapper.writeUInt16BE(this.port); wrapper.writeFQDN(this.target); } _hashRData() { return hash(this.priority, this.weight, this.port, this.target); } _getRDataStr() { return `${this.target} ${this.port} P:${this.priority} W:${this.weight}`; } } ResourceRecord.SRV = SRV; /** * NSEC record * Only handles the limited 'restricted' form (record rrtypes < 255) */ class NSEC extends ResourceRecord { constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 120, isUnique: true }); // rdata: this.existing = (fields.existing || []).sort((a, b) => a - b); if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const rdataLength = wrapper.readUInt16BE(); const rdataEnd = wrapper.tell() + rdataLength; const _name = wrapper.readFQDN(); // doesn't matter, ignored const block = wrapper.readUInt8(); // window block for rrtype bitfield const len = wrapper.readUInt8(); // number of octets in bitfield // Ignore rrtypes over 255 (only implementing the restricted form) // Bitfield length must always be < 32, otherwise skip parsing if (block !== 0 || len > 32) return wrapper.seek(rdataEnd); // NSEC rrtype bitfields can be up to 256 bits (32 bytes), BUT // - js bitwise operators are only do 32 bits // - node's buffer.readIntBE() can only read up to 6 bytes // // So here we're doing 1 byte of the field at a time // for (let maskNum = 0; maskNum < len; maskNum++) { const mask = wrapper.readUInt8(1); if (mask === 0) continue; for (let bit = 0; bit < 8; bit++) { if (mask & (1 << bit)) { // rrtypes in bitfields are in network bit order // 01000000 => 1 === RType.A (bit 6) // 00000000 00000000 00000000 00001000 => 28 === RType.AAAA (bit 3) const rrtype = (8 * maskNum) + (7 - bit); this.existing.push(rrtype); } } } } _writeRData(wrapper) { // restricted form, only rrtypes up to 255 const rrtypes = [...new Set(this.existing)].filter(x => x <= 255); // Same problems as _readRData, 32 bit operators and can't write big ints, // so bitfields are broken up into 1 byte segments and handled one at a time const len = (!rrtypes.length) ? 0 : (Math.ceil(Math.max(...rrtypes) / 8)); const masks = Array(len).fill(0); rrtypes.forEach((rrtype) => { const index = ~~(rrtype / 8); // which mask this rrtype is on const bit = 7 - (rrtype % 8); // convert to network bit order masks[index] |= (1 << bit); }); wrapper.writeFQDN(this.name); // "next domain name", ignored for mdns wrapper.writeUInt8(0); // block number, always 0 for restricted form wrapper.writeUInt8(len); // bitfield length in octets // write masks byte by byte since node buffers can only write 42 bit numbers masks.forEach(mask => wrapper.writeUInt8(mask)); } _hashRData() { return hash(this.existing); } _getRDataStr() { return this.existing.map(rrtype => RNums[rrtype] || rrtype).join(', '); } } ResourceRecord.NSEC = NSEC; /** * Unknown record, anything not describe above. Could be OPT records, etc. */ class Unknown extends ResourceRecord { constructor(fields, wrapper) { super(fields); // defaults: misc.defaults(this, { ttl: 120, isUnique: true }); // rdata: this.rdata = fields.rdata || Buffer.alloc(0); if (wrapper) this._readRData(wrapper); this._makehashes(); } _readRData(wrapper) { const rdataLength = wrapper.readUInt16BE(); this.RData = wrapper.read(rdataLength); } _writeRData(wrapper) { wrapper.add(this.RData); } _hashRData() { return hash(this.RData); } _getRDataStr() { // replace non-ascii characters w/ gray dots function ascii(chr) { return (/[ -~]/.test(chr)) ? chr : misc.color('.', 'grey'); } const chars = this.RData.toString().split(''); const str = chars.slice(0, 30).map(ascii).join(''); return (chars.length <= 30) ? str : str + '…'; } } ResourceRecord.Unknown = Unknown; module.exports = ResourceRecord;