diff --git a/index.js b/index.js index 75e1cc8..07beed0 100644 --- a/index.js +++ b/index.js @@ -1,72 +1,5 @@ +const Mdns = require('./lib'); +const Networking = require('./lib/networking'); -var config = require('./package.json'); -var st = require('./lib/service_type'); -var Networking = require('./lib/networking'); - -var networking = new Networking(); - -/** @member {string} */ -module.exports.version = config.version; -module.exports.name = config.name; - -/* @borrows Browser as Browser */ -var Browser = module.exports.Browser = require('./lib/browser'); //just for convenience -/* @borrows Advertisement as Advertisement */ -var Advertisement = module.exports.Advertisement = require('./lib/advertisement'); //just for convenience - -/** - * Create a browser instance - * @method - * @param {string} [serviceType] - The Service type to browse for. Defaults to ServiceType.wildcard - * @return {Browser} - */ -module.exports.createBrowser = function browserCreated(serviceType) { - if (typeof serviceType === 'undefined') { - serviceType = st.ServiceType.wildcard; - } - return new Browser(networking, serviceType); -}; - - -module.exports.excludeInterface = function (iface) { - if (networking.started) { - throw new Error('can not exclude interfaces after start'); - } - if (iface === '0.0.0.0') { - networking.INADDR_ANY = false; - } - else { - var err = new Error('Not a supported interface'); - err.interface = iface; - } -}; - - -/** - * Create a service instance - * @method - * @param {string|ServiceType} serviceType - The service type to register - * @param {number} [port] - The port number for the service - * @param {object} [options] - ... - * @return {Advertisement} - */ -module.exports.createAdvertisement = - function advertisementCreated(serviceType, port, options) { - return new Advertisement( - networking, serviceType, port, options); - }; - - -/** @property {module:ServiceType~ServiceType} */ -module.exports.ServiceType = st.ServiceType; - -/** @property {module:ServiceType.makeServiceType} */ -module.exports.makeServiceType = st.makeServiceType; - -/** @function */ -module.exports.tcp = st.protocolHelper('tcp'); - -/** @function */ -module.exports.udp = st.protocolHelper('udp'); - +module.exports = new Mdns({networking: new Networking()}); diff --git a/lib/advertisement.js b/lib/advertisement.js index 6f3bfb2..a1204cf 100644 --- a/lib/advertisement.js +++ b/lib/advertisement.js @@ -1,7 +1,6 @@ var debug = require('debug')('mdns:advertisement'); +const { DNSRecord } = require('dns-js'); -var dns = require('dns-js'); -var DNSRecord = dns.DNSRecord; var ServiceType = require('./service_type').ServiceType; var pf = require('./packetfactory'); @@ -23,6 +22,7 @@ internal.handleQuery = function (rec) { debug('skipping query: type not PTR/SRV/ANY'); return; } + var self = this; // check if we should reply via multi or unicast // TODO: handle the is_qu === true case and reply directly to remote // var is_qu = (rec.cl & DNSRecord.Class.IS_QM) === DNSRecord.Class.IS_QM; @@ -32,6 +32,7 @@ internal.handleQuery = function (rec) { return; } try { + debug('trying to handleQuery with %s queries', internal.services.length); var type = new ServiceType(rec.name); internal.services.forEach(function (service) { if (type.isWildcard() || type.matches(service.serviceType)) { @@ -39,7 +40,7 @@ internal.handleQuery = function (rec) { // TODO: should we only send PTR records if the query was for PTR // records? internal.sendDNSPacket( - pf.buildANPacket.apply(service, [DNSRecord.TTL])); + pf.buildANPacket.apply(service, [DNSRecord.TTL, self.networking])); } else { debug('skipping query; type %s not * or %s', type, @@ -98,7 +99,7 @@ internal.probeAndAdvertise = function () { break; case 3: debug('publishing service, suffix=%s', this.nameSuffix); - var packet = pf.buildANPacket.apply(this, [DNSRecord.TTL]); + var packet = pf.buildANPacket.apply(this, [DNSRecord.TTL, this.networking]); internal.sendDNSPacket = this.networking.send.bind(this.networking); this.networking.send(packet); // Repost announcement after 1sec (see rfc6762: 8.3) @@ -121,6 +122,7 @@ internal.probeAndAdvertise = function () { this.status = -1; break; } + //drive the statemachine forward if <3 if (this.status < 3) { this.status++; setTimeout(internal.probeAndAdvertise.bind(this), 250); @@ -157,7 +159,7 @@ var Advertisement = module.exports = function ( this.networking.INADDR_ANY = this.options.INADDR_ANY; } - networking.on('packets', function (packets /*, remote, connection*/) { + this.networking.on('packets', function (packets /*, remote, connection*/) { packets.forEach(function (packet) { packet.question.forEach(internal.handleQuery.bind(self)); packet.answer.forEach(internal.handleAnswer.bind(self)); @@ -165,10 +167,15 @@ var Advertisement = module.exports = function ( }); this.start = function () { - networking.addUsage(self, function () { + debug('start advertisement'); + + function creationCallback() { + debug('creationCallback'); internal.probes.push(self); internal.probeAndAdvertise.apply(self, []); - }); + } + + self.networking.addUsage(self, creationCallback); }; this.stop = function (next) { @@ -176,8 +183,8 @@ var Advertisement = module.exports = function ( internal.services = internal.services.filter(function (service) { return service === self; }); - networking.send(pf.buildANPacket.apply(self, [0]), function () { - networking.stop(); + self.networking.send(pf.buildANPacket.apply(self, [0, networking]), function () { + self.networking.stop(); if (next) { next(); } diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..8c1b868 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,78 @@ + + +var config = require('../package.json'); +var st = require('./service_type'); + + +function Mdns(options) { + this.networking = options.networking; +} + +module.exports = Mdns; + + + +/** @member {string} */ +module.exports.version = config.version; +module.exports.name = config.name; + +/* @borrows Browser as Browser */ +var Browser = module.exports.Browser = require('./browser'); //just for convenience +/* @borrows Advertisement as Advertisement */ +var Advertisement = module.exports.Advertisement = require('./advertisement'); //just for convenience + +/** + * Create a browser instance + * @method + * @param {string} [serviceType] - The Service type to browse for. Defaults to ServiceType.wildcard + * @return {Browser} + */ +Mdns.prototype.createBrowser = function browserCreated(serviceType) { + if (typeof serviceType === 'undefined') { + serviceType = st.ServiceType.wildcard; + } + return new Browser(this.networking, serviceType); +}; + + +Mdns.prototype.excludeInterface = function (iface) { + if (this.networking.started) { + throw new Error('can not exclude interfaces after start'); + } + if (iface === '0.0.0.0') { + this.networking.INADDR_ANY = false; + } + else { + var err = new Error('Not a supported interface'); + err.interface = iface; + } +}; + + +/** + * Create a service instance + * @method + * @param {string|ServiceType} serviceType - The service type to register + * @param {number} [port] - The port number for the service + * @param {object} [options] - ... + * @return {Advertisement} + */ +Mdns.prototype.createAdvertisement = + function advertisementCreated(serviceType, port, options) { + return new Advertisement( + this.networking, serviceType, port, options); + }; + + +/** @property {module:ServiceType~ServiceType} */ +Mdns.prototype.ServiceType = st.ServiceType; + +/** @property {module:ServiceType.makeServiceType} */ +Mdns.prototype.makeServiceType = st.makeServiceType; + +/** @function */ +Mdns.prototype.tcp = st.protocolHelper('tcp'); + +/** @function */ +Mdns.prototype.udp = st.protocolHelper('udp'); + diff --git a/lib/networking.js b/lib/networking.js index 59c89c4..94f81b1 100644 --- a/lib/networking.js +++ b/lib/networking.js @@ -21,6 +21,7 @@ var Networking = module.exports = function (options) { this.connections = []; this.started = false; this.users = []; + this.interfaces = {}; this.INADDR_ANY = typeof this.options.INADDR_ANY === 'undefined' ? true : this.options.INADDR_ANY; }; @@ -46,6 +47,10 @@ Networking.prototype.start = function () { continue; } debug('interface', key, iface.address); + if (!this.interfaces.hasOwnProperty(key)) { + this.interfaces[key] = []; + } + this.interfaces[key].push(iface); this.createSocket(index++, key, iface.address, 0, this.bindToAddress.bind(this)); } diff --git a/lib/packetfactory.js b/lib/packetfactory.js index c1884ed..253c789 100644 --- a/lib/packetfactory.js +++ b/lib/packetfactory.js @@ -18,13 +18,16 @@ module.exports.buildQDPacket = function () { return packet; }; -module.exports.buildANPacket = function (ttl) { +module.exports.buildANPacket = function (ttl, networking) { if (typeof this.nameSuffix !== 'string') { throw new Error('nameSuffix is missing'); } if (typeof this.port !== 'number' && this.port < 1) { throw new Error('port is missing or bad value'); } + //use from networking if possible + var interfaces = networking ? networking.interfaces : os.networkInterfaces(); + var packet = new DNSPacket(DNSPacket.Flag.RESPONSE | DNSPacket.Flag.AUTHORATIVE); var name = this.options.name + this.nameSuffix; @@ -33,7 +36,7 @@ module.exports.buildANPacket = function (ttl) { var serviceType = this.serviceType.toString() + '.' + domain; var cl = DNSRecord.Class.IN | DNSRecord.Class.FLUSH; - debug('alias:', this.alias); + debug('#buildANPacket, alias:', this.alias); packet.answer.push({ name: this.alias, @@ -68,11 +71,12 @@ module.exports.buildANPacket = function (ttl) { ttl: ttl, data:serviceType}); - var interfaces = os.networkInterfaces(); + var ifaceFilter = this.options.networkInterface; var address; var i; for (var key in interfaces) { + debug('#buildANPacket, key', key); if (typeof ifaceFilter === 'undefined' || key === ifaceFilter) { for (i = 0; i < interfaces[key].length; i++) { var iface = interfaces[key][i]; diff --git a/test/advertisement.test.js b/test/advertisement.test.js index 3193e37..9fdb924 100644 --- a/test/advertisement.test.js +++ b/test/advertisement.test.js @@ -1,13 +1,18 @@ +var dns = require('dns-js'); +var DNSPacket = dns.DNSPacket; +var DNSRecord = dns.DNSRecord; + const Lab = require('lab'); const {describe, it } = exports.lab = Lab.script(); const { expect } = require('code'); - +const Mdns = require('../lib'); +const MockNetworking = require('./mock_networking'); +var mockNetworking = new MockNetworking(); +var mdns = new Mdns({networking: mockNetworking}); +const {ServiceType} = require('../lib/service_type'); var pf = require('../lib/packetfactory'); -var mdns = require('../'); -var dns = require('dns-js'); -// var DNSPacket = dns.DNSPacket; -var DNSRecord = dns.DNSRecord; + function mockAdvertisement() { var context = {}; @@ -34,7 +39,7 @@ describe('packetfactory', function () { it('buildANPacket', () => { var context = mockAdvertisement(); var packet = pf.buildQDPacket.apply(context, []); - pf.buildANPacket.apply(context, [DNSRecord.TTL]); + pf.buildANPacket.apply(context, [DNSRecord.TTL, mockNetworking]); expect(packet).to.exist(); }); @@ -54,4 +59,92 @@ describe('packetfactory', function () { expect(service.options, 'options').to.include({name: 'hello'}); }); + + it('issue70_txt-with-dot', {timeout: 5000}, () => { + var mn = new MockNetworking(); + var myMdns = new Mdns({networking: mn}); + var service = myMdns.createAdvertisement(mdns.tcp('_http'), 9876, { + name:'api', + txt:{ + api_proto: 'http', + api_ver: 'v1.0', + pri: 100 + } + }); + expect(service.options, 'options').to.include({name: 'api'}); + expect(service.options.txt).to.include({api_ver: 'v1.0'}); + service.start(); + + function testQd(packet, buffer) { + expect(packet.header).to.include({rd: 1, aa: 0, qr: 0}); + expect(packet).to.include('question'); + expect(packet.question).to.have.length(1); + expect(packet.question[0]).to.include({name: 'api._http._tcp.local', isQD: true, type: 255}); + var parsed = DNSPacket.parse(buffer); + expect(parsed.question[0]).to.include({name: 'api._http._tcp.local', isQD: true, type: 255}); + } + + function testAN(packet, buffer) { + var parsed = DNSPacket.parse(buffer); + + //header + expect(packet.header).to.include({rd: 0, aa: 1, qr: 1}); + + //questions + expect(packet.question).to.have.length(0); + //answers + expect(packet.answer).to.have.length(4); + expect(packet.answer[0]).to.include({name: 'api._http._tcp.local', class: 32769, type: 33}); + expect(parsed.answer[0]).to.include({name: 'api._http._tcp.local', class: 32769, type: 33}); + //authority + expect(packet.authority).to.have.length(0); + } + + function createQuestion(dnsType, dnsClass, st) { + if (typeof st === 'undefined') { + st = ServiceType.wildcard + '.local'; + } + const packet = new DNSPacket(); + packet.question.push(new DNSRecord( + st, + dnsType, dnsClass) + ); + return packet; + } + + return new Promise((resolve)=> { + var received = []; + + mn.on('send', (data) => { + received.push(data); + if (received.length === 5) { + //query + mn.receive([ + createQuestion(DNSRecord.Type.PTR, 1), + createQuestion(DNSRecord.Type.MX, 1), + createQuestion(DNSRecord.Type.PTR, DNSRecord.Class.FLUSH), + + createQuestion(DNSRecord.Type.PTR, 1, '_ftp._tcp'), + ]); + } + + }); + + + setTimeout(() => { + expect(received.length).to.min(5); + testQd(received[0].packet,received[0].buffer ); + testQd(received[1].packet,received[1].buffer ); + testQd(received[2].packet,received[2].buffer ); + testAN(received[3].packet, received[3].buffer); + + // received.forEach((data)=> { + // console.log('test => %j', data.packet); + // }); + setTimeout(()=> { + resolve(); + },1000); + }, 2000); + }); + }); }); diff --git a/test/browser.test.js b/test/browser.test.js new file mode 100644 index 0000000..ee0bbfc --- /dev/null +++ b/test/browser.test.js @@ -0,0 +1,48 @@ +const Lab = require('lab'); +const {describe, it } = exports.lab = Lab.script(); +const { expect } = require('code'); + +const Mdns = require('../lib'); +const MockNetworking = require('./mock_networking'); +var mockNetworking = new MockNetworking(); +const {ServiceType} = require('../lib/service_type'); + +const packets = require('./packets.json'); +const { DNSPacket } = require('dns-js'); + +mockNetworking.on('send', () => { + var p = DNSPacket.parse(new Buffer.from(packets.responses.services.linux_workstation, 'hex')); + mockNetworking.receive([p]); +}); + +describe('browser', () => { + it('should create default browser', () => { + var mdns = new Mdns({networking: mockNetworking}); + var b1 = mdns.createBrowser(); + + return new Promise((resolve) => { + b1.on('ready', () => { + b1.discover(); + }); + + b1.on('update', (data) => { + expect(data).to.include({addresses: ['127.0.0.20']}); + expect(data).to.include({query: [ '_services._dns-sd._udp.local' ]}); + resolve(); + }); + }); + }); + + it('should create browser with servicetype', () => { + var mdns = new Mdns({networking: mockNetworking}); + var b1 = mdns.createBrowser(ServiceType.wildcard); + expect(b1).to.exist(); + }); + + it('should not create browser without good type', () => { + var mdns = new Mdns({networking: mockNetworking}); + expect(() => { + mdns.createBrowser({}); + }).to.throw(Error, 'argument must be instance of ServiceType or valid string'); + }); +}); diff --git a/test/helper.js b/test/helper.js index e9adf12..aaf4821 100644 --- a/test/helper.js +++ b/test/helper.js @@ -4,6 +4,28 @@ var vm = require('vm'); var util = require('util'); const { expect } = require('code'); +const Mdns = require('../lib'); +const Networking = require('../lib/networking'); +const MockNetwork = require('./mock_networking'); +const { DNSPacket } = require('dns-js'); +const packets = require('./packets.json'); + +exports.createMdns = function () { + var options; + if (process.env.MOCKNETWORK) { + const mockNet = new MockNetwork(); + mockNet.on('send', () => { + var p = DNSPacket.parse(new Buffer.from(packets.responses.services.linux_workstation, 'hex')); + mockNet.receive([p]); + }); + options = {networking: mockNet}; + } + else { + options = {networking: new Networking()}; + } + + return new Mdns(options); +}; exports.createJs = function (obj) { return util.inspect(obj, {depth: null}); diff --git a/test/mdns.test.js b/test/mdns.test.js index e7ddafc..51c3da8 100644 --- a/test/mdns.test.js +++ b/test/mdns.test.js @@ -1,13 +1,14 @@ const Lab = require('lab'); const { after, before, describe, it } = exports.lab = Lab.script(); const { expect } = require('code'); +const { createMdns } = require('./helper'); -// var Code = require('code'); // assertion library -// var expect = Code.expect; -var mdns = require('../'); +const mdns = createMdns(); +// var Code = require('code'); // assertion library +// var expect = Code.expect; describe('mDNS', function () { var browser; @@ -29,28 +30,34 @@ describe('mDNS', function () { }); - it('should .discover()', {skip: process.env.MDNS_NO_RESPONSE}, () => { - browser.once('update', function onUpdate(data) { - expect(data).to.include(['interfaceIndex', 'networkInterface', - 'addresses', 'query']); + // it('should .discover()', {skip: process.env.MDNS_NO_RESPONSE}, () => { + it('should .discover()', () => { + setTimeout(browser.discover.bind(browser), 500); + return new Promise((resolve) => { + browser.once('update', function onUpdate(data) { + expect(data).to.include(['interfaceIndex', 'networkInterface', + 'addresses', 'query']); + resolve(); + }); }); - setTimeout(browser.discover.bind(browser), 500); + }); - it('should close all connection socket on stop', function () { + it('should close all connection socket on stop', {timeout: 5000}, () => { let service = mdns.createAdvertisement(mdns.tcp('_http'), 9876, { name: 'hello', txt: { txtvers: '1' } }); - service.start(); let waitClose = [...service.networking.connections].map(connection => new Promise(resolve => { connection.socket.addListener('close', resolve); })); + service.start(); + return new Promise((resolve) => service.stop(resolve) ).then(() => Promise.all(waitClose) diff --git a/test/mock_networking.js b/test/mock_networking.js new file mode 100644 index 0000000..b8dbbbf --- /dev/null +++ b/test/mock_networking.js @@ -0,0 +1,100 @@ +const debug = require('debug')('mdns:test:mock_MockNetworking'); +const util = require('util'); +const EventEmitter = require('events').EventEmitter; +const dns = require('dns-js'); +const DNSPacket = dns.DNSPacket; + +const mockRemote = {address: '127.0.0.20', port: '1024'}; +const mockConnection = {networkInterface: 'ethMock'}; + +const MockNetworking = module.exports = function (options) { + this.options = options || {}; + this.created = 0; + this.connections = []; + this.started = false; + this.users = []; + this.interfaces = {'Ethernet': [{internal: false, address: '127.0.0.10' }]}; + this.INADDR_ANY = typeof this.options.INADDR_ANY === 'undefined' ? + true : this.options.INADDR_ANY; +}; + +util.inherits(MockNetworking, EventEmitter); + + +MockNetworking.prototype.start = function () { + debug('start'); + if (!this.started) { + this.started = true; + process.nextTick(() => { + this.emit('ready'); + }); + } +}; + +MockNetworking.prototype.stop = function () { + debug('stop'); + this.connections.forEach((connection) => { + var socket = connection.socket; + socket.close(); + socket.unref(); + }); + this.connections = []; + this.created = 0; + this.started = false; +}; + + +MockNetworking.prototype.send = function (packet, callback) { + debug('sending mock packet'); + var buf = DNSPacket.toBuffer(packet); + this.emit('send', {packet: packet, buffer: buf}); + if (typeof callback === 'function') { + callback(null, buf.length); + } +}; + +MockNetworking.prototype.addUsage = function (browser, next) { + debug('addUsage(%s, %s)', typeof browser, typeof next); + this.users.push(browser); + this.startRequest(next); +}; + +MockNetworking.prototype.startRequest = function (callback) { + if (this.started) { + debug('startRequest:started', typeof callback); + return process.nextTick(callback); + } + this.start(); + this.once('ready', function () { + debug('startRequest:ready'); + if (typeof callback === 'function') { + callback(); + } + }); +}; + +MockNetworking.prototype.removeUsage = function (browser) { + var index = this.users.indexOf(browser); + if (index > -1) { + this.users.splice(index, 1); + } + // TODO should also clear stale state out of addresses table + this.connections.forEach(function (c) { + if (c.services && c.services[browser.serviceType.toString()]) { + delete c.services[browser.serviceType.toString()]; + } + }); + this.stopRequest(); +}; + +MockNetworking.prototype.receive = function (packets) { + debug('receive %s packets', packets.length); + + this.emit('packets', packets, mockRemote, mockConnection); +}; + +MockNetworking.prototype.stopRequest = function () { + if (this.users.length === 0) { + this.stop(); + } +};