const fs = require('fs-extra'); const path = require('path'); const yargs = require("yargs"); const events = require("events"); const version = require("./package").version; // internal libraries const fileOps = require("lib/fileOps"); const hashFile = require('lib/hash').hashFile; const factorio = require("lib/factorio"); const link = require("lib/link"); const plugin = require("lib/plugin"); const errors = require("lib/errors"); const prometheus = require('lib/prometheus'); const luaTools = require('lib/luaTools'); const config = require('lib/config'); const instanceRconCommandsCounter = new prometheus.Counter( "clusterio_instance_rcon_commands_total", "How many commands have been sent to the instance", { labels: ["instance_id"] } ); /** * Keeps track of the runtime parameters of an instance */ class Instance extends link.Link{ constructor(connector, dir, factorioDir, instanceConfig) { super('instance', 'slave', connector); link.attachAllMessages(this); this._dir = dir; this.plugins = new Map(); this.config = instanceConfig; let serverOptions = { version: this.config.get("factorio.version"), gamePort: this.config.get('factorio.game_port'), rconPort: this.config.get('factorio.rcon_port'), rconPassword: this.config.get('factorio.rcon_password'), }; this.server = new factorio.FactorioServer( factorioDir, this._dir, serverOptions ); let originalSendRcon = this.server.sendRcon; this.server.sendRcon = (...args) => { instanceRconCommandsCounter.labels(String(this.config.get("instance.id"))).inc(); return originalSendRcon.call(this.server, ...args); } this.server.on('output', (output) => { link.messages.instanceOutput.send(this, { instance_id: this.config.get("instance.id"), output }) for (let [name, plugin] of this.plugins) { plugin.onOutput(output).catch(err => { console.error(`Plugin ${name} raised error in onOutput:`, err); }); } }); this.server.on("error", err => { console.log(`Error in instance ${this.name}:`, err); }); this.server.on("exit", err => { link.messages.instanceStopped.send(this); // Notify plugins of exit for (let pluginInstance of this.plugins.values()) { pluginInstance.onExit(); } }); } async init(pluginInfos, slave) { await this.server.init(); // load plugins for (let pluginInfo of pluginInfos) { if (!pluginInfo.instanceEntrypoint || !this.config.group(pluginInfo.name).get("enabled")) { continue; } // require plugin class and initialize it let pluginLoadStarted = Date.now(); let { InstancePlugin } = require(`./plugins/${pluginInfo.name}/${pluginInfo.instanceEntrypoint}`); let instancePlugin = new InstancePlugin(pluginInfo, this, slave); await instancePlugin.init(); this.plugins.set(pluginInfo.name, instancePlugin); plugin.attachPluginMessages(this, pluginInfo, instancePlugin); console.log(`Clusterio | Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`); } } /** * Write the server-settings.json file * * Generate the server-settings.json file from the example file in the * data directory and override any settings configured in the instance's * factorio_settings config entry. */ async writeServerSettings() { let serverSettings = await this.server.exampleSettings(); let overrides = this.config.get('factorio.settings'); for (let [key, value] of Object.entries(overrides)) { if (!Object.hasOwnProperty.call(serverSettings, key)) { console.log(`Warning: Server settings does not have the property '${key}'`); } serverSettings[key] = value; } await fs.writeFile( this.server.writePath("server-settings.json"), JSON.stringify(serverSettings, null, 4) ); } /** * Creates a new empty instance directory * * Creates the neccessary files for starting up a new instance into the * provided instance directory. * * @param {Number} id - * ID of the new instance. Must be unique to the cluster. * @param {String} instanceDir - * Directory to create the new instance into. * @param {String} factorioDir - Path to factorio installation. * @param {Object} options - Options for new instance. */ static async create(instanceConfig, instanceDir, factorioDir) { console.log(`Clusterio | Creating ${instanceDir}`); await fs.ensureDir(instanceDir); await fs.ensureDir(path.join(instanceDir, "script-output")); await fs.ensureDir(path.join(instanceDir, "saves")); } async start(saveName, slaveConfig, socket) { console.log("Clusterio | Writing server-settings.json"); await this.writeServerSettings(); console.log("Clusterio | Rotating old logs..."); // clean old log file to avoid crash try{ let logPath = this.path("factorio-current.log"); let stat = await fs.stat(logPath); if(stat.isFile()){ let logFilename = `factorio-${Math.floor(Date.parse(stat.mtime)/1000)}.log`; await fs.rename(logPath, this.path(logFilename)); console.log(`Log rotated as ${logFilename}`); } }catch(e){} await symlinkMods(this, "sharedMods", console); // Patch save with lua modules from plugins console.log("Clusterio | Patching save"); // Find plugin modules to patch in let modules = new Map(); for (let [pluginName, plugin] of this.plugins) { let modulePath = path.join('plugins', pluginName, 'module'); if (!await fs.pathExists(modulePath)) { continue; } let moduleJsonPath = path.join(modulePath, 'module.json'); if (!await fs.pathExists(moduleJsonPath)) { throw new Error(`Module for plugin ${pluginName} is missing module.json`); } let module = JSON.parse(await fs.readFile(moduleJsonPath)); if (module.name !== pluginName) { throw new Error(`Expected name of module for plugin ${pluginName} to match the plugin name`); } module = Object.assign({ version: plugin.info.version, dependencies: { 'clusterio': '*' }, path: modulePath, load: [], require: [], }, module); modules.set(module.name, module); } // Find stand alone modules to load // XXX for now it's assumed all available modules should be loaded. for (let entry of await fs.readdir('modules', { withFileTypes: true })) { if (entry.isDirectory()) { if (modules.has(entry.name)) { throw new Error(`Module with name ${entry.name} already exists in a plugin`); } let moduleJsonPath = path.join('modules', entry.name, 'module.json'); if (!await fs.pathExists(moduleJsonPath)) { throw new Error(`Module ${entry.name} is missing module.json`); } let module = JSON.parse(await fs.readFile(moduleJsonPath)); if (module.name !== entry.name) { throw new Error(`Expected name of module ${entry.name} to match the directory name`); } module = Object.assign({ path: path.join('modules', entry.name), dependencies: { 'clusterio': '*' }, load: [], require: [], }, module); modules.set(module.name, module); } } await factorio.patch(this.path("saves", saveName), [...modules.values()]); this.server.on('rcon-ready', () => { console.log("Clusterio | RCON connection established"); }); await this.server.start(saveName); await this.server.disableAchievements() await this.updateInstanceData(); for (let pluginInstance of this.plugins.values()) { await pluginInstance.onStart(); } } /** * Update instance information on the Factorio side */ async updateInstanceData() { let name = luaTools.escapeString(this.config.get("instance.name")); let id = this.config.get("instance.id"); await this.server.sendRcon(`/sc clusterio_private.update_instance(${id}, "${name}")`, true); } /** * Stop the instance */ async stop() { // XXX this needs more thought to it if (this.server._state === "running") { for (let pluginInstance of this.plugins.values()) { await pluginInstance.onStop(); } await this.server.stop(); } // Clear metrics this instance is exporting for (let collector of prometheus.defaultRegistry.collectors) { if ( collector instanceof prometheus.ValueCollector && collector.metric.labels.includes('instance_id') ) { collector.removeAll({ instance_id: String(this.config.get("instance.id")) }); } } } async getMetricsRequestHandler() { let results = [] for (let pluginInstance of this.plugins.values()) { let pluginResults = await pluginInstance.onMetrics(); if (pluginResults !== undefined) { for await (let result of pluginResults) { results.push(prometheus.serializeResult(result)) } } } return { results }; } async startInstanceRequestHandler() { // Find save to start let latestSave = await fileOps.getNewestFile( this.path("saves"), (name) => !name.endsWith('.tmp.zip') ); if (latestSave === null) { throw new errors.RequestError( "No savefile was found to start the instance with." ); } await this.start(latestSave, this.config, this.socket); } async createSaveRequestHandler() { console.log("Clusterio | Writing server-settings.json"); await this.writeServerSettings(); console.log("Creating save ....."); await symlinkMods(this, "sharedMods", console); await this.server.create("world"); console.log("Clusterio | Successfully created save"); } async stopInstanceRequestHandler() { await this.stop(); } async sendRconRequestHandler(message) { let result = await this.server.sendRcon(message.data.command); return { result }; } /** * Name of the instance * * This should not be used for filesystem paths. See .path() for that. */ get name() { return this.config.get("instance.name"); } /** * Return path in instance * * Creates a path using path.join with the given parts that's relative to * the directory of the instance. For example instance.path("mods") * returns a path to the mods directory of the instance. If no parts are * given it returns a path to the directory of the instance. */ path(...parts) { return path.join(this._dir, ...parts); } } /** * Searches for instances in the provided directory * * Looks through all sub-dirs of the provided directory for valid * instance definitions and returns a mapping of instance id to * instance config objects. * * @returns {Map} mapping between instance */ async function discoverInstances(instancesDir, logger) { let instanceInfos = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { let instanceConfig = new config.InstanceConfig(); let configPath = path.join(instancesDir, entry.name, "instance.json"); try { await instanceConfig.load(JSON.parse(await fs.readFile(configPath))); } catch (err) { if (err.code === "ENOENT") { continue; // Ignore folders without config.json } logger.error(`Error occured while parsing ${configPath}: ${err}`); continue; } let instancePath = path.join(instancesDir, entry.name); logger.log(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`); instanceInfos.set(instanceConfig.get("instance.id"), { path: instancePath, config: instanceConfig, }); } } return instanceInfos; } class InstanceConnection extends link.Link { constructor(connector, slave, instanceId) { super('slave', 'instance', connector); this.slave = slave; this.instanceId = instanceId; link.attachAllMessages(this); for (let pluginInfo of slave.pluginInfos) { plugin.attachPluginMessages(this, pluginInfo, null); } } async forwardRequestToMaster(message, request) { return await request.send(this.slave, message.data); } async forwardEventToInstance(message, event) { let instanceId = message.data.instance_id; let instanceConnection = this.slave.instanceConnections.get(instanceId); if (!instanceConnection) { // Instance is probably on another slave await this.slave.forwardEventToMaster(message, event); return; } event.send(instanceConnection, message.data); } async forwardEventToMaster(message, event) { event.send(this.slave, message.data); } async broadcastEventToInstance(message, event) { for (let instanceConnection of this.slave.instanceConnections.values()) { if (instanceConnection === this) { continue; // Do not broadcast back to the source } console.log("Broadcasting event"); event.send(instanceConnection, message.data); } } async instanceStoppedEventHandler(message) { this.slave.instanceConnections.delete(this.instanceId); } } class SlaveConnector extends link.WebSocketClientConnector { constructor(slaveConfig) { super(slaveConfig.get("slave.master_url"), slaveConfig.get("slave.reconnect_delay")); this.slaveConfig = slaveConfig; } register() { console.log("SOCKET | registering slave"); this.sendHandshake("register_slave", { token: this.slaveConfig.get("slave.master_token"), agent: "Clusterio Slave", version, id: this.slaveConfig.get("slave.id"), name: this.slaveConfig.get("slave.name"), }); } } /** * Handles running the slave * * Connects to the master server over the WebSocket and manages intsances. */ class Slave extends link.Link { // I don't like God classes, but the alternative of putting all this state // into global variables is not much better. constructor(connector, slaveConfig, pluginInfos) { super('slave', 'master', connector); link.attachAllMessages(this); this.pluginInfos = pluginInfos; for (let pluginInfo of pluginInfos) { plugin.attachPluginMessages(this, pluginInfo, null); } this.config = slaveConfig; this.instanceConnections = new Map(); this.instanceInfos = new Map(); } async _findNewInstanceDir(name) { try { checkFilename(name) } catch (err) { throw new Error(`Instance name ${err.message}`); } // For now add dashes until an unused directory name is found let dir = path.join(this.config.get("slave.instances_directory"), name); while (await fs.pathExists(dir)) { dir += '-'; } return dir; } async forwardRequestToInstance(message, request) { let instanceId = message.data.instance_id; if (!this.instanceInfos.has(instanceId)) { throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`); } let instanceConnection = this.instanceConnections.get(instanceId); if (!instanceConnection) { throw new errors.RequestError(`Instance ID ${instanceId} is not running`); } return await request.send(instanceConnection, message.data); } async forwardEventToInstance(message, event) { let instanceId = message.data.instance_id; if (!this.instanceInfos.has(instanceId)) { return; } let instanceConnection = this.instanceConnections.get(instanceId); if (!instanceConnection) { return; } event.send(instanceConnection, message.data); } async broadcastEventToInstance(message, event) { for (let instanceConnection of this.instanceConnections.values()) { event.send(instanceConnection, message.data); } } async assignInstanceRequestHandler(message) { let { instance_id, serialized_config } = message.data; let instanceInfo = this.instanceInfos.get(instance_id); if (instanceInfo) { instanceInfo.config.update(serialized_config); console.log(`Clusterio | Updated config for ${instanceInfo.path}`); // TODO: Notify of update if instance is running } else { let instanceConfig = new config.InstanceConfig(); await instanceConfig.load(serialized_config); // XXX: race condition on multiple simultanious calls let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name")); await Instance.create(instanceConfig, instanceDir, this.config.get("slave.factorio_directory")); instanceInfo = { path: instanceDir, config: instanceConfig, }; this.instanceInfos.set(instance_id, instanceInfo); console.log(`Clusterio | assigned instance ${instanceConfig.get("instance.name")}`); } // save a copy of the instance config let warnedOutput = Object.assign( { _warning: "Changes to this file will be overwritten by the master server's copy." }, instanceInfo.config.serialize() ); await fs.outputFile( path.join(instanceInfo.path, "instance.json"), JSON.stringify(warnedOutput, null, 4) ); } /** * Initialize and connect an unloaded instance */ async _connectInstance(instanceId) { let instanceInfo = this.instanceInfos.get(instanceId); if (!instanceInfo) { throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`); } if (this.instanceConnections.has(instanceId)) { throw new errors.RequestError(`Instance with ID ${instanceId} is running`); } let [connectionClient, connectionServer] = link.VirtualConnector.makePair(); let instanceConnection = new InstanceConnection(connectionServer, this, instanceId); let instance = new Instance( connectionClient, instanceInfo.path, this.config.get("slave.factorio_directory"), instanceInfo.config ); await instance.init(this.pluginInfos, this); // XXX: race condition on multiple simultanious calls this.instanceConnections.set(instanceId, instanceConnection); return instanceConnection; } async getMetricsRequestHandler() { let requests = []; for (let instanceConnection of this.instanceConnections.values()) { requests.push(link.messages.getMetrics.send(instanceConnection)); } let results = []; for (let response of await Promise.all(requests)) { results.push(...response.results); } for await (let result of prometheus.defaultRegistry.collect()) { if (result.metric.name.startsWith('process_')) { results.push(prometheus.serializeResult(result, { addLabels: { 'slave_id': String(this.config.get("slave.id")) }, metricName: result.metric.name.replace('process_', 'clusterio_slave_'), })); } else { results.push(prometheus.serializeResult(result)); } } return { results }; } async startInstanceRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); try { return await request.send(instanceConnection, message.data); } catch (err) { await this.stopInstance(instanceId); throw err; } } async createSaveRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); await request.send(instanceConnection, message.data); } async stopInstance(instanceId) { let instanceConnection = this.instanceConnections.get(instanceId); await link.messages.stopInstance.send(instanceConnection, { instance_id: instanceId }); } async deleteInstanceRequestHandler(message) { let instanceId = message.data.instance_id; if (this.instanceConnections.has(instanceId)) { throw new errors.RequestError(`Instance with ID ${instanceId} is running`); } let instanceInfo = this.instanceInfos.get(instanceId); if (!instanceInfo) { throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`); } await fs.remove(instanceInfo.path); this.instanceInfos.delete(instanceId); } /** * Discover available instances * * Looks through the instances directory for instances and updates * the slave and master server with the new list of instances. */ async updateInstances() { this.instanceInfos = await discoverInstances(this.config.get("slave.instances_directory"), console); let list = []; for (let instanceInfo of this.instanceInfos.values()) { list.push({ serialized_config: instanceInfo.config.serialize(), }); } link.messages.updateInstances.send(this, { instances: list }); } async start() { await this.updateInstances(); } async shutdown() { await link.messages.shutdownConnection.send(this); for (let instanceId of this.instanceConnections.keys()) { await this.stopInstance(instanceId); } this.connector.close(1001, "Slave Shutdown"); await events.once(this.connector, "close"); } } function checkFilename(name) { // All of these are bad in Windows only, except for /, . and .. // See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file const badChars = /[<>:"\/\\|?*\x00-\x1f]/g; const badEnd = /[. ]$/; const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9]; const badNames = [ // Relative path components '.', '..', // Reserved filenames in Windows 'CON', 'PRN', 'AUX', 'NUL', ...oneToNine.map(n => `COM${n}`), ...oneToNine.map(n => `LPT${n}`), ]; if (typeof name !== "string") { throw new Error("must be a string"); } if (name === "") { throw new Error("cannot be empty"); } if (badChars.test(name)) { throw new Error('cannot contain <>:"\\/|=* or control characters'); } if (badNames.includes(name.toUpperCase())) { throw new Error( "cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9" ); } if (badEnd.test(name)) { throw new Error("cannot end with . or space"); } } /** * Create and update symlinks for shared mods in an instance * * Creates symlinks for .zip and .dat files that are not present in the * instance mods directory but is present in the sharedMods directory, * and removes any symlinks that don't point to a file in the instance * mods directory. If the instance mods directory doesn't exist it will * be created. * * Note that on Windows this creates hard links instead of symbolic * links as the latter requires elevated privileges. This unfortunately * means the removal of mods from the shared mods dir can't be detected. * * @param {Instance} instance - Instance to link mods for * @param {string} sharedMods - Path to folder to link mods from. * @param {object} logger - console like logging interface. */ async function symlinkMods(instance, sharedMods, logger) { await fs.ensureDir(instance.path("mods")); // Remove broken symlinks in instance mods. for (let entry of await fs.readdir(instance.path("mods"), { withFileTypes: true })) { if (entry.isSymbolicLink()) { if (!await fs.pathExists(instance.path("mods", entry.name))) { logger.log(`Removing broken symlink ${entry.name}`); await fs.unlink(instance.path("mods", entry.name)); } } } // Link entries that are in sharedMods but not in instance mods. let instanceModsEntries = new Set(await fs.readdir(instance.path("mods"))); for (let entry of await fs.readdir(sharedMods, { withFileTypes: true })) { if (entry.isFile()) { if (['.zip', '.dat'].includes(path.extname(entry.name))) { if (!instanceModsEntries.has(entry.name)) { logger.log(`linking ${entry.name} from ${sharedMods}`); let target = path.join(sharedMods, entry.name); let link = instance.path("mods", entry.name); if (process.platform !== "win32") { await fs.symlink(path.relative(path.dirname(link), target), link); // On Windows symlinks require elevated privileges, which is // not something we want to have. For this reason the mods // are hard linked instead. This has the drawback of not // being able to identify when mods are removed from the // sharedMods directory, or which mods are linked. } else { await fs.link(target, link); } } } else { logger.warning(`Warning: ignoring file '${entry.name}' in sharedMods`); } } else { logger.warning(`Warning: ignoring non-file '${entry.name}' in sharedMods`); } } } async function startSlave() { // add better stack traces on promise rejection process.on('unhandledRejection', r => console.log(r)); // argument parsing const args = yargs .scriptName("slave") .usage("$0 [options]") .option('config', { nargs: 1, describe: "slave config file to use", default: 'config-slave.json', type: 'string', }) .command("config", "Manage Slave config", config.configCommand) .command("run", "Run slave") .demandCommand(1, "You need to specify a command to run") .strict() .argv ; console.log("Loading Plugin info"); let pluginInfos = await plugin.loadPluginInfos("plugins"); config.registerPluginConfigGroups(pluginInfos); config.finalizeConfigs(); console.log(`Loading config from ${args.config}`); let slaveConfig = new config.SlaveConfig(); try { await slaveConfig.load(JSON.parse(await fs.readFile(args.config))); } catch (err) { if (err.code === 'ENOENT') { console.log("Config not found, initializing new config"); await slaveConfig.init(); } else { throw err; } } let command = args._[0]; if (command === "config") { await config.handleConfigCommand(args, slaveConfig, args.config); return; } // If we get here the command was run await fs.ensureDir(slaveConfig.get("slave.instances_directory")); await fs.ensureDir("sharedMods"); await fs.ensureDir("modules"); // Set the process title, shows up as the title of the CMD window on windows // and as the process name in ps/top on linux. process.title = "clusterioSlave"; // make sure we have the master access token if (slaveConfig.get("slave.master_token") === "enter token here") { console.error("ERROR invalid config!"); console.error( "Master server now needs an access token for write operations. As clusterio\n"+ "slaves depends upon this, please set your token using the command node slave\n"+ "config set slave.master_token . You can retrieve your auth token from\n"+ "the master in secret-api-token.txt after running it once." ); process.exitCode = 1; return; } // make sure url ends with / if (!slaveConfig.get("slave.master_url").endsWith("/")) { console.error("ERROR invalid config!"); console.error("slave.master_url must end with '/'"); process.exitCode = 1; return; } let slaveConnector = new SlaveConnector(slaveConfig); let slave = new Slave(slaveConnector, slaveConfig, pluginInfos); // Handle interrupts let secondSigint = false process.on('SIGINT', () => { if (secondSigint) { console.log("Caught second interrupt, terminating immediately"); process.exit(1); } secondSigint = true; console.log("Caught interrupt signal, shutting down"); slave.shutdown().catch(err => { console.error(` +---------------------------------------------------------------+ | Unexpected error occured while stopping slave, please report | | it to https://github.com/clusterio/factorioClusterio/issues | +---------------------------------------------------------------+` ); console.error(err); process.exit(1); }); }); await slaveConnector.connect(); await slave.start(); /* } else if (command == "manage"){ await manage(config, instance); // process.exit(0); */ } // string, function // returns [{modName:string,hash:string}, ... ] function hashMods(instance, callback) { if(!callback) { throw new Error("ERROR in function hashMods NO CALLBACK"); } function hashMod(name) { if (path.extname(name) != ".zip") { // Can't hash unzipped mods, return null that's filtered out later return null; } else { return hashFile(instance.path("mods", name)).then(hash => ( {modName: name, hash: hash} )); } } let promises = fs.readdirSync(instance.path("mods")).map(hashMod); Promise.all(promises).then(hashes => { // Remove null entries from hashMod callback(hashes.filter(entry => entry !== null)); }); } module.exports = { // For testing only _Instance: Instance, _checkFilename: checkFilename, _symlinkMods: symlinkMods, _discoverInstances: discoverInstances, _Slave: Slave, }; if (module === require.main) { console.warn(` +==========================================================+ I WARNING: This is the development branch for the 2.0 I I version of clusterio. Expect things to break. I +==========================================================+ ` ); startSlave().catch(err => { console.error(` +--------------------------------------------------------------+ | Unexpected error occured while starting slave, please report | | it to https://github.com/clusterio/factorioClusterio/issues | +--------------------------------------------------------------+` ); console.error(err); process.exit(1); }); }