iobroker.asuswrt
Version:
find active devices in asus wrt routers for ioBroker
732 lines (652 loc) • 23 kB
JavaScript
/*
* ioBroker ASUSWRT Adapter
*/
;
//SSH2
const Client = require('ssh2').Client;
let conn = null;
//OTHER
const utils = require('@iobroker/adapter-core');
const deviceCommand = 'PATH=$PATH:/bin:/usr/sbin:/sbin && ip neigh';
const fs = require('node:fs');
const { parseNeighborOutput } = require('./lib/parsing');
//Maybe in future releases
//const clearIPCacheCommand = 'ip -s -s neigh flush all';
let timer = null;
let stopTimer = null;
let isStopping = false;
let stopExecute = false;
let lastTimeUpdateDevices = 0;
const host = '';
let useKeyFile = false;
let adapter;
function startAdapter(options) {
options = options || {};
Object.assign(options, {
name: 'asuswrt',
ready: function () {
main();
},
unload: function (callback) {
if (timer) {
clearInterval(timer);
timer = 0;
}
if (conn) {
conn.removeAllListeners();
conn.end();
conn = null;
}
isStopping = true;
callback && callback();
},
objectChange: function (id, obj) {
adapter.log.info(`objectChange ${id} ${JSON.stringify(obj)}`);
},
});
adapter = new utils.Adapter(options);
return adapter;
}
function stop() {
if (stopTimer) {
adapter.clearTimeout(stopTimer);
}
// Stop only if schedule mode
if (adapter.common && adapter.common.mode == 'schedule') {
stopTimer = adapter.setTimeout(function () {
stopTimer = null;
if (timer) {
clearInterval(timer);
}
isStopping = true;
adapter.stop();
}, 30000);
}
}
process.on('SIGINT', function () {
if (timer) {
clearTimeout(timer);
}
});
function createDevice(name, mac, callback) {
const id = mac.replace(/:/g, '').toLowerCase();
adapter.setObjectNotExists(
id,
{
type: 'device',
common: {
name: name || mac,
},
native: {
mac: mac,
},
},
() => {
const states = [
{
id: 'last_time_seen_active',
common: {
name: name || mac,
def: '-',
type: 'string',
read: true,
write: false,
role: 'value',
desc: 'last update',
},
},
{
id: 'ip_address',
common: {
name: name || mac,
def: '',
type: 'string',
read: true,
write: false,
role: 'value',
desc: 'Last known IP Address',
},
},
{
id: 'mac',
common: {
name: name || mac,
def: mac,
type: 'string',
read: true,
write: false,
role: 'value',
desc: 'MAC address',
},
},
{
id: 'last_status',
common: {
name: name || mac,
def: '',
type: 'string',
read: true,
write: false,
role: 'value',
desc: 'last known status',
},
},
{
id: 'active',
common: {
name: name || mac,
def: false,
type: 'boolean',
read: true,
write: false,
role: 'value',
desc: 'Device Active',
},
},
];
states.forEach(state => {
adapter.setObjectNotExists(
`${id}.${state.id}`,
{
type: 'state',
common: state.common,
native: {
mac: id,
},
},
callback,
);
});
adapter.log.debug(`${id} generated ${mac}`);
},
);
}
function syncConfig(callback) {
adapter.getStatesOf('', host, function (err, _states) {
const configToDelete = [];
const configToAdd = [];
let k;
let id;
if (adapter.config.devices) {
for (k = 0; k < adapter.config.devices.length; k++) {
configToAdd.push(adapter.config.devices[k].mac);
}
}
const tasks = [];
if (_states) {
for (let j = 0; j < _states.length; j++) {
const mac = _states[j].native.mac;
if (!mac) {
adapter.log.warn(`No mac address found for ${JSON.stringify(_states[j])}`);
continue;
}
id = mac.replace(/:/g, '');
id = id.toLowerCase();
const pos = configToAdd.indexOf(mac);
if (pos != -1) {
configToAdd.splice(pos, 1);
for (let u = 0; u < adapter.config.devices.length; u++) {
if (adapter.config.devices[u].mac == mac) {
if (
_states[j].common.name !==
(adapter.config.devices[u].name || adapter.config.devices[u].mac)
) {
tasks.push({
type: 'extendObject',
id: _states[j]._id,
data: {
common: {
name: adapter.config.devices[u].name || adapter.config.devices[u].mac,
read: true,
write: false,
},
},
});
} else if (typeof _states[j].common.read !== 'boolean') {
tasks.push({
type: 'extendObject',
id: _states[j]._id,
data: { common: { read: true, write: false } },
});
}
}
}
} else {
configToDelete.push(mac);
}
}
}
if (configToDelete.length) {
for (let e = 0; e < configToDelete.length; e++) {
id = configToDelete[e].replace(/:/g, '');
id = id.toLowerCase();
tasks.push({
type: 'delObject',
id: id,
});
}
}
processTasks(tasks, function () {
let count = 0;
if (configToAdd.length) {
for (let r = 0; r < adapter.config.devices.length; r++) {
if (configToAdd.indexOf(adapter.config.devices[r].mac) !== -1) {
count++;
createDevice(adapter.config.devices[r].name, adapter.config.devices[r].mac, function () {
if (!--count && callback) {
callback();
}
});
}
}
}
if (!count && callback) {
callback();
}
});
});
}
function processTasks(tasks, callback) {
if (!tasks || !tasks.length) {
callback && callback();
} else {
const task = tasks.shift();
let timeout = adapter.setTimeout(function () {
adapter.log.warn('please update js-controller to at least 1.2.0');
timeout = null;
processTasks(tasks, callback);
}, 1000);
if (task.type === 'extendObject') {
adapter.extendObject(task.id, task.data, function (/* err */) {
if (timeout) {
adapter.clearTimeout(timeout);
timeout = null;
setImmediate(processTasks, tasks, callback);
}
});
} else if (task.type === 'delObject') {
adapter.delObject(task.id, { recursive: true }, function (/* err */) {
if (timeout) {
adapter.clearTimeout(timeout);
timeout = null;
setImmediate(processTasks, tasks, callback);
}
});
} else {
adapter.log.error(`Unknown task name: ${JSON.stringify(task)}`);
if (timeout) {
adapter.clearTimeout(timeout);
timeout = null;
setImmediate(processTasks, tasks, callback);
}
}
}
}
function getActualDateTime() {
const today = new Date();
let hh = today.getHours();
let mm = today.getMinutes();
let ss = today.getSeconds();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
if (hh < 10) {
hh = `0${hh}`;
}
if (mm < 10) {
mm = `0${mm}`;
}
if (ss < 10) {
ss = `0${ss}`;
}
const returntext = `${year}.${month}.${day} ${hh}:${mm}:${ss}`;
return returntext;
}
function setDeviceActive(mac, macArray, arraystdout) {
if (macArray.indexOf(mac) != -1) {
const realmac = arraystdout[4];
const ip_address = arraystdout[0];
const actualstatus = arraystdout[5];
const lastupdate = getActualDateTime();
adapter.setState(`${mac}.last_time_seen_active`, lastupdate || '-1', true);
adapter.setState(`${mac}.ip_address`, ip_address || 'undefined', true);
adapter.setState(`${mac}.mac`, realmac || 'undefined', true);
adapter.setState(`${mac}.last_status`, actualstatus || 'undefined', true);
adapter.setState(`${mac}.active`, true, true);
adapter.log.debug(`Device ${mac} is active`);
}
}
function checkActiveDevices(macArray) {
const arraylength = macArray.length;
if (arraylength > 0) {
for (let i = 0; i < arraylength; i++) {
let mac = macArray[i];
mac = mac.toLowerCase();
checkDevice(mac);
}
}
}
function checkDevice(mac) {
adapter.log.debug(`Check ${mac} if still active`);
adapter.getState(`${mac}.active`, function (err, state) {
if (state) {
if (state.val == true) {
adapter.getState(`${mac}.last_time_seen_active`, function (err, updatestate) {
if (updatestate) {
const date = new Date();
const timenow = date.getTime();
const timebefore = updatestate.lc;
adapter.log.debug(`Last Time Device Changed: ${timebefore}, now: ${timenow}`);
let timeelapsed = timenow - timebefore;
if (lastTimeUpdateDevices != 0 && lastTimeUpdateDevices < timenow) {
const timebuffer = timenow - lastTimeUpdateDevices;
timeelapsed = timeelapsed - timebuffer;
}
if (timeelapsed > adapter.config.active_interval) {
adapter.setState(`${mac}.active`, false);
adapter.setState(`${mac}.last_status`, 'offline');
adapter.log.debug(`Device ${mac} is not active anymore`);
}
}
});
}
}
});
}
function startCheckActiveDevices(hosts) {
if (stopTimer) {
adapter.clearTimeout(stopTimer);
}
adapter.log.debug('Start check if Device still active');
if (!isStopping) {
checkActiveDevices(hosts);
adapter.setTimeout(function () {
startCheckActiveDevices(hosts);
}, 30000);
}
}
function setLastUpdateTime() {
const date = new Date();
lastTimeUpdateDevices = date.getTime();
}
function createSSHConnection() {
if (conn) {
conn.removeAllListeners();
conn.end();
}
conn = new Client();
return conn;
}
function startUpdateDevicesSSH2(hosts) {
conn = createSSHConnection();
try {
const connectionConfig = {
host: adapter.config.asus_ip,
port: Number(adapter.config.ssh_port),
username: adapter.config.asus_user,
keepaliveInterval: 60000,
readyTimeout: 30000,
};
if (useKeyFile) {
connectionConfig.privateKey = fs.readFileSync(adapter.config.keyfile);
if (adapter.config.keyfile_passphrase != '') {
connectionConfig.passphrase = adapter.config.keyfile_passphrase;
}
} else {
connectionConfig.password = adapter.config.asus_pw;
}
conn.connect(connectionConfig);
} catch (error) {
adapter.log.error(`Failed to connect to SSH: ${error.message}`);
stop();
return;
}
conn.on('ready', function () {
adapter.log.info('SSH Connection to Router is ready, starting Device Checking');
stopExecute = false;
startCommandSSH2(hosts);
});
conn.on('error', function (err) {
adapter.log.error(`SSH connection error: ${err.message}`);
stopExecute = true;
if (!isStopping) {
adapter.log.info('Attempting to reconnect in 90 seconds...');
adapter.setTimeout(function () {
restartSSH2(hosts);
}, 90000);
}
});
conn.on('close', function () {
adapter.log.info('SSH connection closed');
if (!isStopping && !stopExecute) {
adapter.log.info('Connection closed unexpectedly, attempting to reconnect in 90 seconds...');
stopExecute = true;
adapter.setTimeout(function () {
restartSSH2(hosts);
}, 90000);
}
});
conn.on('timeout', function () {
adapter.log.error('SSH connection timeout');
conn.end();
});
}
function startCommandSSH2(hosts) {
if (stopTimer) {
adapter.clearTimeout(stopTimer);
}
if (!isStopping) {
if (stopExecute === false) {
updateDeviceSSH2(hosts);
setLastUpdateTime();
adapter.setTimeout(function () {
startCommandSSH2(hosts);
}, adapter.config.interval);
}
}
}
function updateDeviceSSH2(macArray) {
try {
conn.exec(deviceCommand, function (err, stream) {
if (err) {
throw err;
}
stream
.on('data', function (data) {
adapter.log.debug(`STDOUT: ${data}`);
const devices = parseNeighborOutput(data);
devices.forEach(device => {
setDeviceActive(device.mac, macArray, device.tokens);
});
})
.stderr.on('data', function (data) {
adapter.log.debug(`STDERR: ${data}`);
});
});
} catch (error) {
if (String(error) === 'Error: Not connected') {
adapter.log.error('SSH2 is not connected, try new Connection in 90s');
stopExecute = true;
adapter.setTimeout(function () {
restartSSH2(macArray);
}, 90000);
} else {
adapter.log.error(error);
stopExecute = true;
}
}
}
function restartSSH2(hosts) {
startUpdateDevicesSSH2(hosts);
}
function checkKeyFile(callback) {
if (!adapter.config.keyfile || adapter.config.keyfile === '') {
if (adapter.config.asus_pw === '') {
adapter.log.error('No Key File and No Password set for the SSH Connection');
return callback(new Error('No authentication method configured'));
}
useKeyFile = false;
return callback(null);
}
fs.stat(adapter.config.keyfile, function (err) {
if (err) {
adapter.log.warn(`Key File ${adapter.config.keyfile} not found: ${err.message}`);
adapter.log.info('Attempting to use password instead');
if (adapter.config.asus_pw === '') {
adapter.log.error('No Key File and No Password set for the SSH Connection');
return callback(new Error('No valid authentication method'));
}
useKeyFile = false;
return callback(null);
}
useKeyFile = true;
callback(null);
});
}
function getActiveDevices(hosts) {
if (stopTimer) {
adapter.clearTimeout(stopTimer);
}
if (!hosts) {
hosts = [];
for (let i = 0; i < adapter.config.devices.length; i++) {
if (adapter.config.devices[i].mac.length > 11) {
if (adapter.config.devices[i].active) {
try {
let mac = sanitizeMac(adapter.config.devices[i].mac);
mac = mac.replace(/:/g, '');
mac = mac.toLowerCase();
hosts.push(mac);
} catch (err) {
adapter.log.warn(`Skipping invalid MAC: ${err.message}`);
}
}
}
}
}
if (!hosts.length) {
adapter.log.error('No Devices to watch found or no Devices set with active');
stop();
return;
}
const checkRouterAddress = validateIPaddress(adapter.config.asus_ip) || validateHostname(adapter.config.asus_ip);
if (!checkRouterAddress) {
adapter.log.error(`The Server-Address ${adapter.config.asus_ip} is neither a valid IP-Address or Hostname`);
stop();
return;
}
// polling mininum 5 Seconds for SSH2
if (adapter.config.interval < 5000) {
adapter.config.interval = 5000;
}
checkKeyFile(function (err) {
if (err) {
stop();
return;
}
adapter.setTimeout(function () {
startUpdateDevicesSSH2(hosts);
}, 5000);
adapter.setTimeout(function () {
startCheckActiveDevices(hosts);
}, 30000);
});
}
function validateIPaddress(inputText) {
const ipformat =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return !!inputText.match(ipformat);
}
/**
* Validates if the input is a hostname checked by regex
*
* @param {string} inputText - The text to validate as a hostname
* @returns {boolean} True if the input is a valid hostname
*/
function validateHostname(inputText) {
const validHostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/;
return !!inputText.match(validHostnameRegex);
}
function sanitizeMac(mac) {
const sanitized = mac.replace(/[^0-9A-Fa-f:]/g, '');
const macRegex = /^([0-9A-Fa-f]{2}[:]){5}([0-9A-Fa-f]{2})$/;
if (!macRegex.test(sanitized)) {
throw new Error(`Invalid MAC address format: ${mac}`);
}
return sanitized;
}
function validateConfig() {
const errors = [];
// Validate IP/hostname
if (!adapter.config.asus_ip) {
errors.push('Router IP/hostname is required');
} else if (!validateIPaddress(adapter.config.asus_ip) && !validateHostname(adapter.config.asus_ip)) {
errors.push(`Invalid router address: ${adapter.config.asus_ip}`);
}
// Validate user
if (!adapter.config.asus_user) {
errors.push('Router username is required');
}
// Validate authentication
if (!adapter.config.keyfile && !adapter.config.asus_pw) {
errors.push('Either password or SSH key file must be provided');
}
// Validate port
const port = parseInt(adapter.config.ssh_port, 10);
if (isNaN(port) || port < 1 || port > 65535) {
errors.push(`Invalid SSH port: ${adapter.config.ssh_port}`);
}
// Validate MAC addresses
if (adapter.config.devices) {
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
for (let idx = 0; idx < adapter.config.devices.length; idx++) {
const device = adapter.config.devices[idx];
if (device.mac && !macRegex.test(device.mac)) {
errors.push(`Invalid MAC address for device ${idx + 1}: ${device.mac}`);
}
}
}
// Validate intervals
const interval = parseInt(adapter.config.interval, 10);
if (isNaN(interval) || interval < 5000) {
errors.push('Polling interval must be at least 5000ms');
}
const activeInterval = parseInt(adapter.config.active_interval, 10);
if (isNaN(activeInterval) || activeInterval < 60000) {
errors.push('Active interval must be at least 60000ms');
}
return errors;
}
function main() {
if (!adapter.config.devices) {
adapter.log.info('No Devices to watch configured');
stop();
return;
}
const validationErrors = validateConfig();
if (validationErrors.length > 0) {
adapter.log.error('Configuration validation failed:');
validationErrors.forEach(function (err) {
adapter.log.error(` - ${err}`);
});
stop();
return;
}
adapter.config.interval = parseInt(adapter.config.interval, 10);
adapter.config.active_interval = parseInt(adapter.config.active_interval, 10);
// Active Intervall mininum 60 Seconds
if (adapter.config.active_interval < 60000) {
adapter.config.active_interval = 60000;
}
syncConfig(function () {
getActiveDevices();
});
}
// If started as allInOne/compact mode => return function to create instance
if (module && module.parent) {
module.exports = startAdapter;
} else {
// or start the instance directly
startAdapter();
}