electron-dns-sd
Version:
The electron-dns-sd is a Node.js module which is a pure javascript implementation of mDNS/DNS-SD (Apple Bonjour) browser and packet parser. It allows you to discover IPv4 addresses in the local network specifying a service name such as `_http._tcp.local`.
602 lines (557 loc) • 15.4 kB
JavaScript
/* ------------------------------------------------------------------
* node-dns-sd - dns-sd.js
*
* Copyright (c) 2018 - 2020, Futomi Hatano, All rights reserved.
* Released under the MIT license
* Date: 2020-09-30
* ---------------------------------------------------------------- */
'use strict';
let mDgram;
if (typeof window !== 'undefined' && typeof window.dgram !== undefined) mDgram = window.dgram;
else mDgram = require('dgram'); // dgram is UDP
let mOs;
if (typeof window !== 'undefined' && typeof window.os !== undefined) mOs = window.os;
else mOs = require('os');
const mDnsSdParser = require('./dns-sd-parser.js');
const mDnsSdComposer = require('./dns-sd-composer.js');
/* ------------------------------------------------------------------
* Constructor: DnsSd()
* ---------------------------------------------------------------- */
const DnsSd = function () {
// Public
this.ondata = () => {};
// Private
this._MULTICAST_ADDR = '224.0.0.251';
this._UDP_PORT = 5353;
this._DISCOVERY_WAIT_DEFAULT = 3; // sec
this._udp = null;
this._source_address_list = [];
this._discovered_devices = {};
this._is_discovering = false;
this._is_monitoring = false;
this._is_listening = false;
this._onreceive = () => {};
};
/* ------------------------------------------------------------------
* Method: discover(params)
* - params | Object | Required |
* - name | String or | Required | Servcie name.(e.g., "_googlecast._tcp.local")
* | Array | |
* - type | String | Optional | Query Type (e.g., "PTR"). The default value is "*".
* - key | String | Optional | "address" (default) or "fqdn".
* | | | - "address": IP address based discovery
* | | | - "fqdn": FQDN (service) based discovery
* - wait | Integer | Optional | Duration of monitoring. The default value is 3 (sec).
* - quick | Boolean | Optional | If `true`, this method returns immediately after
* | | | a device was found ignoring the value of the `wait`.
* | | | The default value is `false`.
* - filter | String or | Optional | If specified as a string, this method discovers only
* | Function | | devices which the string is found in the `fqdn`,
* | | | `address`, `modelName` or `familyName`.
* | | | If specified as a function, this method discovers
* | | | only devices for which the function returns `true`.
* ---------------------------------------------------------------- */
DnsSd.prototype.discover = function (params) {
let promise = new Promise((resolve, reject) => {
if (this._is_discovering === true) {
reject(new Error('The discovery process is running.'));
return;
}
// Check the parameters
let res = this._checkDiscoveryParameters(params);
if (res['error']) {
reject(res['error']);
return;
}
let device_list = [];
this._startListening()
.then(() => {
return this._startDiscovery(res['params']);
})
.then(() => {
for (let addr in this._discovered_devices) {
let device = this._discovered_devices[addr];
device_list.push(device);
}
this._stopDiscovery().then(() => {
resolve(device_list);
});
})
.catch((error) => {
this._stopDiscovery().then(() => {
reject(error);
});
});
});
return promise;
};
DnsSd.prototype._createDeviceObject = function (packet) {
let o = {};
let trecs = {};
['answers', 'authorities', 'additionals'].forEach((k) => {
packet[k].forEach((r) => {
let type = r['type'];
if (!trecs[type]) {
trecs[type] = [];
}
trecs[type].push(r);
});
});
o['address'] = null;
if (trecs['A']) {
o['address'] = trecs['A'][0]['rdata'];
}
if (!o['address']) {
o['address'] = packet['address'];
}
o['fqdn'] = null;
let hostname = null;
if (trecs['PTR']) {
let rec = trecs['PTR'][0];
o['fqdn'] = rec['rdata'];
}
o['modelName'] = null;
o['familyName'] = null;
if (trecs['TXT'] && trecs['TXT'][0] && trecs['TXT'][0]['rdata']) {
let r = trecs['TXT'][0];
let d = r['rdata'] || {};
let name = r['name'] || '';
if (/Apple TV/.test(name)) {
o['modelName'] = 'Apple TV';
if (trecs['TXT']) {
for (let i = 0; i < trecs['TXT'].length; i++) {
let r = trecs['TXT'][i];
if (/_device-info/.test(r['name']) && r['rdata'] && r['rdata']['model']) {
o['modelName'] = 'Apple TV ' + r['rdata']['model'];
break;
}
}
}
} else if (/_googlecast/.test(name)) {
o['modelName'] = d['md'] || null;
o['familyName'] = d['fn'] || null;
} else if (/Philips hue/.test(name)) {
o['modelName'] = 'Philips hue';
if (d['md']) {
o['modelName'] += ' ' + d['md'];
}
} else if (/Canon/.test(name)) {
o['modelName'] = d['ty'] || null;
}
}
if (!o['modelName']) {
if (trecs['A'] && trecs['A'][0]) {
let r = trecs['A'][0];
let name = r['name'];
if (/Apple\-TV/.test(name)) {
o['modelName'] = 'Apple TV';
} else if (/iPad/.test(name)) {
o['modelName'] = 'iPad';
}
}
}
if (!o['modelName']) {
if (o['fqdn']) {
let hostname = o['fqdn'].split('.').shift();
if (hostname && / /.test(hostname)) {
o['modelName'] = hostname;
}
}
}
o['service'] = null;
if (trecs['SRV']) {
let rec = trecs['SRV'][0];
let name_parts = rec['name'].split('.');
name_parts.reverse();
o['service'] = {
port: rec['rdata']['port'],
protocol: name_parts[1].replace(/^_/, ''),
type: name_parts[2].replace(/^_/, ''),
};
}
o['packet'] = packet;
return o;
};
DnsSd.prototype._checkDiscoveryParameters = function (params) {
let p = {};
if (params) {
if (typeof params !== 'object') {
return { error: new Error('The argument `params` is invalid.') };
}
} else {
return { error: new Error('The argument `params` is required.') };
}
if ('name' in params) {
let v = params['name'];
if (typeof v === 'string') {
if (v === '') {
return { error: new Error('The `name` must be an non-empty string.') };
}
p['name'] = [v];
} else if (Array.isArray(v)) {
if (v.length === 0) {
return { error: new Error('The `name` must be a non-empty array.') };
} else if (v.length > 255) {
return { error: new Error('The `name` can include up to 255 elements.') };
}
let err = null;
let list = [];
for (let i = 0; i < v.length; i++) {
if (typeof v[i] === 'string' && v[i] !== '') {
list.push(v[i]);
} else {
err = new Error('The `name` must be an Array object including non-empty strings.');
break;
}
}
if (err) {
return { error: err };
}
p['name'] = list;
} else {
return { error: new Error('The `name` must be a string or an Array object.') };
}
} else {
return { error: new Error('The `name` is required.') };
}
if ('type' in params) {
let v = params['type'];
if (typeof v !== 'string' || !(/^[a-zA-Z0-9]{1,10}$/.test(v) || v === '*')) {
return { error: new Error('The `type` is invalid.') };
}
p['type'] = v.toUpperCase();
}
if ('key' in params) {
let v = params['key'];
if (typeof v !== 'string' || !/^(address|fqdn)$/.test(v)) {
return { error: new Error('The `key` is invalid.') };
}
if (!v) {
v = 'address';
}
p['key'] = v;
}
if ('wait' in params) {
let v = params['wait'];
if (typeof v !== 'number' || v <= 0 || v % 1 !== 0) {
return { error: new Error('The `wait` is invalid.') };
}
p['wait'] = v;
}
if ('quick' in params) {
let v = params['quick'];
if (typeof v !== 'boolean') {
return { error: new Error('The `quick` must be a boolean.') };
}
p['quick'] = v;
} else {
p['quick'] = false;
}
if (`filter` in params) {
let v = params['filter'];
if (typeof v !== 'string' && typeof v !== 'function') {
return { error: new Error('The `filter` must be a string.') };
}
if (v) {
p['filter'] = v;
}
}
return { params: p };
};
DnsSd.prototype._startDiscovery = function (params) {
let promise = new Promise((resolve, reject) => {
this._discovered_devices = {};
this._is_discovering = true;
let wait = params && params['wait'] ? params['wait'] : this._DISCOVERY_WAIT_DEFAULT;
// Create a request packet
let buf = mDnsSdComposer.compose({
name: params['name'],
type: params['type'],
});
// Timer
let send_timer = null;
let wait_timer = null;
let clearTimer = () => {
if (send_timer) {
clearTimeout(send_timer);
send_timer = null;
}
if (wait_timer) {
clearTimeout(wait_timer);
wait_timer = null;
}
this._onreceive = () => {};
};
wait_timer = setTimeout(() => {
clearTimer();
resolve();
}, wait * 1000);
let quick = params['quick'];
let key = params['key'];
if (!key) {
key = 'address';
}
this._onreceive = (addr, packet) => {
if (!this._isTargettedDevice(packet, params['name'])) {
return;
}
let device = this._createDeviceObject(packet);
if (!this._evaluateDeviceFilter(device, params['filter'])) {
return;
}
if (key === 'fqdn') {
let fqdn = device['fqdn'];
this._discovered_devices[fqdn] = device;
} else {
this._discovered_devices[addr] = device;
}
if (quick) {
clearTimer();
resolve();
}
};
// Send a packet
let send_num = 0;
let sendQueryPacket = () => {
this._udp.send(buf, 0, buf.length, this._UDP_PORT, this._MULTICAST_ADDR, (error, bytes) => {
if (error) {
clearTimer();
reject(error);
} else {
send_num++;
if (send_num < 3) {
send_timer = setTimeout(() => {
sendQueryPacket();
}, 1000);
} else {
send_timer = null;
}
}
});
};
sendQueryPacket();
});
return promise;
};
DnsSd.prototype._isTargettedDevice = function (packet, name_list) {
let hit = false;
packet['answers'].forEach((ans) => {
let name = ans['name'];
if (name && name_list.indexOf(name) >= 0) {
hit = true;
}
});
return hit;
};
DnsSd.prototype._evaluateDeviceFilter = function (device, filter) {
if (filter) {
let filter_type = typeof filter;
if (filter_type === 'string') {
return this._evaluateDeviceFilterString(device, filter);
} else if (filter_type === 'function') {
return this._evaluateDeviceFilterFunction(device, filter);
} else {
return false;
}
} else {
return true;
}
};
DnsSd.prototype._evaluateDeviceFilterString = function (device, filter) {
if (device['fqdn'] && device['fqdn'].indexOf(filter) >= 0) {
return true;
}
if (device['address'] && device['address'].indexOf(filter) >= 0) {
return true;
}
if (device['modelName'] && device['modelName'].indexOf(filter) >= 0) {
return true;
}
if (device['familyName'] && device['familyName'].indexOf(filter) >= 0) {
return true;
}
return false;
};
DnsSd.prototype._evaluateDeviceFilterFunction = function (device, filter) {
let res = false;
try {
res = filter(device);
} catch (e) {}
res = res ? true : false;
return res;
};
DnsSd.prototype._stopDiscovery = function () {
let promise = new Promise((resolve, reject) => {
this._discovered_devices = {};
this._is_discovering = false;
this._stopListening()
.then(() => {
resolve();
})
.catch((error) => {
resolve();
});
});
return promise;
};
DnsSd.prototype._addMembership = function () {
this._source_address_list.forEach((netif) => {
try {
this._udp.addMembership(this._MULTICAST_ADDR, netif);
} catch (e) {
console.log(`Catching error on address already in use: ${JSON.stringify(e)}`);
}
});
};
DnsSd.prototype._dropMembership = function () {
this._source_address_list.forEach((netif) => {
try {
this._udp.dropMembership(this._MULTICAST_ADDR, netif);
} catch (e) {
console.log(`Catching error on dropMembership: ${JSON.stringify(e)}`);
}
});
};
DnsSd.prototype._getSourceAddressList = function () {
let list = [];
let netifs = mOs.networkInterfaces();
for (let dev in netifs) {
netifs[dev].forEach((info) => {
if (info.family === 'IPv4' && info.internal === false) {
let m = info.address.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/);
if (m) {
list.push(m[1]);
}
}
});
}
return list;
};
/* ------------------------------------------------------------------
* Method: startMonitoring()
* ---------------------------------------------------------------- */
DnsSd.prototype.startMonitoring = function () {
let promise = new Promise((resolve, reject) => {
if (this._is_monitoring === true) {
resolve();
return;
}
this._startListening()
.then(() => {
this._is_monitoring = true;
resolve();
})
.catch((error) => {
this._is_monitoring = false;
this._stopListening().then(() => {
reject(error);
});
});
});
return promise;
};
/* ------------------------------------------------------------------
* Method: stopMonitoring()
* ---------------------------------------------------------------- */
DnsSd.prototype.stopMonitoring = function () {
let promise = new Promise((resolve, reject) => {
this._is_monitoring = false;
this._stopListening()
.then(() => {
resolve();
})
.catch((error) => {
resolve();
});
});
return promise;
};
DnsSd.prototype._startListening = function () {
let promise = new Promise((resolve, reject) => {
if (this._is_listening === true) {
resolve();
return;
}
// Get the source IP address
this._source_address_list = this._getSourceAddressList();
// Set up a UDP tranceiver
this._udp = mDgram.createSocket({
type: 'udp4',
reuseAddr: true,
});
this._udp.once('error', (error) => {
this._is_listening = false;
reject(error);
return;
});
this._udp.once('listening', () => {
this._udp.setMulticastLoopback(false);
this._addMembership();
this._is_listening = true;
resolve();
return;
});
this._udp.on('message', (buf, rinfo) => {
this._receivePacket(buf, rinfo);
});
this._udp.bind({ port: this._UDP_PORT }, () => {
this._udp.removeAllListeners('error');
});
});
return promise;
};
DnsSd.prototype._stopListening = function () {
let promise = new Promise((resolve, reject) => {
this._dropMembership();
if (this._is_discovering || this._is_monitoring) {
resolve();
} else {
let cleanObj = () => {
if (this._udp) {
this._udp.unref();
this._udp = null;
}
this._is_listening = false;
resolve();
};
if (this._udp) {
this._udp.removeAllListeners('message');
this._udp.removeAllListeners('error');
this._udp.removeAllListeners('listening');
this._udp.close(() => {
cleanObj();
});
} else {
cleanObj();
}
}
});
return promise;
};
DnsSd.prototype._receivePacket = function (buf, rinfo) {
let p = mDnsSdParser.parse(buf);
if (!p) {
return;
}
p['address'] = rinfo.address;
if (this._is_discovering) {
if (this._isAnswerPacket(p, rinfo.address)) {
this._onreceive(rinfo.address, p);
}
}
if (this._is_monitoring) {
if (typeof this.ondata === 'function') {
this.ondata(p);
}
}
};
DnsSd.prototype._isAnswerPacket = function (p, address) {
if (this._source_address_list.indexOf(address) >= 0) {
return false;
}
if (!(p['header']['qr'] === 1 && p['header']['op'] === 0)) {
return false;
}
return true;
};
module.exports = new DnsSd();