UNPKG

iobroker.js-controller

Version:

Updated by reinstall.js on 2018-06-11T15:19:56.688Z

1,217 lines (1,117 loc) • 409 kB
/* 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);