UNPKG

iobroker.js-controller

Version:

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

373 lines • 12.4 kB
import { exec as execAsync } from 'promisify-child-process'; import { tools, logger } from '@iobroker/js-controller-common'; import { valid } from 'semver'; import { dbConnectAsync } from '@iobroker/js-controller-cli'; import http from 'node:http'; import https from 'node:https'; import { setTimeout as wait } from 'node:timers/promises'; import fs from 'fs-extra'; import url from 'node:url'; import process from 'node:process'; class UpgradeManager { /** Wait ms until controller is stopped */ STOP_TIMEOUT_MS = 5_000; /** Wait ms for delivery of final response */ SHUTDOWN_TIMEOUT = 10_000; /** Instance of admin to get information from */ adminInstance; /** Desired controller version */ version; /** Group id the process should run as */ gid; /** User id the process should run as */ uid; /** Response send by webserver */ response = { running: true, stderr: [], stdout: [], }; /** Used to stop the stop shutdown timeout */ shutdownAbortController; /** Logger to log to file and other transports */ logger; /** The server used for communicating upgrade status */ server; /** All socket connections of the webserver */ sockets = new Set(); /** Name of the host for logging purposes */ hostname = tools.getHostName(); constructor(args) { this.adminInstance = args.adminInstance; this.version = args.version; this.logger = this.setupLogger(); this.gid = args.gid; this.uid = args.uid; } /** * To prevent commands (including npm) running as root, we apply the passed in gid and uid */ applyUser() { if (!process.setuid || !process.setgid) { const errMessage = 'Cannot ensure user and group ids on this system, because no POSIX platform'; this.log(errMessage, true); throw new Error(errMessage); } try { process.setgid(this.gid); process.setuid(this.uid); } catch (e) { const errMessage = `Could not ensure user and group ids on this system: ${e.message}`; this.log(errMessage, true); throw new Error(errMessage); } } /** * Set up the logger, to stream to file and other configured transports */ setupLogger() { const config = fs.readJSONSync(tools.getConfigFileName()); return logger({ ...config.log, noStdout: false }); } /** * Parse the commands from the cli */ static parseCliCommands() { const additionalArgs = process.argv.slice(2); const version = additionalArgs[0]; const adminInstance = parseInt(additionalArgs[1]); const uid = parseInt(additionalArgs[2]); const gid = parseInt(additionalArgs[3]); const isValid = !!valid(version); if (!isValid) { UpgradeManager.printUsage(); throw new Error('The provided version is not valid'); } if (isNaN(adminInstance)) { UpgradeManager.printUsage(); throw new Error('Please provide a valid admin instance'); } if (isNaN(uid)) { UpgradeManager.printUsage(); throw new Error('Please provide a valid uid'); } if (isNaN(gid)) { UpgradeManager.printUsage(); throw new Error('Please provide a valid gid'); } return { version, adminInstance, uid, gid }; } /** * Log via console and provide the logs for the server too * * @param message the message which will be logged * @param error if it is an error */ log(message, error = false) { if (error) { this.logger.error(`host.${this.hostname} [CONTROLLER_AUTO_UPGRADE] ${message}`); this.response.stderr.push(message); return; } this.logger.info(`host.${this.hostname} [CONTROLLER_AUTO_UPGRADE] ${message}`); this.response.stdout.push(message); } /** * Stops the js-controller via cli call */ async stopController() { if (tools.isDocker()) { await execAsync('/opt/scripts/maintenance.sh on -kbn'); } else { await execAsync(`${tools.appNameLowerCase} stop`); } await wait(this.STOP_TIMEOUT_MS); } /** * Starts the js-controller via cli */ startController() { if (tools.isDocker()) { return execAsync('/opt/scripts/maintenance.sh off -y'); } return execAsync(`${tools.appNameLowerCase} start`); } /** * Print how the module should be used */ static printUsage() { console.info('Example usage: "node upgradeManager.js <version> <adminInstance> <uid> <gid>"'); } /** * Install given version of js-controller */ async npmInstall() { const res = await tools.installNodeModule(`iobroker.js-controller@${this.version}`, { cwd: '/opt/iobroker', debug: true, }); this.response.stderr.push(...res.stderr.split('\n')); this.response.stdout.push(...res.stdout.split('\n')); this.response.success = res.success; if (!res.success) { throw new Error(`Could not install js-controller@${this.version}`); } } /** * Starts the web server for admin communication either secure or insecure * * @param params Web server configuration */ async startWebServer(params) { const { useHttps } = params; if (useHttps) { await this.startSecureWebServer(params); } else { await this.startInsecureWebServer(params); } } /** * Shuts down the server, restarts the controller and exits the program */ shutdownApp() { if (this.shutdownAbortController) { this.shutdownAbortController.abort(); } if (!this.server) { process.exit(); } this.destroySockets(); this.server.close(async () => { await this.startController(); this.log('Successfully started js-controller'); process.exit(); }); } /** * Destroy all sockets, to prevent requests from keeping server alive */ destroySockets() { for (const socket of this.sockets) { socket.destroy(); this.sockets.delete(socket); } } /** * This function is called when the webserver receives a message * * @param res server response */ webServerCallback(res) { res.writeHead(200, { 'Access-Control-Allow-Origin': '*', }); res.end(JSON.stringify(this.response)); if (!this.response.running) { this.log('Final information delivered'); this.shutdownApp(); } } /** * Start an insecure web server for admin communication * * @param params Web server configuration */ async startInsecureWebServer(params) { const { port } = params; this.server = http.createServer((_req, res) => { this.webServerCallback(res); }); this.monitorSockets(this.server); await new Promise(resolve => { this.server.listen(port, () => { resolve(); }); }); this.log(`Server is running on http://localhost:${port}`); } /** * Start a secure web server for admin communication * * @param params Web server configuration */ async startSecureWebServer(params) { const { port, certPublic, certPrivate } = params; this.server = https.createServer({ key: certPrivate, cert: certPublic }, (_req, res) => { this.webServerCallback(res); }); this.monitorSockets(this.server); await new Promise(resolve => { this.server.listen(port, () => { resolve(); }); }); this.log(`Server is running on https://localhost:${port}`); } /** * Keep track of all existing sockets * * @param server the webserver */ monitorSockets(server) { server.on('connection', socket => { this.sockets.add(socket); server.once('close', () => { this.sockets.delete(socket); }); }); } /** * Get certificates from the DB * * @param params certificate information */ async getCertificates(params) { const { objects, certPublicName, certPrivateName } = params; const obj = await objects.getObjectAsync('system.certificates'); if (!obj) { throw new Error('No certificates found'); } const certs = obj.native.certificates; return { certPrivate: certs[certPrivateName], certPublic: certs[certPublicName] }; } /** * Collect parameters for webserver from admin instance */ async collectWebServerParameters() { const { objects } = await dbConnectAsync(false); const obj = await objects.getObjectAsync(`system.adapter.admin.${this.adminInstance}`); if (!obj) { UpgradeManager.printUsage(); throw new Error('Please provide a valid admin instance'); } if (obj.native.secure) { const { certPublic: certPublicName, certPrivate: certPrivateName } = obj.native; const { certPublic, certPrivate } = await this.getCertificates({ objects, certPublicName, certPrivateName, }); return { useHttps: obj.native.secure, port: obj.native.port, certPublic, certPrivate, }; } return { useHttps: false, port: obj.native.port, }; } /** * Tells the upgrade manager, that server can be shut down on next response or on timeout */ async setFinished() { this.response.running = false; await this.startShutdownTimeout(); } /** * Start a timeout which starts controller and shuts down the app if expired */ async startShutdownTimeout() { this.shutdownAbortController = new AbortController(); try { await wait(this.SHUTDOWN_TIMEOUT, null, { signal: this.shutdownAbortController.signal }); this.log('Timeout expired, initializing shutdown'); this.shutdownApp(); } catch (e) { if (e.code !== 'ABORT_ERR') { this.log(e.message, true); } } } } /** * Main logic */ async function main() { const upgradeArguments = UpgradeManager.parseCliCommands(); const upgradeManager = new UpgradeManager(upgradeArguments); registerErrorHandlers(upgradeManager); const webServerParameters = await upgradeManager.collectWebServerParameters(); upgradeManager.log('Stopping controller'); await upgradeManager.stopController(); upgradeManager.log('Successfully stopped js-controller'); await upgradeManager.startWebServer(webServerParameters); // do this after web server is started, else we cannot bind on privileged ports after using setgid upgradeManager.applyUser(); try { await upgradeManager.npmInstall(); } catch (e) { upgradeManager.log(e.message, true); } await upgradeManager.setFinished(); } /** * Stream unhandled errors to the log files * * @param upgradeManager the instance of Upgrade Manager */ function registerErrorHandlers(upgradeManager) { process.on('uncaughtException', e => { upgradeManager.log(`Uncaught Exception: ${e.stack}`, true); }); process.on('unhandledRejection', rej => { upgradeManager.log(`Unhandled rejection: ${rej instanceof Error ? rej.stack : JSON.stringify(rej)}`, true); }); } /** * This file always needs to be executed in a process different from js-controller * else it will be canceled when the file itself stops the controller */ // eslint-disable-next-line unicorn/prefer-module const modulePath = url.fileURLToPath(import.meta.url || `file://${__filename}`); if (process.argv[1] === modulePath) { main(); } //# sourceMappingURL=upgradeManager.js.map