Merge pull request #142 from J-Lassauw/ui_overhaul

Web UI Overhaul
This commit is contained in:
Daniel authored and GitHub committed 2018-12-15 16:47:38 +01:00
commit d4600c960e
136 files changed
+5432 -11240

No files matched your search

+13
View File
@@ -50,3 +50,16 @@ sharedPlugins/*
!sharedPlugins/remoteCommands/
!sharedPlugins/researchSync/
!sharedPlugins/globalChat/
!sharedPlugins/clusterioMod/
!sharedPlugins/serverManager/
sharedPlugins/serverManager/database/*
sharedPlugins/serverManager/config.json
sharedPlugins/serverManager/secret-api-token.txt
!sharedPlugins/scenario_loader/
sharedPlugins/scenario_loader/scenarios
!sharedPlugins/statisticsExporter/
# File used for marking plugins as disabled
sharedPlugins/*/DISABLED
\.cache-require-paths\.json
+8
View File
@@ -0,0 +1,8 @@
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
version: v1.12.0
ignore: {}
# patches apply the minimum changes required to fix a vulnerability
patch:
'npm:lodash:20180130':
- html5-validator > caporal > cli-table2 > lodash:
patched: '2018-07-03T06:07:55.532Z'
+1 -1
View File
@@ -1,6 +1,6 @@
language: node_js
node_js:
- "node"
- "9"
before_install:
- sudo apt-get -qq update
- sudo apt-get install -y curl git tar
+9 -7
View File
@@ -1,19 +1,21 @@
FROM node:latest
RUN apt-get update && apt install git -y
FROM node:9.11.2
RUN apt-get update && apt install git curl tar -y
RUN mkdir factorioClusterio
RUN git clone -b dev https://github.com/Danielv123/factorioClusterio.git && cd factorioClusterio && npm install && curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 && tar -xvzf factorio.tar.gz
RUN git clone -b master https://github.com/Danielv123/factorioClusterio.git && cd factorioClusterio && npm install
RUN cd factorioClusterio && curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz
WORKDIR factorioClusterio
RUN mkdir instances sharedMods sharedPlugins
RUN mkdir instances sharedMods
RUN cp config.json.dist config.json
RUN node client.js download
LABEL maintainer "danielv@live.no"
LABEL maintainer "Sir3lit@gmail.com"
EXPOSE 8080 34167
VOLUME /factorioClusterio/instances
VOLUME /factorioClusterio/sharedMods
VOLUME /factorioClusterio/sharedPlugins
CMD RCONPORT="$RCONPORT" FACTORIOPORT="$FACTORIOPORT" MODE="$MODE" node $MODE\.js start $INSTANCE
CMD RCONPORT="$RCONPORT" FACTORIOPORT="$FACTORIOPORT" MODE="$MODE" node $MODE\.js start $INSTANCE
+117 -32
View File
@@ -1,12 +1,12 @@
# factorioClusterio
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FDanielv123%2FfactorioClusterio.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FDanielv123%2FfactorioClusterio?ref=badge_shield)
| Branch | Coverage | Build |
|--------|----------| ----- |
| Master | [![codecov](https://codecov.io/gh/Danielv123/factorioClusterio/branch/master/graph/badge.svg)](https://codecov.io/gh/Danielv123/factorioClusterio) | [![Build Status](https://travis-ci.org/Danielv123/factorioClusterio.svg?branch=master)](https://travis-ci.org/Danielv123/factorioClusterio) |
| Dev | [![codecov](https://codecov.io/gh/Danielv123/factorioClusterio/branch/dev/graph/badge.svg)](https://codecov.io/gh/Danielv123/factorioClusterio) | [![Build Status](https://travis-ci.org/Danielv123/factorioClusterio.svg?branch=dev)](https://travis-ci.org/Danielv123/factorioClusterio) |
Discord for development/support/play: https://discord.gg/5XuDkje
If you want to connect to a clusterio cluster, please reffer to the [client](https://github.com/Danielv123/factorioClusterioClient)
### Ways to support me/the project:
* Contribute with code/documentation. Always nice to move the project forward
* Support me monetarily on [patreon](https://www.patreon.com/danielv123) or paypal: danielv@live.no
### Table of contents
@@ -16,6 +16,10 @@ If you want to connect to a clusterio cluster, please reffer to the [client](htt
* [Windows setup](#windows-setup)
* [Optional plugins](#Plugins)
* [Common problems](#Common-problems)
* [Command cheatsheet](#cheatsheet)
## Introduction
@@ -32,7 +36,16 @@ Features:
- Inventory combinator to display item levels in the cluster (and epoch time)
- Reporting of graphs and UPS on master interface
- Reporting of graphs and UPS on master interface (Also has extensive Prometheus reporting)
Optional extras (see [Plugins](#Plugins))
- Have your inventory synchronize across servers
- Teleport trans from the border of one world to te next
- Show in-game chat in discord
Connection diagram:
@@ -70,27 +83,43 @@ NodeJS does not support EOL ubuntu releases. Make sure you are on the most recen
Master and all slaves:
sudo curl -sL https://deb.nodesource.com/setup_9.x | sudo -E bash - \
sudo apt install -y nodejs python-dev git wget curl tar build-essential \
sudo npm install pm2 -g \
git clone https://github.com/Danielv123/factorioClusterio.git \
cd factorioClusterio \
npm install \
curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 \
sudo curl -sL https://deb.nodesource.com/setup_9.x | sudo -E bash -
sudo apt install -y nodejs python-dev git wget curl tar build-essential
sudo npm install pm2 -g
git clone https://github.com/Danielv123/factorioClusterio.git
cd factorioClusterio
curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.gz
npm install
cp config.json.dist config.json
node ./lib/npmPostinstall.js
downloads and installs nodejs, pm2, git and clusterio. To specify a version, change "latest" in the link to a version number like 0.14.21.
Now you need to edit the `config.json` file. If you skip this step nothing will work.
Pretty much all the blank fields should be filled in, except on the master where a few can be omitted.
* You get the `masterAuthToken` from `secret-api-token.txt` in the master install dir after running the master twice.
* You get your factorio matchmaking token from factorio.com
* The `masterAuthSecret` should never be touched unless you want to invalidate everyones authentication tokens
**Master**
pm2 start master.js --name master
OR
node master.js
**Server Host**
To download the mod for all its non vanilla features and items, (optional)
node client.js download
To download the mod for all its non vanilla features and items, (optional, but very recommended)
node client.js manage shared mods add clusterio
To create a new instance (its own save, set of mods and config files)
node client.js start [instancename]
@@ -103,7 +132,7 @@ use `nano config.json` to change settings.
**Ubuntu with Docker**
Clusterio has limited support for using docker.
Clusterio has *very* limited support for using docker.
sudo docker build -t clusterio --no-cache --force-rm factorioClusterio
@@ -123,13 +152,9 @@ Server host (Slave) = client.js + factorio server
Game Client = The people connecting to the server
All Server Hosts (Slaves) AND Game Clients need to be running the clusterio mod located at [github](https://github.com/Danielv123/factorioClusterioMod) Install it by dropping it into either the sharedMods folder or instances/[instanceName]/instanceMods folder.
There are no requirements for other mods, they can be ran in any configuration allowed by the base game.
**Requirements**
download and install nodeJS 6+ from http://nodejs.org
download and install nodeJS 8 or 9 from http://nodejs.org
download and install git from https://git-scm.com/
@@ -139,29 +164,40 @@ reboot when you are done, then proceed to the next steps. *reboots matter*
1. download and run https://puu.sh/toFHl/01eebbb333.bat
2. You do not *need* to follow the given instructions, but you should
2. Copy config.json.dist to config.json
3. type `node master.js`
3. Follow the instructions in the bat file
3. Some of the instructions are outdated. If you get stuck somewhere, look at the Ubuntu section.
4. Run `node client.js manage shared mods add clusterio`
5. type `node master.js` to start the server
**Server Host**
1. download and run https://puu.sh/toFHl/01eebbb333.bat
2. Follow the instructions given
2. Copy config.json.dist to config.json
3. Type `node client.js start [instancename]` to create a new instance.
3. Follow the instructions given.
3.5 Some of the instructions are outdated. If you get stuck somewhere, look at the Ubuntu section.
4. Type `node client.js start [instancename]` to create a new instance.
To connect to a master server running on a remote machine, open config.json with your favourite text editor (notepad++). You can also set it up to use the official server browser.
Change masterIP to something like 31.152.123.14 (provided by master server owner)
Change `masterIP `to something like `31.152.123.14` (provided by master server owner)
Change masterPort to something like 8080 (provided by master server owner)
Change `masterPort` to something like `8080` (provided by master server owner)
Repeat step 3 for more servers on one machine. You should be able to find its port by looking at the slave section on master:8080 (the web interface)
Change `masterAuthToken` to the value found in `secret-api-token.txt` on the master server
Repeat step 4 for more servers on one machine. You should be able to find its port by looking at the slave section on master:8080 (the web interface)
**GameClient**
Fancy game client that does the following steps automatically: [clusterioClient](https://github.com/Danielv123/factorioClusterioClient)
Fancy game client that does the following steps automatically, but is really old so be warned: [clusterioClient](https://github.com/Danielv123/factorioClusterioClient)
1. Download the same version of the mod as the slave is running from [the mod portal](https://mods.factorio.com/mods/Danielv123/clusterio) or [github](https://github.com/Danielv123/factorioClusterioMod
@@ -169,6 +205,55 @@ Fancy game client that does the following steps automatically: [clusterioClient]
3. Run factorio and connect to slave as a normal MP game. You will find the port number to connect to at http://[masterAddress]:8080
## Plugins
Here are the known Clusterio plugins in the wild:
1. [Player Manager](https://github.com/Danielv123/playerManager) - Adds player management to the Web UI and shared inventory handling (beta)
2. [DiscordChat](https://github.com/jakedraddy/ClusterioDiscordChat) - Logs in-game chat/joins/leave messages on all instances to a Discord webhook.
3. [TrainTeleports](https://github.com/Godmave/clusterioTrainTeleports) - Allows you to teleport cargotrains between servers.
## Common problems
### Cannot find module: `/../../config`
Copy your config.json.dist to config.json and configure it.
### EACCESS [...] LISTEN 443
Some systems don't let non root processes listen to ports below 1000. Either run with `sudo` or change config.json to use higher port numbers.
### InitializationError: First argument must be an abstract-leveldown compliant store
do:
npm install linvodb3@3.25.1
if that does not suffice do
npm install levelup@1.3.9
If it still doesn't work, come over to our support discord linked at the top of this readme.
### Portforwarding doesn't work on the master server when running under WSL
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip. This is also true when you correctly port-forwarded the correct ports. Even when routing this server through nginx in WSL, the issue persists. Then, on a hunch, I tried to run nginx from windows itself and found that this DID work. It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
TLDR: the tested fix is:
- open your windows firewall and go to advanced settings
- click on inbound rules and click on new rule...
- select port and click next >
- select TCP and select specific local ports and type in the ports that you want to open (comma separated) and click next > 3 times
- give the rule a name (like 'web server' or something), give it a description (optionally) and click finish
### Other fixes for other potential problems:
Sometimes the install fails. Try `node ./lib/npmPostinstall.js` to complete it.
## Cheatsheet
**To create a new instance/start it**
@@ -194,5 +279,5 @@ npm install
3. Download the latest version of the factorioClusterioMod from its github repo
```
node client.js download
node client.js manage shared mods add clusterio
```
+288 -471
View File
File diff suppressed because it is too large. Load diff
-42
View File
@@ -1,42 +0,0 @@
{
"__comment": "Where factorio folder. Should be in the same folder as this config file.",
"factorioDirectory": "factorio",
"__comment1": "IP players should connect to to join your servers, should be portforwarded and stuff",
"publicIP": "localhost",
"__comment2": "IP the master web server runs at, can be ignored on the master system",
"masterIP": "localhost",
"masterPort": 8080,
"masterAuthToken": "",
"__commentAuthSecret": "This is a long random string generated by keyboard mashing. Please mash you own string for security.",
"masterAuthSecret": "anfeåpvunuennuusnvzunsrevunåzeufnewnufasiojfåqwoiuajknwkjfdabewfkjadfnewåfundjkcvnewmfqewrmn123",
"__comment3": "Your factorio.com username for factorio MP services, also username will be admin",
"username": "",
"__comment4": "Token from your factorio.com profile page. Required for MP and mod manager services.",
"token": "",
"factorio_version": "0.16.41",
"__comment7": "Used for communication with the mod portal, not needed if you manage mods by hand",
"__comment5": "General user settings",
"verify_user_identity": false,
"auto_pause": false,
"allow_commands": "admins-only",
"game_password": "",
"description": "This server runs Clusterio",
"visibility": {
"public": true,
"lan": true
},
"__comment6": "Statistic gathering statistics, may affect server performance (master memory usage especially)",
"itemStats": {
"maxEntries":60,
"entriesPerSecond": 0.01666666667
},
"logItemTransfers": false,
"uploadModsToMaster": true,
"msBetweenCommands": 10,
"allowRemoteCommandExecution": false
}
+60
View File
@@ -0,0 +1,60 @@
{
"__comment": "Where factorio folder. Should be in the same folder as this config file.",
"factorioDirectory": "./factorio",
"instanceDirectory": "./instances",
"databaseDirectory": "./database",
"__comment1": "IP players should connect to to join your servers, should be portforwarded and stuff",
"publicIP": "localhost",
"__comment2": "IP the master web server runs at, can be ignored on the master system",
"masterIP": "localhost",
"__commentMasterPort": "HTTP port for the master to listen to",
"masterPort": 8080,
"__commentsslPort": "HTTPS port for the master to listen to. Set to 0 or an empty string to not listen for HTTPS.",
"sslPort": 443,
"sslCert": "database/certificates/cert.crt",
"sslPrivKey": "database/certificates/cert.key",
"__commentMasterAuthToken": "Authentication token to connect to the master server. Find it in secret-api-token.txt on the master.",
"masterAuthToken": "",
"__commentAuthSecret": "This is a long random string used for generating authentication tokens. Do not share. Regenerates when secret-api-token.txt is deleted.",
"masterAuthSecret": "",
"__comment3": "Your factorio.com username for factorio MP services, also username will be admin on new instances",
"username": "",
"__comment4": "Token from your factorio.com profile page. Required for MP and mod manager services.",
"token": "",
"__comment7": "Used for communication with the mod portal, not needed if you manage mods by hand",
"factorio_version": "0.16.41",
"__comment5": "General user settings",
"verify_user_identity": true,
"auto_pause": false,
"allow_commands": "admins-only",
"game_password": "",
"description": "This server runs Clusterio",
"visibility": {
"public": true,
"lan": true
},
"__comment6": "Statistic gathering statistics, may affect server performance (master memory usage especially)",
"itemStats": {
"maxEntries":60,
"entriesPerSecond": 0.01666666667
},
"logItemTransfers": false,
"uploadModsToMaster": true,
"msBetweenCommands": 10,
"allowRemoteCommandExecution": true,
"enableCrossServerShout": true,
"mirrorAllChat": false,
"__comment7": "Autosave interval for items in the master storage in milliseconds. Defaults to one minute when set to 0 or not set.",
"autosaveInterval": 600000,
"disablePrometheusPushgateway": false,
"__comment8": "This setting only affects the master. It makes every import for things that arent 'electricity' return a maximum of 0. Effectively disables teleport chests.",
"disableImportsOfEverythingExceptElectricity": false
}
+14 -12
View File
@@ -1,15 +1,17 @@
const jwt = require('jsonwebtoken');
const config = require("./../config");
// const config = require("./../config");
var middleware = function (req, res, next) {
var token = req.headers['x-access-token'];
if(!token) return res.status(401).send({ auth: false, message: 'No token provided.', endpoint: req.route.path});
jwt.verify(token, config.masterAuthSecret, function(err, decoded) {
if(err) return res.status(500).send({ auth: false, message: 'Failed to authenticate token.', endpoint: req.route.path});
next();
});
}
module.exports = {middleware};
module.exports = function(config){
return {
middleware: function (req, res, next) {
var token = req.headers['x-access-token'];
if(!token) return res.status(401).send({ auth: false, message: 'No token provided.', endpoint: req.route.path});
jwt.verify(token, config.masterAuthSecret, function(err, decoded) {
if(err) return res.status(500).send({ auth: false, message: 'Failed to authenticate token.', endpoint: req.route.path});
next();
});
},
}
};
+13 -10
View File
@@ -1,14 +1,17 @@
const fileOps = require("_app/fileOps");
// const config = require("./../config");
module.exports = {
sync: function(){
let instances;
try {
instances = fileOps.getDirectoriesSync("./instances/");
} catch (e) {
// there are no instances created yet, probably missing the folder
instances = [];
}
return instances;
module.exports = function(config) {
return {
sync: function(){
let instances;
try {
instances = fileOps.getDirectoriesSync(config.instanceDirectory);
} catch (e) {
// there are no instances created yet, probably missing the folder
instances = [];
}
return instances;
},
}
}
+26 -5
View File
@@ -15,6 +15,9 @@ console.log()s each entry in the config
@param {string} instance Either undefined, "shared" or the instance to show the config for.
*/
module.exports.displayConfig = function(instance){
console.log(asTable(this.getConfig(instance)));
}
module.exports.getConfig = async function(instance){
if(!instance || instance == "shared"){
let configArray = [];
Object.keys(config).forEach(key=>{
@@ -24,7 +27,7 @@ module.exports.displayConfig = function(instance){
} else configArray.push({entry: key, value: config[key]});
}
});
console.log(asTable(configArray));
return configArray;
}
}
module.exports.editConfig = function(instance, entry, newValue){
@@ -33,16 +36,34 @@ module.exports.editConfig = function(instance, entry, newValue){
if(objectOps.isJSON(newValue)){
config[entry] = JSON.parse(newValue);
console.log("Changed config entry "+entry+" from "+JSON.stringify(config[entry])+" to "+JSON.stringify(newValue));
console.log("new config file:");
console.log(JSON.stringify(config, null, " "))
// console.log("new config file:");
// console.log(JSON.stringify(config, null, " "))
fs.writeFileSync("config.json", JSON.stringify(config, null, 4));
return {
ok:true,
msg:`Changed config entry ${entry} from Object to ${JSON.stringify(newValue)}`,
}
} else {
config[entry] = newValue;
console.log("Changed config entry "+entry+" from "+config[entry]+" to '"+newValue+"'");
console.log("new config file:");
console.log(JSON.stringify(config, null, " "))
// console.log("new config file:");
// console.log(JSON.stringify(config, null, " "))
fs.writeFileSync("config.json", JSON.stringify(config, null, 4));
return {
ok:true,
msg:`Changed config entry ${entry} from ***** to '${newValue}'`,
};
}
} else {
return {
ok:false,
msg:`No such config parameter`,
}
}
} else {
return {
ok:false,
msg:`Editing config for single instances is not yet supported`,
}
}
};
+236 -197
View File
@@ -7,15 +7,15 @@ const rightpad = require("right-pad");
const asTable = require("as-table").configure({delimiter: ' | '});
const moment = require("moment");
const syncRequest = require("sync-request");
const fs = require("fs");
const fs = require("fs-extra");
const https = require('follow-redirects').https;
const factorioAPI = require("node-factorio-api");
const sLog = require("single-line-log").stdout;
const query = require('cli-interact').getYesNo;
const fileOps = require("_app/fileOps");
const config = require("./../../config");
// const config = require("./../../config");
const instances = require("./../getInstances.js").sync();
function ellipse(str, max){
return str.length > (max - 3) ? str.substring(0,max-3) + '...' : str;
@@ -23,209 +23,248 @@ function ellipse(str, max){
// https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
function formatBytes(a,b){if(0==a)return"0 Bytes";var c=1024,d=b||2,e=["Bytes","KB","MB","GB","TB","PB","EB","ZB","YB"],f=Math.floor(Math.log(a)/Math.log(c));return parseFloat((a/Math.pow(c,f)).toFixed(d))+" "+e[f]};
/**
Lists mods for an instance as a table. Warning, handles printing internally.
@param {string} instance - Name of the instance or "shared"
@example
var modManager = require("modManager");
modManager.listMods("testInstance");
// console.logs table of mods installed on instance
modManager.listMods("shared");
// console.logs mods located in /sharedMods as a nice table
*/
module.exports.listMods = function(instance){
if(instance && instances.includes(instance)){
factorioAPI.init(false, 'instances/'+instance+'/instanceMods', 'instances/'+instance+'/saves', config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(token => {
console.log("* Currently installed:");
factorioAPI.readModZips().then(list => {
console.log(asTable(list.map(mod => {
return {
name:(mod.title || mod.name),
version:mod.version,
date:mod.date,
factorio_ver:mod.factorio_version,
author:mod.author,
}
})));
});
// Had issues earlier due to bug in node-factorio-api, see https://github.com/Danacus/node-factorio-api/issues/2
// bug is resolved, thanks to @Danacus and @brotherpot
factorioAPI.getModsFromSaves().then(list => {
console.log("* Mods used in save:");
console.log(asTable(list[0].mods))
});
}).catch(reason => {
console.log("Failed to authenticate to factorio.com! Please check your connection and the username+token used for authentication in config.json");
});
} else {
// list shared mods in /sharedMods/
factorioAPI.init(false, 'sharedMods/', '', config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
console.log("\r\n")
console.log("* Mods shared by all instances:")
factorioAPI.getInstalledMods().then(mods => {
console.log(asTable(mods));
});
});
}
// get JSON data about GH 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;
console.log(JSON.parse(res.getBody()))
*/
}
/**
Searches mods.factorio.com for mods matching a search term and prints
the results to console as a neat looking table
module.exports = function(config){
if(!config) config = require("./../../config");
const instances = require("./../getInstances.js")(config).sync();
return {
/**
Lists mods for an instance as a table. Warning, handles printing internally.
@param {string} instance - Name of the instance or "shared"
@param {string} searchTerm What to search for
*/
module.exports.findMods = function(searchTerm){
factorioAPI.init(false);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(token => {
factorioAPI.searchMods(searchTerm).then(mods => {
console.log(asTable(mods.reverse().map(mod => {
return {
title:ellipse(mod.title, 50),
name:ellipse(mod.name, 30),
downloads:mod.downloads_count,
updated: moment(mod.updated_at).format("DD.MM.YY"),
latest_version: mod.latest_release.version,
owner:mod.owner,
//license: mod.license_name,
}
})))
})
});
}
module.exports.addMod = function(term, instance){
if(instance == "shared"){ // modDirectory has to end in /
var modDirectory = "sharedMods/";
} else {
var modDirectory = "instances/"+instance+"/instanceMods/";
}
if(term && term.length > 0){
if(term.includes("mods.factorio.com/mods/")){ // https://mods.factorio.com/mods/Danielv123/ChooChoo
// treat it as a link download
factorioAPI.init(false, modDirectory, "", config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
factorioAPI.searchMods(term.split("/")[term.split("/").length-1]).then(body => {
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
console.log("Mod downloaded");
@example
var modManager = require("modManager")(config);
modManager.listMods("testInstance");
// console.logs table of mods installed on instance
modManager.listMods("shared");
// console.logs mods located in /sharedMods as a nice table
*/
listMods: function(instance){
return new Promise((resolve, reject) => {
if(instance && instances.includes(instance)){
factorioAPI.init(false, config.instanceDirectory+'/'+instance+'/instanceMods', config.instanceDirectory+'/'+instance+'/saves', config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(token => {
console.log("* Currently installed:");
factorioAPI.readModZips().then(list => {
console.log(asTable(list.map(mod => {
return {
name:(mod.title || mod.name),
version:mod.version,
date:mod.date,
factorio_ver:mod.factorio_version,
author:mod.author,
}
})));
});
// Had issues earlier due to bug in node-factorio-api, see https://github.com/Danacus/node-factorio-api/issues/2
// bug is resolved, thanks to @Danacus and @brotherpot
factorioAPI.getModsFromSaves().then(list => {
console.log("* Mods used in save:");
console.log(asTable(list[0].mods))
});
}).catch(reason => {
console.log("Failed to authenticate to factorio.com! Please check your connection and the username+token used for authentication in config.json");
resolve();
});
} else {
// list shared mods in /sharedMods/
factorioAPI.init(false, 'sharedMods/', '', config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
console.log("\r\n")
console.log("* Mods shared by all instances:")
factorioAPI.getInstalledMods().then(mods => {
console.log(asTable(mods));
resolve();
});
}).catch(e => {
reject(new Error(e));
});
}
// get JSON data about GH 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;
console.log(JSON.parse(res.getBody()))
*/
});
},
/**
Searches mods.factorio.com for mods matching a search term and prints
the results to console as a neat looking table
@param {string} searchTerm What to search for
*/
findMods: function(searchTerm){
return new Promise((resolve, reject) => {
factorioAPI.init(false);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(token => {
factorioAPI.searchMods(searchTerm).then(mods => {
let result = asTable(mods.reverse().map(mod => {
return {
title:ellipse(mod.title, 50),
name:ellipse(mod.name, 30),
downloads:mod.downloads_count,
updated: moment(mod.updated_at).format("DD.MM.YY"),
latest_version: mod.latest_release.version,
owner:mod.owner,
//license: mod.license_name,
}
}));
resolve(result);
// console.log(result);
});
}).catch(e => {
if(e.contains("Insufficient information")) console.log("We are missing some parameters required for logging in! Make sure your factorio username and token are specified in config.json.");
reject(new Error(e));
});
});
} else if(term.includes("github.com")){ // https://github.com/Danielv123/factorioClusterioMod
// treat it as a github releases download
let apiURL = "https://api.github.com/repos/" + term.split("/")[3] + "/" + term.split("/")[4] + "/releases"; // https://api.github.com/repos/Danielv123/factorioClusterioMod/releases
let res = syncRequest('GET', apiURL, {"headers":{"User-Agent":"Fuck you for requiring user agents!"}});
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
},
addMod: function(term, instance){
return new Promise((resolve, reject) => {
if(instance == "shared"){ // modDirectory has to end in /
var modDirectory = "sharedMods/";
} else {
var modDirectory = config.instanceDirectory+"/"+instance+"/instanceMods/";
}
if(term && term.length > 0){
if(term.includes("mods.factorio.com/mods/")){ // https://mods.factorio.com/mods/Danielv123/ChooChoo
// treat it as a link download
factorioAPI.init(false, modDirectory, "", config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
factorioAPI.searchMods(term.split("/")[term.split("/").length-1]).then(body => {
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
console.log("Mod downloaded");
resolve();
});
});
});
} else if(term.includes("github.com")){ // https://github.com/Danielv123/factorioClusterioMod
// treat it as a github releases download
let apiURL = "https://api.github.com/repos/" + term.split("/")[3] + "/" + term.split("/")[4] + "/releases"; // https://api.github.com/repos/Danielv123/factorioClusterioMod/releases
let res = syncRequest('GET', apiURL, {"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("Downloading " + url);
let file = fs.createWriteStream(modDirectory + name);
let bytesDownloaded = 0;
let downloadStartTime = Date.now();
https.get(url, function(response) {
let bytesToDownload = response.headers['content-length'];
response.pipe(file);
response.on("data", chunk => {
bytesDownloaded += chunk.length;
let downloadTimeElapsed = Date.now() - downloadStartTime;
let bytesPerSecond = bytesDownloaded / (downloadTimeElapsed/1000);
let timeToComplete = Math.floor((bytesToDownload - bytesDownloaded)/bytesPerSecond);
sLog("Downloading "+name+" "+(bytesDownloaded/bytesToDownload*100).toPrecision(3)+"% ("+formatBytes(bytesDownloaded)+" / "+formatBytes(bytesToDownload)+")\r\n"+
"about "+timeToComplete+" seconds remaining ("+formatBytes(bytesPerSecond)+"/s)");
}).on("end", x => {
sLog.clear();
console.log(" Downloaded in "+moment.utc(moment.duration(Date.now()-downloadStartTime).as('milliseconds')).format('mm:ss')); // because sLog.clear() doesn't seem to do the job of ending the line properly
resolve();
});
});
}
} else {
// treat it as a search and download the top result
factorioAPI.init(false, modDirectory, "", config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
factorioAPI.searchMods(term).then(body => {
console.log(body)
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
console.log("Mod downloaded");
resolve();
});
});
});
}
} else {
reject(new Error("Invalid search term"));
}
});
},
removeMod: function removeMod(term, instance, force = false){
return new Promise((resolve, reject) => {
if(instance == "shared"){ // modDirectory has to end in /
var modDirectory = "sharedMods/";
} else {
var modDirectory = config.instanceDirectory+"/"+instance+"/instanceMods/";
}
if(term && typeof term == "string" && term.length > 0){
let mods = fs.readdirSync(modDirectory);
let modsMatchingFilter = mods.filter(mod => mod.toLowerCase().includes(term.toLowerCase()));
if(modsMatchingFilter.length < 1){
console.log("No mods found matching search");
} else if(modsMatchingFilter.length == 1){
console.log("Deleting "+modsMatchingFilter[0]);
fs.unlinkSync(modDirectory+modsMatchingFilter[0]);
console.log("Deleted.");
} else if(modsMatchingFilter.length > 1){
console.log("Found multiple mods");
modsMatchingFilter.forEach(mod => console.log(mod));
if(query("Delete all mods matching filter?")){
let modsToDelete = modsMatchingFilter.length;
for(let i = 0; i < modsToDelete; i++){
fs.unlinkSync(modDirectory+modsMatchingFilter[i]);
console.log("Deleted "+modsMatchingFilter[i]);
}
} else console.log("Did not delete mods.")
}
resolve();
} else {
reject(new Error("Invalid search term"));
}
});
},
// 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases'
/**
Download mod from github repo by looking for the first binary download in
the latest release, saving it to /sharedMods
@param {string} url a github repo URL, for example https://api.github.com/repos/Danielv123/factorioClusterioMod/releases. Must link to /releases
*/
downloadFromGitURL: function(url){
console.log("Downloading mod from "+url);
// get JSON data about releases
let res = syncRequest('GET', url, {"headers":{"User-Agent":"Please don't require setting a user agent"}});
let downloadUrl = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
let name = JSON.parse(res.getBody())[0].assets[0].name;
if(url) {
console.log("Downloading " + url);
let file = fs.createWriteStream(modDirectory + name);
let bytesDownloaded = 0;
let downloadStartTime = Date.now();
https.get(url, function(response) {
let bytesToDownload = response.headers['content-length'];
console.log(JSON.parse(res.getBody()))
if(downloadUrl) {
console.log(downloadUrl);
let file = fs.createWriteStream("sharedMods/"+name);
https.get(downloadUrl, function(response) {
response.pipe(file);
response.on("data", chunk => {
bytesDownloaded += chunk.length;
let downloadTimeElapsed = Date.now() - downloadStartTime;
let bytesPerSecond = bytesDownloaded / (downloadTimeElapsed/1000);
let timeToComplete = Math.floor((bytesToDownload - bytesDownloaded)/bytesPerSecond);
sLog("Downloading "+name+" "+(bytesDownloaded/bytesToDownload*100).toPrecision(3)+"% ("+formatBytes(bytesDownloaded)+" / "+formatBytes(bytesToDownload)+")\r\n"+
"about "+timeToComplete+" seconds remaining ("+formatBytes(bytesPerSecond)+"/s)");
}).on("end", x => {
sLog.clear();
console.log(" Downloaded in "+moment.utc(moment.duration(Date.now()-downloadStartTime).as('milliseconds')).format('mm:ss')); // because sLog.clear() doesn't seem to do the job of ending the line properly
});
console.log("Downloaded "+name);
});
}
} else {
// treat it as a search and download the top result
factorioAPI.init(false, modDirectory, "", config.factorio_version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
factorioAPI.searchMods(term).then(body => {
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
console.log("Mod downloaded");
});
});
});
}
}
}
module.exports.removeMod = function removeMod(term, instance, force = false){
if(instance == "shared"){ // modDirectory has to end in /
var modDirectory = "sharedMods/";
} else {
var modDirectory = "instances/"+instance+"/instanceMods/";
}
if(term && typeof term == "string" && term.length > 0){
let mods = fs.readdirSync(modDirectory);
let modsMatchingFilter = mods.filter(mod => mod.toLowerCase().includes(term.toLowerCase()));
if(modsMatchingFilter.length < 1){
console.log("No mods found matching search");
} else if(modsMatchingFilter.length == 1){
console.log("Deleting "+modsMatchingFilter[0]);
fs.unlinkSync(modDirectory+modsMatchingFilter[0]);
console.log("Deleted.");
} else if(modsMatchingFilter.length > 1){
console.log("Found multiple mods, not doing anything");
modsMatchingFilter.forEach(mod => console.log(mod));
}
}
}
// 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases'
/**
Download mod from github repo by looking for the first binary download in
the latest release, saving it to /sharedMods
@param {string} url a github repo URL, for example https://api.github.com/repos/Danielv123/factorioClusterioMod/releases. Must link to /releases
*/
module.exports.downloadFromGitURL = function(url){
console.log("Downloading mod from "+url);
// get JSON data about releases
let res = syncRequest('GET', url, {"headers":{"User-Agent":"Please don't require setting a user agent"}});
let downloadUrl = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
let name = JSON.parse(res.getBody())[0].assets[0].name;
console.log(JSON.parse(res.getBody()))
if(downloadUrl) {
console.log(downloadUrl);
let file = fs.createWriteStream("sharedMods/"+name);
https.get(downloadUrl, function(response) {
response.pipe(file);
console.log("Downloaded "+name);
});
},
}
}
/*
+116
View File
@@ -0,0 +1,116 @@
const fs = require("fs-extra");
const path = require("path");
const git = require("simple-git")("./sharedPlugins");
module.exports = function(config){
if(!config) config = require("./../../config");
return {
listPlugins: async function(){
let plugins = await this.getPlugins();
plugins.forEach(plug => {
console.log(`${plug.name}`.padEnd(22).substr(0,22)+` - ${plug.description}`.substr(0,80-22));
});
},
getPlugins: async function(){
let directories = await fs.readdir("./sharedPlugins");
let returnArray = [];
for(let i in directories){
let dir = directories[i];
let pluginContents = await fs.readdir(path.join("./sharedPlugins", dir));
// console.log(pluginContents)
if(pluginContents.includes("config.js") || pluginContents.includes("config.json")){
try{
var pluginConfig = require(path.join("./../../sharedPlugins", dir, "config"));
pluginConfig.pluginPath = path.join("./sharedPlugins", dir);
}catch(e){
console.log(e)
}
// check if plugin is enabled
try{
await fs.stat(path.join(pluginConfig.pluginPath, "DISABLED"))
pluginConfig.enabled = false;
}catch(e){
pluginConfig.enabled = true;
}
returnArray.push(pluginConfig);
}
}
return returnArray;
},
addPlugin: function(path){
return new Promise((resolve, reject) => {
if(path.includes("git")){ // we are decently sure this is a git repo
git.clone(path, data => resolve(data))
}
});
},
removePlugin: async function(name){
let plugins = await this.getPlugins();
for(let i in plugins){
let plug = plugins[i];
if(plug.name.includes(name)){
// console.log(`Removing plugin ${plug.name}`);
await fs.remove(plug.pluginPath);
return {
ok:true,
msg:`Deleted plugin ${plug.name}`
}
}
}
return {
ok:false,
msg:`Plugin matching "${name}" not found`,
}
},
enablePlugin: async function(name, instance){
let plugins = await this.getPlugins();
let plugin = plugins[findInArray("name", name, plugins)[0]];
if(plugin){
let files = await fs.readdir(plugin.pluginPath);
if(files.includes("DISABLED")){
await fs.unlink(path.join(plugin.pluginPath, "DISABLED"));
return {
ok:true,
msg:`Globally enabled plugin ${name}`,
}
} else {
return {
ok:true,
msg:`Plugin ${name} already enabled`,
}
}
} else {
return {
ok:false,
msg:`Found no excact match for plugin ${name}`,
}
}
},
disablePlugin: async function(name, instance){
let plugins = await this.getPlugins();
let plugin = plugins[findInArray("name", name, plugins)[0]];
if(plugin){
await fs.writeFile(path.join(plugin.pluginPath, "DISABLED"), "");
return {
ok:true,
msg:`Plugin ${name} is now disabled on this host`,
}
} else {
return {
ok:false,
msg:`Found no excact match for plugin ${name}`,
}
}
}
}
}
function findInArray(key, value, array){
let indexes = [];
for(let i in array){
if(array[i][key] && array[i][key] === value) indexes.push(i);
}
return indexes;
}
+109
View File
@@ -0,0 +1,109 @@
--[[
Copyright 2018 Chrisgbk
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- MIT License, https://opensource.org/licenses/MIT
-- Hotpatchable control.lua for scenarios
-- Supports multiple simultaneously loaded softmods
-- This is a WIP
-- Version 1.1 alpha
-- probably some performance improvements to be made
_ENV.debug_settings = {
level = 'info',
log_to_console_only = false,
log_to_RCON = false,
log_on_tick = false,
}
local hotpatch_tools = require 'hotpatch.mod-tools'
require 'hotpatch.remote-interface'
require 'hotpatch.commands'
require 'hotpatch.gui'
-- mod code goes here
-- This is the Factorio Freeplay scenario
-- This code, and the freeplay locale files in the zip file, is the property of Wube
-- As this is part of the base mod, the developers have given permission for modders to use/adapt the assets therein, for modding Factorio
-- Any other use would require licensing/permission from Wube
-- If you don't want to run this, either comment this out before creating your scenario, OR uninstall freeplay at runtime
hotpatch_tools.static_mod('freeplay', '1.0.0', [===[
local silo_script = require("silo-script")
local version = 1
script.on_event(defines.events.on_player_created, function(event)
local player = game.players[event.player_index]
player.insert{name="iron-plate", count=8}
player.insert{name="pistol", count=1}
player.insert{name="firearm-magazine", count=10}
player.insert{name="burner-mining-drill", count = 1}
player.insert{name="stone-furnace", count = 1}
player.force.chart(player.surface, {{player.position.x - 200, player.position.y - 200}, {player.position.x + 200, player.position.y + 200}})
if (#game.players <= 1) then
game.show_message_dialog{text = {"msg-intro"}}
else
player.print({"msg-intro"})
end
silo_script.on_player_created(event)
end)
script.on_event(defines.events.on_player_respawned, function(event)
local player = game.players[event.player_index]
player.insert{name="pistol", count=1}
player.insert{name="firearm-magazine", count=10}
end)
script.on_event(defines.events.on_gui_click, function(event)
silo_script.on_gui_click(event)
end)
script.on_init(function()
global.version = version
silo_script.on_init()
end)
script.on_event(defines.events.on_rocket_launched, function(event)
silo_script.on_rocket_launched(event)
end)
script.on_configuration_changed(function(event)
if global.version ~= version then
global.version = version
end
silo_script.on_configuration_changed(event)
end)
silo_script.add_remote_interface()
silo_script.add_commands()
]===])
-- Multi-file test
--[[
local files = {}
files['testfolder.anothertest.test'] = [===[
script.on_event(defines.events.on_player_changed_position, function(e) game.print('position changed') end)
]===]
files['testfolder.test'] = [===[
require 'anothertest.test'
]===]
files['test'] = [===[
require 'testfolder.test'
]===]
hotpatch_tools.static_mod('require-test', '1.0.0', [===[
require 'test'
]===], files)
--]]
-- end of mod code
@@ -0,0 +1,135 @@
local hotpatch_tools = require 'hotpatch.mod-tools'
hotpatch_tools.static_mod('hotpatch-commands', '1.0.1', [===[
--[[
Copyright 2018 Chrisgbk
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- MIT License, https://opensource.org/licenses/MIT
local hotpatch_tools = require 'hotpatch.mod-tools'
--load private API
-- mod installation/uninstallation support functions
-- These take a mod NAME as a first argument
local install_mod = hotpatch_tools.install_mod
local find_installed_mod = hotpatch_tools.find_installed_mod
local install_mod_file = hotpatch_tools.install_mod_file
local uninstall_mod = hotpatch_tools.uninstall_mod
-- mod interaction functions
-- These take a LOADED INDEX as a first argument, except load_mod, which takes an INSTALLED INDEX
local load_mod = hotpatch_tools.load_mod
local find_loaded_mod = hotpatch_tools.find_loaded_mod
local run_mod = hotpatch_tools.run_mod
local reset_mod = hotpatch_tools.reset_mod
local reset_mod_events = hotpatch_tools.reset_mod_events
local register_mod_events = hotpatch_tools.register_mod_events
local unload_mod = hotpatch_tools.unload_mod
-- internal callbacks when a mod registers events
local register_event = hotpatch_tools.register_event
local register_nth_tick = hotpatch_tools.register_nth_tick
local register_on_tick = hotpatch_tools.register_on_tick
-- mod bootstrap functions
-- These take a LOADED INDEX as a first argument
local mod_on_init = hotpatch_tools.mod_on_init
local mod_on_load = hotpatch_tools.mod_on_load
local mod_on_configuration_changed = hotpatch_tools.mod_on_configuration_changed
local static_mods = hotpatch_tools.static_mods
local console = hotpatch_tools.console
local debug_log = hotpatch_tools.debug_log
local loaded_mods = hotpatch_tools.loaded_mods
local installed_mods = hotpatch_tools.installed_mods
local help_commands = {
help = '/hotpatch help\nYou got this far, you probably know all you need to know',
list = '/hotpatch list [type]\nLists \'installed\', \'loaded\' or \'running\' mods',
}
local sub_commands
sub_commands = {
help = function(player_index, param)
local caller = (player_index and game.players[player_index]) or console
if (not param) or (param == '') then
caller.print('Usage: /hotpatch [command] [parameters]')
caller.print('Available commands:')
for k, v in pairs(sub_commands) do caller.print(k) end
caller.print('Use /hotpatch help [command] for help on a particular command')
else
local text = help_commands[param]
if text then
caller.print(text)
end
end
end,
list = function(player_index, param)
local caller = (player_index and game.players[player_index]) or console
if not param or (param == '') then
caller.print('Installed mods:')
for k, v in pairs(installed_mods) do
caller.print(table.concat{'[', k, '] ', v.name, ' ', v.version})
end
caller.print('Loaded/Running mods:')
for k, v in pairs(loaded_mods) do
caller.print(table.concat{'[', k, '] ', v.name, ' ', v.version, ' ', (v.loaded and 'loaded') or 'not loaded', ' ', (v.running and 'running') or 'not running'})
end
else
end
end,
}
local admin_commands = {
reinstall = function(player_index, param)
local caller = (player_index and game.players[player_index]) or console
local mod
local installed_index
local loaded_index
for i = 1, #static_mods do
mod = static_mods[i]
if mod.name:match('^hotpatch%-.*$') then
loaded_index = find_loaded_mod(mod.name)
if loaded_index then
unload_mod(loaded_index)
end
install_mod(mod.name, mod.version, mod.code, mod.files)
installed_index = find_installed_mod(mod.name)
load_mod(installed_index)
loaded_index = find_loaded_mod(mod.name)
run_mod(loaded_index)
mod_on_init(loaded_index)
end
end
end,
}
_ENV.commands.add_command('hotpatch', 'Commands for hotpatch. Run /hotpatch help for details.', function(e)
local caller = (e.player_index and game.players[e.player_index]) or console
if (not e.parameter) or (e.parameter == '') then
sub_commands['help'](e.player_index)
return
end
local sub_command, rest = e.parameter:match('(.-)%s(.*)')
if not sub_command then
sub_command = e.parameter
end
local f = sub_commands[sub_command]
if f then f(e.player_index, rest) end
if caller.admin then
f = admin_commands[sub_command]
if f then f(e.player_index, rest) end
end
end)
]===])
return true
+375
View File
@@ -0,0 +1,375 @@
local hotpatch_tools = require 'hotpatch.mod-tools'
hotpatch_tools.static_mod('hotpatch-gui', '1.0.4', [===[
--[[
Copyright 2018 Chrisgbk
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- MIT License, https://opensource.org/licenses/MIT
local hotpatch_tools = require 'hotpatch.mod-tools'
local mod_gui = require 'mod-gui'
--load private API
-- mod installation/uninstallation support functions
-- These take a mod NAME as a first argument
local install_mod = hotpatch_tools.install_mod
local find_installed_mod = hotpatch_tools.find_installed_mod
local install_mod_file = hotpatch_tools.install_mod_file
local uninstall_mod = hotpatch_tools.uninstall_mod
-- mod interaction functions
-- These take a LOADED INDEX as a first argument, except load_mod, which takes an INSTALLED INDEX
local load_mod = hotpatch_tools.load_mod
local find_loaded_mod = hotpatch_tools.find_loaded_mod
local run_mod = hotpatch_tools.run_mod
local reset_mod = hotpatch_tools.reset_mod
local reset_mod_events = hotpatch_tools.reset_mod_events
local register_mod_events = hotpatch_tools.register_mod_events
local unload_mod = hotpatch_tools.unload_mod
-- internal callbacks when a mod registers events
local register_event = hotpatch_tools.register_event
local register_nth_tick = hotpatch_tools.register_nth_tick
local register_on_tick = hotpatch_tools.register_on_tick
-- mod bootstrap functions
-- These take a LOADED INDEX as a first argument
local mod_on_init = hotpatch_tools.mod_on_init
local mod_on_load = hotpatch_tools.mod_on_load
local mod_on_configuration_changed = hotpatch_tools.mod_on_configuration_changed
local static_mods = hotpatch_tools.static_mods
local console = hotpatch_tools.console
local debug_log = hotpatch_tools.debug_log
local loaded_mods = hotpatch_tools.loaded_mods
local installed_mods = hotpatch_tools.installed_mods
script.on_event(defines.events.on_player_joined_game, function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local button = top['hotpatch-button']
if button then
button.destroy()
end
local menu = left['hotpatch-menu']
if menu then
menu.destroy()
end
local IDE = center['hotpatch-IDE']
if IDE then
IDE.destroy()
end
local main = center['hotpatch-main']
if main then
main.destroy()
end
button = top.add{type = 'sprite-button', name = 'hotpatch-button', sprite='utility/heat_exchange_indication', tooltip = 'Hotpatch', style = mod_gui.button_style}
end)
local on_gui_click_handlers
local on_gui_selection_state_changed_handlers
on_gui_click_handlers = {
['hotpatch-button'] = function(e)
--on_gui_click_handlers['hotpatch-menu.IDE'](e)
--do return end
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local menu = left['hotpatch-menu']
if not menu then
menu = left.add{type = 'frame', name = 'hotpatch-menu', direction = 'vertical'}
menu.add{type = 'button', name = 'hotpatch-menu.IDE', caption = 'Mod IDE', tooltip = 'Open debugging GUI'}
menu.add{type = 'button', name = 'hotpatch-menu.console', caption = 'Mod Console', tooltip = 'Open debugging console'}
menu.style.visible = true
return
end
menu.style.visible = not menu.style.visible
end,
['hotpatch-menu'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local menu = left['hotpatch-menu']
menu.style.visible = not menu.style.visible
on_gui_click_handlers[e.element.name](e)
end,
['hotpatch-menu.IDE'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local IDE = center['hotpatch-IDE']
if not IDE then
IDE = center.add{type = 'frame', name = 'hotpatch-IDE', direction = 'vertical', caption = 'Hotpatch IDE', style = mod_gui.frame_style}
local top_flow = IDE.add{type = 'flow', name = 'hotpatch-IDE-top', direction = 'horizontal'}
top_flow.add{type = 'label', name = 'hotpatch-IDE-mod-label', caption = 'Mod: '}
local IDE_dropdown = top_flow.add{type = 'drop-down', name = 'hotpatch-IDE-mod-selector'}
top_flow.add{type = 'label', name = 'hotpatch-IDE-mod-version', caption = 'Version: No mod selected'}
local IDE_table = IDE.add{type = 'table', name = 'hotpatch-IDE-table', column_count = 2}
local files = IDE_table.add{type = 'scroll-pane', name = 'hotpatch-IDE-files', direction='vertical'}
files.style.height = 600
files.vertical_scroll_policy = 'always'
files = files.add{type = 'table', name = 'hotpatch-IDE-files-table', column_count = 1}
local code = IDE_table.add{type = 'text-box', name = 'hotpatch-IDE-code'}
code.word_wrap = true
files.style.width = 400
files.style.cell_spacing = 0
files.style.top_padding = 0
files.style.bottom_padding = 0
code.style.width = 600
code.style.height = 600
IDE.style.visible = false
end
local IDE_dropdown = IDE['hotpatch-IDE-top']['hotpatch-IDE-mod-selector']
IDE_dropdown.clear_items()
for k, v in ipairs(installed_mods) do
IDE_dropdown.add_item(v.name)
end
IDE.style.visible = not IDE.style.visible
if IDE.style.visible then
player.opened = IDE
else
player.opened = nil
end
end,
['hotpatch-menu.console'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local console = center['hotpatch-console']
if not console then
console = center.add{type = 'frame', name = 'hotpatch-console', direction = 'vertical', caption = 'Hotpatch Console'}
local top_flow = console.add{type = 'flow', name = 'hotpatch-console-top', direction = 'horizontal'}
top_flow.add{type = 'label', name = 'hotpatch-console-mod-label', caption = 'Mod: '}
local console_dropdown = top_flow.add{type = 'drop-down', name = 'hotpatch-console-mod-selector'}
top_flow.add{type = 'label', name = 'hotpatch-console-mod-version', caption = 'Version: No mod selected'}
local bottom_flow = console.add{type = 'flow', name = 'hotpatch-console-bottom', direction = 'vertical'}
local output = bottom_flow.add{type = 'text-box', name = 'hotpatch-console-output'}
local bottom_input_flow = bottom_flow.add{type = 'flow', name = 'hotpatch-console-bottom-input', direction = 'horizontal'}
local input = bottom_input_flow.add{type = 'textfield', name = 'hotpatch-console-input'}
bottom_input_flow.add{type = 'button', name = 'hotpatch-console-run', caption = 'Run', tooltip = 'Run Lua Code'}
output.style.width = 800
output.style.height = 600
input.style.width = 600
end
local console_dropdown = console['hotpatch-console-top']['hotpatch-console-mod-selector']
console_dropdown.clear_items()
for k, v in ipairs(loaded_mods) do
console_dropdown.add_item(v.name)
end
console.style.visible = not console.style.visible
if console.style.visible then
player.opened = console
else
player.opened = nil
end
end,
['hotpatch-console-run'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local console = center['hotpatch-console']
local output = console['hotpatch-console-bottom']['hotpatch-console-output']
local input = console['hotpatch-console-bottom']['hotpatch-console-bottom-input']['hotpatch-console-input']
local console_dropdown = console['hotpatch-console-top']['hotpatch-console-mod-selector']
if not player.admin then
output.text = table.concat{output.text, '\n', 'Only admins!'}
return
end
if not (console_dropdown.selected_index > 0) then
output.text = table.concat{output.text, '\n', 'Select a mod!'}
return
end
local mod_name = console_dropdown.items[console_dropdown.selected_index]
local loaded_index = find_loaded_mod(mod_name)
local env = loaded_mods[loaded_index].env
local old_print = env.print
local old_log = env.log
env.print = function(...)
--local count = select('#', ...)
local t = table.pack(...)
for k, v in pairs(t) do
t[k] = tostring(v)
end
local text = table.concat(t, '\t')
output.text = table.concat{output.text, '\n', text}
end
env.log = function(text)
output.text = table.concat{output.text, '\n', text}
end
local code, err = load(input.text, input.text, 't', env)
if code then
local success, err = pcall(code)
if not success then
output.text = table.concat{output.text, '\n', err}
end
else
output.text = table.concat{output.text, '\n', err}
end
env.print = old_print
env.log = old_log
end,
['hotpatch-IDE-file'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local element = e.element
local previous
for k, v in pairs(center['hotpatch-IDE']['hotpatch-IDE-table']['hotpatch-IDE-files']['hotpatch-IDE-files-table'].children) do
if table.compare(v.style.font_color or {}, {r=1.0, g=1.0, b=0.0, a=1.0}) then
previous = v
break
end
end
if previous then
previous.style.font_color = {r=1.0, g=1.0, b=1.0, a=1.0}
end
element.style.font_color = {r=1.0, g=1.0, b=0.0, a=1.0}
local file = element.name:match('.-%.(.*)')
local selected = center['hotpatch-IDE']['hotpatch-IDE-top']['hotpatch-IDE-mod-selector']
local mod_name = selected.items[selected.selected_index]
local code = center['hotpatch-IDE']['hotpatch-IDE-table']['hotpatch-IDE-code']
local index = find_installed_mod(mod_name)
if index then
local mod = installed_mods[index]
if file == 'control' then
code.text = mod.code
else
code.text = mod.files[file]
end
end
end,
}
script.on_event(defines.events.on_gui_click, function(e)
local element = e.element
if element.valid then
local name = element.name:match('([^%.]*)%.?.-')
local handler = on_gui_click_handlers[name]
if handler then handler(e) end
end
end)
on_gui_selection_state_changed_handlers = {
['hotpatch-console-mod-selector'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local element = e.element
local name = element.items[element.selected_index]
if name then
local index = find_loaded_mod(name)
if index then
local mod = loaded_mods[index]
local version_label = center['hotpatch-console']['hotpatch-console-top']['hotpatch-console-mod-version']
version_label.caption = 'Version: ' .. mod.version
local console = center['hotpatch-console']
local output = console['hotpatch-console-bottom']['hotpatch-console-output']
output.text = ''
end
end
end,
['hotpatch-IDE-mod-selector'] = function(e)
local player = game.players[e.player_index]
local top = mod_gui.get_button_flow(player)
local left = mod_gui.get_frame_flow(player)
local center = player.gui.center
local element = e.element
local name = element.items[element.selected_index]
if name then
local index = find_installed_mod(name)
if index then
local mod = installed_mods[index]
local version_label = center['hotpatch-IDE']['hotpatch-IDE-top']['hotpatch-IDE-mod-version']
version_label.caption = 'Version: ' .. mod.version
local list = center['hotpatch-IDE']['hotpatch-IDE-table']['hotpatch-IDE-files']['hotpatch-IDE-files-table']
list.clear()
local file = list.add{type = 'label', style = 'hoverable_bold_label', caption = 'control', name = 'hotpatch-IDE-file.control'}
file.style.bottom_padding = 0
file.style.top_padding = 0
for k, v in pairs(mod.files) do
file = list.add{type = 'label', style = 'hoverable_bold_label', caption = k, name = 'hotpatch-IDE-file.' .. k}
file.style.bottom_padding = 0
file.style.top_padding = 0
end
local code = center['hotpatch-IDE']['hotpatch-IDE-table']['hotpatch-IDE-code']
code.text = ''
end
end
end,
}
script.on_event(defines.events.on_gui_selection_state_changed, function(e)
local element = e.element
local handler = on_gui_selection_state_changed_handlers[element.name]
if handler then handler(e) end
end)
script.on_event(defines.events.on_gui_closed, function(e)
local player = game.players[e.player_index]
local element = e.element
if e.gui_type == defines.gui_type.custom then
if element and element.valid then
if element.name == 'hotpatch-IDE' then
if element.style.visible then
player.play_sound{path = "utility/gui_click"}
end
element.style.visible = false
elseif element.name == 'hotpatch-console' then
if element.style.visible then
player.play_sound{path = "utility/gui_click"}
end
element.style.visible = false
end
end
end
end)
]===])
return true
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,266 @@
local hotpatch_tools = require 'hotpatch.mod-tools'
hotpatch_tools.static_mod('hotpatch-remote-interface', '1.0.1', [===[
--[[
Copyright 2018 Chrisgbk
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]
-- MIT License, https://opensource.org/licenses/MIT
local hotpatch_tools = require 'hotpatch.mod-tools'
--load private API
-- mod installation/uninstallation support functions
-- These take a mod NAME as a first argument
local install_mod = hotpatch_tools.install_mod
local find_installed_mod = hotpatch_tools.find_installed_mod
local install_mod_file = hotpatch_tools.install_mod_file
local uninstall_mod = hotpatch_tools.uninstall_mod
-- mod interaction functions
-- These take a LOADED INDEX as a first argument, except load_mod, which takes an INSTALLED INDEX
local load_mod = hotpatch_tools.load_mod
local find_loaded_mod = hotpatch_tools.find_loaded_mod
local run_mod = hotpatch_tools.run_mod
local reset_mod = hotpatch_tools.reset_mod
local reset_mod_events = hotpatch_tools.reset_mod_events
local register_mod_events = hotpatch_tools.register_mod_events
local unload_mod = hotpatch_tools.unload_mod
-- internal callbacks when a mod registers events
local register_event = hotpatch_tools.register_event
local register_nth_tick = hotpatch_tools.register_nth_tick
local register_on_tick = hotpatch_tools.register_on_tick
-- mod bootstrap functions
-- These take a LOADED INDEX as a first argument
local mod_on_init = hotpatch_tools.mod_on_init
local mod_on_load = hotpatch_tools.mod_on_load
local mod_on_configuration_changed = hotpatch_tools.mod_on_configuration_changed
local static_mods = hotpatch_tools.static_mods
local console = hotpatch_tools.console
local debug_log = hotpatch_tools.debug_log
local loaded_mods = hotpatch_tools.loaded_mods
local installed_mods = hotpatch_tools.installed_mods
local remote_interface = {}
--IMPORTANT: WHEN COPY PASTING CODE TO CONSOLE THE CODE MUST HAVE SINGLE LINE COMMENTS REMOVED
-- FACTORIO CONSOLE STRIPS LINEFEEDS WHICH MAKES ALL THE CODE BECOME COMMENTED OUT
--TODO: gate these behind admin permissions; if (game.player and game.player.admin) should work for console commands?
debug_log({'hotpatch-info.remote-installing'})
remote_interface['install'] = function(mod_name, mod_version, mod_code, mod_files, only_install)
-- this installs a new mod and runs on_init, then registers events
-- Note that mods may expect that certain events haven't been called yet when their on_init is ran
-- This may prevent them from functioning properly, without manually calling the events they expect
-- examples: on_player_created
local caller = game.player or console
if caller.admin then
local installed_index = find_installed_mod(mod_name)
local loaded_index = find_loaded_mod(mod_name)
if installed_index then
local old_version = installed_mods[installed_index].version
if old_version then
debug_log('WARNING: mod already exists: ' .. mod_name .. ' ' .. old_version)
debug_log('WARNING: reinstalling mod in-place: ' .. mod_name .. ' ' .. mod_version)
end
if loaded_index then
unload_mod(loaded_index)
end
end
install_mod(mod_name, mod_version, mod_code, mod_files)
if not only_install then
installed_index = find_installed_mod(mod_name)
if installed_index then
if not load_mod(installed_index) then
caller.print('compilation failed for mod ' .. mod_name)
return
end
loaded_index = find_loaded_mod(mod_name)
if loaded_index then
if not run_mod(loaded_index) then
caller.print('execution failed for mod ' .. mod_name)
return
end
if not mod_on_init(loaded_index) then
caller.print('on_init failed for mod ' .. mod_name)
return
end
-- TODO: notify all mods
-- TODO: determine vanilla behaviour and replicate it
if not mod_on_configuration_changed(loaded_index, {mod_changes = {mod_name={new_version=version}}}) then
caller.print('on_configuration_changed failed for mod ' .. mod_name)
return
end
else
end
else
end
end
else
caller.print('You must be an admin to run this command.')
end
end
remote_interface['run'] = function(mod_name)
-- this runs on_init, then registers events
-- Note that mods may expect that certain events haven't been called yet when their on_init is ran
-- This may prevent them from functioning properly, without manually calling the events they expect
-- examples: on_player_created
local caller = game.player or console
if caller.admin then
local installed_index = find_installed_mod(mod_name)
local loaded_index = find_loaded_mod(mod_name)
if installed_index and not loaded_index then
if not load_mod(installed_index) then
caller.print('compilation failed for mod ' .. mod_name)
return
end
loaded_index = find_loaded_mod(mod_name)
else
if loaded_index then
local mod = loaded_mods[loaded_index]
local version = mod.version
debug_log('WARNING: mod already loaded: ' .. mod_name .. ' ' .. version)
debug_log('WARNING: reinitializing: ' .. mod_name .. ' ' .. version)
end
end
if loaded_index then
if not run_mod(loaded_index) then
caller.print('execution failed for mod ' .. mod_name)
return
end
if not mod_on_init(loaded_index) then
caller.print('on_init failed for mod ' .. mod_name)
return
end
-- TODO: notify all mods
-- TODO: determine vanilla behaviour and replicate it
if not mod_on_configuration_changed(loaded_index, {mod_changes = {mod_name={new_version=version}}}) then
caller.print('on_configuration_changed failed for mod ' .. mod_name)
return
end
end
else
caller.print('You must be an admin to run this command.')
end
end
remote_interface['update'] = function(mod_name, mod_version, mod_code, mod_files)
-- this updates an existing mod
-- the current mods events are de-registered, the new code is installed, on_load is triggered, and then events are registered
-- finally, the mod is informed of the update, so it can run migrations from the previous version
-- TODO: validation
local caller = game.player or console
if caller.admin then
local installed_index = find_installed_mod(mod_name)
local loaded_index = find_loaded_mod(mod_name)
local old_version
if loaded_index then
old_version = loaded_mods[loaded_index].version
unload_mod(loaded_index)
end
install_mod(mod_name, mod_version, mod_code, mod_files)
installed_index = find_installed_mod(mod_name)
if not load_mod(installed_index) then
caller.print('compilation failed for mod ' .. mod_name)
return
end
loaded_index = find_loaded_mod(mod_name)
if not run_mod(loaded_index) then
caller.print('execution failed for mod ' .. mod_name)
return
end
if old_version then
if not mod_on_load(installed_index) then
caller.print('on_load failed for mod ' .. mod_name)
return
end
-- The mod must do any migrations here
-- TODO: notify all mods
if not mod_on_configuration_changed(installed_index, {mod_changes = {mod_name={old_version=old_version, new_version=mod_version}}}) then
caller.print('on_configuration_changed failed for mod ' .. mod_name)
return
end
else
-- first time install
if not mod_on_init(installed_index) then
caller.print('on_init failed for mod ' .. mod_name)
return
end
-- TODO: notify all mods
if not mod_on_configuration_changed(installed_index, {mod_changes = {mod_name={new_version=mod_version}}}) then
caller.print('on_configuration_changed failed for mod ' .. mod_name)
return
end
end
else
caller.print('You must be an admin to run this command.')
end
end
remote_interface['install_mod_file'] = function(mod_name, mod_file, mod_file_code)
local caller = game.player or console
if caller.admin then
install_mod_file(mod_name, mod_file, mod_file_code)
else
caller.print('You must be an admin to run this command.')
end
end
--TODO: most of this function
remote_interface['clean'] = function(mod_name)
-- Removes ALL mod data and reinitializes
-- doesn't remove things a mod may add like surfaces, etc
local caller = game.player or console
if caller.admin then
mod_reset(mod_name)
run_mod(mod_name)
mod_on_init(mod_name)
mod_on_configuration_changed(mod_name, {mod_changes = {mod_name={new_version=mod_version}}})
else
caller.print('You must be an admin to run this command.')
end
end
remote_interface['uninstall'] = function(mod_name)
-- Uninstalls a mod
local caller = game.player or console
if caller.admin then
uninstall_mod(mod_name)
else
caller.print('You must be an admin to run this command.')
end
end
remote.add_interface('hotpatch', remote_interface)
debug_log({'hotpatch-info.complete', {'hotpatch-info.remote-installing'}})
]===])
--[[
remote.call('hotpatch, 'install', 'test', '1.0.0', [===[
script.on_event(defines.events.on_player_changed_position, function(e)
game.print('changed position')
end
]===]
local updated_code = ....
remote.call('hotpatch', 'update', 'test', '1.0.1', updated_code)
]]
return true
@@ -0,0 +1,3 @@
msg-intro=Hierdie is Factorio vryspel. Jou taak is om 'n vuurpyl in die buitenste ruimte in te stuur. Doen dit deur die bou van 'n Vuurpyl Silo en van daar af 'n vuurpyl te lanseer met a satelliet aan boord. Jy sal baie gevorderde tegnologiëe moet navors om die Vuurpyl Silo te ontsluit. Begin klein, werk jou pad op met outomatisering en moenie vergeet om jouself te beskerm teen die inboorlinge nie.
@@ -0,0 +1,3 @@
msg-intro=.نييلصألا ناكسلا نم كسفن ةيامح ىسنت الو ةياهنلا ىلإ لصتل كتقيرطب لمعأو ،رفصلا نم أدبتس .خيراوصلا عماوص حتف لجأ نم ةمدقتم ايجولونكت نع ثحبلل جاتحت فوس .ةيعانصلا رامقألاب خيراوصلا قالطإو خيراوصلا عماوص ءانب قيرط نع كلذب مايقلا عيطتست .ءاضفلل اخوراص قلطت نا يه كتمهم .Factorio ـل رحلا بعللا وه اذه
@@ -0,0 +1,3 @@
msg-intro=Гэта рэжым свабоднай гульні Factorio. Вашая мэта - запусціць ракету ў космас. Дзеля гэтага вам трэба пабудаваць ракетную шахту і саму ракету з спадарожнікам. Вы мусіце даследваць перадавыя тэхналогіі, каб навучыцца будаваць ракетную шахту. Пачынайце з малога, працуйце над аўтаматызацыяй вытворчасці, даследуйце новыя тэхналогіі і не забывайцеся на абарону ад "тутэйшых"!
@@ -0,0 +1,3 @@
msg-intro=Това е режимът на свободна игра. Задачата ви е да изстреляте ракета в космоса. За това ще е нужно да построите ракетна шахта и изстреляте ракетата със спътник. Ще трябва да проучите развити технологии, които ще отключат ракетната шахта. Започнете с малко и постепенно автоматизирайте процеса, като не забравяте да се защитавате от местните форми на живот.
@@ -0,0 +1,3 @@
msg-intro=Aquest és el mode lliure del Factorio. La vostra tasca és construir un coet i enviar-lo a l'espai. Ho haureu de fer construint un centre de coets espacials i llençant un coet amb un satèl·lit. Necessitareu investigar tecnologies avançades per poder-ho fer. Aneu pas a pas, avanceu cap a l'automatització i no oblideu protegir-vos dels natius.
@@ -0,0 +1,3 @@
msg-intro=Toto je volná hra Factorio. Tvým úkolem je vypustit raketu do vesmíru. Postav raketové silo a vypusť raketu se satelitem. Pro odemknutí raketového sila budeš muset vyzkoumat pokročilé technologie. Začni v malém, postupuj s automatizací a nezapomeň se chránit před domorodci.
@@ -0,0 +1,3 @@
msg-intro=Det her er Factorio fri leg. Din opgave er at affyre en raket for at udforske rummet. For at gøre det skal du bygge en raket silo og affyre en raket med en satellit i. Du bliver nød til at udforske avancerede teknologier for at at kunne bygge en raket silo. Start småt, og arbejd din vej op med hjælp af automatisering, glem ikke at beskytte dig fra de indfødte.
@@ -0,0 +1,3 @@
msg-intro=Dies ist das freie Spiel von Factorio. Deine Aufgabe ist es, eine Rakete in den Weltraum zu starten. Dazu musst Du ein Raketensilo bauen und eine Rakete mit einem Satelliten starten. Du wirst erweiterte Technologien erforschen müssen, um das Raketensilo freizuschalten. Fange klein an, arbeite Dich durch Automatisierung hoch und vergiss nicht, Dich vor den Einheimischen zu schützen.
@@ -0,0 +1,3 @@
msg-intro=Αυτό είναι το ελεύθερο παιχνίδι του Factorio. Ο στόχος σου είναι να εκτοξεύσεις έναν πύραυλο στο διάστημα. Αυτό το κάνεις κατασκευάζοντας ένα σιλό πυραύλου και εκτοξεύοντας έναν πυραύλο με δορυφόρο. Θα πρέπει να ερευνήσεις προηγμένες τεχνολογίες για να ξεκλειδώσεις το σιλό πυραύλου. Ξεκινάς περιορισμένος, δούλεψε με αυτοματισμούς για την άνοδο σου και μην ξεχνάς να προστατέψεις τον εαυτό σου από τους ντόπιους.
@@ -0,0 +1,2 @@
msg-intro=This is the Factorio freeplay. Your task is to launch a rocket into space. Do this by constructing a Rocket Silo and launching a rocket with a satellite. You will need to research advanced technologies in order to unlock the Rocket Silo. Start small, work your way up with automation and don't forget to protect yourself from the natives.
@@ -0,0 +1,77 @@
[hotpatch]
log=@__1__:__2__] __3__
log-mod=@__1__:__2__][__4__] __3__
severe=SEVERE ERROR: __1__
error=ERROR: __1__
warning=WARNING: __1__
info=INFO: __1__
verbose=INFO: __1__
trace=TRACE: __1__
[hotpatch-info]
logging-enabled=Logging enabled
metatable-installed=_ENV metatable installed
complete=__1__ Complete!
on-init=initializing...
on-load=loading...
on-configuration-changed=mod configuration changed...
installing-included-mods=installing included mods...
loading-installed-mods=loading installed mods...
loading-libs=loading Factorio.data.core.lualib...
loading-library=loading library: __1__
uninstalling=Uninstalling mod...
installing=Installing version __1__...
installing-file=Installing file __1__...
script-shim=setting up mod script shim...
setting-env=setting up mod _ENV...
loading=loading...
unloading=unloading...
running=running...
must-be-admin=You must be an admin to run this command
remote-installing=installing remote interface
[hotpatch-trace]
nil-var-access=_ENV nil variable access: __1__
nil-var-assignment=_ENV variable assignment: __1__
event-registering=registering events...
on-tick-event-registered=registered on_tick event...
on-event-registered=registered event __1__...
on-nth-tick-event-registered=registered nth_tick event __1__...
event-running=running event __1__...
nth-tick-event-running=running nth_event __1__...
mod-on-init=running on_init...
mod-on-load=running on_load...
mod-on-configuration-changed=running on_configuration_changed...
adding-event=adding event __1__
adding-nth-tick-event=adding nth_tick event __1__
caching-event=caching event: __1__...
caching-nth-tick-event=caching nth_tick event: __1__...
event-processing=processing event __1__...
nth-tick-event-processing=processing nth_tick event __1__...
nth-tick-handler-added=added nth_tick handler: __1__
nth-tick-handler-removed=removed nth_tick handler: __1__
on-tick-handler-added=added on_tick handler
on-tick-handler-removed=removed on_tick handler
on-event-handler-added=added event handler: __1__
on-event-handler-removed=removed event handler: __1__
cached-load-require=loading cached require'd file: __1__...
load-require=loading require'd file: __1__...
load-core-lib=loading from Factorio.data.core.lualib: __1__...
[hotpatch-warning]
contains-comments=mod code contains comments!
contains-comments-no-lf=mod contains comments and no linefeed!
contains-comments-console=comments from console will comment out the entire code!
reset-events-not-running=tried to reset events for mod that isn't running!
remote-interface-exists=remote interface __1__ already exists, removing...
command-exists=command __1__ already exists, removing...
already-loaded=mod already loaded
reinitializing=reinitializing...
already-exists=mod already exists
reinstalling=reinstalling mod in-place
[hotpatch-error]
invalid-API-access=Invalid API access: __1__
not-installed=mod not installed, cannot install file for mod that does not exist
compilation-failed=compilation failed for mod
execution-failed=execution failed for mod
@@ -0,0 +1,3 @@
msg-intro=Tio estas la Factorio Libera Ludado. Via tasko estas ĵeti raketon en spacon. Fari tiun per konstruanta Raketo Silo kaj ĵeti raketon kun satelito. Vi bezonos esplori altnivelaj teknologioj por malŝlosi la Raketo Silo. Start malgranda, labori vian vojon supren per aŭtomatigo kaj ne forgesu protekti vin mem de la indiĝenoj.
@@ -0,0 +1,3 @@
msg-intro=Este es el modo de juego libre de Factorio. Tu tarea es la de lanzar un cohete al espacio. Logra esto construyendo un Silo de Cohetes y lanzando un cohete con un satélite. Necesitarás investigar tecnologías avanzadas para poder desbloquear el Silo de Cohetes. Comienza poco a poco, trabaja en tu sistema de automatización y no te olvides de protegerte de los nativos.
@@ -0,0 +1,3 @@
msg-intro=See on Factorio vabamäng. Sinu eesmärgiks on saata rakett avakosmosesse. Seda saad teha ehitades Raketi Silotorni ja lennutades Raketti koos satelliidiga. Selleks pead õppima selgeks keerukad tehnoloogiaharud, et Raketi Silotorni lahti lukustada. Alusta väikselt ning liigu edasi automatiseerimise abil ja ära unusta ennast kaitsta kohalike eluvormide eest.
@@ -0,0 +1,3 @@
msg-intro=Tämä on Factorion vapaapelimuoto. Tavoitteesi on lähettää satelliitti ulkoavaruuteen rakentamalla rakettisiilo ja laukaisemalla raketti. Rakettisiilon voit rakentaa vasta, kun olet kehittänyt siihen tarvittavat huipputeknologiat. Aloita pienestä ja automatisoi kaikki tarvittava, äläkä unohda suojella itseäsi alkuasukkailta.
@@ -0,0 +1,3 @@
msg-intro=Il s'agit du mode de jeu libre de Factorio. Votre tâche consiste à lancer une fusée dans l'espace. Ceci en construisant un silo à fusée, et en lançant un satellite avec la fusée. Vous aurez besoin de rechercher des technologies de pointe afin de débloquer le silo. Commencez petit, évoluez en automatisant et n'oubliez pas de vous protéger contre les autochtones.
@@ -0,0 +1,3 @@
msg-intro=.םיימוקמה םירוציה ינפמ םכמצע לע ןגהל וחכשת לאו ,יטמוטוא ןוכימ תועצמאב םכלש הישעתה תא וביחרה ,ןטקב וליחתה .ןקתמה תיינב תא רשפאל ידכ תומדקתמ תויגולונכט רוקחל םכילע היהי .ןייוול ליכמה ליט ותועצמאב רגשלו םיליט רוגיש ןקתמ תונבל םכילע .ללחל ליט רגשל איה םכתמישמ .ויִרֹוטְקַפ לש ישפוחה שיחרתה והז
@@ -0,0 +1,3 @@
msg-intro=Ez a Factorio szabad játék módja. A feladatod az, hogy kilőj egy rakétát az űr felderítésére. Ehhez egy Rakétasilót kell építened, melyből aztán egy műholddal felszerelt rakétát kell kilőnöd. A Rakétasiló kifejlesztéséhez különféle technológiákat kell fejlesztened. Kezdj kicsiben és küzdd fel magad az automatizálásban. Ne felejts el védekezni a honos fajok ellen.
@@ -0,0 +1,3 @@
msg-intro=Questa è la modalità libera di Factorio. Il tuo obiettivo è quello di lanciare il razzo nello spazio. Completalo costruendo un Silo per Razzi e lanciando il razzo con il satellite. Avrai bisogno di ricercare tecnologie avanzate per riuscire a sbloccare il Silo per Razzi. Inizia in piccolo, espandi con l'automazione e non dimenticare di proteggerti dai nativi.
@@ -0,0 +1,3 @@
msg-intro=これは、Factorioのフリープレイです。ロケットサイロを建造し、衛星を載せたロケットを宇宙へ打ち上げるのがあなたの任務です。ロケットサイロをアンロックするためには高度な技術研究をする必要があります。小規模に始め、自動化しつつ進み、原住生物から身を守ることも忘れないでください。
@@ -0,0 +1,3 @@
msg-intro=Factorio 자유플레이입니다. 당신의 목표는 우주로 로켓을 발사하는 것입니다. 로켓 격납고를 건설하고 인공위성을 탑재한 로켓을 발사하여 이 목표를 달성하세요. 로켓 격납고의 잠금을 해제하기 위해 고급 기술들을 연구할 필요가 있습니다. 작은 것부터 시작해 자동화로 차근차근 발전하고, 토착 생물로부터 자신을 보호하는 것도 잊지 마세요.
@@ -0,0 +1,3 @@
msg-intro=Tai Factorio laisvas žaidimas. Jūsų užduotis - paleisti raketą į kosmosą. Tam reikia pastatyti Raketos šachtą ir paleisti raketą su palydovu. Reikės atrasti pažangias technologijas kad atrakintumėte Raketos šachtą. Pradėkite palengva, tobulinkite automatiką ir neužmirškite apsisaugoti nuo vietinių.
@@ -0,0 +1,2 @@
msg-intro=Šis ir Factorio brīvais režīms. Tavs uzdevums ir palaist raķeti kosmosā. Lai to izdarītu, tev vajadzēs uzbūvēt palaišanas platformu un palaist raķeti ar satelītu. Tev būs jāizpēta sarežģītas tehnoloģijas, lai atbloķētu palaišanas platformu. Sāc no mazumiņa, strādā un neaizmirsti pasargāt sevi no šīs planētas iemītniekiem...
@@ -0,0 +1,3 @@
msg-intro=Dit is Factorio vrij spel. Je taak is om een raket te lanceren in de ruimte. Bouw hiervoor een raket-silo en lanceer een raket met een satelliet. Je zult geavanceerde technologieën moeten onderzoeken om de raket-silo vrij te spelen. Begin op kleine schaal, breidt uit en automatiseer je fabriek en vergeet niet jezelf te beschermen tegen de inheemse bewoners.
@@ -0,0 +1,3 @@
msg-intro=Dette er Factorio frispilling. Din oppgave er å sende en rakett til verdensrommet. Bygg en rakettsilo og send opp en rakett med en satellitt. Du blir nødt til å utforske avanserte teknologier for å få tilgang til rakettsiloen. Begynn i det små, jobb deg oppover med hjelp av automatisering, men ikke glem å beskytte deg mot de innfødte.
@@ -0,0 +1,3 @@
msg-intro=To jest swobodna rozgrywka Factorio. Twoim zadaniem jest wysłanie rakiety w kosmos. Skonstruuj silos rakietowy i wystrzel z niego rakietę z satelitą. Żeby odblokować silos rakietowy będziesz musiał odkryć zaawansowane technologie. Rozpocznij od podstaw, rozwijaj się wykorzystując automatyzację i nie zapomnij o obronie przed rodzimymi mieszkańcami planety.
@@ -0,0 +1,3 @@
msg-intro=Este é o modo livre do Factorio. Sua missão é lançar um foguete no espaço. Para fazer isso construa um centro de lançamento de foguetes e lance o foguete com um satélite. Você vai precisar pesquisar tecnologias avançadas para conseguir desbloquear um Centro de lançamento. Comece do começo, continue na automação e não se esqueça de se proteger dos nativos.
@@ -0,0 +1,3 @@
msg-intro=Este é o jogo livre Factorio. A tua tarefa é lançar um foguetão para explorar o espaço. Faça isto, construindo um Silo de Foguetão e lançar um foguetão com um satélite. Tu vais precisar de pesquisar tecnologias avançadas para poderes desbloquear o Silo de Foguetão. Começa simples, trabalha e evolui com a automação e não te esqueças de proteger-te dos nativos.
@@ -0,0 +1,3 @@
msg-intro=Acesta este modul Factorio freeplay. Sarcina ta este de a lansa o rachetă în spațiu. Fă acest lucru prin construirea unui siloz de rachete și lansarea unei rachete cu satelit. Va trebui să cercetezi tehnologiile avansate pentru a debloca silozul de rachete. Începe încet, avansează cu automatizarea și nu uita să te protejezi de inamici.
@@ -0,0 +1,3 @@
msg-intro=Это режим свободной игры Factorio. Ваша задача запустить спутник в космос. Для его запуска потребуется создать ракету и ракетную шахту. Начните с малого, работайте над автоматизацией производства, исследуйте новые технологии и не забывайте о защите от местных форм жизни.
@@ -0,0 +1,3 @@
msg-intro=Toto je voľná hra Factorio. Tvojou úlohou je vypustiť raketu do vesmíru. Postav raketové silo a vypusti raketu so satelitom. Pre odomknutie raketového sila budeš musieť vyskúmať pokročilé technológie. Začni v malom, postupuj s automatizáciou a nezabnudni sa chrániť pred domácimi.
@@ -0,0 +1,3 @@
msg-intro=To je Factorio freeplay. Vaša naloga je poslati raketo v vesolje. To storite tako, da zgradite silos za raketo in jo skupaj s satelitom izstrelite v vesolje. Raziskati bo potrebno napredne tehnologije, da bi lahko odklenili Raketni silos. Začnete z majhnim, nato nadgradite vse s pomočjo avtomatizacije in ne pozabite, zaščitite se pred domorodci.
@@ -0,0 +1,2 @@
msg-intro=Ово је Factorio freeplay. Твој задатак је да лансираш ракету са сателитом у свемир. Уради ово грађом ракетног силоса. Треба да истражиш пуно напредних технологија да би откључао ракетни силос. Почни мало, а онда напредуј са аутоматизацијом и не заборави да се заштитиш од ванземаљаца.
@@ -0,0 +1,3 @@
msg-intro=Det här är Factorio frispel. Ditt uppdrag är att avfyra en raket ut i rymden. Gör det genom att tillverka en raket silo och avfyra en raket med en satellit. Du kommer att behöva forska fram avancerade teknologier för att kunna låsa upp raket silon. Börja smått, jobba dig uppåt med hjälp av automatisering och glöm inte att försvara dig själv mot infödingarna.
@@ -0,0 +1,3 @@
msg-intro=นี้คือโหมดอิสระของ Factorio ภารกิจของคุณคือส่งจรวดสู่อวกาศ ซึ่งทำได้โดยการสร้างไซโลจรวดและส่งจรวดขึ้นไปพร้อมกับดาวเทียม คุณต้องวิจัยค้นคว้าเทคโนโลยีขั้นสูงเพื่อปลดล็อคไซโลจรวด เริ่มจากเล็กๆไปเรื่อยๆด้วยระบบอัตโนมัติและอย่าลืมป้องกันตัวเองจากคนพื้นเมืองด้วย
@@ -0,0 +1,3 @@
msg-intro=Bu Factorio'nun serbest oynanışı. Görevin roketleri uzaya yollamak. Bunu Roket Silosu yaparak ve uyduyla roket yollayarak yapacaksın. Roket Silo'sunu açmak için gelişmiş teknolojileri sırayla açmalısın. Yavaş başla, otomatikleş ve yerlilerden kendini korumayı untuma.
@@ -0,0 +1,3 @@
msg-intro=Це один з типів гри в Factorio - "Вільна гра". Ваше завдання - запустити ракету у космічний простір. Зробіть це, побудувавши ракетну шахту та запустивши ракету зі супутником. Вам потрібно дослідити передові технології, щоб мати змогу побудувати ракетну шахту. Починайте з малого, розвивайтеся за допомогою автоматизації, але не забувайте і про оборону!
@@ -0,0 +1,3 @@
msg-intro=Đầy là chế độ freeplay của Factorio. Nhiệm cụ của bạn là phóng một chiếc tên lửa lên vũ trụ. Hãy xây dựng một bệ phóng tên lửa để phóng một chiếc tên lửa kèm theo vệ tinh. Bạn sẽ cần phải nghiên cứu nhiều công nghệ tiên tiến để có thể xây dựng bệ phóng tên lửa. Bắt đầu từ quy mô nhỏ, phát triển dần dần bằng tự động hóa, và đừng quên bảo vệ bản thân mình khỏi những sinh vật sinh sống ở đây.
@@ -0,0 +1,3 @@
msg-intro=欢迎来到异星工厂标准模式,你的目标是要把火箭发射到太空。为此你需要研发一系列高等科技,解锁并修建火箭发射井,直到把携带有卫星的火箭发射出去。从小作坊生产起步,逐渐建立起属于自己的自动化大工厂吧!同时,别忘了防范异星虫子的骚扰 ...
@@ -0,0 +1,3 @@
msg-intro=這是異星工廠的自由模式。你的任務是發射火箭,探索外太空。請建造一個火箭發射井,並發射攜帶著衛星的火箭。你會需要研究大量的進階科技以解鎖火箭發射井。從頭開始,靠著自動化科技的協助向目標邁進時,也別忘記從外星住民手的中保護你自己啊!
+232 -329
View File
@@ -10,6 +10,12 @@ combinator signals.
node master.js
*/
// Attempt updating
// const updater = require("./updater.js");
// updater.update().then(console.log);
(async ()=>{
// 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 = "clusterioMaster";
@@ -20,32 +26,41 @@ process.on('unhandledRejection', r => console.log(r));
// configgy stuff
debug = false;
// reduces loadtime from 1700 ms to 1200 ms on my i3 + SSD
// tested by logging Date.now() here and when the webserver started listening
require('cache-require-paths');
// argument parsing
const args = require('minimist')(process.argv.slice(2));
// Library for create folder recursively if it does not exist
const mkdirp = require("mkdirp");
const averagedTimeSeries = require("averaged-timeseries");
const deepmerge = require("deepmerge");
const path = require("path");
const fs = require("fs-extra");
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const base64url = require('base64url');
const moment = require("moment");
const request = require("request")
// constants
const masterModFolder = "./database/masterMods/";
var config = require('./config');
console.log(`Requiring config from ${args.config || './config'}`)
const config = require(args.config || './config');
config.databaseDirectory = args.databaseDirectory || config.databaseDirectory || "./database";
const masterModFolder = path.join(config.databaseDirectory, "/masterMods/");
mkdirp.sync(config.databaseDirectory);
mkdirp.sync(masterModFolder);
// homebrew modules
const getFactorioLocale = require("./lib/getFactorioLocale");
const stringUtils = require("./lib/stringUtils");
const fileOps = require("_app/fileOps");
// homemade express middleware for token auth
const authenticate = require("./lib/authenticate");
// Library for create folder recursively if it does not exist
const mkdirp = require("mkdirp");
mkdirp.sync("./database");
mkdirp.sync(masterModFolder);
const averagedTimeSeries = require("averaged-timeseries");
const deepmerge = require("deepmerge");
const path = require("path");
const fs = require("fs");
const nedb = require("nedb");
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
var crypto = require('crypto');
var base64url = require('base64url');
const authenticate = require("./lib/authenticate")(config);
/** Sync */
function randomStringAsBase64Url(size) {
@@ -54,6 +69,11 @@ function randomStringAsBase64Url(size) {
if (!fs.existsSync("secret-api-token.txt")) {
config.masterAuthSecret = randomStringAsBase64Url(256);
fs.writeFileSync("config.json",JSON.stringify(config, null, 4));
fs.writeFileSync("secret-api-token.txt", jwt.sign({ id: "api" }, config.masterAuthSecret, {
expiresIn: 86400*365 // expires in 1 year
}));
console.log("Generated new master authentication private key!");
process.exit(0);
}
// write an auth token to file
fs.writeFileSync("secret-api-token.txt", jwt.sign({ id: "api" }, config.masterAuthSecret, {
@@ -62,12 +82,14 @@ fs.writeFileSync("secret-api-token.txt", jwt.sign({ id: "api" }, config.masterAu
const express = require("express");
const compression = require('compression');
const cookieParser = require('cookie-parser');
const ejs = require("ejs");
// Required for express post requests
const bodyParser = require("body-parser");
const fileUpload = require('express-fileupload');
var app = express();
app.use(cookieParser());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
parameterLimit: 100000,
@@ -82,10 +104,17 @@ app.set('views', path.join(__dirname, 'static'));
app.set('view engine', 'html');
app.engine('html', ejs.renderFile);
var routes = require("./routes.js");
routes(app);
// give ejs access to some interesting information
app.use(function(req, res, next){
res.locals.res = res;
res.locals.req = req;
res.locals.masterPlugins = masterPlugins;
res.locals.slaves = slaves;
res.locals.moment = moment;
next();
});
require("./routes.js")(app);
// Set folder to serve static content from (the website)
app.use(express.static('static'));
// mod downloads
@@ -94,14 +123,19 @@ app.use(express.static(masterModFolder));
// set up logging software
const prometheusPrefix = "clusterio_";
const Prometheus = require('prom-client');
const expressPrometheus = require('express-prometheus-request-metrics');
Prometheus.collectDefaultMetrics({ timeout: 10000 }); // collects RAM usage etc every 10 s
const httpRequestDurationMilliseconds = new Prometheus.Histogram({
name: prometheusPrefix+'http_request_duration_ms',
help: 'Duration of HTTP requests in ms',
labelNames: ['route'],
// buckets for response time from 0.1ms to 500ms
buckets: [0.10, 5, 15, 50, 100, 200, 300, 400, 500]
});
if(!config.disablePrometheusPushgateway){
const pushgateway = new Prometheus.Pushgateway('http://hme.danielv.no:9091');
setInterval(() => {
registerMoreMetrics();
pushgateway.push({ jobName: 'clusterio', groupings: {instance: config.publicIP + ":" + config.masterPort, owner: config.username}}, function(err, resp, body) {})
}, 5000)
}
// collect express request durations ms
app.use(expressPrometheus(Prometheus));
const endpointHitCounter = new Prometheus.Gauge({
name: prometheusPrefix+'endpoint_hit_gauge',
help: "How many requests a particular endpoint has gotten",
@@ -116,11 +150,6 @@ const prometheusWsUsageCounter = new Prometheus.Counter({
help: 'Websocket traffic',
labelNames: ["connectionType", "instanceID"],
});
const prometheusProductionGauge = new Prometheus.Gauge({
name: prometheusPrefix+'production_gauge',
help: 'Items produced by instance',
labelNames: ["instanceID", "itemName"],
});
const prometheusExportGauge = new Prometheus.Gauge({
name: prometheusPrefix+'export_gauge',
help: 'Items exported by instance',
@@ -141,18 +170,24 @@ const prometheusPlayerCountGauge = new Prometheus.Gauge({
help: 'Amount of players connected to this cluster',
labelNames: ["instanceID", "instanceName"],
});
const prometheusUPSGauge = new Prometheus.Gauge({
name: prometheusPrefix+'UPS_gauge',
help: 'UPS of the current server',
labelNames: ["instanceID", "instanceName"],
});
const prometheusTickGauge = new Prometheus.Gauge({
name: prometheusPrefix+'tick_gauge',
help: 'Servers current gametick',
labelNames: ["instanceID", "instanceName"],
});
const prometheusMasterInventoryGauge = new Prometheus.Gauge({
name: prometheusPrefix+'master_inventory_gauge',
help: 'Amount of items stored on master',
labelNames: ["itemName"],
});
setInterval(()=>{
let numberOfActiveSlaves = 0;
for(let instance in slaves){
if(Date.now() - Number(slaves[instance].time) < 1000 * 30) numberOfActiveSlaves++;
}
prometheusConnectedInstancesCounter.set(numberOfActiveSlaves);
},10000);
fs.writeFileSync("database/items.json", JSON.stringify(db.items));
},config.autosaveInterval || 60000);
/**
GET Prometheus metrics endpoint. Returns performance and usage metrics in a prometheus readable format.
@memberof clusterioMaster
@@ -161,53 +196,54 @@ GET Prometheus metrics endpoint. Returns performance and usage metrics in a prom
*/
app.get('/metrics', (req, res) => {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
res.set('Content-Type', Prometheus.register.contentType);
/// gather some static metrics
// playercount
for(let instanceID in slaves){try{
prometheusPlayerCountGauge.labels(instanceID, slaves[instanceID].instanceName).set(Number(slaves[instanceID].playerCount) || 0);
}catch(e){}}
registerMoreMetrics();
res.end(Prometheus.register.metrics());
});
function registerMoreMetrics(){
for(let instanceID in slaves){
// playercount
try{
prometheusPlayerCountGauge.labels(instanceID, slaves[instanceID].instanceName).set(Number(slaves[instanceID].playerCount) || 0);
}catch(e){}
// UPS
try{
prometheusUPSGauge.labels(instanceID, slaves[instanceID].instanceName).set(Number(slaves[instanceID].meta.UPS) || 60);
if(slaves[instanceID].meta.tick && typeof slaves[instanceID].meta.tick === "number") prometheusUPSGauge.labels(instanceID, slaves[instanceID].instanceName).set(Number(slaves[instanceID].meta.tick) || 0);
}catch(e){}
}
// inventory
for(let key in db.items){
if(typeof db.items[key] == "number" || typeof db.items[key] == "string"){
prometheusMasterInventoryGauge.labels(key).set(Number(db.items[key]) || 0);
}
}
res.end(Prometheus.register.metrics());
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
// Slave count
let numberOfActiveSlaves = 0;
for(let instance in slaves){
if(Date.now() - Number(slaves[instance].time) < 1000 * 30) numberOfActiveSlaves++;
}
prometheusConnectedInstancesCounter.set(numberOfActiveSlaves);
}
// set up database
var Datastore = require('nedb');
db = {};
var LinvoDB = require("linvodb3");
LinvoDB.dbPath = "./database/linvodb/";
// database for items in system
// db.items = new Datastore({ filename: 'database/items.db', autoload: true });
// in memory database for combinator signals
db.signals = new Datastore({ filename: 'database/signals.db', autoload: true, inMemoryOnly: true});
db.signals.ensureIndex({ fieldName: 'time', expireAfterSeconds: 3600 }, function (err) {});
// production chart database
db.flows = new LinvoDB("flows", {}, {});
const db = {};
(function(){
try{
let x = fs.statSync("database/slaves.json");
console.log("loading slaves from database/slaves.json");
slaves = JSON.parse(fs.readFileSync("database/slaves.json"));
let x = fs.statSync(path.resolve(config.databaseDirectory, "/slaves.json"));
console.log(`loading slaves from path.resolve(config.databaseDirectory, "slaves.json")`);
slaves = JSON.parse(fs.readFileSync(path.resolve(config.databaseDirectory, "slaves.json")));
} catch (e){
slaves = {};
}
try{
x = fs.statSync("database/items.json");
console.log("loading items from database/items.json");
db.items = JSON.parse(fs.readFileSync("database/items.json"));
x = fs.statSync(path.resolve(config.databaseDirectory, "items.json"));
console.log(`loading items from ${path.resolve(config.databaseDirectory, "items.json")}`);
db.items = JSON.parse(fs.readFileSync(path.resolve(config.databaseDirectory, "items.json")));
} catch (e){
db.items = {};
}
@@ -241,23 +277,35 @@ db.items.removeItem = function(object) {
}
// store slaves and inventory in a .json full of JSON data
process.on('SIGINT', function () {
process.on('SIGINT', shutdown); // ctrl + c
process.on('SIGHUP', shutdown); // terminal closed
async function shutdown() {
console.log('Ctrl-C...');
let exitStartTime = Date.now();
// set insane limit to slave length, if its longer than this we are probably being ddosed or something
if(slaves && Object.keys(slaves).length < 2000000){
console.log("saving to slaves.json");
fs.writeFileSync("database/slaves.json", JSON.stringify(slaves));
fs.writeFileSync(path.resolve(config.databaseDirectory, "slaves.json"), JSON.stringify(slaves));
} else if(slaves) {
console.log("Slave database too large, not saving ("+Object.keys(slaves).length+")");
}
if(db.items && Object.keys(db.items).length < 50000){
console.log("saving to items.json");
fs.writeFileSync("database/items.json", JSON.stringify(db.items));
fs.writeFileSync(path.resolve(config.databaseDirectory, "items.json"), JSON.stringify(db.items));
} else if(slaves) {
console.log("Item database too large, not saving ("+Object.keys(slaves).length+")");
}
process.exit(2);
});
for(let i in masterPlugins){
let plugin = masterPlugins[i];
if(plugin.main && plugin.main.onExit && typeof plugin.main.onExit == "function"){
let startTime = Date.now();
await plugin.main.onExit();
console.log("Plugin "+plugin.pluginConfig.name+" exited in "+(Date.now()-startTime)+"ms");
}
}
console.log("Clusterio cleanly exited in "+(Date.now()-exitStartTime)+"ms");
process.exit(0);
}
/**
POST World ID management. Slaves post here to tell the server they exist
@@ -267,7 +315,6 @@ POST World ID management. Slaves post here to tell the server they exist
*/
app.post("/api/getID", authenticate.middleware, function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
// request.body should be an object
// {rconPort, rconPassword, serverPort, mac, time}
// time us a unix timestamp we can use to check for how long the server has been unresponsive
@@ -284,9 +331,8 @@ app.post("/api/getID", authenticate.middleware, function(req,res) {
slaves[req.body.unique][k] = req.body[k];
}
}
console.log("Slave registered: " + slaves[req.body.unique].mac + " : " + slaves[req.body.unique].serverPort+" at " + slaves[req.body.unique].publicIP + " with name " + slaves[req.body.unique].instanceName);
// console.log("Slave registered: " + slaves[req.body.unique].mac + " : " + slaves[req.body.unique].serverPort+" at " + slaves[req.body.unique].publicIP + " with name " + slaves[req.body.unique].instanceName);
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
/**
POST Allows you to add metadata related to slaves for other tools, like owner data, descriptions or statistics.
@@ -296,7 +342,6 @@ POST Allows you to add metadata related to slaves for other tools, like owner da
*/
app.post("/api/editSlaveMeta", authenticate.middleware, function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
// request.body should be an object
// {instanceID, pass, meta:{x,y,z}}
@@ -309,12 +354,11 @@ app.post("/api/editSlaveMeta", authenticate.middleware, function(req,res) {
slaves[req.body.instanceID].meta = deepmerge(slaves[req.body.instanceID].meta, req.body.meta, {clone:true});
let metaPortion = JSON.stringify(req.body.meta);
if(metaPortion.length > 50) metaPortion = metaPortion.substring(0,20) + "...";
console.log("Updating slave "+slaves[req.body.instanceID].instanceName+": " + slaves[req.body.instanceID].mac + " : " + slaves[req.body.instanceID].serverPort+" at " + slaves[req.body.instanceID].publicIP, metaPortion);
// console.log("Updating slave "+slaves[req.body.instanceID].instanceName+": " + slaves[req.body.instanceID].mac + " : " + slaves[req.body.instanceID].serverPort+" at " + slaves[req.body.instanceID].publicIP, metaPortion);
} else {
res.send("ERROR: Invalid instanceID or password")
}
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
/**
@@ -325,7 +369,6 @@ POST Get metadata from a single slave. For whenever you find the data returned b
*/
app.post("/api/getSlaveMeta", function (req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
console.log("body", req.body);
if(req.body && req.body.instanceID && req.body.password){
console.log("returning meta for ", req.body.instanceID);
@@ -334,7 +377,6 @@ app.post("/api/getSlaveMeta", function (req, res) {
res.status(400);
res.send('{"INVALID REQUEST":1}');
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
// mod management
// should handle uploading and checking if mods are uploaded
@@ -346,7 +388,6 @@ POST Check if a mod has been uploaded to the master before. Only checks against
*/
app.post("/api/checkMod", function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
let files = fs.readdirSync(masterModFolder);
let found = false;
files.forEach(file => {
@@ -361,7 +402,6 @@ app.post("/api/checkMod", function(req,res) {
res.send("found");
}
res.end();
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
/**
POST endpoint for uploading mods to the master server. Required for automatic mod downloads with factorioClusterioClient (electron app, see seperate repo)
@@ -371,22 +411,20 @@ POST endpoint for uploading mods to the master server. Required for automatic mo
*/
app.post("/api/uploadMod", authenticate.middleware, function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
if (!req.files) {
res.send('No files were uploaded.');
return;
} else {
console.log(req.files.file);
req.files.file.mv('./database/masterMods/'+req.files.file.name, function(err) {
if (req.files && req.files.file) {
// console.log(req.files.file);
req.files.file.mv(path.resolve(config.databaseDirectory, "masterMods", req.files.file.name), function(err) {
if (err) {
res.status(500).send(err);
} else {
res.send('File uploaded!');
console.log("Uploaded mod: " + req.files.file.name);
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
}
} else {
res.send('No files were uploaded.');
return;
}
});
/**
GET endpoint for getting information about all our slaves
@@ -399,7 +437,6 @@ let slaveCache = {
};
app.get("/api/slaves", function(req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
if(!slaveCache.cache || Date.now() - slaveCache.timestamp > 5000){
@@ -413,7 +450,6 @@ app.get("/api/slaves", function(req, res) {
}
res.send(slaveCache.cache);
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
var recievedItemStatisticsBySlaveID = {};
var sentItemStatisticsBySlaveID = {};
@@ -436,7 +472,6 @@ POST endpoint for storing items in master's inventory.
*/
app.post("/api/place", authenticate.middleware, function(req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
let x;
@@ -456,10 +491,12 @@ app.post("/api/place", authenticate.middleware, function(req, res) {
}
});
}
if( x.instanceID
&& x.instanceName
&& !isNaN(Number(x.count))// This is in no way a proper authentication or anything, its just to make sure everybody are registered as slaves before modifying the cluster (or not, to maintain backwards compat)
/*&& stringUtils.hashCode(slaves[x.unique].rconPassword) == x.unique*/){
if(x.instanceID
&& x.instanceName
&& !isNaN(Number(x.count))// This is in no way a proper authentication or anything, its just to make sure everybody are registered as slaves before modifying the cluster (or not, to maintain backwards compat)
/*&& stringUtils.hashCode(slaves[x.unique].rconPassword) == x.unique*/
&& x.name
&& typeof x.name == "string"){
if(config.logItemTransfers){
console.log("added: " + req.body.name + " " + req.body.count+" from "+x.instanceName+" ("+x.instanceID+")");
}
@@ -489,7 +526,6 @@ app.post("/api/place", authenticate.middleware, function(req, res) {
} else {
res.send("failure");
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
/**
POST endpoint to remove items from DB when client orders items.
@@ -505,7 +541,6 @@ POST endpoint to remove items from DB when client orders items.
_doleDivisionFactor = {}; //If the server regularly can't fulfill requests, this number grows until it can. Then it slowly shrinks back down.
app.post("/api/remove", authenticate.middleware, function(req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
const doleDivisionRetardation = 10; //lower rates will equal more dramatic swings
const maxDoleDivision = 250; //a higher cap will divide the store more ways, but will take longer to recover as supplies increase
res.header("Access-Control-Allow-Origin", "*");
@@ -521,7 +556,11 @@ app.post("/api/remove", authenticate.middleware, function(req, res) {
if(slaves[object.instanceID]) object.instanceName = slaves[object.instanceID].instanceName;
let item = db.items[object.name]
// console.dir(doc);
if (!item) {
if(!item
||((
config.disableImportsOfEverythingExceptElectricity === true || config.disableImportsOfEverythingExceptElectricity === "true" )
&& object.name != "electricity")
){
if(config.logItemTransfers){
console.log('failure could not find ' + object.name);
}
@@ -559,58 +598,15 @@ app.post("/api/remove", authenticate.middleware, function(req, res) {
prometheusDoleFactorGauge.labels(object.name).set(_doleDivisionFactor[object.name] || 0);
prometheusImportGauge.labels(object.instanceID, object.name).inc(Number(object.count) || 0);
res.send({count: object.count, name: object.name});
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
} else {
// if we didn't have enough, attempt giving out a smaller amount next time
_doleDivisionFactor[object.name] = Math.min(maxDoleDivision, Math.max((_doleDivisionFactor[object.name]||0)||1, 1) * 2);
res.send({name:object.name, count:0});
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
//console.log('failure out of ' + object.name + " | " + object.count + " from " + object.instanceID + " ("+object.instanceName+")");
}
}
});
// circuit stuff
/**
POST endpoint to send and store circuit frames on the master.
Gives no response
@memberof clusterioMaster
@instance
@alias api/setSignal
@param {object} circuitFrame
@param {number} circuitFrame.time
*/
app.post("/api/setSignal", authenticate.middleware, function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
if(typeof req.body == "object" && req.body.time){
db.signals.insert(req.body);
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
// console.log("signal set");
}
});
/**
POST endpoint to read database of circuit signals sent to master
@memberof clusterioMaster
@instance
@alias api/readSignal
@returns {object} circuitFrame
*/
app.post("/api/readSignal", function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
// request.body should be an object
// {since:UNIXTIMESTAMP,}
// we should send an array of all signals since then
db.signals.find({time:{$gte: req.body.since}}, function (err, docs) {
// $gte means greater than or equal to, meaning we only get entries newer than the timestamp
res.send(docs);
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
// console.log(docs);
});
});
/**
GET endpoint to read the masters current inventory of items.
@@ -622,7 +618,6 @@ GET endpoint to read the masters current inventory of items.
// endpoint for getting an inventory of what we got
app.get("/api/inventory", function(req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
// Check it and send it
@@ -633,7 +628,6 @@ app.get("/api/inventory", function(req, res) {
}
}
res.send(JSON.stringify(inventory));
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
/**
GET endpoint to read the masters inventory as an object with key:value pairs
@@ -645,185 +639,39 @@ GET endpoint to read the masters inventory as an object with key:value pairs
*/
app.get("/api/inventoryAsObject", function(req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
// Check it and send it
res.send(JSON.stringify(db.items));
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
// post flowstats here for production graphs
// {timestamp: Date, instanceID: string, data: {"item":number}}
/**
POST endpoint to log production graph statistics. Should contain a timestamp
gathered from Date.now(), a instanceID (also reffered to as "unique") and of
course the timeSeries data.
@memberof clusterioMaster
@instance
@alias api/logStats
@param {object} JSON {timestamp: Date.now(), instanceID: "string", data: {"item": number}}
@returns {string} failure
*/
app.post("/api/logStats", authenticate.middleware, function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
if(typeof req.body == "object" && req.body.instanceID && req.body.timestamp && req.body.data) {
if(Number(req.body.timestamp) != NaN){
req.body.timestamp = Number(req.body.timestamp);
db.flows.insert({
instanceID: req.body.instanceID,
timestamp: req.body.timestamp,
data: req.body.data,
});
try{
Object.keys(req.body.data).forEach(itemName => {
prometheusProductionGauge.labels(req.body.instanceID, itemName).inc(Number(req.body.data[itemName]) || 0);
});
}catch(e){console.log(e)};
console.log("inserted: " + req.body.instanceID + " | " + req.body.timestamp);
} else {
console.log("error invalid timestamp " + req.body.timestamp);
res.send("failure");
}
} else {
res.send("failure");
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
// {instanceID: string, fromTime: Date, toTime, Date}
/**
POST endpoint to get timeSeries statistics stored on the master. Can give production
graphs and other IO statistics.
@memberof clusterioMaster
@instance
@alias api/getStats
@param {object} JSON
@returns {object} - with statistics
*/
app.post("/api/getStats", function(req,res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
if(typeof req.body == "string"){
req.body = JSON.parse(req.body);
}
// console.log(req.body);
if(typeof req.body == "object" && req.body.instanceID && req.body.statistic === undefined) {
// if not specified, get stats for last 24 hours
if(!req.body.fromTime) {
req.body.fromTime = Date.now() - 86400000 // 24 hours in MS
}
if(!req.body.toTime) {
req.body.toTime = Date.now();
}
console.log("Looking... " + JSON.stringify(req.body));
db.flows.find({
instanceID: req.body.instanceID,
}, function(err, docs) {
let entries = docs.filter(function (el) {
return el.timestamp <= req.body.toTime && el.timestamp >= req.body.fromTime;
});
// console.log(entries);
res.send(entries);
});
} else if(typeof req.body == "object" && req.body.instanceID && typeof req.body.instanceID == "string" && req.body.itemName){
if(req.body.statistic == "place"){
console.log(`sending place data for instanceID ${req.body.instanceID} ${req.body.itemName}`);
// Gather data
//console.log(recievedItemStatisticsBySlaveID)
let itemStats = recievedItemStatisticsBySlaveID[req.body.instanceID];
if(itemStats === undefined){
res.send({statusForDebugging:"no data available"});
return false;
}
let data = itemStats.get(config.itemStats.maxEntries, req.body.itemName);
res.send({
maxEntries:config.itemStats.maxEntries,
entriesPerSecond: config.itemStats.entriesPerSecond,
data: data,
});
} else if(req.body.statistic == "remove"){
console.log(`sending remove data for instanceID ${req.body.instanceID} ${req.body.itemName}`);
// Gather data
// console.log(sentItemStatisticsBySlaveID)
let itemStats = sentItemStatisticsBySlaveID[req.body.instanceID];
console.log(itemStats)
if(typeof itemStats == "object"){
let data = itemStats.get(config.itemStats.maxEntries, req.body.itemName);
//console.log(itemStats.get(config.itemStats.maxEntries, req.body.itemName));
res.send({
maxEntries:config.itemStats.maxEntries,
entriesPerSecond: config.itemStats.entriesPerSecond,
data: data,
});
} else {
res.send({statusForDebugging:"no data available"});
}
}
}
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
/**
POST endpoint. Modified version of api/getStats which feeds the web interface production graphs
at /nodes. It is like getStats but it does not report items which were not
produced at the moment of recording. (This is to save space, the 0-items were
making up about 92% of the response body weight.)
GET endpoint to read the masters inventory as an object with key:value pairs
@memberof clusterioMaster
@instance
@alias api/getTimelineStats
@param {object} JSON {instanceID:1941029, fromTime: ???, toTime: ???}
@returns {object[]} timeseries where each entry is a set point in time.
*/
app.post("/api/getTimelineStats", function(req,res) {
@memberof clusterioMaster
@instance
@alias api/modData
@returns {object} JSON
*/
// wrap a request in an promise
async function downloadPage(url) {
return new Promise((resolve, reject) => {
request(url, (error, response, body) => {
resolve(body);
});
});
}
app.get("/api/modmeta", async function(req, res) {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
console.log(req.body);
if(typeof req.body == "object" && req.body.instanceID) {
// if not specified, get stats for last 24 hours
if(!req.body.fromTime) {
req.body.fromTime = Date.now() - 86400000 // 24 hours in MS
}
if(!req.body.toTime) {
req.body.toTime = Date.now();
}
console.log("Looking... " + JSON.stringify(req.body));
db.flows.find({
instanceID: req.body.instanceID,
}, function(err, docs) {
if(err) {
console.error(err);
return;
}
//Filter out all the entries outside the time range.
docs = docs.filter(function (el) {
return el.timestamp <= req.body.toTime && el.timestamp >= req.body.fromTime;
});
//Filter out all the elements that weren't produced.
docs.forEach(el => {
for(let key in el.data) {
if(el.data[key] === '0') {
// Set value to undefined instead of using the delete keyword.
// This is because the delete keyword is super duper slow.
// https://stackoverflow.com/questions/208105/how-do-i-remove-a-property-from-a-javascript-object
//delete el.data[key];
el.data[key] = undefined;
}
}
});
res.send(docs);
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
}
res.header("Access-Control-Allow-Origin", "*");
res.setHeader('Content-Type', 'application/json');
let modData = await downloadPage("https://mods.factorio.com/api/mods/" + req.query.modname);
res.send(modData);
});
/**
POST endpoint for running commands on slaves.
Requires x-access-token header to be set. Find you api token in secret-api-token.txt on the master (after running it once)
@@ -836,7 +684,6 @@ Requires x-access-token header to be set. Find you api token in secret-api-token
*/
app.post("/api/runCommand", (req,res) => {
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
var token = req.headers['x-access-token'];
if(!token) return res.status(401).send({ auth: false, message: 'No token provided.' });
@@ -859,7 +706,6 @@ app.post("/api/runCommand", (req,res) => {
wsSlaves[instanceID].runCommand(body.command);
}
res.status(200).send({auth: true, message: "success", response: "Cluster wide messaging initiated"});
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
} else if (
body.instanceID
&& wsSlaves[body.instanceID]
@@ -870,11 +716,9 @@ app.post("/api/runCommand", (req,res) => {
// execute command
wsSlaves[body.instanceID].runCommand(body.command, data => {
res.status(200).send({auth: true, message: "success", data});
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
} else {
res.status(400).send({auth: true, message: "Error: invalid request.body"});
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
}
});
});
@@ -888,16 +732,30 @@ GET endpoint. Returns factorio's base locale as a JSON object.
*/
app.get("/api/getFactorioLocale", function(req,res){
endpointHitCounter.labels(req.route.path).inc();
let reqStartTime = Date.now();
getFactorioLocale.asObject(config.factorioDirectory, "en", (err, factorioLocale) => {
res.send(factorioLocale);
httpRequestDurationMilliseconds.labels(req.route.path).observe(Date.now()-reqStartTime);
});
});
var server = require("http").Server(app);
server.listen(config.masterPort || 8080, function () {
console.log("Listening on port %s...", server.address().port);
});
if(args.sslPort || config.sslPort){
try{
var certificate = fs.readFileSync(path.resolve(config.databaseDirectory, 'certificates/cert.crt'));
var privateKey = fs.readFileSync(path.resolve(config.databaseDirectory, 'certificates/cert.key'));
httpsServer = require("https").createServer({
key: privateKey,
cert: certificate
}, app).listen(args.sslPort || config.sslPort);
} catch(e){
console.error(e)
}
}
if(args.masterPort || config.masterPort){
server = require("http").Server(app);
server.listen(args.masterPort || config.masterPort || 8080, function () {
console.log("Listening on port %s...", server.address().port);
});
}
/* Websockets for remoteMap */
var io = require("socket.io")(server);
var slaveMappers = {};
@@ -1024,7 +882,7 @@ class wsSlave {
runCommand(command, callback){
let commandID = Math.random().toString();
this.socket.emit("runCommand", {command, commandID});
this.commandsWaitingForReturn[commandID] = {callback, timestamp: Date.now()};
if(commandID) this.commandsWaitingForReturn[commandID] = {callback, timestamp: Date.now()};
}
}
io.on('connection', function (socket) {
@@ -1043,7 +901,7 @@ io.on('connection', function (socket) {
if(terminatedConnections > 0) console.log("SOCKET | There are currently "+currentConnections+" websocket connections, deleting "+terminatedConnections+" on timeout");
// tell our friend that we are listening
socket.emit('hello', { hello: 'world' });
setTimeout(()=>socket.emit('hello', { hello: 'world' }), 5000);
/* initial processing for remoteMap */
socket.on('registerSlaveMapper', function (data) {
@@ -1080,4 +938,49 @@ io.on('connection', function (socket) {
});
});
// handle plugins on the master
pluginManagement();
masterPlugins = [];
async function pluginManagement(){
let startPluginLoad = Date.now();
masterPlugins = await getPlugins();
console.log("All plugins loaded in "+(Date.now() - startPluginLoad)+"ms");
return;
}
async function getPlugins(){
const pluginManager = require("./lib/manager/pluginManager.js")(config);
let plugins = [];
let pluginsToLoad = await pluginManager.getPlugins();
for(let i = 0; i < pluginsToLoad.length; i++){
let pluginStartedLoading = Date.now(); // just for logging
let stats = await fs.stat(pluginsToLoad[i].pluginPath);
if(stats.isDirectory()){
let pluginConfig = pluginsToLoad[i];
if(pluginConfig.masterPlugin && pluginConfig.enabled){
let masterPlugin = require(path.resolve(pluginConfig.pluginPath, pluginConfig.masterPlugin));
plugins.push({
main:new masterPlugin({
config,
pluginConfig,
path: pluginConfig.pluginPath,
socketio: io,
express: app,
Prometheus,
}),
pluginConfig,
});
console.log("Loaded plugin "+pluginConfig.name+" in "+(Date.now() - pluginStartedLoading)+"ms");
}
}
}
for(let i in plugins){
let plugin = plugins[i];
if(plugin.main.onLoadFinish && typeof plugin.main.onLoadFinish == "function") await plugin.main.onLoadFinish({plugins});
}
return plugins;
}
module.exports = app;
})();
+9
View File
@@ -0,0 +1,9 @@
module.exports = async function checkHotpatchInstallation(messageInterface){
let yn = await messageInterface("/silent-command if remote.interfaces['hotpatch'] then rcon.print('true') else rcon.print('false') end");
yn = yn.replace(/(\r\n\t|\n|\r\t)/gm, "");
if(yn == "true"){
return true;
} else if(yn == "false"){
return false;
}
}
+16
View File
@@ -0,0 +1,16 @@
const fs = require("fs-extra");
const getLua = require("./getLua.js");
let commandCache;
module.exports = async function getCommand(file, COMPRESS = true){
commandCache = commandCache || {};
if(!commandCache[file]){
commandCache[file] = await getLua(file);
return commandCache[file];
} else if(typeof commandCache[file] == "string"){
return commandCache[file];
} else {
throw new Error("Command not found");
}
}
+40
View File
@@ -0,0 +1,40 @@
const needle = require("needle");
module.exports = function(config){
const instances = [];
if(config){
return function getInstanceName(instanceID){
return new Promise((resolve, reject) => {
let instance = instances[instanceID];
if(!instance){
needle.get(config.masterIP+":"+config.masterPort+ '/api/slaves', (err, response) => {
if(err || response.statusCode != 200) {
console.log("Unable to get JSON master/api/slaves, master might be unaccessible");
} else if (response && response.body) {
if(Buffer.isBuffer(response.body)) {console.log(response.body.toString("utf-8")); reject(new Error())}
try {
for (let index in response.body)
instances[index] = response.body[index].instanceName;
} catch (e){
console.log(e);
return null;
}
instance = instances[instanceID]
if (!instance) instance = instanceID; //somehow the master doesn't know the instance
resolve(instance);
}
});
} else {
resolve(instance);
}
});
}
} else {
return async function thisAintGonnaWork(){
let err = new Error("getInstanceName from _app/clusterTools needs to be called with {config} to work");
console.log(err);
throw err;
}
}
}
+40
View File
@@ -0,0 +1,40 @@
const fs = require("fs-extra");
module.exports = function getLua(filePath, COMPRESS_LUA = true){
return new Promise((resolve, reject) => {
fs.readFile(filePath, "utf8", (err, contents) => {
if(err){
reject(err);
} else {
// split content into lines
contents = contents.split(/\r?\n/);
// join those lines after making them safe again
contents = contents.reduce((acc, val) => {
val = val.replace(/\\/g ,'\\\\');
// remove leading and trailing spaces
val = val.trim();
// escape single quotes
val = val.replace(/'/g ,'\\\'');
// remove single line comments
let singleLineCommentPosition = val.indexOf("--");
let multiLineCommentPosition = val.indexOf("--[[");
if(multiLineCommentPosition === -1 && singleLineCommentPosition !== -1) {
val = val.substr(0, singleLineCommentPosition);
}
return acc + val + '\\n';
}, ""); // need the "" or it will not process the first row, potentially leaving a single line comment in that disables the whole code
// console.log(contents);
// this takes about 46 ms to minify train_stop_tracking.lua in my tests on an i3 6100u
if(COMPRESS_LUA) contents = require("luamin").minify(contents);
resolve(contents);
}
});
});
}
+41
View File
@@ -0,0 +1,41 @@
/**
Functions frequently needed from different places in the cluster. Ex:
* Loading Lua scripts
* Checking if hotpatch is installed
* Getting an instance's displayname from their ID
*/
module.exports = ({config} = {}) => ({
/**
async function to get Lua from a file, remove comments, minify and return.
arg1 is the file path
arg2 is whether or not to compress the Lua. Makes debugging harder, but reduces bandwidth when many players are online. Recommended for polling commands. Defaults to true.
Promise resolves with a string of Lua.
*/
getLua: require("./getLua.js"),
/**
async function to check whether hotpatch is installed in a server.
First argument is the servers async messageInterface for rcon return values
Promise resolves with boolean.
*/
checkHotpatchInstallation: require("./checkHotpatchInstallation.js"),
/**
async function to get an instances displayname from their instanceID
arg1 is the instanceID
Promise resolves with a string, either the displayName or instanceID if not found.
*/
getInstanceName: require("./getInstanceName.js")(config),
/**
async function same as getLua, but using an in memory cache to lower FS operations.
arg1 is the file path
arg2 is whether or not to compress, default true
Promise resolves with a string of Lua.
*/
getCommand: require("./getCommand.js"),
})
-9638
View File
File diff suppressed because it is too large. Load diff
+17 -9
View File
@@ -10,7 +10,8 @@
"cover": "./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha -- \"./{{,node_modules/_app/**/},!(node_modules)/**/}*.spec.js\" -R spec || echo . && codecov -t f7b38085-36eb-4d72-84e6-64721910171e",
"docs": "documentation build index.js --config documentation.yml -f html -o docs",
"livedocs": "documentation serve --watch *.js lib/** --config documentation.yml",
"install": "node lib/npmPostinstall.js"
"install": "node lib/npmPostinstall.js",
"bundle-dependencies": "bundle-dependencies"
},
"keywords": [
"factorio"
@@ -22,30 +23,34 @@
"as-table": "^1.0.31",
"averaged-timeseries": "^1.4.0",
"base64url": "^3.0.0",
"bcrypt": "^2.0.1",
"bcrypt-promise": "^2.0.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.2",
"cache-require-paths": "^0.3.0",
"cli-interact": "^0.1.9",
"codecov": "^3.0.1",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
"deepmerge": "^1.5.2",
"ejs": "^2.5.7",
"express": "^4.16.2",
"express-fileupload": "0.0.6",
"follow-redirects": "^1.4.1",
"fs-extra": "^1.0.0",
"express-prometheus-request-metrics": "github:burnash/express-prometheus-request-metrics",
"follow-redirects": "^1.5.0",
"fs-extra": "^6.0.1",
"getmac": "^1.4.0",
"hash-files": "^1.1.1",
"html5-validator": "^1.0.7",
"ini": "^1.3.5",
"jsonwebtoken": "^8.2.1",
"leveldown": "^1.9.0",
"linvodb3": "^3.25.1",
"luamin": "^1.0.4",
"minimist": "^1.2.0",
"mkdirp": "^0.5.1",
"mkdirp-promise": "^5.0.1",
"moment": "^2.20.1",
"moment": "^2.22.2",
"ncp": "^2.0.0",
"nedb": "^1.8.0",
"needle": "^1.1.0",
"needle": "^2.2.4",
"node-factorio-api": "^0.3.1-alpha",
"node-forge": "^0.7.1",
"prom-client": "^10.2.3",
@@ -53,6 +58,8 @@
"request": "^2.83.0",
"right-pad": "^1.0.1",
"rmdir-sync": "^1.0.1",
"sanitizer": "^0.1.3",
"simple-git": "^1.96.0",
"single-line-log": "^1.1.2",
"socket.io": "^2.0.4",
"socket.io-client": "^2.0.4",
@@ -68,5 +75,6 @@
"nock": "^9.1.6",
"sinon": "^2.4.1",
"supertest": "^3.0.0"
}
},
"snyk": true
}
+12 -13
View File
@@ -3,12 +3,20 @@ const needle = require("needle");
class UPSdisplay {
constructor(slaveConfig, msgInterface){
constructor(slaveConfig, msgInterface, extras){
this.config = slaveConfig;
this.messageInterface = msgInterface;
this.socket = extras.socket;
this.historicalTicks = [];
setInterval(() => {
this.messageInterface("/silent-command game.write_file('UPSdisplay.txt', game.tick, true, 0)");
this.messageInterface("/silent-command rcon.print(game.tick)", data => {
this.tick = Number(data);
this.historicalTicks[this.historicalTicks.length] = {tick:Number(data), timestamp:Date.now()};
if(this.historicalTicks.length > 30){
this.historicalTicks.shift(); // delete last element in array (position 0);
}
});
},1000);
setInterval(() => {
if(this.historicalTicks && this.historicalTicks[0]){
@@ -17,24 +25,15 @@ class UPSdisplay {
let UPS = Math.round(ticksInPeriod / (timePeriod/1000));
// console.log("UPS: " + UPS);
try{
needle.post(this.config.masterIP+':'+this.config.masterPort+'/api/editSlaveMeta', {instanceID: this.config.unique, password: this.config.clientPassword, meta: {UPS:UPS}}, {headers: {'x-access-token': this.config.masterAuthToken}}, function(err, resp) {
needle.post(this.config.masterIP+':'+this.config.masterPort+'/api/editSlaveMeta', {instanceID: this.config.unique, password: this.config.clientPassword, meta: {UPS:UPS, tick:this.tick}}, {headers: {'x-access-token': this.config.masterAuthToken}}, function(err, resp) {
// success?
});
} catch (err){
console.log(err);
}
}
}, 10000);
}, 5000);
this.messageInterface("/silent-command game.print('UPSdisplay enabled')");
}
scriptOutput(data){
if (data !== null && !isNaN(data)){
// console.log("Tick: " + data);
this.historicalTicks[this.historicalTicks.length] = {tick:Number(data), timestamp:Date.now()};
if(this.historicalTicks.length > 30){
this.historicalTicks.shift(); // delete last element in array (position 0);
}
}
}
}
module.exports = UPSdisplay;
+11
View File
@@ -0,0 +1,11 @@
/*
Clusterio plugin to allow for chat between instances.
*/
module.exports = {
// Name of package. For display somewhere I guess.
name: "clusterioMod",
version: "1.0.0",
binary: "nodePackage",
description: "Server side handling of functionality in the clusterio lua mod",
masterPlugin: "masterPlugin.js",
}
+395
View File
@@ -0,0 +1,395 @@
const needle = require("needle");
const fs = require("fs-extra");
const objectOps = require("./../../lib/objectOps.js");
const fileOps = require("_app/fileOps");
const stringUtils = require("./../../lib/stringUtils.js");
const pluginConfig = require("./config");
const COMPRESS_LUA = false;
module.exports = class remoteCommands {
constructor(mergedConfig, messageInterface, extras){
this.messageInterface = messageInterface;
this.config = mergedConfig;
this.socket = extras.socket;
this.socket.on("hello", () => {
});
const that = this;
const instance = process.argv[3];
const instancedirectory = this.config.instanceDirectory + '/' + instance;
const needleOptionsWithTokenAuthHeader = {
headers: {
'x-access-token': this.config.masterAuthToken
},
};
(async ()=>{
let hotpatchInstallStatus = await this.checkHotpatchInstallation();
this.messageInterface("Hotpach installation status: "+hotpatchInstallStatus);
if(hotpatchInstallStatus){
// let mainCode = await this.getSafeLua("sharedPlugins/playerManager/lua/playerTracking.lua");
// if(mainCode) var returnValue = await messageInterface("/silent-command remote.call('hotpatch', 'update', '"+pluginConfig.name+"', '"+pluginConfig.version+"', '"+mainCode+"')");
}
})().catch(e => console.log(e));
// flow/production statistics ------------------------------------------------------------
var oldFlowStats = false;
var oldTimestamp;
var oldFlowStats;
setInterval(function(){
fs.readFile(instancedirectory + "/script-output/flows.txt", {encoding: "utf8"}, function(err, data) {
if(!err && data) {
let timestamp = Date.now();
data = data.split("\n");
let flowStats = [];
for(let i = 0; i < data.length; i++) {
// try catch to remove any invalid json
try{
flowStats[flowStats.length] = JSON.parse(data[i]);
} catch (e) {
// console.log(" invalid json: " + i);
// some lines of JSON are invalid but don't worry, we just filter em out
}
}
// fluids
let flowStat1 = flowStats[flowStats.length-1].flows.player.input_counts
// items
let flowStat2 = flowStats[flowStats.length-2].flows.player.input_counts
// merge fluid and item flows
let totalFlows = {};
for(let key in flowStat1) totalFlows[key] = flowStat1[key];
for(let key in flowStat2) totalFlows[key] = flowStat2[key];
if(oldFlowStats && totalFlows && oldTimestamp) {
let payload = objectOps.deepclone(totalFlows);
// change from total reported to per time unit
for(let key in oldFlowStats) {
// get production per minute
payload[key] = Math.floor((payload[key] - oldFlowStats[key])/(timestamp - oldTimestamp)*60000);
if(payload[key] < 0) {
payload[key] = 0;
}
}
for(let key in payload) {
if(payload[key] == '0') {
delete payload[key];
}
}
console.log("Recorded flows, copper plate since last time: " + payload["copper-plate"]);
needle.post(that.config.masterIP + ":" + that.config.masterPort + '/api/logStats', {timestamp: timestamp, instanceID: that.config.unique, data: payload}, needleOptionsWithTokenAuthHeader, function (err, response, body) {
// we did it, keep going
});
}
oldTimestamp = timestamp;
oldFlowStats = totalFlows;
fs.writeFileSync(instancedirectory + "/script-output/flows.txt", "");
}
});
// we don't need to update stats quickly as that could be expensive
}, 60000*5);
// provide items --------------------------------------------------------------
// trigger when something happens to output.txt
fs.watch(instancedirectory + "/script-output/output.txt", function (eventType, filename) {
// get array of lines in file
let items = fs.readFileSync(instancedirectory + "/script-output/output.txt", "utf8").split("\n");
// if you found anything, reset the file
if (items[0]) {
fs.writeFileSync(instancedirectory + "/script-output/output.txt", "");
}
for (let i = 0; i < items.length; i++) {
if (items[i]) {
let g = items[i].split(" ");
g[0] = g[0].replace("\u0000", "");
// console.log("exporting " + JSON.stringify(g));
// send our entity and count to the master for him to keep track of
needle.post(that.config.masterIP + ":" + that.config.masterPort + '/api/place', {
name: g[0],
count: g[1],
instanceName: instance, // name of instance
instanceID: that.config.unique, // a hash computed from the randomly generated rcon password
}, needleOptionsWithTokenAuthHeader, function (err, resp, body) {
if(body == "failure") console.error("#### Export failed! Lost: "+g[1]+" "+g[0]);
if(that.config.logItemTransfers){
if(body == "success") console.log(`Exported ${g[1]} ${g[0]} to master`);
}
});
}
}
});
// request items --------------------------------------------------------------
setInterval(function () {
// get array of lines in file
let items = fs.readFileSync(instancedirectory + "/script-output/orders.txt", "utf8").split("\n");
// if we actually got anything from the file, proceed and reset file
if (items[0]) {
fs.writeFileSync(instancedirectory + "/script-output/orders.txt", "");
// prepare a package of all our requested items in a more transfer friendly format
var preparedPackage = {};
for (let i = 0; i < items.length; i++) {
(function (i) {
if (items[i]) {
items[i] = items[i].split(" ");
items[i][0] = items[i][0].replace("\u0000", "");
items[i][0] = items[i][0].replace(",", "");
if (preparedPackage[items[i][0]]) {
// if we have buffered some already, sum the new items
if (typeof Number(preparedPackage[items[i][0]].count) == "number" && typeof Number(items[i][1]) == "number") {
preparedPackage[items[i][0]] = {
"name": items[i][0],
"count": Number(preparedPackage[items[i][0]].count) + Number(items[i][1]),
"instanceName":instance,
"instanceID":that.config.unique,
};
// else just add em in without summing
} else if (typeof Number(items[i][1]) == "number") {
preparedPackage[items[i][0]] = {
"name": items[i][0],
"count": Number(items[i][1]),
"instanceName":instance,
"instanceID":that.config.unique,
};
}
// this condition will NEVER be triggered but we know how that goes
} else if (typeof Number(items[i][1]) == "number") {
preparedPackage[items[i][0]] = {
"name": items[i][0],
"count": Number(items[i][1]),
"instanceName":instance,
"instanceID":that.config.unique,
};
}
}
})(i);
}
// request our items, one item at a time
for (let i = 0; i < Object.keys(preparedPackage).length; i++) {
// console.log(preparedPackage[Object.keys(preparedPackage)[i]]);
needle.post(that.config.masterIP + ":" + that.config.masterPort + '/api/remove', preparedPackage[Object.keys(preparedPackage)[i]], needleOptionsWithTokenAuthHeader, function (err, response, body) {
if (response && response.body && typeof response.body == "object") {
// buffer confirmed orders
confirmedOrders[confirmedOrders.length] = {name:response.body.name,count:response.body.count}
if(that.config.logItemTransfers){
console.log(`Imported ${response.body.count} ${response.body.name} from master`);
}
}
});
}
// if we got some confirmed orders
// console.log("Importing " + confirmedOrders.length + " items! " + JSON.stringify(confirmedOrders));
//if (!(confirmedOrders.length>0)){return;}
let cmd="local t={";
for(let i=0;i<confirmedOrders.length;i++)
{
cmd+='["'+confirmedOrders[i].name+'"]='+confirmedOrders[i].count+',';
if(cmd.length>320) // Factorio max packet size is 508
{
that.messageInterface("/silent-command remote.call('clusterio', 'runcode', '"+cmd.slice(0, -1)+"}"+ " for k, item in pairs(t) do GiveItemsToStorage(k, item) end')");
cmd="local t={";
}
}
if (!(cmd==="local t={")){
that.messageInterface("/silent-command remote.call('clusterio', 'runcode', '"+cmd.slice(0, -1)+"}"+ " for k, item in pairs(t) do GiveItemsToStorage(k, item) end')");
}
confirmedOrders=[];
}
}, 1000);
// COMBINATOR SIGNALS ---------------------------------------------------------
// get inventory from Master and RCON it to our slave
setInterval(() => {
needle.get(that.config.masterIP + ":" + that.config.masterPort + '/api/inventory', function (err, response, body) {
if(err){
console.error("Unable to get JSON master/api/inventory, master might be unaccessible");
} else if (response && response.body) {
// Take the inventory we (hopefully) got and turn it into the format LUA accepts
if(Buffer.isBuffer(response.body)) {console.log(response.body.toString("utf-8")); throw new Error();} // We are probably contacting the wrong webserver
try {
var inventory = JSON.parse(response.body);
var inventoryFrame = {};
for (let i = 0; i < inventory.length; i++) {
inventoryFrame[inventory[i].name] = Number(inventory[i].count);
if(inventoryFrame[inventory[i].name] >= 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));
let first = true;
let cmd="local s={";
for (let key in inventoryFrame)
{
cmd+='["'+key+'"]='+inventoryFrame[key]+",";
if(first && cmd.length>300 || !first && cmd.length > 320) // Factorio max packet size is 508
{
that.messageInterface("/silent-command remote.call('clusterio', 'runcode', '"+(first ? 'global.ticksSinceMasterPinged=0 ':'')+cmd.slice(0, -1)+"}"+ " for name,count in pairs(s) do global.invdata[name]=count end')");
cmd="local s={";
first = false;
}
}
if (!(cmd==="local s={")){
that.messageInterface("/silent-command remote.call('clusterio', 'runcode', '"+(first ? 'global.ticksSinceMasterPinged=0 ':'')+cmd.slice(0, -1)+"}"+ " for name,count in pairs(s) do global.invdata[name]=count end')");
}
that.messageInterface("/silent-command remote.call('clusterio', 'runcode', 'UpdateInvCombinators()')");
} catch (e){
console.log(e);
}
}
});
}, 1550);
// Make sure world has its worldID
setTimeout(function(){
messageInterface("/silent-command remote.call('clusterio','setWorldID',"+that.config.unique+")")
}, 20000);
/* REMOTE SIGNALLING
* send any signals the slave has been told to send
* Fetch combinator signals from the server
*/
this.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()
this.socket.emit("combinatorSignal", doneframe);
} else {
// console.log("Invalid jsony: "+typeof signals[i])
}
}
}
});
readingTxBufferSoon = false;
},100);
}
});
}
async getCommand(file){
this.commandCache = this.commandCache || {};
if(!this.commandCache[file]){
try{
let command = (await fs.readFile(file)).toString();
this.commandCache[file] = command.replace(/(\r\n\t|\n|\r\t)/gm, " "); // remove newlines
return this.commandCache[file];
} catch(e){
console.log("Unable to get command from file!");
console.log(e)
}
} else if(typeof this.commandCache[file] == "string"){
return this.commandCache[file];
} else {
throw new Error("Command not found");
}
}
async factorioOutput(data){
}
getInstanceName(instanceID){
return new Promise((resolve, reject) => {
let instance = this.instances[instanceID];
if(!instance){
needle.get(this.config.masterIP+":"+this.config.masterPort+ '/api/slaves', (err, response) => {
if(err || response.statusCode != 200) {
console.log("Unable to get JSON master/api/slaves, master might be unaccessible");
} else if (response && response.body) {
if(Buffer.isBuffer(response.body)) {console.log(response.body.toString("utf-8")); throw new Error();}
try {
for (let index in response.body)
this.instances[index] = response.body[index].instanceName;
} catch (e){
console.log(e);
return null;
}
instance = this.instances[instanceID]
if (!instance) instance = instanceID; //somehow the master doesn't know the instance
resolve(instance);
}
});
} else {
resolve(instance);
}
});
}
async getSafeLua(filePath){
return new Promise((resolve, reject) => {
fs.readFile(filePath, "utf8", (err, contents) => {
if(err){
reject(err);
} else {
// split content into lines
contents = contents.split(/\r?\n/);
// join those lines after making them safe again
contents = contents.reduce((acc, val) => {
val = val.replace(/\\/g ,'\\\\');
// remove leading and trailing spaces
val = val.trim();
// escape single quotes
val = val.replace(/'/g ,'\\\'');
// remove single line comments
let singleLineCommentPosition = val.indexOf("--");
let multiLineCommentPosition = val.indexOf("--[[");
if(multiLineCommentPosition === -1 && singleLineCommentPosition !== -1) {
val = val.substr(0, singleLineCommentPosition);
}
return acc + val + '\\n';
}, ""); // need the "" or it will not process the first row, potentially leaving a single line comment in that disables the whole code
// console.log(contents);
// this takes about 46 ms to minify train_stop_tracking.lua in my tests on an i3
if(COMPRESS_LUA) contents = require("luamin").minify(contents);
resolve(contents);
}
});
});
}
async checkHotpatchInstallation(){
let yn = await this.messageInterface("/silent-command if remote.interfaces['hotpatch'] then rcon.print('true') else rcon.print('false') end");
yn = yn.replace(/(\r\n\t|\n|\r\t)/gm, "");
if(yn == "true"){
return true;
} else if(yn == "false"){
return false;
}
}
}
async function sleep(s){
return new Promise((resolve, reject) => {
setTimeout(resolve, s*1000);
});
}
@@ -0,0 +1,83 @@
const fs = require("fs-extra");
const path = require("path");
const Express = require("express");
const ejs = require("ejs");
class masterPlugin {
constructor({config, pluginConfig, pluginPath, socketio, express}){
this.config = config;
this.pluginConfig = pluginConfig;
this.pluginPath = pluginPath;
this.io = socketio;
this.app = express;
// load databases
this.items = getDatabaseSync("database/items.json");
this.clients = {};
this.slaves = {};
// initialize web API
// require("./js/api-endpoints.js")(this);
// expose UI elements embedded in the master
// this.ui = require("./js/ui.js").ui;
this.io.on("connection", socket => {
});
}
async broadcastCommand(command){
let returnValues = [];
for(let instanceID in this.slaves){
let slave = this.slaves[instanceID];
slave.emit("runCommand", {
// commandID:Math.random(),
command,
});
};
return returnValues;
}
findInArray(key, value, array){
let indexes = [];
for(let i in array){
if(array[i][key] && array[i][key] === value) indexes.push(i);
}
return indexes;
}
async onExit(){
// await saveDatabase("database/items.json", this.items);
return;
}
}
module.exports = masterPlugin;
function arrayRemoveDuplicates(array){
let newArray = [];
array.forEach(value => {
if(!newArray.includes(value)) newArray.push(value);
});
return newArray;
}
function getDatabaseSync(path){
let db;
try {
db = JSON.parse(fs.readFileSync(path, "utf8"));
} catch(e){
db = {};
}
return db;
}
async function saveDatabase(path, database){
if(!path){
throw new Error("No path provided!");
} else if(!database){
throw new Error("No database provided!");
} else {
try {
await fs.writeFile(path, JSON.stringify(database, null, 4));
} catch(e){
throw new Error("Unable to write to database! "+path);
}
}
}
+7 -2
View File
@@ -5,14 +5,19 @@ module.exports = class remoteCommands {
this.messageInterface = messageInterface;
this.config = mergedConfig;
this.socket = extras.socket;
console.log(mergedConfig)
this.instances = {};
this.socket.on("hello", () => this.socket.emit("registerChatReciever"));
this.socket.on("gameChat", async data => {
if(data.instanceID && data.instanceID != this.config.unique.toString()){
if(data.data.includes("[CHAT]") && (data.data.toLowerCase().includes("/shout") || data.data.toLowerCase().includes("!shout"))){
if(data.data.includes("[CHAT]")
// if this isn't used as an info command or something (reduce spam)
&& !data.data.includes("!info")
// check if we are allowed to cross chat at all, default to true
&& (mergedConfig.enableCrossServerShout == undefined || mergedConfig.enableCrossServerShout)
// check if we are supposed to relay this message
&& (mergedConfig.mirrorAllChat || data.data.toLowerCase().includes("/shout") || data.data.toLowerCase().includes("!shout"))){
let words = data.data.trim().split(" ");
words.shift();
words.shift();
+1 -1
View File
@@ -12,7 +12,7 @@ class inventoryImports {
this.messageInterface = messageInterface;
messageInterface("/silent-command game.print('InventoryImports enabled')");
setInterval(function(){
messageInterface("Polling inventories\n")
// messageInterface("Polling inventories\n")
messageInterface(functions.pollInventories(pluginConfig.scriptOutputFileSubscription));
}, 10000);
}
@@ -141,7 +141,7 @@ function pollInventories(outputFile){
//console.log("/silent-command "+'local a=game.players["Danielv123"].get_inventory(defines.inventory.player_main).get_contents()local b=game.players["Danielv123"].get_quickbar().get_contents()local c={}for d=1,game.players["Danielv123"].force.character_logistic_slot_count do c[d]=game.players["Danielv123"].character.get_request_slot(d)end;local e="{inventory:{"for f,g in pairs(a)do e=e.."['+"'"+'"..f.."'+"'"+']:"..g..","end;for f,g in pairs(b)do e=e.."['+"'"+'"..f.."'+"'"+']:"..g..","end;e=e.."},requestSlots:{"for f,g in pairs(c)do e=e.."['+"'"+'"..g["name"].."'+"'"+']:"..g["count"]..", "end;e=e.."}}"game.write_file("'+pluginConfig.scriptOutputFileSubscription+'",e)')
// console.log("/silent-command "+'local a="{"for b,c in pairs(game.players)do local d=game.players[b].get_inventory(defines.inventory.player_main).get_contents()local e=game.players[b].get_quickbar().get_contents()local f={}for g=1,game.players[b].force.character_logistic_slot_count do f[g]=game.players[b].character.get_request_slot(g)end;a=a..b..":{inventory:{"for h,i in pairs(d)do a=a.."['+"'"+'"..h.."'+"'"+']:"..i..","end;for h,i in pairs(e)do a=a.."['+"'"+'"..h.."'+"'"+']:"..i..","end;a=a.."},requestSlots:{"for h,i in pairs(f)do a=a.."['+"'"+'"..i["name"].."'+"'"+']:"..i["count"]..", "end;a=a.."}},"end;a=a.."}"game.write_file("'+pluginConfig.scriptOutputFileSubscription+'",a)')
//return "/silent-command "+'local a="{"for b,c in pairs(game.players)do if c.connected then local d=game.players[b].get_inventory(defines.inventory.player_main).get_contents()local e=game.players[b].get_quickbar().get_contents()local f={}for g=1,game.players[b].force.character_logistic_slot_count do f[g]=game.players[b].character.get_request_slot(g)end;a=a..b..":{inventory:{"for h,i in pairs(d)do a=a.."['+"'"+'"..h.."'+"'"+']:"..i..","end;for h,i in pairs(e)do a=a.."['+"'"+'"..h.."'+"'"+']:"..i..","end;a=a.."},requestSlots:{"for h,i in pairs(f)do a=a.."['+"'"+'"..i["name"].."'+"'"+']:"..i["count"]..", "end;a=a.."}},"end end;a=a.."}"game.write_file("'+outputFile+'",a)'
return '/silent-command '+'local a='+"'"+'{"players":{'+"'"+'local b=false;for c,d in pairs(game.players)do if d.connected and d.character then if b then a=a..'+"'"+','+"'"+'else b=true end;local e=game.players[c].get_inventory(defines.inventory.player_main).get_contents()local f=game.players[c].get_quickbar().get_contents()local g={}for h=1,game.players[c].force.character_logistic_slot_count do g[h]=game.players[c].character.get_request_slot(h)end;a=a..'+"'"+'"'+"'"+'..c..'+"'"+'":{"inventory":{'+"'"+'local i=false;for j,k in pairs(e)do if i then a=a..'+"'"+','+"'"+'else i=true end;a=a..'+"'"+'"'+"'"+'..j..'+"'"+'":'+"'"+'..k end;for j,k in pairs(f)do if i then a=a..'+"'"+','+"'"+'else i=true end;a=a..'+"'"+'"'+"'"+'..j..'+"'"+'":'+"'"+'..k end;a=a..'+"'"+'},"requestSlots":{'+"'"+'i=false;for j,k in pairs(g)do if i then a=a..'+"'"+','+"'"+'else i=true end;a=a..'+"'"+'"'+"'"+'..k['+"'"+'name'+"'"+']..'+"'"+'":'+"'"+'..k['+"'"+'count'+"'"+']end;a=a..'+"'"+'}}'+"'"+'end end;a=a.."}}"game.write_file("'+outputFile+'",a,true,0)'
return '/silent-command '+'local a='+"'"+'{"players":{'+"'"+'local b=false;for c,d in pairs(game.players)do if d.connected and d.character then if b then a=a..'+"'"+','+"'"+'else b=true end;local e=game.players[c].get_inventory(defines.inventory.player_main).get_contents()local f=game.players[c].get_quickbar().get_contents()local g={}local z={}for j,k in pairs(e)do if z[j] ~= nil then z[j] = z[j] + k else z[j] = k end end for j,k in pairs(f)do if z[j] ~= nil then z[j] = z[j] + k else z[j] = k end end for h=1,game.players[c].force.character_logistic_slot_count do g[h]=game.players[c].character.get_request_slot(h)end;a=a..'+"'"+'"'+"'"+'..c..'+"'"+'":{"inventory":{'+"'"+'local i=false;for j,k in pairs(z)do if i then a=a..'+"'"+','+"'"+'else i=true end;a=a..'+"'"+'"'+"'"+'..j..'+"'"+'":'+"'"+'..k end;a=a..'+"'"+'},"requestSlots":{'+"'"+'i=false;for j,k in pairs(g)do if i then a=a..'+"'"+','+"'"+'else i=true end;a=a..'+"'"+'"'+"'"+'..k['+"'"+'name'+"'"+']..'+"'"+'":'+"'"+'..k['+"'"+'count'+"'"+']end;a=a..'+"'"+'}}'+"'"+'end end;a=a.."}}"game.write_file("'+outputFile+'",a,true,0)'
/*
needle.post(config.masterIP+':'+config.masterPort+'/api/editSlaveMeta', {instanceID: config.unique, password: config.clientPassword, meta: {UPS:UPS}}, function(err, resp) {
// success?
+1 -1
View File
@@ -22,4 +22,4 @@ module.exports = class remoteCommands {
scriptOutput(data){
// we don't return anything yet
}
}
}
View File
Whitespace-only changes.
+3 -1
View File
@@ -2,11 +2,12 @@ var mkdirp = require("mkdirp-promise");
var mkdirpSync = require("mkdirp").sync;
var fs = require("fs");
var events = require("events");
var path = require("path");
var objectOps = require("./../../lib/objectOps");
var LinvoDB = require("linvodb3");
LinvoDB.dbPath = "./database/linvodb/";
// LinvoDB.dbPath = "./database/linvodb/";
let allowLogging = true;
@@ -20,6 +21,7 @@ module.exports = class chunkStore {
this.chunkSize = chunkSize;
this.path = (path[path.length] == "/") ? path : path+"/";
mkdirpSync(path+name);
LinvoDB.dbPath = this.path;
this.db = new LinvoDB(name, {}, {});
+10
View File
@@ -0,0 +1,10 @@
/*
Clusterio plugin for loading softmod scenarios using Hotpatch
To load a scenario, simply drop it inside sharedPlugins/scenario_loader/scenarios/
*/
module.exports = {
name: "scenario_loader",
version: "1.0.0",
binary: "nodePackage",
description: "Simple way to load scenarios using Hotpatch"
}
+97
View File
@@ -0,0 +1,97 @@
const fs = require("fs-extra");
const path = require("path");
const clusterTools = require("_app/clusterTools")();
module.exports = class remoteCommands {
constructor(mergedConfig, messageInterface, extras){
this.messageInterface = messageInterface;
this.config = mergedConfig;
(async () => {
let hotpatchInstallStatus = await this.checkHotpatchInstallation();
messageInterface("Hotpach installation status: "+hotpatchInstallStatus);
if(hotpatchInstallStatus){
let scenarioDir = "scenarios";
await fs.ensureDir(scenarioDir)
let scenarios = await fs.readdir(scenarioDir);
for(let i = 0; i < scenarios.length; i++){
let startTime = Date.now();
let scenPath = path.join(scenarioDir, scenarios[i]);
let stat = await fs.stat(scenPath);
if(stat.isDirectory){
async function loadFiles(dir, rootdir){
let returnFiles = [];
let entries = await fs.readdir(path.join(rootdir, dir));
for(let o = 0; o < entries.length; o++){
let stat = await fs.stat(path.join(rootdir, dir, entries[o]));
if(stat.isDirectory()){
// recurse
let subFolder = await loadFiles(path.join(dir, entries[o]), rootdir);
returnFiles = returnFiles.concat(subFolder);
} else if(entries[o].split(".")[entries[o].split(".").length-1].toLowerCase() == "lua"){
// if it is a Lua file, read its contents
// let data = await fs.readFile(path.join(rootdir, dir, entries[o]));
let data = await clusterTools.getLua(path.join(rootdir, dir, entries[o]), false);
returnFiles.push({
name: path.join(dir, entries[o].split(".")[0]),
data,
});
}
}
return returnFiles;
}
let fileMap = await loadFiles("", scenPath);
let files = {};
fileMap.forEach(map => {
files[map.name] = map.data;
console.log(map.name);
});
let fileImportString = `\{`;
for(let k in files){
let name = k.replace(/\\/g, '/');
name = name.replace(/ /g, '');
// make sure *not* to include control.lua as it is provided as a seperate argument
if(name != "control") fileImportString += `["${name}"] = '${files[k]}', `;
}
fileImportString += `\}`;
if(files.control) var returnValue = await messageInterface(`/silent-command remote.call('hotpatch', 'update', '${scenarios[i]}', '1.0.0', '${files.control}', ${fileImportString})`);
if(returnValue) messageInterface(returnValue);
messageInterface(`Loaded scenario ${scenarios[i]} in ${Math.floor(Date.now()-startTime)}ms`);
}
}
}
})();
}
async checkHotpatchInstallation(){
let yn = await this.messageInterface("/silent-command if remote.interfaces['hotpatch'] then rcon.print('true') else rcon.print('false') end");
yn = yn.replace(/(\r\n\t|\n|\r\t)/gm, "");
if(yn == "true"){
return true;
} else if(yn == "false"){
return false;
}
}
}
// this function is bad in terms of scope since it relies on a global for return values, should be refactored.
var filesCollection = [];
const directoriesToSkip = ['bower_components', 'node_modules', 'www', 'platforms', '.git'];
function readDirectorySynchronously(directory) {
var currentDirectorypath = /*path.join(__dirname + */directory//);
var currentDirectory = fs.readdirSync(currentDirectorypath, 'utf8');
currentDirectory.forEach(file => {
var fileShouldBeSkipped = directoriesToSkip.indexOf(file) > -1;
var pathOfCurrentItem = path.join(/*__dirname + */directory + '/' + file);
if (!fileShouldBeSkipped && fs.statSync(pathOfCurrentItem).isFile()) {
filesCollection.push(pathOfCurrentItem);
}
else if (!fileShouldBeSkipped) {
var directorypath = path.join(directory, file);
readDirectorySynchronously(directorypath);
}
});
}
+13
View File
@@ -0,0 +1,13 @@
/*
Clusterio plugin for remote server management
*/
module.exports = {
name: "serverManager",
version: "1.0.0",
binary: "nodePackage",
description: "Server management tools for remote config edits and web based server management",
masterPlugin: "masterPlugin.js",
dependencies: [
"playerManager",
]
}
+43
View File
@@ -0,0 +1,43 @@
const needle = require("needle");
const fs = require("fs-extra");
const pluginConfig = require("./config");
const COMPRESS_LUA = false;
module.exports = class remoteCommands {
constructor(mergedConfig, messageInterface, extras){
this.messageInterface = messageInterface;
this.config = mergedConfig;
this.socket = extras.socket;
const pluginManager = require("./../../lib/manager/pluginManager.js")(this.config);
const configManager = require("./../../lib/manager/configManager.js");
// const modManager = require("./../../lib/manager/modManager.js")(this.config);
this.socket.on("hello", () => {
this.socket.emit("registerServerManager");
});
this.socket.on("serverManagerGetStatus", async data => {
});
this.socket.on("serverManagerGetMods", async (data, callback) => {
// TODO: Implement this function separately from listMods (which console.logs it)
// let mods = modManager.getMods()
// callback(mods);
});
this.socket.on("serverManagerGetConfig", async (data, callback) => {
let config = await configManager.getConfig(data.instance);
callback(config);
});
this.socket.on("serverManagerEditConfig", async (data, callback) => {
callback(configManager.editConfig(data.instance, data.entry, data.value));
});
this.socket.on("serverManagerGetPlugins", async (data, callback) => {
callback(await pluginManager.getPlugins());
});
this.socket.on("serverManagerEnablePlugin", async (data, callback) => {
callback(await pluginManager.enablePlugin(data.name, data.instanceID /* Currently ignored */));
});
this.socket.on("serverManagerDisablePlugin", async (data, callback) => {
callback(await pluginManager.disablePlugin(data.name, data.instanceID /* Currently ignored */));
});
}
}
@@ -0,0 +1,127 @@
module.exports = that => {
/**
- Enable a plugin by deleting the DISABLED file
- Use socket.io to update masters list of plugins for this server
Give standard JSON object response
*/
that.app.post("/api/serverManager/enablePlugin", async (req, res) => {
if((req.body.token || req.cookies.token)
&& req.body.instanceID
&& typeof req.body.instanceID == "string"
&& req.body.pluginName
&& typeof req.body.pluginName == "string"
&& that.playerManager){
let perms = await that.playerManager.main.getPermissions((req.body.token || req.cookies.token));
if(perms.cluster.includes("enablePlugin")
|| (perms.instance[req.body.instanceID]
&& perms.instance[req.body.instanceID].includes("enablePlugin"))){
that.serverSockets[req.body.instanceID].socket.emit("serverManagerEnablePlugin", {
name: req.body.pluginName,
instanceID: req.body.instanceID,
}, async resp => {
if(resp.ok){
await updateData("plugins", req.body.instanceID);
}
res.send(resp);
});
} else {
res.send({
ok:false,
msg:"Insufficient permissions",
});
}
} else {
res.send({
ok:false,
msg:"Invalid request body",
});
}
});
/**
- Disable a plugin by adding an empty file named DISABLED to the plugins install pathname
- Use socket.io to update masters lost of plugins for this server
Give standard JSON object response
*/
that.app.post("/api/serverManager/disablePlugin", async (req, res) => {
if((req.body.token || req.cookies.token)
&& req.body.instanceID
&& typeof req.body.instanceID == "string"
&& req.body.pluginName
&& typeof req.body.pluginName == "string"
&& that.playerManager){
let perms = await that.playerManager.main.getPermissions((req.body.token || req.cookies.token));
if(perms.cluster.includes("disablePlugin")
|| (perms.instance[req.body.instanceID]
&& perms.instance[req.body.instanceID].includes("disablePlugin"))){
that.serverSockets[req.body.instanceID].socket.emit("serverManagerDisablePlugin", {
name: req.body.pluginName,
instanceID: req.body.instanceID,
}, async resp => {
if(resp.ok){
await updateData("plugins", req.body.instanceID);
}
res.send(resp);
});
} else {
res.send({
ok:false,
msg:"Insufficient permissions",
});
}
} else {
res.send({
ok:false,
msg:"Invalid request body",
});
}
});
that.app.post("/api/serverManager/editConfig", async (req,res) => {
if((req.body.token || req.cookies.token)
&& req.body.instanceID
&& typeof req.body.instanceID == "string"
&& req.body.entry
&& typeof req.body.entry == "string"
&& req.body.value !== undefined
&& that.playerManager){
let perms = await that.playerManager.main.getPermissions((req.body.token || req.cookies.token));
if(perms.cluster.includes("writeConfig")
|| (perms.instance[req.body.instanceID]
&& perms.instance[req.body.instanceID].includes("writeConfig"))){
that.serverSockets[req.body.instanceID].socket.emit("serverManagerEditConfig", {
entry: req.body.entry,
value: req.body.value,
}, async resp => {
if(resp.ok){
// Retrieve the latest config from the server and store it
await updateData("config", req.body.instanceID);
}
res.send(resp);
});
} else {
res.send({
ok:false,
msg:"Insufficient permissions",
});
}
} else {
res.send({
ok:false,
msg:"Invalid request body",
});
}
});
async function updateData(type, instanceID){
// Retrieve the latest ${type} from the server and store it
if(type == "config"){
that.serverSockets[instanceID].socket.emit("serverManagerGetConfig", {}, data => {
that.serverSockets[instanceID].config = data;
});
} else if(type == "plugins"){
that.serverSockets[instanceID].socket.emit("serverManagerGetPlugins", {}, data => {
that.serverSockets[instanceID].plugins = data;
});
}
}
}
+19
View File
@@ -0,0 +1,19 @@
let masterPlugin;
module.exports = {
injectThis: function(x){masterPlugin = x; return this},
ui: {
slaveListing: [
{
name:"ServerMangerButton",
getHtml: ({slave}) => {
let html = "";
if(masterPlugin.serverSockets[slave.unique]){
let socket = masterPlugin.serverSockets[slave.unique];
return `<a style="margin: 5px;" href='/serverManager?instanceID=${slave.unique}'>Server manager</a>`
}
},
}
],
}
}
+158
View File
@@ -0,0 +1,158 @@
const fs = require("fs-extra");
const path = require("path");
const Express = require("express");
const ejs = require("ejs");
const bcrypt = require("bcrypt-promise");
const crypto = require('crypto');
const base64url = require('base64url');
const sanitizer = require('sanitizer');
class masterPlugin {
constructor({config, pluginConfig, pluginPath, socketio, express}){
this.config = config;
this.pluginConfig = pluginConfig;
this.pluginPath = pluginPath;
this.io = socketio;
this.app = express;
// load databases
// const database = getDatabaseSync("database/playerManager.json");
// this.whitelist = getDatabaseSync("database/whitelist.json").whitelist || [];
// this.banlist = getDatabaseSync("database/banlist.json").banlist || [];
// initialize web API
require("./js/api-endpoints.js")(this);
// expose UI elements embedded in the master
this.ui = require("./js/ui.js").injectThis(this).ui;
this.serverSockets = {};
this.io.on("connection", socket => {
let instanceID = "unknown";
let isServerManager = false;
socket.on("registerSlave", data => {
if(data.instanceID && !isNaN(Number(data.instanceID))){
instanceID = data.instanceID;
if(isServerManager){
this.serverSockets[instanceID] = {socket};
validServerManager({server: this.serverSockets[instanceID]});
}
}
});
socket.on("registerServerManager", () => {
isServerManager = true;
if(instanceID !== "unknown"){
this.serverSockets[instanceID] = {socket};
validServerManager({server: this.serverSockets[instanceID]});
}
console.log("Registered serverManager socket")
socket.on("disconnect", () => {
if(this.serverSockets[instanceID]) delete this.serverSockets[instanceID];
console.log(`serverManager ${instanceID} disconnected, ${(Object.keys(this.serverSockets).length)} left`);
});
});
socket.on("gameChat", async data => {
// let chatLine = data.data.replace(/(\r\n\t|\n|\r\t)/gm, "").replace("\r", "");
// if(typeof chatLine == "string") this.handleChatLine(chatLine, instanceID);
});
});
let validServerManager = ({server}) => {
server.socket.emit("serverManagerGetPlugins", {}, data => {
server.plugins = data;
});
server.socket.emit("serverManagerGetConfig", {}, data => {
server.config = data;
});
}
// I can't seem to get express static pages + ejs rendering to work properly, so I write my own thing.
let pages = [
{
addr: "/serverManager",
path: path.join(__dirname,"static/index.html"),
render: ejs
},
]
pages.forEach(page => {
this.app.get(page.addr, async (req,res) => {
if(page.render){
if((req.query.token || req.cookies.token) && this.playerManager){
var permissions = await this.playerManager.main.getPermissions((req.query.token || req.cookies.token));
} else {
var permissions = {};
}
page.render.renderFile(page.path, {
permissions,
serverManager: this,
req: req,
}, async (err, prom) => {
if(err) console.log(err);
let str = await prom;
res.send(str);
});
} else {
res.send(await fs.readFile(page.path));
}
});
});
this.app.use('/serverManager', Express.static(path.join(__dirname, 'static')));
}
findInArray(key, value, array){
let indexes = [];
for(let i in array){
if(array[i][key] && array[i][key] === value) indexes.push(i);
}
return indexes;
}
async onExit(){
// await saveDatabase("database/banlist.json", {banlist: this.banlist});
return;
}
async onLoadFinish({plugins}){
this.masterPlugins = plugins;
plugins.forEach(plugin => {
if(plugin.pluginConfig.name == "playerManager"){
this.playerManager = plugin;
}
});
}
async onPlayerManagerGetPermissions({permissions, user}){
if(user
&& user.admin){
permissions.cluster.push("addPlugin");
permissions.cluster.push("removePlugin");
permissions.cluster.push("enablePlugin");
permissions.cluster.push("disablePlugin");
permissions.cluster.push("readConfig");
permissions.cluster.push("writeConfig");
}
return permissions;
}
}
module.exports = masterPlugin;
function getDatabaseSync(path){
let db;
try {
db = JSON.parse(fs.readFileSync(path, "utf8"));
} catch(e){
db = {};
}
return db;
}
async function saveDatabase(path, database){
if(!path){
throw new Error("No path provided!");
} else if(!database){
throw new Error("No database provided!");
} else {
try {
await fs.writeFile(path, JSON.stringify(database, null, 4));
} catch(e){
throw new Error("Unable to write to database! "+path);
}
}
}
@@ -0,0 +1,54 @@
<!doctype HTML>
<head>
<%- include("./../../../static/head.html") %>
<link rel="stylesheet" type="text/css" href="/serverManager/serverManager.css">
<!-- <script defer type="module" src="/serverManager/index.js"></script> -->
</head>
<body>
<%- include("./../../../static/sidebar.html") %>
<div id="body">
<div id="serverlistDisplayContainer" class="serverManagerMainContainer">
<%
html = "";
if(req.query
&& req.query.instanceID
&& slaves[req.query.instanceID]
&& slaves[req.query.instanceID].mods
&& serverManager.serverSockets[req.query.instanceID]){
let slave = slaves[req.query.instanceID];
html += `
<div id="mods">
${slave.mods.map(mod => mod.name).reduce((acc, modname) => acc + "<li>" + modname + "</li>")}
</div>
<div id="plugins"><ul>`
let server = serverManager.serverSockets[req.query.instanceID];
if(server.plugins) server.plugins.forEach(plugin => {
html += `<li><div class="pluginStatus" style="background-color: ${plugin.enabled ? 'green' : 'red'}"></div><div style="width: calc( 100% - 50px )"><h3>${plugin.name}</h3><p>${plugin.description}</p></div></li>`
});
html += `</ul></div><div id="config">`
if(permissions && permissions.cluster && permissions.cluster.includes("readConfig")){
if(server.config){
html += `<ul>`;
server.config.forEach(setting => {
if(setting.entry[1] !== "_" && setting.entry !== "masterAuthSecret"){
html += `<li class="settingInput">
<p style="width: 50%; display:inline-block;">${setting.entry}</p>
<input style="color:black; width: calc(50% - 4px); box-sizing: border-box;" value='${setting.value}'>
</li>`
}
});
}
}
html += `</ul>
<input id="submitSettings" type="button" value="Save changes" style="color:black"/>
</div>`
}
%>
<%- html %>
</div>
</div>
<script>
<%- include("./index.js") %>
</script>
</body>
@@ -0,0 +1,60 @@
// Handle config editing
originalSettings = {};
let settings = document.querySelectorAll(".settingInput");
settings.forEach(setting => {
let entry = setting.children[0].innerText
originalSettings[setting.children[0].innerText] = {
entry: setting.children[0].innerText,
value: setting.children[1].value,
}
setting.children[1].addEventListener("input", ()=>{
let newValue = setting.children[1].value;
if(newValue != originalSettings[entry].value){
setting.children[1].style.backgroundColor = "lightgreen";
} else {
setting.children[1].style.backgroundColor = "";
}
});
});
let submitSettings = document.querySelector("#submitSettings");
submitSettings.addEventListener("click", async ()=>{
let settings = document.querySelectorAll(".settingInput");
for(let i = 0; i < settings.length; i++){
let setting = settings[i];
let entry = setting.children[0].innerText
if(setting.children[1].value != originalSettings[entry].value){
// upload new changes, display results somehow
let status = await postJSON("/api/serverManager/editConfig", {
entry,
value: setting.children[1].value,
instanceID: getParameterByName("instanceID"),
});
if(status.ok){
setting.children[1].style.backgroundColor = "";
}
}
}
});
// Handle plugin management
let pluginStatuses = document.querySelectorAll(".pluginStatus");
pluginStatuses.forEach(pluginStatus => {
pluginStatus.addEventListener("click", async ()=>{
if(pluginStatus.style.backgroundColor == "green"){
let status = await postJSON("/api/serverManager/disablePlugin", {
instanceID: getParameterByName("instanceID"),
pluginName: pluginStatus.parentElement.children[1].children[0].innerText,
});
if(status.ok){
pluginStatus.style.backgroundColor = "red";
}
} else {
let status = await postJSON("/api/serverManager/enablePlugin", {
instanceID: getParameterByName("instanceID"),
pluginName: pluginStatus.parentElement.children[1].children[0].innerText,
});
if(status.ok){
pluginStatus.style.backgroundColor = "green";
}
}
});
});
@@ -0,0 +1,23 @@
#mods {
width: calc(( 100% ) / 4);
float:left;
}
#plugins {
width:calc((( 100% ) - (( 100% ) / 4))/2);
float:left;
}
#config {
width:calc((( 100% ) - (( 100% ) / 4))/2);
float:left;
}
ul {
list-style: none;
padding:0px;
}
#plugins > ul > li > div {
float:left;
}
#plugins .pluginStatus {
width:40px;
height: 40px;
}
@@ -0,0 +1,19 @@
local statisticsData = "["
for _, force in pairs(game.forces) do
statisticsData = statisticsData .. '{"forceName":"'..force.name..'","data":{'
local statistics = {"item_production_statistics", "fluid_production_statistics", "kill_count_statistics", "entity_build_count_statistics"}
for key, stat in pairs(statistics) do
statisticsData = statisticsData..'"'..stat..'":{"input":{'
for name, value in pairs(force[stat].input_counts) do
statisticsData = statisticsData..'"'..name..'":'..value..','
end
statisticsData = statisticsData..'},"output":{'
for name, value in pairs(force[stat].output_counts) do
statisticsData = statisticsData..'"'..name..'":'..value..','
end
statisticsData = statisticsData .. '}},'
end
statisticsData = statisticsData..'}},'
end
statisticsData = statisticsData..']'
rcon.print(statisticsData)
@@ -0,0 +1,10 @@
/*
Clusterio plugin for gathering ingame statistics
*/
module.exports = {
name: "statisticsExporter",
version: "1.0.0",
binary: "nodePackage",
description: "Gathers ingame stats such as production, consumption, kills and buildings placed",
masterPlugin: "masterPlugin.js"
}
+40
View File
@@ -0,0 +1,40 @@
const needle = require("needle");
const fs = require("fs-extra");
const path = require("path");
const clusterTools = require("_app/clusterTools")();
const pluginConfig = require("./config");
module.exports = class remoteCommands {
constructor(mergedConfig, messageInterface, extras){
this.messageInterface = messageInterface;
this.config = mergedConfig;
const needleOptionsWithTokenAuthHeader = {
headers: {
'x-access-token': this.config.masterAuthToken
},
};
// Gather and submit statistics every 15 seconds
setInterval(async () => {
let stats = await this.getStats();
stats.forEach(force => {
needle.post(`${mergedConfig.masterIP}:${mergedConfig.masterPort}/api/logStats`, {
instanceID: this.config.unique,
force: force.forceName,
data: force.data,
}, needleOptionsWithTokenAuthHeader, (err, resp) => {
if(!err && resp.body && resp.body.ok == false) console.log(resp.body);
});
});
}, 15000);
}
async getStats(){
let command = fs.readFileSync(path.join(__dirname, "Lua/exportStatistics.lua"), "utf8")//clusterTools.getLua(path.join(__dirname, "Lua/exportStatistics.lua"), false);
let string = await this.messageInterface(`/silent-command ${command}`);
var data;
eval(`data = ${string}`);
return data;
}
}
@@ -0,0 +1,58 @@
const fs = require("fs-extra");
const path = require("path");
class masterPlugin {
constructor({config, socketio, express, Prometheus}){
const authenticate = require("./../../lib/authenticate")(config);
this.config = config;
this.io = socketio;
const app = express;
const prometheusStatisticsGauge = new Prometheus.Gauge({
name: 'clusterio_statistics_gauge',
help: 'Items produced/built/killed by a force',
labelNames: ["instanceID", "force", "statistic", "direction", "itemName"],
});
/** --- TODO: Replace this with a simpler Prometheus exporter. Requires changes to the mod as well.
POST endpoint to log production graph statistics. Should contain an instanceID (also reffered to as "unique")
and of course the timeSeries data organized by category.
@memberof clusterioMaster
@instance
@alias api/logStats
@param {object} JSON {instanceID: "string", force: "string" data: {"item_production_statistics": {"input":{"iron-plate":150}, "output":{}}, ...}}
@returns {string} failure
*/
app.post("/api/logStats", authenticate.middleware, function(req,res) {
// endpointHitCounter.labels(req.route.path).inc();
if(typeof req.body == "object"
&& req.body.instanceID
&& typeof req.body.instanceID == "string"
&& req.body.instanceID.length < 100
&& req.body.data
&& typeof req.body.data == "object"
&& !Array.isArray(req.body.data)){
try{
Object.keys(req.body.data).forEach(statisticName => {
Object.keys(req.body.data[statisticName]).forEach(direction => {
let statHash = req.body.data[statisticName][direction];
Object.keys(statHash).forEach(item => {
let value = Number(statHash[item]);
if(value) prometheusStatisticsGauge.labels(req.body.instanceID, req.body.force, statisticName, direction, item).set(value);
});
});
});
res.send({ok:true, msg:`Statistics successfully reported at ${Date.now()}`});
}catch(e){
res.send({ok:false, msg:"Statistics reporter crashed, see sharedPlugins/statisticsExporter/masterPlugin.js"});
console.log(e)
};
} else {
res.send("failure");
}
});
}
}
module.exports = masterPlugin;
+12
View File
@@ -0,0 +1,12 @@
/*!
* Bootswatch v4.1.3
* Homepage: https://bootswatch.com
* Copyright 2012-2018 Thomas Park
* Licensed under MIT
* Based on Bootstrap
*//*!
* Bootstrap v4.1.3 (https://getbootstrap.com/)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#ee5f5b;--orange:#fd7e14;--yellow:#f89406;--green:#62c462;--teal:#20c997;--cyan:#5bc0de;--white:#fff;--gray:#7A8288;--gray-dark:#3A3F44;--primary:#3A3F44;--secondary:#7A8288;--success:#62c462;--info:#5bc0de;--warning:#f89406;--danger:#ee5f5b;--light:#e9ecef;--dark:#272B30;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-family-monospace:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*,*::before,*::after{-webkit-box-sizing:border-box;box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:0.9375rem;font-weight:400;line-height:1.5;color:#aaa;text-align:left;background-color:#272B30}[tabindex="-1"]:focus{outline:0 !important}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:0.5rem}p{margin-top:0;margin-bottom:1rem}abbr[title],abbr[data-original-title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#fff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#d9d9d9;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):hover,a:not([href]):not([tabindex]):focus{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}pre,code,kbd,samp{font-family:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:0.75rem;padding-bottom:0.75rem;color:#7A8288;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:0.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{padding:0;border-style:none}input[type="radio"],input[type="checkbox"]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{outline-offset:-2px;-webkit-appearance:none}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none !important}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{margin-bottom:0.5rem;font-family:inherit;font-weight:500;line-height:1.2;color:inherit}h1,.h1{font-size:2.34375rem}h2,.h2{font-size:1.875rem}h3,.h3{font-size:1.640625rem}h4,.h4{font-size:1.40625rem}h5,.h5{font-size:1.171875rem}h6,.h6{font-size:0.9375rem}.lead{font-size:1.171875rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-heigLine truncated
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more