From c6d1d49a20e6dd3d43f33d00951d7f70737a6971 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 9 May 2019 11:10:29 +0100 Subject: [PATCH] feat: compatibility with go-libp2p-mdns (#80) Adds an additional mdns poller to interop with go-libp2p until both implementations comply with the new spec, https://github.com/libp2p/specs/blob/4c5a459ae8fb9a250e5f87f0c64fadaa7997266a/discovery/mdns.md. --- .gitignore | 1 + README.md | 3 +- package.json | 4 +- src/compat/constants.js | 6 + src/compat/index.js | 60 +++++ src/compat/querier.js | 176 +++++++++++++++ src/compat/responder.js | 97 ++++++++ src/index.js | 40 +++- test/compat/go-multicast-dns.spec.js | 84 +++++++ test/compat/querier.spec.js | 322 +++++++++++++++++++++++++++ test/compat/responder.spec.js | 181 +++++++++++++++ test/multicast-dns.spec.js | 39 +++- 12 files changed, 995 insertions(+), 18 deletions(-) create mode 100644 src/compat/constants.js create mode 100644 src/compat/index.js create mode 100644 src/compat/querier.js create mode 100644 src/compat/responder.js create mode 100644 test/compat/go-multicast-dns.spec.js create mode 100644 test/compat/querier.spec.js create mode 100644 test/compat/responder.spec.js diff --git a/.gitignore b/.gitignore index 1c73b37..2640756 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ docs **/*.log test/repo-tests* **/bundle.js +.nyc_output # Logs logs diff --git a/README.md b/README.md index 27720d9..3b43987 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,10 @@ mdns.start(() => setTimeout(() => mdns.stop(() => {}), 20 * 1000)) - options - `peerInfo` - PeerInfo to announce - - `broadcast` - (true/false) announce our presence through mDNS, default false + - `broadcast` - (true/false) announce our presence through mDNS, default `false` - `interval` - query interval, default 10 * 1000 (10 seconds) - `serviceTag` - name of the service announce , default 'ipfs.local` + - `compat` - enable/disable compatibility with go-libp2p-mdns, default `true` ## MDNS messages diff --git a/package.json b/package.json index f58ed99..41a79f5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ ], "scripts": { "lint": "aegir lint", - "coverage": "aegir coverage", + "coverage": "nyc --reporter=lcov --reporter=text npm run test:node", "test": "aegir test -t node", "test:node": "aegir test -t node", "release": "aegir release -t node --no-build", @@ -35,11 +35,11 @@ "homepage": "https://github.com/libp2p/js-libp2p-mdns", "devDependencies": { "aegir": "^18.2.2", - "async": "^2.6.2", "chai": "^4.2.0", "dirty-chai": "^2.0.1" }, "dependencies": { + "async": "^2.6.2", "debug": "^4.1.1", "libp2p-tcp": "~0.13.0", "multiaddr": "^6.0.6", diff --git a/src/compat/constants.js b/src/compat/constants.js new file mode 100644 index 0000000..df83a3f --- /dev/null +++ b/src/compat/constants.js @@ -0,0 +1,6 @@ +'use strict' + +exports.SERVICE_TAG = '_ipfs-discovery._udp' +exports.SERVICE_TAG_LOCAL = `${exports.SERVICE_TAG}.local` +exports.MULTICAST_IP = '224.0.0.251' +exports.MULTICAST_PORT = 5353 diff --git a/src/compat/index.js b/src/compat/index.js new file mode 100644 index 0000000..495f3cb --- /dev/null +++ b/src/compat/index.js @@ -0,0 +1,60 @@ +'use strict' + +// Compatibility with Go libp2p MDNS + +const EE = require('events') +const parallel = require('async/parallel') +const Responder = require('./responder') +const Querier = require('./querier') + +class GoMulticastDNS extends EE { + constructor (peerInfo) { + super() + this._started = false + this._peerInfo = peerInfo + this._onPeer = this._onPeer.bind(this) + } + + start (callback) { + if (this._started) { + return callback(new Error('MulticastDNS service is already started')) + } + + this._started = true + this._responder = new Responder(this._peerInfo) + this._querier = new Querier(this._peerInfo.id) + + this._querier.on('peer', this._onPeer) + + parallel([ + cb => this._responder.start(cb), + cb => this._querier.start(cb) + ], callback) + } + + _onPeer (peerInfo) { + this.emit('peer', peerInfo) + } + + stop (callback) { + if (!this._started) { + return callback(new Error('MulticastDNS service is not started')) + } + + const responder = this._responder + const querier = this._querier + + this._started = false + this._responder = null + this._querier = null + + querier.removeListener('peer', this._onPeer) + + parallel([ + cb => responder.stop(cb), + cb => querier.stop(cb) + ], callback) + } +} + +module.exports = GoMulticastDNS diff --git a/src/compat/querier.js b/src/compat/querier.js new file mode 100644 index 0000000..eaa9878 --- /dev/null +++ b/src/compat/querier.js @@ -0,0 +1,176 @@ +'use strict' + +const assert = require('assert') +const EE = require('events') +const MDNS = require('multicast-dns') +const Multiaddr = require('multiaddr') +const PeerInfo = require('peer-info') +const PeerId = require('peer-id') +const nextTick = require('async/nextTick') +const log = require('debug')('libp2p:mdns:compat:querier') +const { SERVICE_TAG_LOCAL, MULTICAST_IP, MULTICAST_PORT } = require('./constants') + +class Querier extends EE { + constructor (peerId, options) { + super() + assert(peerId, 'missing peerId parameter') + options = options || {} + this._peerIdStr = peerId.toB58String() + // Re-query every 60s, in leu of network change detection + options.queryInterval = options.queryInterval || 60000 + // Time for which the MDNS server will stay alive waiting for responses + // Must be less than options.queryInterval! + options.queryPeriod = Math.min( + options.queryInterval, + options.queryPeriod == null ? 5000 : options.queryPeriod + ) + this._options = options + this._onResponse = this._onResponse.bind(this) + } + + start (callback) { + this._handle = periodically(() => { + // Create a querier that queries multicast but gets responses unicast + const mdns = MDNS({ multicast: false, interface: '0.0.0.0', port: 0 }) + + mdns.on('response', this._onResponse) + + mdns.query({ + id: nextId(), // id > 0 for unicast response + questions: [{ name: SERVICE_TAG_LOCAL, type: 'PTR', class: 'IN' }] + }, null, { + address: MULTICAST_IP, + port: MULTICAST_PORT + }) + + return { + stop: callback => { + mdns.removeListener('response', this._onResponse) + mdns.destroy(callback) + } + } + }, { + period: this._options.queryPeriod, + interval: this._options.queryInterval + }) + + nextTick(() => callback()) + } + + _onResponse (event, info) { + const answers = event.answers || [] + const ptrRecord = answers.find(a => a.type === 'PTR' && a.name === SERVICE_TAG_LOCAL) + + // Only deal with responses for our service tag + if (!ptrRecord) return + + log('got response', event, info) + + const txtRecord = answers.find(a => a.type === 'TXT') + if (!txtRecord) return log('missing TXT record in response') + + let peerIdStr + try { + peerIdStr = txtRecord.data[0].toString() + } catch (err) { + return log('failed to extract peer ID from TXT record data', txtRecord, err) + } + + if (this._peerIdStr === peerIdStr) { + return log('ignoring reply to myself') + } + + let peerId + try { + peerId = PeerId.createFromB58String(peerIdStr) + } catch (err) { + return log('failed to create peer ID from TXT record data', peerIdStr, err) + } + + PeerInfo.create(peerId, (err, info) => { + if (err) return log('failed to create peer info from peer ID', peerId, err) + + const srvRecord = answers.find(a => a.type === 'SRV') + if (!srvRecord) return log('missing SRV record in response') + + log('peer found', peerIdStr) + + const { port } = srvRecord.data || {} + const protos = { A: 'ip4', AAAA: 'ip6' } + + const multiaddrs = answers + .filter(a => ['A', 'AAAA'].includes(a.type)) + .reduce((addrs, a) => { + const maStr = `/${protos[a.type]}/${a.data}/tcp/${port}` + try { + addrs.push(new Multiaddr(maStr)) + log(maStr) + } catch (err) { + log(`failed to create multiaddr from ${a.type} record data`, maStr, port, err) + } + return addrs + }, []) + + multiaddrs.forEach(addr => info.multiaddrs.add(addr)) + this.emit('peer', info) + }) + } + + stop (callback) { + this._handle.stop(callback) + } +} + +module.exports = Querier + +/** + * Run `fn` for a certain period of time, and then wait for an interval before + * running it again. `fn` must return an object with a stop function, which is + * called when the period expires. + * + * @param {Function} fn function to run + * @param {Object} [options] + * @param {Object} [options.period] Period in ms to run the function for + * @param {Object} [options.interval] Interval in ms between runs + * @returns {Object} handle that can be used to stop execution + */ +function periodically (fn, options) { + let handle, timeoutId + let stopped = false + + const reRun = () => { + handle = fn() + timeoutId = setTimeout(() => { + handle.stop(err => { + if (err) log(err) + if (!stopped) { + timeoutId = setTimeout(reRun, options.interval) + } + }) + handle = null + }, options.period) + } + + reRun() + + return { + stop (callback) { + stopped = true + clearTimeout(timeoutId) + if (handle) { + handle.stop(callback) + } else { + callback() + } + } + } +} + +const nextId = (() => { + let id = 0 + return () => { + id++ + if (id === Number.MAX_SAFE_INTEGER) id = 1 + return id + } +})() diff --git a/src/compat/responder.js b/src/compat/responder.js new file mode 100644 index 0000000..da3ec4c --- /dev/null +++ b/src/compat/responder.js @@ -0,0 +1,97 @@ +'use strict' + +const OS = require('os') +const assert = require('assert') +const MDNS = require('multicast-dns') +const log = require('debug')('libp2p:mdns:compat:responder') +const TCP = require('libp2p-tcp') +const nextTick = require('async/nextTick') +const { SERVICE_TAG_LOCAL } = require('./constants') + +const tcp = new TCP() + +class Responder { + constructor (peerInfo) { + assert(peerInfo, 'missing peerInfo parameter') + this._peerInfo = peerInfo + this._peerIdStr = peerInfo.id.toB58String() + this._onQuery = this._onQuery.bind(this) + } + + start (callback) { + this._mdns = MDNS() + this._mdns.on('query', this._onQuery) + nextTick(() => callback()) + } + + _onQuery (event, info) { + const multiaddrs = tcp.filter(this._peerInfo.multiaddrs.toArray()) + // Only announce TCP for now + if (!multiaddrs.length) return + + const questions = event.questions || [] + + // Only respond to queries for our service tag + if (!questions.some(q => q.name === SERVICE_TAG_LOCAL)) return + + log('got query', event, info) + + const answers = [] + const peerServiceTagLocal = `${this._peerIdStr}.${SERVICE_TAG_LOCAL}` + + answers.push({ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }) + + // Only announce TCP multiaddrs for now + const port = multiaddrs[0].toString().split('/')[4] + + answers.push({ + name: peerServiceTagLocal, + type: 'SRV', + class: 'IN', + ttl: 120, + data: { + priority: 10, + weight: 1, + port, + target: OS.hostname() + } + }) + + answers.push({ + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: [Buffer.from(this._peerIdStr)] + }) + + multiaddrs.forEach((ma) => { + const proto = ma.protoNames()[0] + if (proto === 'ip4' || proto === 'ip6') { + answers.push({ + name: OS.hostname(), + type: proto === 'ip4' ? 'A' : 'AAAA', + class: 'IN', + ttl: 120, + data: ma.toString().split('/')[2] + }) + } + }) + + log('responding to query', answers) + this._mdns.respond(answers, info) + } + + stop (callback) { + this._mdns.removeListener('query', this._onQuery) + this._mdns.destroy(callback) + } +} + +module.exports = Responder diff --git a/src/index.js b/src/index.js index d2c3ef3..f2c3657 100644 --- a/src/index.js +++ b/src/index.js @@ -3,9 +3,12 @@ const multicastDNS = require('multicast-dns') const EventEmitter = require('events').EventEmitter const assert = require('assert') +const nextTick = require('async/nextTick') +const parallel = require('async/parallel') const debug = require('debug') const log = debug('libp2p:mdns') const query = require('./query') +const GoMulticastDNS = require('./compat') class MulticastDNS extends EventEmitter { constructor (options) { @@ -18,10 +21,18 @@ class MulticastDNS extends EventEmitter { this.port = options.port || 5353 this.peerInfo = options.peerInfo this._queryInterval = null + this._onPeer = this._onPeer.bind(this) + + if (options.compat !== false) { + this._goMdns = new GoMulticastDNS(options.peerInfo, { + queryPeriod: options.compatQueryPeriod, + queryInterval: options.compatQueryInterval + }) + this._goMdns.on('peer', this._onPeer) + } } start (callback) { - const self = this const mdns = multicastDNS({ port: this.port }) this.mdns = mdns @@ -34,7 +45,7 @@ class MulticastDNS extends EventEmitter { return log('Error processing peer response', err) } - self.emit('peer', foundPeer) + this._onPeer(foundPeer) }) }) @@ -42,15 +53,32 @@ class MulticastDNS extends EventEmitter { query.gotQuery(event, this.mdns, this.peerInfo, this.serviceTag, this.broadcast) }) - setImmediate(() => callback()) + if (this._goMdns) { + this._goMdns.start(callback) + } else { + nextTick(() => callback()) + } + } + + _onPeer (peerInfo) { + this.emit('peer', peerInfo) } stop (callback) { if (!this.mdns) { - callback(new Error('MulticastDNS service had not started yet')) + return callback(new Error('MulticastDNS service had not started yet')) + } + + clearInterval(this._queryInterval) + this._queryInterval = null + + if (this._goMdns) { + this._goMdns.removeListener('peer', this._onPeer) + parallel([ + cb => this._goMdns.stop(cb), + cb => this.mdns.destroy(cb) + ], callback) } else { - clearInterval(this._queryInterval) - this._queryInterval = null this.mdns.destroy(callback) this.mdns = undefined } diff --git a/test/compat/go-multicast-dns.spec.js b/test/compat/go-multicast-dns.spec.js new file mode 100644 index 0000000..699f2d7 --- /dev/null +++ b/test/compat/go-multicast-dns.spec.js @@ -0,0 +1,84 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const expect = chai.expect +chai.use(dirtyChai) +const PeerInfo = require('peer-info') +const map = require('async/map') +const series = require('async/series') + +const GoMulticastDNS = require('../../src/compat') + +describe('GoMulticastDNS', () => { + const peerAddrs = [ + '/ip4/127.0.0.1/tcp/20001', + '/ip4/127.0.0.1/tcp/20002' + ] + let peerInfos + + before(done => { + map(peerAddrs, (addr, cb) => { + PeerInfo.create((err, info) => { + expect(err).to.not.exist() + info.multiaddrs.add(addr) + cb(null, info) + }) + }, (err, infos) => { + expect(err).to.not.exist() + peerInfos = infos + done() + }) + }) + + it('should start and stop', done => { + const mdns = new GoMulticastDNS(peerInfos[0]) + + mdns.start(err => { + expect(err).to.not.exist() + mdns.stop(err => { + expect(err).to.not.exist() + done() + }) + }) + }) + + it('should not start when started', done => { + const mdns = new GoMulticastDNS(peerInfos[0]) + + mdns.start(err => { + expect(err).to.not.exist() + + mdns.start(err => { + expect(err.message).to.equal('MulticastDNS service is already started') + mdns.stop(done) + }) + }) + }) + + it('should not stop when not started', done => { + const mdns = new GoMulticastDNS(peerInfos[0]) + + mdns.stop(err => { + expect(err.message).to.equal('MulticastDNS service is not started') + done() + }) + }) + + it('should emit peer info when peer is discovered', done => { + const mdnsA = new GoMulticastDNS(peerInfos[0]) + const mdnsB = new GoMulticastDNS(peerInfos[1]) + + mdnsA.on('peer', info => { + if (!info.id.isEqual(peerInfos[1].id)) return + expect(info.multiaddrs.has(peerAddrs[1])).to.be.true() + done() + }) + + series([ + cb => mdnsA.start(cb), + cb => mdnsB.start(cb) + ], err => expect(err).to.not.exist()) + }) +}) diff --git a/test/compat/querier.spec.js b/test/compat/querier.spec.js new file mode 100644 index 0000000..5921a0c --- /dev/null +++ b/test/compat/querier.spec.js @@ -0,0 +1,322 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const expect = chai.expect +chai.use(dirtyChai) +const PeerInfo = require('peer-info') +const parallel = require('async/parallel') +const map = require('async/map') +const MDNS = require('multicast-dns') +const OS = require('os') + +const Querier = require('../../src/compat/querier') +const { SERVICE_TAG_LOCAL } = require('../../src/compat/constants') + +describe('Querier', () => { + let querier, mdns + const peerAddrs = [ + '/ip4/127.0.0.1/tcp/20001', + '/ip4/127.0.0.1/tcp/20002' + ] + let peerInfos + + before(done => { + map(peerAddrs, (addr, cb) => { + PeerInfo.create((err, info) => { + expect(err).to.not.exist() + info.multiaddrs.add(addr) + cb(null, info) + }) + }, (err, infos) => { + expect(err).to.not.exist() + peerInfos = infos + done() + }) + }) + + afterEach(done => { + parallel([ + cb => querier ? querier.stop(cb) : cb(), + cb => mdns ? mdns.destroy(cb) : cb() + ], err => { + querier = mdns = null + done(err) + }) + }) + + it('should start and stop', done => { + const querier = new Querier(peerInfos[0].id) + + querier.start(err => { + expect(err).to.not.exist() + querier.stop(err => { + expect(err).to.not.exist() + done() + }) + }) + }) + + it('should query on interval', done => { + querier = new Querier(peerInfos[0].id, { queryPeriod: 0, queryInterval: 10 }) + mdns = MDNS() + + let queryCount = 0 + + mdns.on('query', event => { + const questions = event.questions || [] + if (!questions.some(q => q.name === SERVICE_TAG_LOCAL)) return + queryCount++ + }) + + querier.start(err => expect(err).to.not.exist()) + + setTimeout(() => { + // Should have queried at least twice by now! + expect(queryCount >= 2).to.be.true() + done() + }, 100) + }) + + it('should not emit peer for responses with non matching service tags', done => { + ensureNoPeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + const bogusServiceTagLocal = '_ifps-discovery._udp' + + return [{ + name: bogusServiceTagLocal, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }] + }, done) + }) + + it('should not emit peer for responses with missing TXT record', done => { + ensureNoPeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }] + }, done) + }) + + it('should not emit peer for responses with missing peer ID in TXT record', done => { + ensureNoPeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }, { + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: [] // undefined peer ID + }] + }, done) + }) + + it('should not emit peer for responses to self', done => { + ensureNoPeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }, { + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: peerInfos[0].id.toB58String() + }] + }, done) + }) + + // TODO: unskip when https://github.com/libp2p/js-peer-id/issues/83 is resolved + it.skip('should not emit peer for responses with invalid peer ID in TXT record', done => { + ensureNoPeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }, { + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: '🤪' + }] + }, done) + }) + + it('should not emit peer for responses with missing SRV record', done => { + ensureNoPeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }, { + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: peerInfos[1].id.toB58String() + }] + }, done) + }) + + it('should emit peer for responses even if no multiaddrs', done => { + ensurePeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }, { + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: peerInfos[1].id.toB58String() + }, { + name: peerServiceTagLocal, + type: 'SRV', + class: 'IN', + ttl: 120, + data: { + priority: 10, + weight: 1, + port: parseInt(peerAddrs[1].split().pop()), + target: OS.hostname() + } + }] + }, done) + }) + + it('should emit peer for responses with valid multiaddrs', done => { + ensurePeer(event => { + const peerServiceTagLocal = `${peerInfos[1].id.toB58String()}.${SERVICE_TAG_LOCAL}` + + return [{ + name: SERVICE_TAG_LOCAL, + type: 'PTR', + class: 'IN', + ttl: 120, + data: peerServiceTagLocal + }, { + name: peerServiceTagLocal, + type: 'TXT', + class: 'IN', + ttl: 120, + data: peerInfos[1].id.toB58String() + }, { + name: peerServiceTagLocal, + type: 'SRV', + class: 'IN', + ttl: 120, + data: { + priority: 10, + weight: 1, + port: parseInt(peerAddrs[1].split().pop()), + target: OS.hostname() + } + }, { + name: OS.hostname(), + type: peerAddrs[1].startsWith('/ip4') ? 'A' : 'AAAA', + class: 'IN', + ttl: 120, + data: peerAddrs[1].split('/')[2] + }] + }, done) + }) + + /** + * Ensure peerInfos[1] are emitted from `querier` + * @param {Function} getResponse Given a query, construct a response to test the querier + * @param {Function} callback Callback called when test finishes + */ + function ensurePeer (getResponse, callback) { + querier = new Querier(peerInfos[0].id) + mdns = MDNS() + + mdns.on('query', (event, info) => { + const questions = event.questions || [] + if (!questions.some(q => q.name === SERVICE_TAG_LOCAL)) return + mdns.respond(getResponse(event, info), info) + }) + + let peerInfo + + querier.on('peer', info => { + // Ignore non-test peers + if (!info.id.isEqual(peerInfos[1].id)) return + peerInfo = info + }) + + querier.start(err => { + if (err) return callback(err) + setTimeout(() => { + callback(peerInfo ? null : new Error('Missing peer')) + }, 100) + }) + } + + /** + * Ensure none of peerInfos are emitted from `querier` + * @param {Function} getResponse Given a query, construct a response to test the querier + * @param {Function} callback Callback called when test finishes + */ + function ensureNoPeer (getResponse, callback) { + querier = new Querier(peerInfos[0].id) + mdns = MDNS() + + mdns.on('query', (event, info) => { + const questions = event.questions || [] + if (!questions.some(q => q.name === SERVICE_TAG_LOCAL)) return + mdns.respond(getResponse(event, info), info) + }) + + let peerInfo + + querier.on('peer', info => { + // Ignore non-test peers + if (!info.id.isEqual(peerInfos[0].id) && !info.id.isEqual(peerInfos[1].id)) return + peerInfo = info + }) + + querier.start(err => { + if (err) return callback(err) + setTimeout(() => { + if (!peerInfo) return callback() + callback(Object.assign(new Error('Unexpected peer'), { peerInfo })) + }, 100) + }) + } +}) diff --git a/test/compat/responder.spec.js b/test/compat/responder.spec.js new file mode 100644 index 0000000..67eaefe --- /dev/null +++ b/test/compat/responder.spec.js @@ -0,0 +1,181 @@ +/* eslint-env mocha */ +'use strict' + +const chai = require('chai') +const dirtyChai = require('dirty-chai') +const expect = chai.expect +chai.use(dirtyChai) +const PeerInfo = require('peer-info') +const parallel = require('async/parallel') +const map = require('async/map') +const MDNS = require('multicast-dns') + +const Responder = require('../../src/compat/responder') +const { SERVICE_TAG_LOCAL, MULTICAST_IP, MULTICAST_PORT } = require('../../src/compat/constants') + +describe('Responder', () => { + let responder, mdns + const peerAddrs = [ + '/ip4/127.0.0.1/tcp/20001', + '/ip4/127.0.0.1/tcp/20002' + ] + let peerInfos + + before(done => { + map(peerAddrs, (addr, cb) => { + PeerInfo.create((err, info) => { + expect(err).to.not.exist() + info.multiaddrs.add(addr) + cb(null, info) + }) + }, (err, infos) => { + expect(err).to.not.exist() + peerInfos = infos + done() + }) + }) + + afterEach(done => { + parallel([ + cb => responder ? responder.stop(cb) : cb(), + cb => mdns ? mdns.destroy(cb) : cb() + ], err => { + responder = mdns = null + done(err) + }) + }) + + it('should start and stop', done => { + const responder = new Responder(peerInfos[0]) + + responder.start(err => { + expect(err).to.not.exist() + responder.stop(err => { + expect(err).to.not.exist() + done() + }) + }) + }) + + it('should not respond to a query if no TCP addresses', done => { + PeerInfo.create((err, peerInfo) => { + expect(err).to.not.exist() + + responder = new Responder(peerInfo) + mdns = MDNS({ multicast: false, interface: '0.0.0.0', port: 0 }) + + responder.start(err => { + expect(err).to.not.exist() + + let response + + mdns.on('response', event => { + if (isResponseFrom(event, peerInfo)) { + response = event + } + }) + + mdns.query({ + id: 1, // id > 0 for unicast response + questions: [{ name: SERVICE_TAG_LOCAL, type: 'PTR', class: 'IN' }] + }, null, { + address: MULTICAST_IP, + port: MULTICAST_PORT + }) + + setTimeout(() => { + done(response ? new Error('Unexpected repsonse') : null) + }, 100) + }) + }) + }) + + it('should not respond to a query with non matching service tag', done => { + responder = new Responder(peerInfos[0]) + mdns = MDNS({ multicast: false, interface: '0.0.0.0', port: 0 }) + + responder.start(err => { + expect(err).to.not.exist() + + let response + + mdns.on('response', event => { + if (isResponseFrom(event, peerInfos[0])) { + response = event + } + }) + + const bogusServiceTagLocal = '_ifps-discovery._udp' + + mdns.query({ + id: 1, // id > 0 for unicast response + questions: [{ name: bogusServiceTagLocal, type: 'PTR', class: 'IN' }] + }, null, { + address: MULTICAST_IP, + port: MULTICAST_PORT + }) + + setTimeout(() => { + done(response ? new Error('Unexpected repsonse') : null) + }, 100) + }) + }) + + it('should respond correctly', done => { + responder = new Responder(peerInfos[0]) + mdns = MDNS({ multicast: false, interface: '0.0.0.0', port: 0 }) + + responder.start(err => { + expect(err).to.not.exist() + + mdns.on('response', event => { + if (!isResponseFrom(event, peerInfos[0])) return + + const srvRecord = event.answers.find(a => a.type === 'SRV') + if (!srvRecord) return done(new Error('Missing SRV record')) + + const { port } = srvRecord.data || {} + const protos = { A: 'ip4', AAAA: 'ip6' } + + const addrs = event.answers + .filter(a => ['A', 'AAAA'].includes(a.type)) + .map(a => `/${protos[a.type]}/${a.data}/tcp/${port}`) + + if (!addrs.includes(peerAddrs[0])) { + return done(new Error('Missing peer address in response: ' + peerAddrs[0])) + } + + done() + }) + + mdns.query({ + id: 1, // id > 0 for unicast response + questions: [{ name: SERVICE_TAG_LOCAL, type: 'PTR', class: 'IN' }] + }, null, { + address: MULTICAST_IP, + port: MULTICAST_PORT + }) + }) + }) +}) + +function isResponseFrom (res, fromPeerInfo) { + const answers = res.answers || [] + const ptrRecord = answers.find(a => a.type === 'PTR' && a.name === SERVICE_TAG_LOCAL) + if (!ptrRecord) return false // Ignore irrelevant + + const txtRecord = answers.find(a => a.type === 'TXT') + if (!txtRecord) return false // Ignore missing TXT record + + let peerIdStr + try { + peerIdStr = txtRecord.data[0].toString() + } catch (err) { + return false // Ignore invalid peer ID data + } + + // Ignore response from someone else + if (fromPeerInfo.id.toB58String() !== peerIdStr) return false + + return true +} diff --git a/test/multicast-dns.spec.js b/test/multicast-dns.spec.js index 8958477..b762bf2 100644 --- a/test/multicast-dns.spec.js +++ b/test/multicast-dns.spec.js @@ -67,12 +67,14 @@ describe('MulticastDNS', () => { const mdnsA = new MulticastDNS({ peerInfo: pA, broadcast: false, // do not talk to ourself - port: 50001 + port: 50001, + compat: false }) const mdnsB = new MulticastDNS({ peerInfo: pB, - port: 50001 // port must be the same + port: 50001, // port must be the same + compat: false }) parallel([ @@ -97,15 +99,18 @@ describe('MulticastDNS', () => { const mdnsA = new MulticastDNS({ peerInfo: pA, broadcast: false, // do not talk to ourself - port: 50003 + port: 50003, + compat: false }) const mdnsC = new MulticastDNS({ peerInfo: pC, - port: 50003 // port must be the same + port: 50003, // port must be the same + compat: false }) const mdnsD = new MulticastDNS({ peerInfo: pD, - port: 50003 // port must be the same + port: 50003, // port must be the same + compat: false }) parallel([ @@ -134,12 +139,14 @@ describe('MulticastDNS', () => { const mdnsA = new MulticastDNS({ peerInfo: pA, broadcast: false, // do not talk to ourself - port: 50001 + port: 50001, + compat: false }) const mdnsB = new MulticastDNS({ peerInfo: pB, - port: 50001 + port: 50001, + compat: false }) series([ @@ -164,12 +171,14 @@ describe('MulticastDNS', () => { const mdnsA = new MulticastDNS({ peerInfo: pA, - port: 50004 // port must be the same + port: 50004, // port must be the same + compat: false }) const mdnsC = new MulticastDNS({ peerInfo: pC, - port: 50004 + port: 50004, + compat: false }) series([ @@ -184,4 +193,16 @@ describe('MulticastDNS', () => { }) }) }) + + it('should start and stop with go-libp2p-mdns compat', done => { + const mdns = new MulticastDNS({ peerInfo: pA, port: 50004 }) + + mdns.start(err => { + expect(err).to.not.exist() + mdns.stop(err => { + expect(err).to.not.exist() + done() + }) + }) + }) })