iobroker.net-tools
Version:
This adapter cyclic polls configured IPs, can send wake-on-lan packages and scan for open ports.
843 lines (752 loc) • 24.3 kB
JavaScript
;
/*
* Created with @iobroker/create-adapter v2.5.0
*/
const utils = require('@iobroker/adapter-core');
const os = require('os');
const dmNetTools = require('./lib/devicemgmt.js');
const migration = require('./lib/migration.js');
const asTools = require('@all-smart/all-smart-tools');
const arp = require('@network-utils/arp-lookup');
const wol = require('wol');
const portscanner = require('evilscan');
const oui = require('oui');
const ping = require('./lib/ping');
const objects = require('./lib/object_definition').object_definitions;
const { nslookup } = require('./lib/nslookup');
const { CronJob } = require('cron');
const { checkPingRights } = require('./lib/utils');
const { calculateSubnetMask } = require('./lib/ip-calculator');
let timer = null;
let isStopping = false;
let wolTries = 3;
let wolTimer = null;
let discoverTimeout = null;
let pingTimeout = null;
/**
* @type {Array.<Object>}
*
* Represents a list of tasks to be executed.
*
* Each element in the taskList array is an object with specific properties. It looks like:
* ```
* {
* mac: '00:80:41:ae:fd:7e' // The mac of the device
* host: '192.168.11.15', // IP of the host
* extendedInfo: true, // Indicates if extended information should be obtained
* pingInterval: 60, // Time interval in seconds between two consecutive pings
* retries: 0, // The number of retries already made
* retryCounter: 0, // Used to count the number of retries
* stateAlive: { channel: '84b8b87e0294', state: 'alive' }, // Information about the 'alive' state
* stateTime: { channel: '84b8b87e0294', state: 'time' }, // Information about 'time' state
* stateRps: { channel: '84b8b87e0294', state: 'rps' } // Information about 'rps' state
* wakeWithIp: false // boolean per device
* }
* ```
*/
let taskList = [];
let cronJob = null;
const FORBIDDEN_CHARS = /[\]\[*,;'"`<>\\?]/g;
class NetTools extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
constructor(options) {
super({
...options,
name: 'net-tools',
});
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('objectChange', this.onObjectChange.bind(this));
this.on('message', this.onMessage.bind(this));
this.on('unload', this.onUnload.bind(this));
this.deviceManagement = new dmNetTools(this);
this.arpScanInstalled = false;
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
await migration.startMigration(this);
this.asTools = new asTools(this);
if (!this.config.licenseKey) {
const adapterObject = await this.getForeignObjectAsync(`system.adapter.net-tools`);
if (adapterObject && adapterObject.native && adapterObject.native.licenseKey === '') {
this.log.error(
'License Key is not set! Enter a valid license key in the adapter settings.'
);
} else if (adapterObject && adapterObject.native && adapterObject.native.licenseKey){
await this.extendForeignObjectAsync(`system.adapter.net-tools.${this.instance}`, {native: {
licenseKey: adapterObject.native.licenseKey,
}
});
}
} else {
const adapterObject = await this.getForeignObjectAsync(`system.adapter.net-tools`);
if (adapterObject && adapterObject.native && adapterObject.native.licenseKey !== this.config.licenseKey) {
await this.extendForeignObjectAsync(`system.adapter.net-tools`, {native: {licenseKey: this.config.licenseKey}});
}
this.extendHostInformation();
await this.asTools.checkObjectsUpdate();
if(this.asTools.isLxc) {
checkPingRights();
}
this.config.pingInterval = parseInt(this.config.pingInterval, 10);
if (this.config.pingInterval < 5) {
this.log.warn('Poll interval is too short. Reset to 5s.');
this.config.pingInterval = 5;
}
const preparedObjects = await this.prepareObjectsByConfig();
this.pingAll();
if(this.config.autoSearch === true && this.config.searchSchedule !== ''){
this.log.info('Auto search is enabled');
cronJob = new CronJob(this.config.searchSchedule, () => {
this.log.debug('Start auto search');
this.discover();
});
cronJob.start();
}
console.log(JSON.stringify(await this.getLocalNetworkInterfaces()));
this.subscribeStates('*discover');
this.subscribeStates('*wol');
this.subscribeStates('*scan');
this.subscribeObjects('*');
}
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
* @param {() => void} callback
*/
onUnload(callback) {
try {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (wolTimer) {
clearTimeout(wolTimer);
wolTimer = null;
}
if (discoverTimeout) {
clearTimeout(discoverTimeout);
discoverTimeout = null;
}
if(pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
}
isStopping = true;
if(cronJob){
cronJob.stop();
}
callback();
} catch (e) {
callback();
}
}
// If you need to react to object changes, uncomment the following block and the corresponding line in the constructor.
// You also need to subscribe to the objects with `this.subscribeObjects`, similar to `this.subscribeStates`.
/**
* Is called if a subscribed object changes
* @param {string} id
* @param {ioBroker.Object | null | undefined} obj
*/
onObjectChange(id, obj) {
if (obj) {
// The object was changed
if (obj.type === 'device') {
// Look if there is a host entry in taskList for the ip
const hostEntry = taskList.find(entry => entry?.mac === obj.native.mac);
if (hostEntry) {
hostEntry.host = obj.native.ip;
hostEntry.pingInterval = obj.native.pingInterval;
hostEntry.retries = obj.native.retries;
taskList.push(hostEntry);
}
}
} else {
// The object was deleted
//this.log.info(`object ${id} deleted`);
}
}
/**
* Is called if a subscribed state changes
* @param {string} id
* @param {ioBroker.State | null | undefined} state
*/
async onStateChange(id, state) {
if (state) {
const tmp = id.split('.');
const dp = tmp.pop();
switch (dp) {
case 'discover':
if (state.val) {
this.discover();
}
break;
case 'wol':
if (state.val && state.ack === false) {
const parentId = await this.getParentId(id);
const obj = await this.getObjectAsync(parentId);
if(obj === null || obj === undefined){
this.log.warn(`Object ${parentId} not found`);
return;
}
this.wake(obj.native.mac);
this.setState(id, {val: state.val, ack: true});
}
break;
case 'scan':
if (state.val) {
const parentId = await this.getParentId(id);
const obj = await this.getObjectAsync(parentId);
if(obj === null || obj === undefined){
this.log.warn(`Object ${parentId} not found`);
return;
}
let ports = await this.getStateAsync(`${parentId}.portList`);
if (!ports) {
ports = this.config.portList.split(',');
}
await this.scanPortsInBatches(parentId, obj.native.ip, ports.val);
}
break;
}
} else {
// The state was deleted
//this.log.info(`state ${id} deleted`);
}
}
// If you need to accept messages in your adapter, uncomment the following block and the corresponding line in the constructor.
// /**
// * Some message was sent to this instance over message box. Used by email, pushover, text2speech, ...
// * Using this method requires "common.messagebox" property to be set to true in io-package.json
// * @param {ioBroker.Message} obj
// */
async onMessage(obj) {
this.log.debug('Message received: ' + JSON.stringify(obj));
if (typeof obj === 'object' && obj.message && !obj.command.includes('dm:')) {
if (obj.command === 'send') {
switch (obj.command) {
case 'ping': {
// Try to ping one IP or name
if (obj.callback && obj.message) {
//ping.probe(obj.message, {log: this.log.debug}, (err, result) =>
ping.probe(obj.message, {}, (err, result) =>
this.sendTo(obj.from, obj.command, {result}, obj.callback));
}
break;
}
case 'getMac': {
if (obj.callback && obj.message) {
this.sendTo(obj.from, obj.command, await arp.toMAC(obj.message), obj.callback);
}
break;
}
case 'wol': {
if (obj.callback && obj.message) {
this.wake(obj.message);
this.sendTo(obj.from, obj.command, null , obj.callback);
}
break;
}
case 'deleteDevice': {
await this.delDevice(obj.message)
.then( () => {
if (obj.callback) this.sendTo(obj.from, obj.command, 'Device deleted', obj.callback);
});
break;
}
case 'addDevice': {
await this.addDevice(obj.message.ip, obj.message.name, obj.message.enabled, obj.message.mac)
.then( () => {
if (obj.callback) this.sendTo(obj.from, obj.command, 'Device added', obj.callback);
});
break;
}
case 'updateDevice': {
await this.updateDevices(obj.message);
break;
}
}
}
}
}
/**
* Scans specified ports on a given IP address in batches.
*
* @param {string} id - Object id.
* @param {string} ip - The IP address to scan.
* @param {number[]|string} ports - An array of port numbers or a comma-separated string of ports to be scanned.
* @return {Promise<void>} A promise that resolves when all port scans are completed.
*/
async scanPortsInBatches(id, ip, ports) {
// Ensure ports is an array
if (typeof ports === 'string' && ports.length > 0) {
ports = ports.split(',').map(p => p.trim());
} else if (!ports) {
ports = this.config.portList.split(',').map(p => p.trim());
} else if (!Array.isArray(ports)) {
this.log.error('Invalid ports parameter: expected array or string');
return;
}
const batchSize = 10;
let portArrays = [];
for (let i = 0; i < ports.length; i += batchSize) {
portArrays.push(ports.slice(i, i + batchSize));
}
let foundPorts = [];
for (const portBatch of portArrays) {
const result = await this.portScan(id, ip, portBatch.join(','));
if(result.length >= 1) {
foundPorts.push(result);
}
}
this.log.info(`Found open ports: ${foundPorts}`);
await this.setState(`${id}.ports`, { val: foundPorts.toString(), ack: true });
this.log.info('Port scan finished');
}
/**
*
* @param {string} id - object id for host
* @param {string} ip - ip address like 127.0.0.1
* @param {string} ports - string of ports to scan e.g. '80,443,8080'
*/
async portScan(id, ip, ports) {
const alive = await this.getStateAsync(`${id}.alive`);
if (!(id === 'localhost' || alive?.val === true)) return;
const portList = (ports && String(ports).trim())
? String(ports).split(',').map(p => p.trim()).join(',')
: '80,443';
this.log.info(`Scanning for open ports (${portList}) at ${id}, please wait`);
await this.setState(`${id}.ports`, { val: 'Scanning, please wait', ack: true });
const openPorts = [];
const options = {
target: ip,
port: portList, // z. B. "22,80,443" oder "20-30"
status: 'TROU', // wir filtern selbst auf "open"
banner: false,
reverse: false,
timeout: 3000, // pro Port
concurrency: 500 // optional: Ressourcen begrenzen
};
const scanner = new portscanner(options);
try {
await new Promise((resolve, reject) => {
const timeoutGuard = setTimeout(() => {
try { scanner.destroy && scanner.destroy(); } catch {}
reject(new Error('Port scan timeout nach 30 Sekunden'));
}, 30000);
scanner.on('result', (data) => {
this.log.debug(`Port scan result for ${id}: ${data.port} (${data.status})`);
if (data.status === 'open') openPorts.push(data.port);
});
scanner.on('error', (err) => {
clearTimeout(timeoutGuard);
this.log.error(`Port scan error for ${id}: ${err.message}`);
reject(err);
});
scanner.on('done', () => {
clearTimeout(timeoutGuard);
resolve();
});
// wichtig: Scan starten
scanner.run();
});
return openPorts;
/*const resultString = openPorts.length ? openPorts.join(', ') : 'none';
this.log.info(`Found open ports: ${resultString}`);
await this.setState(`${id}.ports`, { val: resultString, ack: true });*/
} catch (err) {
const msg = err && err.message ? err.message : String(err);
this.log.error(`Port scan failed for ${id}: ${msg}`);
await this.setState(`${id}.ports`, { val: `Scan failed: ${msg}`, ack: true });
}
}
async getParentId(id){
let parentId = id.replace(this.namespace + '.', '');
parentId = parentId.replace(/\..*/g, '');
return this.namespace + '.' + parentId;
}
wake(mac){
const device = taskList.find(entry => entry.mac === mac);
console.log(device);
wol.wake(mac, device?.wakeWithIp ? {address: device.ip}: null, (err, res) => {
wolTries = wolTries - 1;
if (err) {
this.log.debug('Wake-on-LAN error: ' + err);
wolTries = 3;
}
if (wolTries > 0){
wolTimer = setTimeout(() => {
this.wake(mac);
}, 750);
} else if (wolTries === 0){
wolTries = 3;
}
this.log.debug('Wake on LAN trie ' + (wolTries + 1) + ': ' + res);
});
}
async discover(){
let oldDevices = await this.getDevicesAsync();
let devices = [];
for(let device in oldDevices){
let newDevice = {mac: '', ip: ''};
if(oldDevices[device].native?.mac) {
newDevice.mac = oldDevices[device].native.mac;
}
if(oldDevices[device].native?.ip) {
newDevice.ip = oldDevices[device].native.ip;
}
// Check if device is on ignore list, if not add it to array
const ignore = await this.checkIgnore(newDevice.mac);
if(!ignore) devices.push(newDevice);
}
oldDevices = [];
try {
const ips = this.getIpRange();
const decimalSeparator = getDecimalSeparator();
for (let i = 0; i < ips.length; i += 10){
let promises = [];
for(let j = 0; j < 10; j++) {
if (i + j < ips.length) {
promises.push(this.handleDiscoveryProbe(ips[i+j], devices, decimalSeparator));
}
}
const result = await Promise.all(promises);
}
this.log.info('Discovery finished')
return true;
} catch (err) {
this.log.warn('Discovery faild: ' + err);
return false;
}
}
async checkIgnore(mac){
if(!this.config.ignoreListTable) {
return false;
}
return this.config.ignoreListTable.find((/** @type {{ mac: string; }} */ entry) => entry.mac === mac);
}
async handleDiscoveryProbe(ip, oldDevices, decimalSeparator){
return new Promise(async (resolve, reject) => {
try {
ping.probe(ip, {
timeout: parseFloat(`0${decimalSeparator}25`),
log: this.log.info
}, async (error, result) => {
if(error) {
reject(false);
}
if (result.alive === true) {
result.mac = await arp.toMAC(result.host);
if (result.mac !== undefined && result.mac !== null) {
result.vendor = oui(result.mac)
} else {
this.log.info('Can not get mac for ' + result.host + '. Going to next IP.')
resolve(true);
}
try {
result.name = await nslookup(result.host);
} catch (error) {
result.name = result.host;
}
let exists = false;
for (const entry of oldDevices) {
if (entry.mac !== '' && entry.mac !== undefined && entry.mac === result.mac) {
exists = true;
}
if (exists === true && entry.ip !== '' && entry.ip !== undefined && entry.ip !== result.host && entry.mac === result.mac) {
const idName = result.mac.replace(/:/g, '');
await this.extendObject(this.namespace + '.' + idName, {
native: {
ip: result.host,
vendor: result.vendor
}
});
}
}
const ignore = await this.checkIgnore(result.mac);
if (!exists && !ignore) {
await this.addDevice(result.host, result.name, true, result.mac);
}
}
resolve(true);
});
} catch (error){
this.log.warn('Error in handleDiscoveryProbe: ' + error);
reject(false);
}
})
}
/**
* Retrieves the range of IP addresses based on the given configuration.
*
* @returns {Array} - An array of IP addresses in the specified range.
*/
getIpRange(){
const nets = os.networkInterfaces();
let iface;
let ips = [];
let startIP, endIP;
if(nets[this.config.interface] && this.config.startIp === '' && this.config.endIp === '') {
iface = nets[this.config.interface].filter(net => net.family === 'IPv4');
const cidr = iface[0].cidr.split('/');
const subnetRange = calculateSubnetMask(cidr[0], parseInt(cidr[1]));
startIP = subnetRange.ipLowStr.split(".").map(Number);
endIP = subnetRange.ipHighStr.split(".").map(Number);
} else {
// Use defined range from config
startIP = this.config.startIp.split(".").map(Number);
endIP = this.config.endIp.split(".").map(Number);
}
while(!(startIP[0] > endIP[0] || (startIP[0] === endIP[0] && (startIP[1] > endIP[1] || (startIP[1] === endIP[1] && (startIP[2] > endIP[2] || (startIP[2] === endIP[2] && startIP[3] > endIP[3]))))))){
ips.push(startIP.join('.'));
startIP[3]++;
for(let i = 3; i > 0; i--){
if(startIP[i] > 254){
startIP[i] = 0;
startIP[i-1]++;
}
}
}
return ips;
}
/**
* @param {string} ip
* @param {string} name
* @param {boolean} enabled
* @param {string | null | undefined} mac
* @param {number} [pingInterval]
* @param {number} [retries]
*/
async addDevice(ip, name, enabled, mac, pingInterval, retries){
let idName, vendor = '';
if (!mac || mac === '') {
mac = await arp.toMAC(ip);
this.log.info(`MAC address for ${ip}: ${mac}`);
if(!mac) {
return;
}
mac = mac.toLowerCase();
if (mac === '(unvollständig)' || mac === undefined) {
this.log.info(`Could not find the mac address for ${ip}`);
idName = name;
} else {
idName = mac.replace(/:/g, '');
vendor = oui(mac);
if (vendor) {
vendor = vendor.replace(/\n/g, ', ');
}
}
} else {
idName = mac.replace(/:/g, '');
vendor = oui(mac);
if (vendor){
vendor = vendor.replace(/\n/g, ', ');
}
}
await this.extendObject(this.namespace + '.' + idName, {
type: 'device',
common: {
name: name || ip
},
native: {
enabled: enabled,
pingInterval: pingInterval ? pingInterval : this.config.pingInterval,
retries: retries ? retries : 0,
wakeWithIp: false,
ip: ip,
mac: mac,
vendor: vendor
}
});
for (const obj in objects){
await this.extendObject(idName + '.' + obj, objects[obj]);
}
const preparedObjects = await this.prepareObjectsByConfig();
this.pingAll();
}
/**
* Delete device
* @param {string} deviceId
* @return {Promise<boolean>}
*/
async delDevice(deviceId) {
const name = deviceId.replace(/net-tools\.\d\./, '');
const res = await this.deleteDeviceAsync(name);
if(res !== null) {
this.log.info(`${name} deleted`);
// Delete device from taskList
for(const i in taskList){
if(taskList[i] === null){
continue;
}
if(taskList[i].stateAlive.channel === name){
taskList.splice(parseInt(i), 1);
}
}
return true;
} else {
this.log.error(`Can not delete device ${name}: ${JSON.stringify(res)}`);
return false;
}
}
/**
* Update devices
* @param {object} devices - array of devices
* @return {Promise<void>}
*/
async updateDevices(devices){
for(const i in devices){
const mac = devices[i].mac.replace(/:/g, '').toLowerCase();
const allDevices = await this.getDevicesAsync();
let deleted = true;
for(const d in allDevices){
if(`${this.namespace}.${mac}` !== allDevices[d]._id){
deleted = true;
} else {
deleted = false;
break;
}
}
if(deleted === true){
this.log.info(`Delete device ${mac}`);
await this.delDevice(mac);
} else {
await this.extendObject(`${this.namespace}.${mac}`, {
common: {
name: devices[i].name
},
native: devices[i]
});
}
}
}
pingAll() {
for(const host in taskList) {
this.pingDevice(host);
}
}
/**
* Pings a device to check its availability.
*
* @param {string} host - The hostname or IP address of the device to ping.
*
* @return {undefined}
*/
pingDevice(host) {
if(!taskList[host]) {
return;
}
ping.probe(taskList[host].host, { log: this.log.debug }, (err, result) => {
err && this.log.error(err);
if(taskList.length === 0 || taskList[host] === null || taskList[host] === undefined){
return;
}
if (result) {
if (result.alive === true) {
this.setState(taskList[host].stateAlive.channel + '.alive', {val: true, ack: true});
this.setState(taskList[host].stateTime.channel + '.time', {
val: result.ms === null ? 0 : result.ms / 1000,
ack: true
});
let rps = 0;
if (result.alive && result.ms !== null && result.ms > 0) {
rps = result.ms <= 1 ? 1000 : 1000.0 / result.ms;
}
this.setState(taskList[host].stateRps.channel + '.rps', {val: rps, ack: true});
taskList[host].retryCounter = 0;
} else if(taskList[host].retryCounter <= taskList[host].retries) {
taskList[host].retryCounter++;
} else {
this.setState(taskList[host].stateAlive.channel + '.alive', { val: false, ack: true });
taskList[host].retryCounter = 0;
}
}
if(!isStopping) {
// Planen Sie den nächsten Ping basierend auf dem Intervall
pingTimeout = setTimeout(() => this.pingDevice(host), taskList[host].pingInterval * 1000);
}
});
}
/**
* Prepare objects for host
* @param {object} config - config object
* @return {{ping_task: {stateTime: {channel: (string|*), state: string}, stateRps: {channel: (string|*), state: string}, mac: string, host: string, extendedInfo: boolean, pingInterval: number, retries: number, retryCounter: number, wakeWithIp: boolean, stateAlive: {channel: (string|*), state: string}}}}
*/
prepareObjectsForHost(config) {
const host = config.ip;
const mac = config.mac;
const idName = mac ? mac.replace(FORBIDDEN_CHARS, '_').replace(/:/g, '') : config.name;
const stateAliveID = {channel: idName, state: 'alive'};
const stateTimeID = {channel: idName, state: 'time'};
const stateRpsID = {channel: idName, state: 'rps'};
return {
ping_task: {
mac: mac,
host: host,
extendedInfo: true,
pingInterval: config.pingInterval ? config.pingInterval : this.config.pingInterval,
retries: config.retries ? config.retries : 0,
retryCounter: 0,
wakeWithIp: config.wakeWithIp ? config.wakeWithIp : false,
stateAlive: stateAliveID,
stateTime: stateTimeID,
stateRps: stateRpsID
}
};
}
/**
* Get all devices
* @return {Promise<{}>}
*/
async prepareObjectsByConfig() {
taskList = [];
const objs = await this.getDevicesAsync();
let devices = [];
for (const d in objs){
if(objs[d]._id.includes('localhost')) {
continue;
}
let json = objs[d].native;
json.name = objs[d].common.name;
devices.push(json);
}
devices.forEach( device => {
if (device.enabled === false) {
return;
}
const config = this.prepareObjectsForHost(device);
if(config) {
taskList.push(config.ping_task);
}
});
return true;
}
async extendHostInformation(){
if (this.config.portScan === true){
const ports = this.config.portList.split(',');
await this.scanPortsInBatches('localhost', '127.0.0.1', ports);
}
}
async getLocalNetworkInterfaces() {
const networkInterfaces = os.networkInterfaces();
return networkInterfaces;
}
}
function getDecimalSeparator() {
const numberWithDecimalSeparator = 1.1;
return Intl.NumberFormat().format(numberWithDecimalSeparator).substring(1, 2);
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param {Partial<utils.AdapterOptions>} [options={}]
*/
module.exports = (options) => new NetTools(options);
} else {
// otherwise start the instance directly
new NetTools();
}