iobroker.js-controller
Version:
Updated by reinstall.js on 2018-06-11T15:19:56.688Z
1,217 lines (1,117 loc) • 409 kB
JavaScript
/* jshint -W097 */
/* jshint strict: false */
/* jslint node: true */
'use strict';
// This is file, that makes all communication with controller. All options are optional except name.
// following options are available:
// name: name of the adapter. Must be exactly the same as directory name.
// dirname: adapter directory name
// instance: instance number of adapter
// objects: true or false, if desired to have oObjects. This is a list with all states, channels and devices of this adapter and it will be updated automatically.
// states: true or false, if desired to have oStates. This is a list with all states values and it will be updated automatically.
// systemConfig: if required system configuration. Store it in systemConfig attribute
// objectChange: callback function (id, obj) that will be called if object changed
// stateChange: callback function (id, obj) that will be called if state changed
// message: callback to inform about new message the adapter
// unload: callback to stop the adapter
// config: configuration of the connection to controller
// noNamespace: return short names of objects and states in objectChange and in stateChange
// strictObjectChecks: flag which defaults to true - if true, adapter warns if states are set without an corresponding existing object
const net = require('net');
const fs = require('fs-extra');
const extend = require('node.extend');
const util = require('util');
const os = require('os');
const EventEmitter = require('events').EventEmitter;
const tools = require('./tools');
const pidUsage = require('pidusage');
const deepClone = require('deep-clone');
const EXIT_CODES = require('./exitCodes');
const {PluginHandler} = require('@iobroker/plugin-base');
const controllerVersion = require('../package.json').version;
const password = require('./password');
const { FORBIDDEN_CHARS } = tools;
const DEFAULT_SECRET = 'Zgfr56gFe87jJOM';
const ALIAS_STARTS_WITH = 'alias.';
const SYSTEM_ADMIN_USER = 'system.user.admin';
const SYSTEM_ADMIN_GROUP = 'system.group.administrator';
const QUALITY_SUBS_INITIAL = 0x20;
const supportedFeatures = [
'ALIAS', // Alias Feature supported, Since js-controller 2.0
'ALIAS_SEPARATE_READ_WRITE_ID', // Alias support separated ids for read and write, Since js-controller 3.0
'ADAPTER_GETPORT_BIND', // getPort method of adapter supports second parameter to bind to a special network interface, Since js-controller 2.0
'ADAPTER_DEL_OBJECT_RECURSIVE', // delObject supports options.recursive flag to delete objects structures recursive, Since js-controller 2.2
'ADAPTER_SET_OBJECT_SETS_DEFAULT_VALUE', // setObject(*) methods set the default (def) value via setState after the object is created. Since js-controller 2.0
'ADAPTER_AUTO_DECRYPT_NATIVE', // all native attributes, that are listed in an array `encryptedNative` in io-pack will be automatically decrypted and encrypted. Since js-controller 3.0
'PLUGINS', // configurable plugins supported. Since js-controller 3.0
'CONTROLLER_NPM_AUTO_REBUILD', // Automatic rebuild when node version mismatch is detected. Since js-controller 3.0
'CONTROLLER_READWRITE_BASE_SETTINGS' // If base settings could be read and written. Since js-controller 3.0
];
//const ACCESS_EVERY_EXEC = 0x1;
const ACCESS_EVERY_WRITE = 0x2;
const ACCESS_EVERY_READ = 0x4;
//const ACCESS_EVERY_RW = ACCESS_EVERY_WRITE | ACCESS_EVERY_READ;
//const ACCESS_EVERY_ALL = ACCESS_EVERY_WRITE | ACCESS_EVERY_READ | ACCESS_EVERY_EXEC;
//const ACCESS_GROUP_EXEC = 0x10;
const ACCESS_GROUP_WRITE = 0x20;
const ACCESS_GROUP_READ = 0x40;
//const ACCESS_GROUP_RW = ACCESS_GROUP_WRITE | ACCESS_GROUP_READ;
//const ACCESS_GROUP_ALL = ACCESS_GROUP_WRITE | ACCESS_GROUP_READ | ACCESS_GROUP_EXEC;
//const ACCESS_USER_EXEC = 0x100;
const ACCESS_USER_WRITE = 0x200;
const ACCESS_USER_READ = 0x400;
//const ACCESS_USER_RW = ACCESS_USER_WRITE | ACCESS_USER_READ;
//const ACCESS_USER_ALL = ACCESS_USER_WRITE | ACCESS_USER_READ | ACCESS_USER_EXEC;
// const ACCESS_EXEC = 0x1;
// const ACCESS_WRITE = 0x2;
// const ACCESS_READ = 0x4;
// const ACCESS_LIST = 'list';
// const ACCESS_DELETE = 'delete';
// const ACCESS_CREATE = 'create';
const ERROR_PERMISSION = 'permissionError';
/**
* Look up the error description for an error code
*
* @param {number} code error code
* @return {string} error description
*/
function getErrorText(code) {
code = code || 0;
return (EXIT_CODES[code] || code).toString();
}
class Log {
/**
* @param {string} namespaceLog Logging namespace to prefix
* @param {string} level The log level
* @param {object} logger logger instance
*/
constructor(namespaceLog, level, logger) {
this.namespaceLog = namespaceLog;
this.level = level;
// We have to bind the this context here or it is possible that `this` is
// undefined when passing around the logger methods. This happens e.g. when doing this:
// const log = new Log(...);
// const test = log.info;
// test();
this.logger = logger;
this.silly = this.silly.bind(this);
this.debug = this.debug.bind(this);
this.info = this.info.bind(this);
this.error = this.error.bind(this);
this.warn = this.warn.bind(this);
}
silly(msg) {
this.logger.silly(this.namespaceLog + ' ' + msg);
}
debug(msg) {
this.logger.debug(this.namespaceLog + ' ' + msg);
}
info(msg) {
this.logger.info(this.namespaceLog + ' ' + msg);
}
error(msg) {
this.logger.error(this.namespaceLog + ' ' + msg);
}
warn(msg) {
this.logger.warn(this.namespaceLog + ' ' + msg);
}
}
/**
* Adapter class
*
* How the initialization happens:
* initObjects => initStates => prepareInitAdapter => createInstancesObjects => initAdapter => initLogging => ready
*
* @class
* @param {string|object} options object like {name: "adapterName", systemConfig: true} or just "adapterName"
* @return {object} object instance
*/
function Adapter(options) {
if (!(this instanceof Adapter)) {
return new Adapter(options);
}
/** @type {Record<string, any>} */
let config = null;
let defaultObjs;
const configFileName = tools.getConfigFileName();
if (fs.existsSync(configFileName)) {
config = fs.readJSONSync(configFileName);
config.states = config.states || {type: 'file'};
config.objects = config.objects || {type: 'file'};
} else {
throw new Error(`Cannot find ${configFileName}`);
}
if (!options || (!config && !options.config)) {
throw new Error('Configuration not set!');
}
let schedule;
let restartScheduleJob;
let initializeTimeout;
let adapterStates;
let adapterObjects;
let timers = {};
let timerId = 1;
let intervals = {};
let stopInProgress = false;
if (options.config && !options.config.log) {
options.config.log = config.log;
}
config = options.config || config;
this.startedInCompactMode = options.compact;
const regUser = /^system\.user\./;
const regGroup = /^system\.group\./;
let firstConnection = true;
let systemSecret = null;
let reportInterval;
this.logList = [];
this.aliases = {};
this.aliasPatterns = [];
// TODO: Remove this backward compatibility shim in the future
this.objects = {};
this.eventLoopLags = [];
this.overwriteLogLevel = false;
this.adapterReady = false;
// Provide tools for use in adapter
this.tools = tools;
// possible arguments
// 0,1,.. - instance
// info, debug, warn, error - log level
// --force
// --logs
// --silent
// --install
// --debug = --force + --logs
if (process.argv) {
for (let a = 1; a < process.argv.length; a++) {
if (process.argv[a] === 'info' || process.argv[a] === 'debug' || process.argv[a] === 'error' || process.argv[a] === 'warn' || process.argv[a] === 'silly') {
config.log.level = process.argv[a];
this.overwriteLogLevel = true;
} else if (process.argv[a] === '--silent') {
config.isInstall = true;
process.argv[a] = '--install';
} else if (process.argv[a] === '--install') {
config.isInstall = true;
} else if (process.argv[a] === '--logs') {
config.consoleOutput = true;
} else if (process.argv[a] === '--force') {
config.forceIfDisabled = true;
} else if (process.argv[a] === '--debug') {
config.forceIfDisabled = true;
config.consoleOutput = true;
if (config.log.level !== 'silly') {
config.log.level = 'debug';
this.overwriteLogLevel = true;
}
} else if (process.argv[a] === '--console') {
config.consoleOutput = true;
} else if (parseInt(process.argv[a], 10).toString() === process.argv[a]) {
config.instance = parseInt(process.argv[a], 10);
}
}
}
config.log.level = config.log.level || 'info';
config.log.noStdout = !config.consoleOutput;
const logger = require('./logger.js')(config.log);
// compatibility
if (!logger.silly) {
logger.silly = logger.debug;
}
// enable "var adapter = require(__dirname + '/../../lib/adapter.js')('adapterName');" call
if (typeof options === 'string') {
options = {name: options};
}
if (!options.name) {
throw new Error('No name of adapter!');
}
this.performStrictObjectChecks = options.strictObjectChecks !== false;
this._getObjectsByArray = (keys, objects, options, cb, _index, _result, _errors) => {
if (objects) {
return tools.maybeCallbackWithError(cb, null, objects);
}
_index = _index || 0;
_result = _result || [];
_errors = _errors || [];
while(!keys[_index] && _index < keys.length) {
_index++;
}
if (_index >= keys.length) {
return tools.maybeCallbackWithError(cb, _errors.find(e => e) ? _errors : null, _result);
}
// if empty => skip immediately
this.getForeignObject(keys[_index], options, (err, obj) => {
_result[_index] = obj;
setImmediate(() => this._getObjectsByArray(keys, objects, options, cb, _index + 1, _result, _errors));
});
};
/**
* stops the execution of adapter, but not disables it.
*
* Sometimes, the adapter must be stopped if some libraries are missing.
*
* @alias terminate
* @memberof Adapter
* @param {string | number} [reason] optional termination description
* @param {number} [exitCode] optional exit code
*/
this.terminate = (reason, exitCode) => {
// This function must be defined very first, because in the next lines will be yet used.
if (this.terminated) {
return;
}
this.terminated = true;
this.pluginHandler && this.pluginHandler.destroyAll();
if (reportInterval) {
clearInterval(reportInterval);
reportInterval = null;
}
if (restartScheduleJob) {
restartScheduleJob.cancel();
restartScheduleJob = null;
}
if (typeof reason === 'number') {
// Only the exit code was passed
exitCode = reason;
reason = null;
}
if (typeof exitCode !== 'number') {
exitCode = process.argv.indexOf('--install') === -1 ? EXIT_CODES.ADAPTER_REQUESTED_TERMINATION : EXIT_CODES.NO_ERROR;
}
const isNotCritical =
exitCode === EXIT_CODES.ADAPTER_REQUESTED_TERMINATION
|| exitCode === EXIT_CODES.START_IMMEDIATELY_AFTER_STOP
|| exitCode === EXIT_CODES.NO_ERROR
;
const text = `${this.namespaceLog} Terminated (${getErrorText(exitCode)}): ${reason ? reason : 'Without reason'}`;
if (isNotCritical) {
logger.info(text);
} else {
logger.warn(text);
}
setTimeout(async () => { // give last states some time to get handled
if (adapterStates) {
try {
await adapterStates.destroy();
} catch {
// ignore
}
}
if (adapterObjects) {
try {
await adapterObjects.destroy();
} catch {
//ignore
}
}
if (this.startedInCompactMode) {
this.emit('exit', exitCode, reason);
adapterStates = null;
adapterObjects = null;
} else {
process.exit(exitCode === undefined ? EXIT_CODES.ADAPTER_REQUESTED_TERMINATION : exitCode);
}
}, 500);
};
// If installed as npm module
if (options.dirname) {
this.adapterDir = options.dirname.replace(/\\/g, '/');
} else {
this.adapterDir = __dirname.replace(/\\/g, '/').split('/');
// it can be .../node_modules/appName.js-controller/node_modules/appName.adapter
// .../appName.js-controller/node_modules/appName.adapter
// .../appName.js-controller/adapter/adapter
// remove "lib"
this.adapterDir.pop();
const jsc = this.adapterDir.pop();
if ((jsc === tools.appName + '.js-controller' || jsc === tools.appName.toLowerCase() + '.js-controller') && this.adapterDir.pop() === 'node_modules') {
// js-controller is installed as npm
const appName = tools.appName.toLowerCase();
this.adapterDir = this.adapterDir.join('/');
if (fs.existsSync(this.adapterDir + '/node_modules/' + appName + '.' + options.name)) {
this.adapterDir += '/node_modules/' + appName + '.' + options.name;
} else if (fs.existsSync(this.adapterDir + '/node_modules/' + appName + '.js-controller/node_modules/' + appName + '.' + options.name)) {
this.adapterDir += '/node_modules/' + appName + '.js-controller/node_modules/' + appName + '.' + options.name;
} else if (fs.existsSync(this.adapterDir + '/node_modules/' + appName + '.js-controller/adapter/' + options.name)) {
this.adapterDir += '/node_modules/' + appName + '.js-controller/adapter/' + options.name;
} else if (fs.existsSync(this.adapterDir + '/node_modules/' + tools.appName + '.js-controller/node_modules/' + appName + '.' + options.name)) {
this.adapterDir += '/node_modules/' + tools.appName + '.js-controller/node_modules/' + appName + '.' + options.name;
} else {
logger.error(this.namespaceLog + ' Cannot find directory of adapter ' + options.name);
this.terminate(EXIT_CODES.CANNOT_FIND_ADAPTER_DIR);
}
} else {
this.adapterDir = __dirname.replace(/\\/g, '/');
// remove "/lib"
this.adapterDir = this.adapterDir.substring(0, this.adapterDir.length - 4);
if (fs.existsSync(this.adapterDir + '/node_modules/' + tools.appName + '.' + options.name)) {
this.adapterDir += '/node_modules/' + tools.appName + '.' + options.name;
} else if (fs.existsSync(this.adapterDir + '/../node_modules/' + tools.appName + '.' + options.name)) {
const parts = this.adapterDir.split('/');
parts.pop();
this.adapterDir = parts.join('/') + '/node_modules/' + tools.appName + '.' + options.name;
} else {
logger.error(this.namespaceLog + ' Cannot find directory of adapter ' + options.name);
this.terminate(EXIT_CODES.CANNOT_FIND_ADAPTER_DIR);
}
}
}
if (fs.existsSync(this.adapterDir + '/package.json')) {
this.pack = fs.readJSONSync(this.adapterDir + '/package.json');
} else {
logger.info(this.namespaceLog + ' Non npm module. No package.json');
}
if (!this.pack || !this.pack.io) {
if (fs.existsSync(this.adapterDir + '/io-package.json')) {
this.ioPack = fs.readJSONSync(this.adapterDir + '/io-package.json');
} else {
logger.error(this.namespaceLog + ' Cannot find: ' + this.adapterDir + '/io-package.json');
this.terminate(EXIT_CODES.CANNOT_FIND_ADAPTER_DIR);
}
} else {
this.ioPack = this.pack.io;
}
// If required system configuration. Store it in systemConfig attribute
if (options.systemConfig) {
this.systemConfig = config;
// Workaround for an admin 5 issue which could lead to deleting the dataDir folder
// TODO: remove it as soon as all adapters are fixed which use systemConfig.dataDir
if (!Object.prototype.hasOwnProperty.call(this.systemConfig, 'dataDir')) {
this.systemConfig.dataDir = tools.getDefaultDataDir();
}
}
let States;
if (config.states && config.states.type) {
try {
States = require(`@iobroker/db-states-${config.states.type}`).Client;
} catch (err) {
throw new Error(`Unknown states type: ${config.states.type}: ${err.message}`);
}
} else {
States = require('./states');
}
let Objects;
if (config.objects && config.objects.type) {
try {
Objects = require(`@iobroker/db-objects-${config.objects.type}`).Client;
} catch (err) {
throw new Error(`Unknown objects type: ${config.objects.type}: ${err.message}`);
}
} else {
Objects = require('./objects');
}
const ifaces = os.networkInterfaces();
const ipArr = [];
for (const dev in ifaces) {
if (!Object.prototype.hasOwnProperty.call(ifaces, dev)) {
continue;
}
/*jshint loopfunc:true */
ifaces[dev].forEach(details => !details.internal && ipArr.push(details.address));
}
const instance = parseInt(options.compactInstance !== undefined ? options.compactInstance : ((options.instance !== undefined) ? options.instance : (config.instance || 0)), 10);
this.name = options.name;
this.namespace = options.name + '.' + instance;
this.namespaceLog = this.namespace + (this.startedInCompactMode ? ' (COMPACT)' : ' (' + process.pid + ')');
this._namespaceRegExp = new RegExp('^' + (this.namespace + '.').replace(/\./g, '\\.')); // cache the regex object 'adapter.0.'
/** The cache of users */
this.users = {};
/** The cache of usernames */
this.usernames = {};
/** The cache of user groups */
this.groups = {};
this.defaultHistory = null;
/** An array of instances, that support auto subscribe */
this.autoSubscribe = null;
this.inputCount = 0;
this.outputCount = 0;
/** A RegExp to test for forbidden chars in object IDs */
this.FORBIDDEN_CHARS = FORBIDDEN_CHARS;
/** Whether the adapter has already terminated */
this.terminated = false;
let callbackId = 1;
this.getPortRunning = null;
/**
* Checks if a passed ID is valid. Throws an error if id is invalid
*
* @param {string|object} id id to check or object with properties device, channel and state
* @param {boolean} isForeignId true&false if the ID is a foreign/full ID or only an "adapter local" id
* @param {object} options optional
* @throws Error when id is invalid
*/
const validateId = (id, isForeignId, options) => {
// there is special maintenance mode to clear the DB from invalid IDs
if (options && options.maintenance && options.user === SYSTEM_ADMIN_USER) {
return;
}
if (!id && id !== 0) {
throw new Error('The id is empty! Please provide a valid id.');
}
const type = typeof id;
if (!isForeignId && type === 'number') {
logger.warn(`${this.namespaceLog} The id "${id}" has an invalid type!: Expected "string" or "object", received "number".`);
logger.warn(`${this.namespaceLog} This will be refused in future versions. Please report this to the developer.`);
} else if (type !== 'string' && !tools.isObject(id)) {
throw new Error(`The id "${id}" has an invalid type! Expected "string" or "object", received "${type}".`);
}
if (tools.isObject(id)) {
// id can be an object, at least one of the following properties has to exist
const reqProperties = ['device', 'channel', 'state'];
let found = false;
for (const reqProperty of reqProperties) {
if (reqProperty !== undefined) {
if (typeof reqProperty !== 'string') {
throw new Error(`The id's property "${reqProperty}" of "${JSON.stringify(id)}" has an invalid type! Expected "string", received "${typeof reqProperty}".`);
}
if (reqProperty.includes('.')) {
throw new Error(`The id's property "${reqProperty}" of "${JSON.stringify(id)}" contains the invalid character "."!`);
}
found = true;
}
}
if (found === false) {
throw new Error(`The id "${JSON.stringify(id)}" is an invalid object! Expected at least one of the properties "device", "channel" or "state" to exist.`);
}
} else {
if (type !== 'string') {
throw new Error(`The id "${JSON.stringify(id)}" has an invalid type! Expected "string", received "${type}".`);
}
if (id.endsWith('.')) {
throw new Error(`The id "${id}" is invalid. Ids are not allowed to end in "."`);
}
}
};
/**
* Helper function to find next free port
*
* Looks for first free TCP port starting with given one:
* <pre><code>
* adapter.getPort(8081, function (port) {
* adapter.log.debug('Following port is free: ' + port);
* });
* </code></pre>
*
* @alias getPort
* @memberof Adapter
* @param {number} port port number to start the search for free port
* @param {string} [host] optional hostname for the port search
* @param {(port: number) => void} callback return result
* <pre><code>function (port) {}</code></pre>
*/
this.getPort = (port, host, callback) => {
if (!port) {
throw new Error('adapterGetPort: no port');
}
if (typeof host === 'function') {
callback = host;
host = null;
}
if (!host) {
host = undefined;
}
if (typeof port === 'string') {
port = parseInt(port, 10);
}
this.getPortRunning = {port, host, callback};
const server = net.createServer();
try {
server.listen({port, host},(/* err */) => {
server.once('close', () => {
return tools.maybeCallback(callback, port);
});
server.close();
});
server.on('error', (/* err */) => {
setTimeout(() => this.getPort(port + 1, host, callback), 100);
});
} catch {
setImmediate(() => this.getPort(port + 1, host, callback));
}
};
/**
* Promise-version of Adapter.getPort
*/
this.getPortAsync = tools.promisifyNoError(this.getPort, this);
/**
* Method to check for available Features for adapter development
*
* Use it like ...
* <pre><code>
* if (adapter.supportsFeature && adapter.supportsFeature('ALIAS')) {
* ...
* }
* </code></pre>
* @alias supportsFeature
* @memberof Adapter
* @param {string} featureName the name of the feature to check
* @returns {boolean} true/false wether the featufre is in the list of supported features
*/
this.supportsFeature = featureName => {
return supportedFeatures.includes(featureName);
};
/**
* validates user and password
*
*
* @alias checkPassword
* @memberof Adapter
* @param {string} user user name as text
* @param {string} pw password as text
* @param {object} [options] optional user context
* @param {(success: boolean, user: string) => void} callback return result
* <pre><code>
* function (result) {
* if (result) adapter.log.debug('User is valid');
* }
* </code></pre>
*/
this.checkPassword = async (user, pw, options, callback) => {
if (typeof options === 'function') {
callback = options;
options = null;
}
if (!callback) {
throw new Error('checkPassword: no callback');
}
if (user && !regUser.test(user)) {
// its not yet a `system.user.xy` id, thus we assume it's a username
if (!this.usernames[user]) {
// we did not find the id of the username in our cache -> update cache
try {
await updateUsernameCache();
} catch (e) {
this.log.error(e.message);
}
if (!this.usernames[user]) {
// user still not there, its no valid user -> fallback to legacy check
user = `system.user.${user.toString().replace(this.FORBIDDEN_CHARS, '_').replace(/\s/g, '_').replace(/\./g, '_').toLowerCase()}`;
} else {
user = this.usernames[user].id;
}
} else {
user = this.usernames[user].id;
}
}
this.getForeignObject(user, options, (err, obj) => {
if (err || !obj || !obj.common || (!obj.common.enabled && user !== SYSTEM_ADMIN_USER)) {
return tools.maybeCallback(callback, false, user);
} else {
password(pw).check(obj.common.password, (err, res) => {
return tools.maybeCallback(callback, res, user);
});
}
});
};
/**
* Promise-version of Adapter.checkPassword
*/
this.checkPasswordAsync = tools.promisifyNoError(this.checkPassword, this);
/**
* Return ID of given username
*
* @param {string} username - name of the user
* @return {Promise<undefined|string>}
*/
this.getUserID = async username => {
if (!this.usernames[username]) {
try {
// did not find username, we should have a look in the cache
await updateUsernameCache();
if (!this.usernames[username]) {
return;
}
} catch (e) {
this.log.error(e.message);
return;
}
}
return this.usernames[username].id;
};
/**
* sets the user's password
*
* @alias setPassword
* @memberof Adapter
* @param {string} user user name as text
* @param {string} pw password as text
* @param {object} [options] optional user context
* @param {ioBroker.ErrorCallback} [callback] return result
* <pre><code>
* function (err) {
* if (err) adapter.log.error('Cannot set password: ' + err);
* }
* </code></pre>
*/
this.setPassword = async (user, pw, options, callback) => {
if (typeof options === 'function') {
callback = options;
options = null;
}
if (user && !regUser.test(user)) {
// its not yet a `system.user.xy` id, thus we assume it's a username
if (!this.usernames[user]) {
// we did not find the id of the username in our cache -> update cache
try {
await updateUsernameCache();
} catch (e) {
this.log.error(e);
}
if (!this.usernames[user]) {
// user still not there, fallback to legacy check
user = `system.user.${user.toString().replace(this.FORBIDDEN_CHARS, '_').replace(/\s/g, '_').replace(/\./g, '_').toLowerCase()}`;
} else {
user = this.usernames[user].id;
}
} else {
user = this.usernames[user].id;
}
}
this.getForeignObject(user, options, (err, obj) => {
if (err || !obj) {
return tools.maybeCallbackWithError(callback, 'User does not exist');
}
// BF: (2020.05.22) are the empty passwords allowed??
if (!pw) {
this.extendForeignObject(user, {
common: {
password: ''
}
}, options, () => {
return tools.maybeCallback(callback);
});
} else {
password(pw).hash(null, null, (err, res) => {
if (err) {
return tools.maybeCallbackWithError(callback, err);
}
this.extendForeignObject(user, {
common: {
password: res
}
}, options, () => {
return tools.maybeCallbackWithError(callback, null);
});
});
}
});
};
/**
* Promise-version of Adapter.setPassword
*/
this.setPasswordAsync = tools.promisify(this.setPassword, this);
/**
* returns if user exists and is in the group
*
* This function used mostly internally and the adapter developer do not require it.
*
* @alias checkGroup
* @memberof Adapter
* @param {string} user user name as text
* @param {string} group group name
* @param {object} [options] optional user context
* @param {(result: boolean) => void} callback return result
* <pre><code>
* function (result) {
* if (result) adapter.log.debug('User exists and in the group');
* }
* </code></pre>
*/
this.checkGroup = async (user, group, options, callback) => {
user = (user || '');
if (typeof options === 'function') {
callback = options;
options = null;
}
if (user && !regUser.test(user)) {
// its not yet a `system.user.xy` id, thus we assume it's a username
if (!this.usernames[user]) {
// we did not find the id of the username in our cache -> update cache
try {
await updateUsernameCache();
} catch (e) {
this.log.error(e);
}
if (!this.usernames[user]) {
// user still not there, its no valid user -> fallback
user = `system.user.${user.toString().replace(this.FORBIDDEN_CHARS, '_').replace(/\s/g, '_').replace(/\./g, '_').toLowerCase()}`;
} else {
user = this.usernames[user].id;
}
} else {
user = this.usernames[user].id;
}
}
if (group && !regGroup.test(group)) {
group = 'system.group.' + group;
}
this.getForeignObject(user, options, (err, obj) => {
if (err || !obj) {
return tools.maybeCallback(callback, false);
}
this.getForeignObject(group, options, (err, obj) => {
if (err || !obj) {
return tools.maybeCallback(callback, false);
}
if (obj.common.members.indexOf(user) !== -1) {
return tools.maybeCallback(callback, true);
} else {
return tools.maybeCallback(callback, false);
}
});
});
};
/**
* Promise-version of Adapter.checkGroup
*/
this.checkGroupAsync = tools.promisifyNoError(this.checkGroup, this);
/** @typedef {{[permission: string]: {type: 'object' | 'state' | '' | 'other' | 'file', operation: string}}} CommandsPermissions */
/**
* get the user permissions
*
* This function used mostly internally and the adapter developer do not require it.
* The function reads permissions of user's groups (it can be more than one) and merge permissions together
*
* @alias calculatePermissions
* @memberof Adapter
* @param {string} user user name as text
* @param {CommandsPermissions} commandsPermissions object that describes the access rights like
* <pre><code>
* // static information
* var commandsPermissions = {
* getObject: {type: 'object', operation: 'read'},
* getObjects: {type: 'object', operation: 'list'},
* getObjectView: {type: 'object', operation: 'list'},
* setObject: {type: 'object', operation: 'write'},
* subscribeObjects: {type: 'object', operation: 'read'},
* unsubscribeObjects: {type: 'object', operation: 'read'},
*
* getStates: {type: 'state', operation: 'list'},
* getState: {type: 'state', operation: 'read'},
* setState: {type: 'state', operation: 'write'},
* getStateHistory: {type: 'state', operation: 'read'},
* subscribe: {type: 'state', operation: 'read'},
* unsubscribe: {type: 'state', operation: 'read'},
* getVersion: {type: '', operation: ''},
*
* httpGet: {type: 'other', operation: 'http'},
* sendTo: {type: 'other', operation: 'sendto'},
* sendToHost: {type: 'other', operation: 'sendto'},
*
* readFile: {type: 'file', operation: 'read'},
* readFile64: {type: 'file', operation: 'read'},
* writeFile: {type: 'file', operation: 'write'},
* writeFile64: {type: 'file', operation: 'write'},
* unlink: {type: 'file', operation: 'delete'},
* rename: {type: 'file', operation: 'write'},
* mkdir: {type: 'file', operation: 'write'},
* readDir: {type: 'file', operation: 'list'},
* chmodFile: {type: 'file', operation: 'write'},
* chownFile: {type: 'file', operation: 'write'},
*
* authEnabled: {type: '', operation: ''},
* disconnect: {type: '', operation: ''},
* listPermissions: {type: '', operation: ''},
* getUserPermissions: {type: 'object', operation: 'read'}
* };
* </code></pre>
* @param {object} [options] optional user context
* @param {(result: ioBroker.PermissionSet) => void} [callback] return result
* <pre><code>
* function (acl) {
* // Access control object for admin looks like:
* // {
* // file: {
* // read: true,
* // write: true,
* // 'delete': true,
* // create: true,
* // list: true
* // },
* // object: {
* // read: true,
* // write: true,
* // 'delete': true,
* // list: true
* // },
* // state: {
* // read: true,
* // write: true,
* // 'delete': true,
* // create: true,
* // list: true
* // },
* // user: 'admin',
* // users: {
* // read: true,
* // write: true,
* // create: true,
* // 'delete': true,
* // list: true
* // },
* // other: {
* // execute: true,
* // http: true,
* // sendto: true
* // },
* // groups: ['administrator'] // can be more than one
* // }
* }
* </code></pre>
*/
this.calculatePermissions = async (user, commandsPermissions, options, callback) => {
user = (user || '');
if (typeof options === 'function') {
callback = options;
options = null;
}
if (user && !regUser.test(user)) {
// its not yet a `system.user.xy` id, thus we assume it's a username
if (!this.usernames[user]) {
// we did not find the id of the username in our cache -> update cache
try {
await updateUsernameCache();
} catch (e) {
this.log.error(e.message);
}
// user still not there, fallback
if (!this.usernames[user]) {
user = `system.user.${user.toString().replace(this.FORBIDDEN_CHARS, '_').replace(/\s/g, '_').replace(/\./g, '_').toLowerCase()}`;
} else {
user = this.usernames[user].id;
}
} else {
user = this.usernames[user].id;
}
}
// read all groups
let acl = {user: user};
if (user === SYSTEM_ADMIN_USER) {
acl.groups = [SYSTEM_ADMIN_GROUP];
for (const c of Object.keys(commandsPermissions)) {
if (!commandsPermissions[c].type) {
continue;
}
acl[commandsPermissions[c].type] = acl[commandsPermissions[c].type] || {};
acl[commandsPermissions[c].type][commandsPermissions[c].operation] = true;
}
return tools.maybeCallback(callback, acl);
}
acl.groups = [];
this.getForeignObjects('*', 'group', null, options, (err, groups) => {
// aggregate all groups permissions, where this user is
if (groups) {
for (const g of Object.keys(groups)) {
if (groups[g] &&
groups[g].common &&
groups[g].common.members &&
groups[g].common.members.indexOf(user) !== -1) {
acl.groups.push(groups[g]._id);
if (groups[g]._id === SYSTEM_ADMIN_GROUP) {
acl = {
file: {
read: true,
write: true,
'delete': true,
create: true,
list: true
},
object: {
read: true,
write: true,
'delete': true,
list: true
},
state: {
read: true,
write: true,
'delete': true,
create: true,
list: true
},
user: user,
users: {
read: true,
write: true,
create: true,
'delete': true,
list: true
},
other: {
execute: true,
http: true,
sendto: true
},
groups: acl.groups
};
break;
}
const gAcl = groups[g].common.acl;
try {
for (const type of Object.keys(gAcl)) {
// fix bug. Some version have user instead of users.
if (type === 'user') {
acl.users = acl.users || {};
} else {
acl[type] = acl[type] || {};
}
for (const op of Object.keys(gAcl[type])) {
// fix error
if (type === 'user') {
acl.users[op] = acl.users[op] || gAcl.user[op];
} else {
acl[type][op] = acl[type][op] || gAcl[type][op];
}
}
}
} catch (e) {
logger.error(this.namespaceLog + ' Cannot set acl: ' + e);
logger.error(this.namespaceLog + ' Cannot set acl: ' + JSON.stringify(gAcl));
logger.error(this.namespaceLog + ' Cannot set acl: ' + JSON.stringify(acl));
}
}
}
}
return tools.maybeCallback(callback, acl);
});
};
/**
* Promise-version of Adapter.calculatePermissions
*/
this.calculatePermissionsAsync = tools.promisifyNoError(this.calculatePermissions, this);
const readFileCertificate = cert => {
if (typeof cert === 'string') {
try {
// if length < 1024 its no valid cert, so we assume a path to a valid certificate
if (cert.length < 1024 && fs.existsSync(cert)) {
const certFile = cert;
cert = fs.readFileSync(certFile, 'utf8');
// start watcher of this file
fs.watch(certFile, (eventType, filename) => {
logger.warn(`${this.namespaceLog} New certificate "${filename}" detected. Restart adapter`);
setTimeout(stop, 2000, false, true);
});
}
} catch (e) {
logger.error(`${this.namespaceLog} Could not read certificate from file ${cert}: ${e.message}`);
}
}
return cert;
};
/**
* returns SSL certificates by name
*
* This function returns SSL certificates (private key, public cert and chained certificate).
* Names are defined in the system's configuration in admin, e.g. "defaultPrivate", "defaultPublic".
* The result can be directly used for creation of https server.
*
* @alias getCertificates
* @memberof Adapter
* @param {string} [publicName] public certificate name
* @param {string} [privateName] private certificate name
* @param {string} [chainedName] optional chained certificate name
* @param {(err: string | null, certs?: ioBroker.Certificates, useLetsEncryptCert?: boolean) => void} callback return result
* <pre><code>
* function (err, certs, letsEncrypt) {
* adapter.log.debug('private key: ' + certs.key);
* adapter.log.debug('public cert: ' + certs.cert);
* adapter.log.debug('chained cert: ' + certs.ca);
* }
* </code></pre>
*/
this.getCertificates = (publicName, privateName, chainedName, callback) => {
if (typeof publicName === 'function') {
callback = publicName;
publicName = null;
}
if (typeof privateName === 'function') {
callback = privateName;
privateName = null;
}
if (typeof chainedName === 'function') {
callback = chainedName;
chainedName = null;
}
publicName = publicName || this.config.certPublic;
privateName = privateName || this.config.certPrivate;
chainedName = chainedName || this.config.certChained;
// Load certificates
this.getForeignObject('system.certificates', null, (err, obj) => {
if (err || !obj ||
!obj.native.certificates ||
!publicName ||
!privateName ||
!obj.native.certificates[publicName] ||
!obj.native.certificates[privateName] ||
(chainedName && !obj.native.certificates[chainedName])
) {
logger.error(this.namespaceLog + ' Cannot configure secure web server, because no certificates found: ' + publicName + ', ' + privateName + ', ' + chainedName);
return tools.maybeCallbackWithError(callback, tools.ERRORS.ERROR_NOT_FOUND);
} else {
let ca;
if (chainedName) {
const chained = readFileCertificate(obj.native.certificates[chainedName]).split('-----END CERTIFICATE-----\r\n');
ca = [];
for (let c = 0; c < chained.length; c++) {
if (chained[c].replace(/(\r\n|\r|\n)/g, '').trim()) {
ca.push(chained[c] + '-----END CERTIFICATE-----\r\n');
}
}
}
return tools.maybeCallbackWithError(callback, null, {
key: readFileCertificate(obj.native.certificates[privateName]),
cert: readFileCertificate(obj.native.certificates[publicName]),
ca
}, obj.native.letsEncrypt);
}
});
};
/**
* Promise-version of Adapter.getCertificates
*/
this.getCertificatesAsync = tools.promisify(this.getCertificates, this);
/**
* Restarts an instance of the adapter.
*
* @memberof Adapter
*/
this.restart = () => {
logger.warn(this.namespaceLog + ' Restart initiated');
this.stop();
};
/**
* Updates the adapter config with new values. Only a subset of the configuration has to be provided,
* since merging with the existing config is done automatically, e.g. like this:
*
* `adapter.updateConfig({prop1: "newValue1"})`
*
* After updating the configuration, the adapter is automatically restarted.
*
* @param {Record<string, any>} newConfig The new config values to be stored
*/
this.updateConfig = newConfig => {
// merge the old and new configuration
const _config = Object.assign({}, this.config, newConfig);
// update the adapter config object
const configObjId = `system.adapter.${this.namespace}`;
this.getForeignObjectAsync(configObjId)
.then(obj => {
if (!obj) {
return Promise.reject(new Error(tools.ERRORS.ERROR_DB_CLOSED));
}
obj.native = _config;
return this.setForeignObjectAsync(configObjId, obj);
})
.catch(err => logger.error(`${this.namespaceLog} Updating the adapter config failed: ${err.message}`))
;
};
/**
* Disables and stops the adapter instance.
*/
this.disable = () => {
// update the adapter config object
const configObjId = `system.adapter.${this.namespace}`;
this.getForeignObjectAsync(configObjId)
.then(obj => {
if (!obj) {
return Promise.reject(new Error(tools.ERRORS.ERROR_DB_CLOSED));
}
obj.common.enabled = false;
return this.setForeignObjectAsync(configObjId, obj);