const fs = require('fs-extra'); const https = require('follow-redirects').https; const needle = require("needle"); const child_process = require('child_process'); const path = require('path'); const syncRequest = require('sync-request'); const request = require("request"); const ncp = require('ncp').ncp; const Rcon = require('simple-rcon'); const hashFiles = require('hash-files'); const _ = require('underscore'); const deepmerge = require("deepmerge"); const getMac = require('getmac').getMac; const rmdirSync = require('rmdir-sync'); const ioClient = require("socket.io-client"); // internal libraries const objectOps = require("./lib/objectOps.js"); const fileOps = require("_app/fileOps"); const stringUtils = require("./lib/stringUtils.js"); const modManager = require("./lib/manager/modManager.js"); const configManager = require("./lib/manager/configManager.js"); // require config.json var config = require('./config'); var global = {}; if (!fs.existsSync("./instances/")) { fs.mkdirSync("instances"); } if (!fs.existsSync("./sharedPlugins/")) { fs.mkdirSync("sharedPlugins"); } if (!fs.existsSync("./sharedMods/")) { fs.mkdirSync("sharedMods"); } const instance = process.argv[3]; const instancedirectory = './instances/' + instance; const command = process.argv[2]; // 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 "+instance; var instanceInfo = {}; // function to handle sending commands into the game function messageInterface(command, callback) { // try to save us if you send a buffer instead of string if(typeof command == "object") { command = command.toString('utf8'); } if(process.platform == "linux" && typeof command == "string" && serverprocess) { /* to send to stdin, use: serverprocess.stdin.write("/c command;\n") */ serverprocess.stdin.write(command+"\n"); if(typeof callback == "function"){ callback(); } } else if(typeof command == "string" && client && client.exec && typeof client.exec == "function") { try { client.exec(command+"\n", callback); } catch (err) { console.log(err); } if(typeof callback == "function"){ callback(); } } } // handle commandline parameters if (!command || command == "help" || command == "--help") { console.error("Usage: "); console.error("node client.js start [instance name]"); console.error("node client.js list"); console.error("node client.js delete [instance]"); console.error("To download the latest version of the Clusterio lua mod, do"); console.error("node client.js manage shared mods download clusterio"); console.error("For more management options, do"); console.error("node client.js manage"); process.exit(1); } else if (command == "list") { let instanceNames = fileOps.getDirectoriesSync("./instances/"); instanceNames.unshift("Name:"); let longestInstanceName = 0; // determine longest instance name instanceNames.forEach(function(instance){ if(instance.length > longestInstanceName) longestInstanceName = instance.length; }); let displayLines = []; // push name coloumn to array instanceNames.forEach(function(instance){ while(instance.length < longestInstanceName+1){ instance += " "; } displayLines.push("| "+ instance + "|"); }); // create port colloumn let factorioPorts = []; instanceNames.forEach(function(instance){ let factorioPort if(instance.includes("Name:")){ factorioPort = "Port:" } else { factorioPort = require("./instances/"+instance+"/config").factorioPort; } factorioPorts.push(factorioPort); }); factorioPorts.forEach((port, index) => { let longestPort = 0; factorioPorts.forEach((port, index) => { if(port.toString().length > longestPort) longestPort = port.toString().length; }); while(port.toString().length < longestPort){ port += " "; } factorioPorts[index] = port; }); instanceNames.forEach(function(instance, index){ displayLines[index] += " " + factorioPorts[index] + " |"; }); displayLines.forEach(line => console.log(line)); process.exit(1); } else if (command == "manage"){ // console.log("Launching mod manager"); //const fullUsage = 'node client.js manage [instance, "shared"] ["mods", "config"] ...'; function usage(instance, tool, action){ if(tool && tool == "mods"){ console.log('node client.js manage '+instance+' '+tool+' ["list", "search", "add", "remove", "update"]'); } else if(tool && tool == "config") { console.log('node client.js manage '+instance+' '+tool+' ["list", "edit"]'); } else { console.log('node client.js manage '+(instance || '[instance, "shared"]') +' '+ (tool || '["mods", "config"]') + ' ...'); } } const tool = process.argv[4] || ""; const action = process.argv[5] || ""; if(instance){ if(tool == "mods"){ // allow managing mods if(action == "list"){ modManager.listMods(instance); } else if(action == "search"){ modManager.findMods(process.argv[6]); } else if(action == "add" || action == "download"){ modManager.addMod(process.argv[6], instance); } else if(action == "remove" || action == "rm" || action == "delete"){ modManager.removeMod(process.argv[6], instance); } else if(action == "update"){ modManager.updateAllMods(); } else { usage(instance, tool); } } else if(tool == "config"){ // allow managing the config if(action == "list" || action == "show" || action == "display"){ configManager.displayConfig(instance); } else if(action == "edit"){ let newConfigValue = ""; process.argv.forEach((arg, i)=>{ if(i >= 8){ newConfigValue += " "+arg; } else if(i >= 7){ newConfigValue += arg; } }); configManager.editConfig(instance, process.argv[6], newConfigValue); } else { usage(instance, tool); } } else { usage(instance); } } else { console.log('Usage:'); usage(instance); } // process.exit(1); } else if (command == "delete") { if (!process.argv[3]) { console.error("Usage: node client.js delete [instance]"); process.exit(1); } else if (typeof process.argv[3] == "string" && fs.existsSync("./instances/" + process.argv[3]) && process.argv[3] != "/" && process.argv[3] != "") { fileOps.deleteFolderRecursiveSync("./instances/" + process.argv[3]); console.log("Deleted instance " + process.argv[3]); process.exit(1); } else { console.error("Instance not found: " + process.argv[3]); process.exit(1); } } else if (command == "download") { console.log("Downloading mods..."); // get JSON data about releases let res = syncRequest('GET', 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases', {"headers":{"User-Agent":"Fuck you for requiring user agents!"}}); let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url; let name = JSON.parse(res.getBody())[0].assets[0].name; if(url) { console.log(url); let file = fs.createWriteStream("sharedMods/"+name); https.get(url, function(response) { response.pipe(file); console.log("Downloaded "+name); }); } } else if (command == "start" && instance === undefined) { console.log("ERROR: No instanceName provided!"); console.log("Usage: node client.js start [instanceName]"); process.exit(0); } else if (command == "start" && typeof instance == "string" && instance != "/" && !fs.existsSync(instancedirectory)) { // if instance does not exist, create it console.log("Creating instance..."); fs.mkdirSync(instancedirectory); fs.mkdirSync(instancedirectory + "/script-output/"); fs.mkdirSync(instancedirectory + "/saves/"); fs.writeFileSync(instancedirectory + "/script-output/output.txt", ""); fs.writeFileSync(instancedirectory + "/script-output/orders.txt", ""); fs.writeFileSync(instancedirectory + "/script-output/txbuffer.txt", ""); fs.mkdirSync(instancedirectory + "/mods/"); fs.mkdirSync(instancedirectory + "/instanceMods/"); // fs.symlinkSync('../../../sharedMods', instancedirectory + "/mods", 'junction') // This is broken because it can only take a file as first argument, not a folder fs.writeFileSync(instancedirectory + "/config.ini", "[path]\r\n\ read-data=__PATH__executable__/../../data\r\n\ write-data=__PATH__executable__/../../../instances/" + instance + "\r\n\ "); // this line is probably not needed anymore but Im not gonna remove it fs.copySync('sharedMods', instancedirectory + "/mods"); let instconf = { "factorioPort": process.env.FACTORIOPORT || Math.floor(Math.random() * 65535), "clientPort": process.env.RCONPORT || Math.floor(Math.random() * 65535), "clientPassword": Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 8), } console.log("Clusterio | Created instance with settings:") console.log(instconf); // create instance config fs.writeFileSync(instancedirectory + "/config.json", JSON.stringify(instconf, null, 4)); let name = "Clusterio instance: " + instance; if (config.username) { name = config.username + "'s clusterio " + instance; } let serversettings = { "name": name, "description": config.description, "tags": ["clusterio"], "max_players": "20", "visibility": config.visibility, "username": config.username, "token": config.token, "game_password": config.game_password, "verify_user_identity": config.verify_user_identity, "admins": [config.username], "allow_commands": config.allow_commands, "autosave_interval": 10, "autosave_slots": 5, "afk_autokick_interval": 0, "auto_pause": config.auto_pause, } fs.writeFileSync(instancedirectory + "/server-settings.json", JSON.stringify(serversettings, null, 4)); let createSave = child_process.spawnSync( './' + config.factorioDirectory + '/bin/x64/factorio', [ '-c', instancedirectory + '/config.ini', '--create', instancedirectory + '/saves/save.zip', ] ); console.log("Instance created!"); } else if (command == "start" && typeof instance == "string" && instance != "/" && fs.existsSync(instancedirectory)) { // Exit if no instance specified (it should be, just a safeguard); if(instancedirectory != "./instances/undefined"){ var instanceconfig = require(instancedirectory + '/config'); instanceconfig.unique = stringUtils.hashCode(instanceconfig.clientPassword); if(process.env.FACTORIOPORT){ instanceconfig.factorioPort = process.env.FACTORIOPORT; } if(process.env.RCONPORT){ instanceconfig.rconPort = process.env.RCONPORT; } } else { process.exit(1); } console.log("Deleting .tmp.zip files"); let savefiles = fs.readdirSync(instancedirectory + "/saves/"); for(i = 0; i < savefiles.length; i++){ if(savefiles[i].substr(savefiles[i].length - 8, 8) == ".tmp.zip") { fs.unlinkSync(instancedirectory + "/saves/" + savefiles[i]); } } console.log("Deleting logs"); // clean old log file to avoid crash // file exists, delete so we don't get in trouble try { fs.unlinkSync(instancedirectory+'/factorio-current.log'); } catch (err){ if(err){ console.log(err); } else { console.log("Clusterio | Deleting old logs..."); } } // move mods from ./sharedMods to the instances mod directory try { fs.mkdirSync(instancedirectory + "/instanceMods/"); } catch(e){} try{rmdirSync(instancedirectory + "/mods");}catch(e){} try { // mods directory that will be emptied (deleted) when closing the server to facilitate seperation of instanceMods and sharedMods fs.mkdirSync(instancedirectory + "/mods/"); } catch(e){} console.log("Clusterio | Moving shared mods from sharedMods/ to instance/mods..."); fs.copySync('sharedMods', instancedirectory + "/mods"); console.log("Clusterio | Moving instance specific mods from instance/instanceMods to instance/mods..."); fs.copySync(instancedirectory + "/instanceMods", instancedirectory + "/mods"); process.on('SIGINT', function () { console.log("Caught interrupt signal"); messageInterface("/quit"); }); // Spawn factorio server //var serverprocess = child_process.exec(commandline); fileOps.getNewestFile(instancedirectory + "/saves/", fs.readdirSync(instancedirectory + "/saves/"),function(err, latestSave) { if(err) { console.log("Your savefile seems to be missing. This might because you created an instance without having factorio\ installed and configured properly. Try installing factorio and adding your savefile to instances/[instancename]/saves/"); throw err; } // implicit global serverprocess = child_process.spawn( './' + config.factorioDirectory + '/bin/x64/factorio', [ '-c', instancedirectory + '/config.ini', '--start-server', latestSave.file, '--rcon-port', Number(process.env.RCONPORT) || instanceconfig.clientPort, '--rcon-password', instanceconfig.clientPassword, '--server-settings', instancedirectory + '/server-settings.json', '--port', Number(process.env.FACTORIOPORT) || instanceconfig.factorioPort ], { 'stdio': ['pipe', 'pipe', 'pipe'] } ); serverprocess.on('close', (code) => { console.log(`child process exited with code ${code}`); process.exit(); }); serverprocess.stdout.on("data", (data) => { // log("Stdout: " + data); if(data.toString('utf8').includes("Couldn't parse RCON data: Maximum payload size exceeded")){ console.error("ERROR: RCON CONNECTION BROKE DUE TO TOO LARGE PACKET!"); console.error("Attempting reconnect..."); client.close(); client.connect(); } if(process.platform == "linux"){ // we have to log on linux because linux only shows stdout of the first process launched console.log('Factorio: ' + data); } }); serverprocess.stderr.on('data', (chunk) => { console.log('ERR: ' + chunk); }); // connect to the server with rcon if(process.platform != "linux"){ // IP, port, password client = new Rcon({ host: 'localhost', port: Number(process.env.RCONPORT) || instanceconfig.clientPort, password: instanceconfig.clientPassword, timeout: 0 }); // check the logfile to see if the RCON interface is running as there is no way to continue without it // we read the log every 2 seconds and stop looping when we start connecting to factorio function checkRcon() { fs.readFile(instancedirectory+"/factorio-current.log", function (err, data) { // if (err) console.log(err); if(data && data.indexOf('Starting RCON interface') > 0){ client.connect(); } else { setTimeout(function(){ checkRcon(); },2000); } }); } setTimeout(checkRcon, 5000); client.on('authenticated', function () { console.log('Clusterio | RCON Authenticated!'); instanceManagement(); // start using rcons }).on('connected', function () { console.log('Clusterio | RCON Connected!'); // getID(); }).on('disconnected', function () { console.log('Clusterio | RCON Disconnected!'); process.exit(0); // exit because RCON disconnecting is undefined behaviour and we rather just wanna restart now }); } else if(process.platform == "linux"){ // TODO: Check if this works fine on linux or if it has to be delayed until the server starts properly instanceManagement(); } // set some globals confirmedOrders = []; lastSignalCheck = Date.now(); }); } // ensure instancemanagement only ever runs once _.once(instanceManagement); function instanceManagement() { /* Open websocket connection to master */ var socket = ioClient("http://"+config.masterIP+":"+config.masterPort); socket.on("hello", data => { console.log("SOCKET | registering slave!"); socket.emit("registerSlave", { instanceID: instanceconfig.unique, }); setInterval(B=> socket.emit("heartbeat"), 10000); }); console.log("Started instanceManagement();"); // load plugins and execute onLoad event let pluginDirectories = fileOps.getDirectoriesSync("./sharedPlugins/"); let plugins = []; for(let i=0; i { if(data.toString('utf8')[0] != "/") { log("Stdout: " + data.toString('utf8')) } else { messageInterface(data.toString('utf8')); } }); plugins[i].stderr.on("data", (data) => { log("STDERR: " + data); }); plugins[i].on('close', (code) => { log(`child process exited with code ${code}`); }); } } // world IDs ------------------------------------------------------------------ hashMods(instance, function(modHashes){ setInterval(getID, 10000); getID(); function getID() { messageInterface("/silent-command game.write_file('tempfile.txt', 'connected_players ' .. #game.connected_players .. '\\n', true, 0)", function(err) {setTimeout(function(){ // get array of lines in file if(fs.existsSync(instancedirectory + "/script-output/tempfile.txt")) { var data = fs.readFileSync(instancedirectory + "/script-output/tempfile.txt", "utf8").split("\n"); // delete when we are done fs.unlink(instancedirectory + "/script-output/tempfile.txt", function(){}); } // if we actually got anything from the file, proceed to categorize it if (data && data[0]) { while (data[0]) { let q = data[0].split(" "); // delete array element data.splice(0,1); if(q[0] == "connected_players" && Number(q[1]) != NaN) { instanceInfo.playerCount = q[1]; } } } else { instanceInfo.playerCount = 0; } var payload = { time: Date.now(), rconPort: instanceconfig.clientPort, rconPassword: instanceconfig.clientPassword, serverPort: instanceconfig.factorioPort, unique: instanceconfig.unique, publicIP: config.publicIP, // IP of the server should be global for all instances, so we pull that straight from the config mods:modHashes, playerCount: instanceInfo.playerCount || 0, instanceName: instance, } function callback(err, mac) { if (err) { console.log("##### getMac crashed, but we don't really give a shit because we are probably closing down #####"); } else { payload.mac = mac console.log("Registered our precense with master "+config.masterIP+" at " + payload.time); needle.post(config.masterIP + ":" + config.masterPort + '/api/getID', payload, function (err, response, body) { if (err && err.code != "ECONNRESET"){ console.error("We got problems, something went wrong when contacting master"); console.error(err); } else if (response && response.body) { // In the future we might be interested in whether or not we actually manage to send it, but honestly I don't care. console.log(response.body); } }); } } if(global.mac){ callback(undefined, global.mac); } else { getMac(callback); } },1000)}); } }); // Mod uploading and management ----------------------------------------------- // get mod names and hashes // string: instance, function: callback setTimeout(function(){hashMods(instance, uploadMods)}, 5000); function uploadMods(modHashes) { // [{modName:string,hash:string}, ... ] for(let i=0;i= Math.pow(2, 31)){ inventoryFrame[inventory[i].name] = Math.pow(2, 30); // set it waaay lower, 31 -1 would probably suffice } } inventoryFrame["signal-unixtime"] = Math.floor(Date.now()/1000); // console.log("RCONing inventory! " + JSON.stringify(inventoryFrame)); messageInterface("/silent-command remote.call('clusterio', 'receiveInventory', '" + JSON.stringify(inventoryFrame) + "')"); } }); }, 1000); // Make sure world has its worldID setTimeout(function(){ messageInterface("/silent-command remote.call('clusterio','setWorldID',"+instanceconfig.unique+")") }, 20000); /* REMOTE SIGNALLING * send any signals the slave has been told to send * Fetch combinator signals from the server */ socket.on("processCombinatorSignal", circuitFrameWithMeta => { if(circuitFrameWithMeta && typeof circuitFrameWithMeta == "object" && circuitFrameWithMeta.frame && Array.isArray(circuitFrameWithMeta.frame)){ messageInterface("/silent-command remote.call('clusterio', 'receiveFrame', '"+JSON.stringify(circuitFrameWithMeta.frame)+"')"); } }); // get outbound frames from file and send to master // get array of lines in file, each line should correspond to a JSON encoded frame let signals = fs.readFileSync(instancedirectory + "/script-output/txbuffer.txt", "utf8").split("\n"); // if we actually got anything from the file, proceed and reset file let readingTxBufferSoon = false; let txBufferClearCounter = 0; fs.watch(instancedirectory + "/script-output/txbuffer.txt", "utf-8", (eventType, filename) => { if(!readingTxBufferSoon){ // use a 100ms delay to avoid messing with rapid sequential writes from factorio (I think that might be a problem maybe?) readingTxBufferSoon = true; setTimeout(()=>{ txBufferClearCounter++; fs.readFile(instancedirectory + "/script-output/txbuffer.txt", "utf-8", (err, signals) => { signals = signals.split("\n"); if (signals[0]) { //if(txBufferClearCounter > 500){ fs.writeFileSync(instancedirectory + "/script-output/txbuffer.txt", ""); // txBufferClearCounter = 0; //} // loop through all our frames for (let i = 0; i < signals.length; i++) { if (signals[i] && objectOps.isJSON(signals[i])) { // signals[i] is a JSON array called a "frame" of signals. We timestamp it for storage on master // then we unpack and RCON in this.frame to the game later. let framepart = JSON.parse(signals[i]); let doneframe = { time: Date.now(), frame: framepart, // thats our array of objects(single signals); } // send to master using socket.io, opened at the top of instanceManagement() socket.emit("combinatorSignal", doneframe); } else { // console.log("Invalid jsony: "+typeof signals[i]) } } } }); readingTxBufferSoon = false; },100); } }); } // END OF INSTANCE START --------------------------------------------------------------------- // string, function // returns [{modName:string,hash:string}, ... ] function hashMods(instanceName, callback) { if(!callback) { throw new Error("ERROR in function hashMods NO CALLBACK"); } function callback2(hash, modName){ hashedMods[hashedMods.length] = { modName: modName, hash: hash, } // check if this callback has ran once for each mod if(hashedMods.length == instanceMods.length) { callback(hashedMods); } //console.log(modname); } let hashedMods = []; /*let mods = fs.readdirSync("./sharedMods/")*/ let instanceMods = fs.readdirSync("./instances/"+instanceName+"/mods/"); if(instanceMods.length == 0){ callback({}); // there are no mods installed, return an empty object } for(let o=0;o