#!/usr/bin/env node /** * Command line interface for controlling a Clusterio cluster * @module ctl/ctl */ "use strict"; const fs = require("fs-extra"); const yargs = require("yargs"); const version = require("./package").version; const setBlocking = require("set-blocking"); const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); const libConfig = require("@clusterio/lib/config"); const libPlugin = require("@clusterio/lib/plugin"); const libPluginLoader = require("@clusterio/lib/plugin_loader"); const libCommand = require("@clusterio/lib/command"); const libSharedCommands = require("@clusterio/lib/shared_commands"); const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); const commands = require("./src/commands"); /** * Connector for control connection to controller * @private */ class ControlConnector extends libLink.WebSocketClientConnector { constructor(url, maxReconnectDelay, tlsCa, token) { super(url, maxReconnectDelay, tlsCa); this._token = token; } register() { logger.verbose("Connector | registering control"); this.sendHandshake("register_control", { token: this._token, agent: "clusterioctl", version: version, }); } } /** * Handles running the control * * Connects to the controller over WebSocket and sends commands to it. * @static */ class Control extends libLink.Link { constructor(connector, controlConfig, tlsCa, controlPlugins) { super("control", "controller", connector); libLink.attachAllMessages(this); /** * Control config used for connecting to the controller. * @type {module:lib/config.ControlConfig} */ this.config = controlConfig; /** * Certificate authority used to validate TLS connections to the controller. * @type {?string} */ this.tlsCa = tlsCa; /** * Mapping of plugin names to their instance for loaded plugins. * @type {Map} */ this.plugins = controlPlugins; for (let controlPlugin of controlPlugins.values()) { libPlugin.attachPluginMessages(this, controlPlugin); } /** * Keep the control connection alive after the command completes. * @type {boolean} */ this.keepOpen = false; } async accountUpdateEventHandler() { } async hostUpdateEventHandler() { } async instanceUpdateEventHandler() { } async saveListUpdateEventHandler() { } async modPackUpdateEventHandler() { } async modUpdateEventHandler() { } async userUpdateEventHandler() { } async setLogSubscriptions({ all = false, controller = false, host_ids = [], instance_ids = [], max_level = null, }) { await libLink.messages.setLogSubscriptions.send(this, { all, controller, host_ids, instance_ids, max_level, }); } async logMessageEventHandler(message) { logger.log(message.data.info); } async debugWsMessageEventHandler(message) { // eslint-disable-next-line no-console console.log("WS", message.data.direction, message.data.content); } async shutdown() { try { await libLink.messages.prepareDisconnect.send(this); } catch (err) { if (!(err instanceof libErrors.SessionLost)) { throw err; } } await this.connector.close(1000, "Control Quit"); } } async function loadPlugins(pluginList) { let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList); libConfig.registerPluginConfigGroups(pluginInfos); libConfig.finalizeConfigs(); let controlPlugins = new Map(); for (let pluginInfo of pluginInfos) { if (!pluginInfo.controlEntrypoint) { continue; } let ControlPluginClass = await libPluginLoader.loadControlPluginClass(pluginInfo); let controlPlugin = new ControlPluginClass(pluginInfo, logger); controlPlugins.set(pluginInfo.name, controlPlugin); await controlPlugin.init(); } return controlPlugins; } async function startControl() { yargs .scriptName("clusterioctl") .usage("$0 [options]") .option("log-level", { nargs: 1, describe: "Log level to print to stderr", default: "server", choices: Object.keys(levels), type: "string", }) .option("config", { nargs: 1, describe: "config file to get credentails from", default: "config-control.json", defaultDescription: "auto", type: "string", }) .option("plugin-list", { nargs: 1, describe: "File containing list of plugins available with their install path", default: "plugin-list.json", type: "string", }) .command("plugin", "Manage available plugins", libSharedCommands.pluginCommand) .command("control-config", "Manage Control config", libSharedCommands.configCommand) .wrap(yargs.terminalWidth()) .help(false) // Disable help to avoid triggering it on the first parse. ; // Parse the args first to get the configured plugin list. let args = yargs.parse(); // Log stream for the ctl session. logger.add(new ConsoleTransport({ errorLevels: Object.keys(levels), level: args.logLevel, format: new libLoggingUtils.TerminalFormat(), })); libLoggingUtils.handleUnhandledErrors(logger); logger.verbose(`Loading available plugins from ${args.pluginList}`); let pluginList = new Map(); try { pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList))); } catch (err) { if (err.code !== "ENOENT") { throw err; } } // If the command is plugin management we don't try to load plugins if (args._[0] === "plugin") { await libSharedCommands.handlePluginCommand(args, pluginList, args.pluginList); return; } logger.verbose("Loading Plugins"); let controlPlugins = await loadPlugins(pluginList); // Add all cluster management commands including ones from plugins let rootCommands = await commands.registerCommands(controlPlugins, yargs); // Reparse after commands have been added with help and strict checking. args = yargs .help() .strict() .parse() ; logger.verbose(`Loading config from ${args.config}`); let controlConfig = new libConfig.ControlConfig("control"); try { await controlConfig.load(JSON.parse(await fs.readFile(args.config))); } catch (err) { if (err.code === "ENOENT") { logger.verbose("Config not found, initializing new config"); await controlConfig.init(); } else { throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`); } } if (args._.length === 0) { yargs.showHelp(); yargs.exit(); } // Handle the control-config command before trying to connect. if (args._[0] === "control-config") { await libSharedCommands.handleConfigCommand(args, controlConfig, args.config); return; } // Determine which command is being executed. let commandPath = [...args._]; let targetCommand = rootCommands; while (commandPath.length && targetCommand instanceof libCommand.CommandTree) { targetCommand = targetCommand.get(commandPath.shift()); } // The remaining commands require connecting to the controller. if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) { logger.error("Missing URL and/or token to connect with. See README.md for setting up access."); process.exitCode = 1; return; } let tlsCa = null; let tlsCaPath = controlConfig.get("control.tls_ca"); if (tlsCaPath) { tlsCa = await fs.readFile(tlsCaPath); } let controlConnector = new ControlConnector( controlConfig.get("control.controller_url"), controlConfig.get("control.max_reconnect_delay"), tlsCa, controlConfig.get("control.controller_token") ); let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins); try { await controlConnector.connect(); } catch (err) { if (err instanceof libErrors.AuthenticationFailed) { throw new libErrors.StartupError(err.message); } throw err; } process.on("SIGINT", () => { logger.info("Caught interrupt signal, closing connection"); control.shutdown().catch(err => { setBlocking(true); logger.error(err.stack); // eslint-disable-next-line node/no-process-exit process.exit(1); }); }); try { await targetCommand.run(args, control); } catch (err) { control.keepOpen = false; if (err instanceof libErrors.CommandError) { logger.error(`Error running command: ${err.message}`); process.exitCode = 1; } else if (err instanceof libErrors.RequestError) { logger.error(`Error sending request: ${err.message}`); process.exitCode = 1; } else { throw err; } } finally { if (!control.keepOpen) { await control.shutdown(); } } } module.exports = { Control, }; if (module === require.main) { // eslint-disable-next-line no-console console.warn(` +==========================================================+ I WARNING: This is the development branch for the 2.0 I I version of clusterio. Expect things to break. I +==========================================================+ ` ); startControl().catch(err => { if (!(err instanceof libErrors.StartupError)) { logger.fatal(` +------------------------------------------------------------+ | Unexpected error occured while starting control, please | | report it to https://github.com/clusterio/clusterio/issues | +------------------------------------------------------------+ ${err.stack}` ); } else { logger.error(` +---------------------------------+ | Unable to to start clusterioctl | +---------------------------------+ ${err.stack}` ); } process.exitCode = 1; }); }