const fs = require('fs-extra'); const path = require('path'); const yargs = require("yargs"); 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 errors = require("lib/errors"); /** * 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 is expected to change with the config system rewrite this.config = { id: instanceConfig.id, name: instanceConfig.name, gamePort: instanceConfig.factorioPort, rconPort: instanceConfig.clientPort, rconPassword: instanceConfig.clientPassword, } let serverOptions = { gamePort: this.config.gamePort, rconPort: this.config.rconPort, rconPassword: this.config.rconPassword, }; this.server = new factorio.FactorioServer( path.join(factorioDir, "data"), this._dir, serverOptions ); this.server.on('output', (output) => { link.messages.instanceOutput.send(this, { instance_id: this.config.id, output }) }); } async init() { await this.server.init(); } /** * 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(id, instanceDir, factorioDir, options) { let instanceConfig = { id, name: options.name, factorioPort: null, clientPort: null, clientPassword: null, } console.log(`Clusterio | Creating ${instanceDir}`); await fs.ensureDir(instanceDir); await fs.ensureDir(path.join(instanceDir, "script-output")); await fs.ensureDir(path.join(instanceDir, "saves")); // save instance config await fs.outputFile(path.join(instanceDir, "config.json"), JSON.stringify(instanceConfig, null, 4)); let serverSettings = await factorio.FactorioServer.exampleSettings(path.join(factorioDir, "data")); let gameName = "Clusterio instance: " + options.name; if (options.username) { gameName = options.username + "'s clusterio " + options.name; } let overrides = { "name": gameName, "description": options.description, "tags": ["clusterio"], "visibility": options.visibility, "username": options.username, "token": options.token, "game_password": options.game_password, "require_user_verification": options.verify_user_identity, "allow_commands": options.allow_commands, "auto_pause": options.auto_pause, }; for (let [key, value] of Object.entries(overrides)) { if (!Object.hasOwnProperty.call(serverSettings, key)) { throw Error(`Expected server settings to have a ${key} property`); } serverSettings[key] = value; } await fs.writeFile( path.join(instanceDir, "server-settings.json"), JSON.stringify(serverSettings, null, 4) ); } async start(saveName, slaveConfig, socket) { 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"); // For now it's assumed that all files in the lua folder of a plugin is // to be patched in under the name of the plugin and loaded for all // plugins that are not disabled. This will most likely change in the // future when the plugin refactor is done. let modules = []; for (let pluginName of await fs.readdir("sharedPlugins")) { let pluginDir = path.join("sharedPlugins", pluginName); if (await fs.pathExists(path.join(pluginDir, "DISABLED"))) { continue; } if (!await fs.pathExists(path.join(pluginDir, "lua"))) { continue; } let module = { "name": pluginName, "files": [], }; for (let fileName of await fs.readdir(path.join(pluginDir, "lua"))) { module["files"].push({ path: pluginName+"/"+fileName, content: await fs.readFile(path.join(pluginDir, "lua", fileName)), load: true, }); } modules.push(module); } await factorio.patch(this.path("saves", saveName), modules); this.server.on('rcon-ready', () => { console.log("Clusterio | RCON connection established"); // Temporary measure for backwards compatibility let compatConfig = { id: this.config.id, unique: this.config.id, name: this.config.name, // FactorioServer.init may have generated a random port or password // if they were null. factorioPort: this.server.gamePort, clientPort: this.server.rconPort, clientPassword: this.server.rconPassword, } }); await this.server.start(saveName); } /** * Stop the instance */ async stop() { // XXX this needs more thought to it if (this.server._state === "running") { await this.server.stop(); } } 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("Creating save ....."); 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.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 info 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; let configPath = path.join(instancesDir, entry.name, "config.json"); try { instanceConfig = 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; } // XXX should probably validate the entire config with a JSON Schema. if (typeof instanceConfig.id !== "number" || isNaN(instanceConfig.id)) { logger.error(`${configPath} is missing id`); continue; } if (typeof instanceConfig.name !== "string") { logger.error(`${configPath} is missing name`); continue; } let instancePath = path.join(instancesDir, entry.name); logger.log(`found instance ${instanceConfig.name} in ${instancePath}`); instanceInfos.set(instanceConfig.id, { path: instancePath, config: instanceConfig, }); } } return instanceInfos; } class InstanceConnection extends link.Link { constructor(connector, slave) { super('slave', 'instance', connector); this.slave = slave; link.attachAllMessages(this); } async forwardEventToMaster(message, event) { event.send(this.slave, message.data); } } class SlaveConnector extends link.SocketIOClientConnector { constructor(slaveConfig) { super(slaveConfig.masterURL, slaveConfig.masterAuthToken); this.id = slaveConfig.id; this.name = slaveConfig.name; } register() { console.log("SOCKET | registering slave"); this.send('register_slave', { agent: 'Clusterio Slave', version, id: this.id, name: this.name, }); } } /** * Handles running the slave * * Connects to the master server over the socket.io connection 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) { super('slave', 'master', connector); link.attachAllMessages(this); this.config = { id: slaveConfig.id, name: slaveConfig.name, instancesDir: slaveConfig.instanceDirectory, factorioDir: slaveConfig.factorioDirectory, masterUrl: slaveConfig.masterURL, masterToken: slaveConfig.masterAuthToken, publicAddress: slaveConfig.publicIP, } 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.instancesDir, 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 active`); } return await request.send(instanceConnection, message.data); } async createInstanceRequestHandler(message) { let { id, options } = message.data; if (this.instanceInfos.has(id)) { throw new Error(`Instance with ID ${id} already exists`); } // XXX: race condition on multiple simultanious calls let instanceDir = await this._findNewInstanceDir(options.name); await Instance.create(id, instanceDir, this.config.factorioDir, options); await this.updateInstances(); } /** * 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 active`); } let [connectionClient, connectionServer] = link.VirtualConnector.makePair(); let instanceConnection = new InstanceConnection(connectionServer, this); let instance = new Instance( connectionClient, instanceInfo.path, this.config.factorioDir, instanceInfo.config ); await instance.init(); // XXX: race condition on multiple simultanious calls this.instanceConnections.set(instanceId, instanceConnection); return instanceConnection; } async startInstanceRequestHandler(message, request) { let instanceConnection = await this._connectInstance(message.data.instance_id); return await request.send(instanceConnection, message.data); } async createSaveRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); await request.send(instanceConnection, message.data); this.instanceConnections.delete(instanceId); } async stopInstanceRequestHandler(message, request) { await this.forwardRequestToInstance(message, request); this.instanceConnections.delete(message.data.instance_id); } async deleteInstanceRequestHandler(message) { let instanceId = message.data.instance_id; if (this.instanceConnections.has(instanceId)) { throw new errors.RequestError(`Instance with ID ${instanceId} is active`); } let instanceInfo = this.instanceInfos.get(instanceId); if (!instanceInfo) { throw new errors.RequestError(`Instance with ID ${instanceId} does not exist`); } await fs.remove(instanceInfo.path); await this.updateInstances(); } /** * 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.instancesDir, console); let list = []; for (let instanceInfo of this.instanceInfos.values()) { list.push({ id: instanceInfo.config.id, name: instanceInfo.config.name, }); } link.messages.updateInstances.send(this, { instances: list }); } async start() { await this.updateInstances(); } async stop() { for (let instanceConnection of this.instanceConnections.values()) { // XXX this neeeds more thought to it // await instance.stop(); } } } 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 startClient() { // add better stack traces on promise rejection process.on('unhandledRejection', r => console.log(r)); // argument parsing const args = yargs .scriptName("client") .usage("$0 [options]") .option('config', { nargs: 1, describe: "slave config file to use", default: 'config-slave.json', type: 'string', }) .command('create-config', "Create slave config", (yargs) => { yargs.options({ 'name': { describe: "Name of the slave", nargs: 1, type: 'string', demandOption: true }, 'url': { describe: "Master URL", nargs: 1, type: 'string', default: "http://localhost:8080/" }, 'token': { describe: "Master token", nargs: 1, type: 'string', demandOption: true }, 'ip': { describe: "Public facing IP", nargs: 1, type: 'string', default: "localhost" }, 'instances-dir': { describe: "Instances directory", nargs: 1, type: 'string', default: "instances" }, 'factorio-dir': { describe: "Factorio directory", nargs: 1, type: 'string', default: "factorio" }, 'id': { describe: "Numeric id of the slave", nargs: 1, type: 'number', default: Math.random() * 2**31 | 0, defaultDescription: "random id", }, }); }) .command('edit-config', "Edit slave config", (yargs) => { yargs.options({ 'name': { describe: "Set name of the slave", nargs: 1, type: 'string' }, 'url': { describe: "Set master URL", nargs: 1, type: 'string' }, 'token': { describe: "Set master token", nargs: 1, type: 'string' }, 'ip': { describe: "Set public facing IP", nargs: 1, type: 'string' }, 'instances-dir': { describe: "Set instances directory", nargs: 1, type: 'string' }, 'factorio-dir': { describe: "Set Factorio directory", nargs: 1, type: 'string' }, 'id': { describe: "Set id of the slave", nargs: 1, type: 'number' }, }); }) .command('show-config', "Show slave config") .command('start', "Start slave") .demandCommand(1, "You need to specify a command to run") .strict() .argv ; let command = args._[0]; if (command === "create-config") { await fs.outputFile(args.config, JSON.stringify({ name: args.name, masterURL: args.url, masterAuthToken: args.token, publicIP: args.ip, instanceDirectory: args.instancesDir, factorioDirectory: args.factorioDir, id: args.id, }, null, 4), { flag: 'wx' }); return; } else if (command == "edit-config") { let slaveConfig = JSON.parse(await fs.readFile(args.config)); if ('name' in args) slaveConfig.name = args.name; if ('url' in args) slaveConfig.masterURL = args.url; if ('token' in args) slaveConfig.masterAuthToken = args.token; if ('ip' in args) slaveConfig.publicIP = args.ip; if ('instancesDir' in args) slaveConfig.instanceDirectory = args.instancesDir; if ('factorioDir' in args) slaveConfig.factorioDirectory = args.factorioDir; if ('id' in args) slaveConfig.id = args.id; await fs.outputFile(args.config, JSON.stringify(slaveConfig, null, 4)); return; } else if (command == "show-config") { let slaveConfig = JSON.parse(await fs.readFile(args.config)); console.log(slaveConfig); return; } // If we get here the command was start // handle commandline parameters console.log(`Loading ${args.config}`); const slaveConfig = JSON.parse(await fs.readFile(args.config)); await fs.ensureDir(slaveConfig.instanceDirectory); await fs.ensureDir("sharedPlugins"); await fs.ensureDir("sharedMods"); // 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 = "clusterioClient"; // make sure we have the master access token if(!slaveConfig.masterAuthToken || typeof slaveConfig.masterAuthToken !== "string"){ 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 add your token to config.json in the field\n"+ "named masterAuthToken. You can retrieve your auth token from the master in\n"+ "secret-api-token.txt after running it once." ); process.exitCode = 1; return; } // make sure url ends with / if (!slaveConfig.masterURL.endsWith("/")) { console.error("ERROR invalid config!"); console.error("masterURL (set with --url) must end with '/'"); process.exitCode = 1; return; } let slaveConnector = new SlaveConnector(slaveConfig); let slave = new Slave(slaveConnector, slaveConfig); // 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.stop().then(() => { // There's currently no shutdown mechanism for instance plugins so // they keep the event loop alive. process.exit(); }); }); 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 +==========================================================+ ` ); startClient().catch(err => { console.error(` +---------------------------------------------------------------+ | Unexpected error occured while starting client, please report | | it to https://github.com/clusterio/factorioClusterio/issues | +---------------------------------------------------------------+` ); console.error(err); process.exit(1); }); }