Compare commits

..
Author SHA1 Message Date
Hornwitser a578719fc5 v2.0.0-alpha.8 2021-06-28 16:29:34 +02:00
Hornwitser 72e0b6a3b1 Merge pull request #397 from Hornwitser/fix-export
Fix export
2021-06-28 16:17:50 +02:00
Hornwitser 90a400b782 Handle mod not found in data export
Handle the case of the mod file not being found from the mod version.
This could happen if the naming scheme is weird or the mod is unpacked.
2021-06-26 17:59:48 +02:00
Hornwitser 80eae94fe1 Handle leading zeros in mod filenames
It's possible to put leading zeroes in the names of mod zip files which
cause the existing algorithm to find the mod zip from the mod version to
fail.  Fix by listing the mods directory and fixing the version numbers
for any of the mods with leading zeros in them.
2021-06-26 17:59:48 +02:00
Hornwitser 5daa492f68 Emitt more useful error on parse error
Rethrow parse errors with the JSON content that failed to parse for JSON
content sent to Clusterio from Factorio.
2021-06-26 17:59:48 +02:00
Hornwitser b43a181bf4 Fix JSON serialization in export mod
Fix incorrect serialization of control characters to \xHH escapes since
JSON only support \uHHHH escapes, and serialization of infinite and NaN
numbers.
2021-06-26 17:59:48 +02:00
Hornwitser a31ba31567 Fix Docker build referencing the old mod branch 2021-06-26 17:42:34 +02:00
Hornwitser b03b201c20 Merge pull request #395 from Hornwitser/rename-repository
Rename repository
2021-06-22 18:46:24 +02:00
Hornwitser af995428bc Rename clusterio/factorioClusterioMod repository
The mod was rebranded to subspace_storage quite some time ago.
2021-06-22 18:36:02 +02:00
Hornwitser d79e30f9be Rename clusterio/factorioClusterio repository
Drop the factorio part of the Clusterio repository name.
2021-06-22 18:36:02 +02:00
Hornwitser f5228e6c2e Merge pull request #396 from Hornwitser/fix-upload
Fix upload
2021-06-21 17:44:57 +02:00
Hornwitser ea77e09058 Show upload progress
Add progress bar for file uploads in progress and notify user if the
upload fails.
2021-06-21 16:59:36 +02:00
Hornwitser 5ab4b95e3c Support non-standard zip mime types in save upload
Browser like Chrome doesn't use the application/zip mime type when
uploading .zip files.  Add support for application/x-zip-compressed to
the save upload endpoint.
2021-06-21 16:59:36 +02:00
Hornwitser 8dc1415404 Skip slaveUpdateEventHandler test on Windows
Due to the lack of a simple way to automate graceful shutdown of a slave
on Windows causing the test to be unreliable, skip it on Windows.
2021-06-21 16:59:04 +02:00
Hornwitser 122bab303d Remove suggestion to use the 1.2.x branch (#394)
Development on 1.2.x has effectively been abandoned.  Discourage
installing or working on 1.2.x.
2021-06-18 14:00:21 +02:00
Hornwitser 160051fc3c Merge pull request #392 from Hornwitser/installer
Add Nodej.s based installer
2021-06-17 15:13:11 +02:00
Hornwitser 500baffb2b Use installer in installation guide
Switch over to referencing the installer as the method for installing
Clusterio in the readme and contributing docs.
2021-06-16 21:04:58 +02:00
Hornwitser 4c77f5ac31 Add installation script
Provide a create package for the clusterio npm org which gathers the
necessary details to install a working cluster either as standalone or
as separate nodes.
2021-06-16 18:56:37 +02:00
Hornwitser 8ad518e7b6 v2.0.0-alpha.7 2021-06-16 13:08:58 +02:00
Hornwitser 1faf6df5a3 Merge pull request #390 from Hornwitser/alpha-7
Alpha 7 preparations
2021-06-16 13:06:30 +02:00
Hornwitser fc08438164 Add generate slave token button
Add a generate slave token button to the web interface and modify the
generateSlaveToken message and corresponding ctl command to have the
slave id be optional.  The random id is the desired behavior when adding
a slave with the installer, as it'll ask for the token for the new slave
and deduce the id from the token.
2021-06-15 14:46:44 +02:00
Hornwitser cc562a451e Fix create instance modal
Fix copy and paste bug in the name check for the create instance moddal.
2021-06-15 14:25:45 +02:00
Hornwitser 782e93f4a1 Add changelog entry for #380
Add entry in CHANGELOG.md for the save management feature.
2021-06-15 14:24:35 +02:00
Hornwitser cf1e333191 Add bootstrap command to generate slave token (#389)
To simplify the standalone installation add command to create a slave
token without having to start up the master server.
2021-06-14 12:08:24 +02:00
Hornwitser 31f1099dc0 Expect error loading configs (#388)
Rethrow error loading configs as StartupError as these are most likely a
result of user error.
2021-06-14 12:07:59 +02:00
Hornwitser ddaaf15b2e Merge pull request #383 from Danielv123/documentation-updates
Documentation updates
2021-06-12 15:15:19 +02:00
Hornwitser a6cafeb5c6 Merge pull request #386 from Hornwitser/instances-page
Instances page rewrite
2021-06-12 15:15:04 +02:00
Hornwitser 9b34b949a6 Accept invalid IDs in useSlave and useInstance
Gracefully handle invalid IDs in useSlave and useInstance as this is
necessary when when the ID isn't known by the component using the hook,
since it can't conditionally call the hook.
2021-06-10 10:26:40 +02:00
Hornwitser 82c3d27bff Rewrite instances page
Render the table in the instances page using an antd Table component
directly and feed it the data via an automatically updating list of
instances.
2021-06-10 10:26:40 +02:00
Hornwitser 0e9893fcb6 Fix create save modal
Fix the wrong variable being passed as mapSettings and clear the form
after successfully creating the save.
2021-06-10 10:26:40 +02:00
Hornwitser f37c387d52 Fix instance deleted status not being sent out 2021-06-10 10:26:40 +02:00
Daniel 7a49e8aa15 Merge pull request #385 from Hornwitser/fix-command-after-stop
Fix subspace storage sending commands during stop
2021-06-10 06:58:20 +02:00
Daniel c80be802d1 Merge pull request #384 from Hornwitser/test-fixes
Fix tests on Windows and remove internet requirement to pass
2021-06-10 06:54:52 +02:00
Daniel ff9b05fc85 Merge pull request #382 from Hornwitser/slave-updates
Add slave update subscriptions
2021-06-10 06:50:03 +02:00
Hornwitser 55f3dac9b6 Fix integration tests requiring internet to work
When factorio starts up with with the require_user_verification setting
turn on it tries to obtain a server padlock from the matchmaking server
and will fail if this does not succeed.  Turn off user verification and
public visibility which require user verification both of which are on
by default for the integration tests so that they work without internet.
2021-06-09 14:53:33 +02:00
Hornwitser 1b082c6a25 Fix subspace storage sending commands during stop
Add logic to stop sending commands and wait for any pending commands to
complete during instance stop in subspace_storage.  This should fix the
RCON connection lost errors printed in the console during stop.
2021-06-09 14:47:52 +02:00
Hornwitser 5b78a86166 Fix HttpCloser test on Windows
Double timeout for the refuse connnection test as it's taking 2 seconds
on Windows for some reason.
2021-06-09 13:28:18 +02:00
Daniel 7d40b086e4 Fix norwegian text in web interface 2021-06-09 09:48:47 +02:00
Daniel 59b2338891 Updated guide with commands to start from source 2021-06-08 08:20:40 +02:00
Daniel af525c01ad Added syntax highlighting to docs/writing-plugins 2021-06-07 14:00:21 +02:00
Hornwitser 2be91cbd3a Add slave update subscriptions
Allow the web interface to listen for updates to the slave list and
apply it live to the places where slaves are shown in the web interface.
2021-06-02 16:19:18 +02:00
Hornwitser cc438574ff Merge pull request #380 from Hornwitser/save-management
Save management
2021-06-01 20:38:50 +02:00
Hornwitser 09e4256c18 Fix hang on missing/wrong Factorio version
Fix the start command hanging if the configured Factorio version for the
instance started is not available on the slave.
2021-06-01 16:38:58 +02:00
Hornwitser a0f5864324 Copy save if the save to start is an autosave
Prevent potential data loss when starting an autosave by copying it
first.  This avoids overwriting a random autosave slot when the server
shuts down after having loaded an autosave.
2021-06-01 16:38:58 +02:00
Hornwitser 12db0d5a84 Add download save feature
Add command and web interface button to download saves from instances.
2021-06-01 16:38:56 +02:00
Hornwitser 35f54a0ee8 Add delete save feature
Add command and web interface button to delete saves.
2021-06-01 16:32:58 +02:00
Hornwitser 2e314ff098 Add upload save feature
Add /api/upload-save endpoint that streams the data via the slave
assigned to the target instance to the instance saves directory, along
with a ctl command and web interface button to do the upload.
2021-06-01 16:27:02 +02:00
Hornwitser 0066a06107 Add findUnusedName to file_ops.js
Add function to find an unused name in a directory to lib/file_ops and
use it for finding an unused instance directory.
2021-06-01 11:22:22 +02:00
Hornwitser 544d8deb81 Add load save button to web interface 2021-06-01 11:22:22 +02:00
Hornwitser e730718671 Update antd 2021-06-01 11:22:22 +02:00
Hornwitser a96fae5ffd Add save listing and subscriptions
Add link messages for listing saves and listening for changes to the
list of saves for instances along with a table in the instance view page
showing the save list for the instance and a list-saves command to ctl.
2021-06-01 11:22:22 +02:00
Hornwitser b53ed7f6ca Add Export data button to instance page
Add missing button to do the export data action on an instance.
2021-05-21 18:39:24 +02:00
Hornwitser dd216318b0 Add option to pass map settings to Factorio
Add options to pass map seed, map exchange string, map gen settings
and/or map settings when creating a save either through the create save
request or the load scenario request.
2021-05-21 18:39:19 +02:00
Hornwitser 4a8a4c8227 Check if test control exists before closing it
Fix after action in integration test throwing in certain error cases.
2021-05-21 17:30:06 +02:00
Hornwitser 177278fee3 Ignore instance status changes that didn't change
Check if the instance status in the instanceStatusChanged event actually
changed and ignore the event if it didn't.  This prevents bogus updates
from being pushed to control clients and plugins.
2021-05-21 17:27:25 +02:00
Hornwitser 6ffcb8c451 Add instance update subscription
Provide the control link with the ability to subscribe to updates when
instance status and properties changes.  This allows the web interface
to respond in real time to instance starts and stops that are triggered
from somewhere else.
2021-05-21 17:27:01 +02:00
Hornwitser bda103d7a0 Fix --dev mode broken by master refactor 2021-05-20 21:51:51 +02:00
Hornwitser 2eadc3ea10 Add Map Exchange String parser
Add readMapExchangeString to lib/factorio which parses and decodes the
data in Map Exchange into a format suitable for giving to the Factorio
server command line.
2021-05-20 21:51:51 +02:00
Hornwitser b352e89b48 Merge pull request #379 from Hornwitser/small-things-4
Small things 4
2021-05-17 22:02:40 +02:00
Hornwitser 19ebee4697 Remove PluginsContext
The plugins are available as a property on the Control provided by the
ControlContext which makes the PluginsContext redundant.  Remove it and
use ControlContext to retreive the plugin list in the app.
2021-05-16 20:36:09 +02:00
Hornwitser 4014eb4ec2 Fix log query broken after refactor
Add missing levels import from the logging library in
ControlConnection.js.
2021-05-15 23:22:37 +02:00
Hornwitser e94662350a Support symbolic links to modules
Ignore files instead checking if the entry is a directory looking for
modules to patch in.  This is to support symbolic link to modules.
2021-05-15 23:22:37 +02:00
Hornwitser 0ae4fa0d2e Create equipment grid when deserializing it
Create equipment grid for items that support it and doesn't currently
have a grid.  Fixes deserialization of spidertron equipment grids.
2021-05-15 23:22:37 +02:00
Hornwitser b0067eccc6 Use node plugin for Eslint
The Node.js specific rules for Eslint were deprecated in favour of the
node plugin for Eslint.  Add node plugin for eslint and switch over to
using its rules, fixing any issues detected.
2021-05-15 23:22:37 +02:00
Hornwitser 95b2262cc3 Remove log rotation
Remove log rotation as Factorio implemented its own log rotation an
eternety ago and the cluster log already contains all the log entries.
2021-05-15 23:22:32 +02:00
Hornwitser 7cef5528b3 Check for 0.16 startup string
Adjust the regex for detecting startup to also work with 0.16 as
updateTick was called mapTick before 0.17.  Note: This commit does not
imply 0.16 is going to be supported by Cluestio.
2021-05-15 20:53:16 +02:00
Hornwitser a7d728c3ac v2.0.0-alpha.6 2021-05-12 23:50:26 +02:00
Hornwitser 4e61c62965 Add changelog entry for previous commit 2021-05-12 22:06:22 +02:00
Hornwitser 1c4010a744 Drop support for Node.js v10
Version 10 of Node.js is no longer supported upstream, so stop
supporting running Clusterio with it.
2021-05-12 21:43:40 +02:00
Hornwitser cbfe6fbc11 Merge pull request #376 from Hornwitser/master-refactor
Refactor master
2021-05-12 21:42:16 +02:00
Hornwitser e9c3c38a53 Add SlaveConnection.id
Add .id property to access the id of the slave this SlaveConnection is
for.
2021-05-12 20:42:52 +02:00
Hornwitser 322570f298 Add instance config event for master plugins
Add onInstanceConfigFieldChanged hook for master plugin code which
triggers when instance configs are changed on the master server.
2021-05-12 20:42:52 +02:00
Hornwitser 8b857224dc Move app routes into separate function
Reduce the _startupInternal complexity by moving out the express app
route logic into a separate function.
2021-05-12 20:42:52 +02:00
Hornwitser d3ea85d5ac Add stopping instance status
Notify when an instance is in the process of stopping with a separate
status for it.  This makes handling shutdown gracefully a bit simpler in
plugin code.
2021-05-12 20:42:52 +02:00
Hornwitser f6ab825692 Add status events for create/delete instance
Notify plugins of status changes when instances are created and deleted.
2021-05-12 20:42:52 +02:00
Hornwitser 67219ede6b Refactor master code
Move implementation of master systems into dedicated classes for better
organization.
2021-05-12 20:42:40 +02:00
Hornwitser 8b32267858 Merge pull request #377 from Hornwitser/test-package
Move test dependencies into separate package
2021-05-12 20:39:51 +02:00
Hornwitser 0a469a0524 Add missing test dependencies
The usage of lerna bootstrap --hoist meant these dependencies being
missing went unnoticed.
2021-05-12 19:57:19 +02:00
Hornwitser 65c6fd592c Log stderr instead of writing to test log
During Travis integration tests a BrokenPipeError is emitted on stderr
after the Factorio server has quit.  I have no idea what's going on here
so for now just log it and ignore it.
2021-05-07 20:07:40 +02:00
Hornwitser a6ce755743 Update development tools 2021-05-07 18:04:13 +02:00
Hornwitser c2e7a02e5a Use --hoist
Save time installing dependencies by recommending the usage of --hoist.
2021-05-07 17:39:49 +02:00
Hornwitser ef0218ddfe Move test dependencies to separate package
Move the depenedncies needed to run the tests into test/package.json and
manage it using lerna.  This should make it easier to selectively
install only what's needed to run the software.
2021-05-07 17:39:21 +02:00
Hornwitser ea645da3cb Fix chalk dependency being in the wrong package 2021-05-07 17:39:21 +02:00
Alyssa Smith 9a42f784ff Make community plugin list clearer (#374)
Include npm package names to be copied by users.
2021-03-29 00:45:42 +02:00
104 changed files with 6900 additions and 2594 deletions

No files matched your search

+83 -10
View File
@@ -1,5 +1,8 @@
"use strict";
module.exports = {
"plugins": [
"node",
],
"env": {
"node": true,
"commonjs": true,
@@ -18,6 +21,8 @@ module.exports = {
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
},
{
@@ -31,6 +36,44 @@ module.exports = {
"jsx": true,
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "off",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
},
],
@@ -46,7 +89,6 @@ module.exports = {
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
@@ -81,10 +123,8 @@ module.exports = {
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"global-require": "error",
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
@@ -160,7 +200,6 @@ module.exports = {
],
},
],
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
@@ -172,19 +211,14 @@ module.exports = {
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-path-concat": "error",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "error",
"no-process-exit": "error",
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "error",
@@ -194,7 +228,6 @@ module.exports = {
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["yargs"] }],
"no-spaced-func": "error",
"no-sync": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-template-curly-in-string": "error",
"no-ternary": "off",
@@ -279,5 +312,45 @@ module.exports = {
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", { "exceptRange": true }],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "error",
"node/handle-callback-err": "error",
"node/no-callback-literal": "error",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-extraneous-import": "error",
"node/no-extraneous-require": "error",
"node/no-missing-import": "error",
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error",
"node/no-process-env": "error",
"node/no-process-exit": "error",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "error",
"node/no-unpublished-import": "error",
"node/no-unpublished-require": [
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unsupported-features/es-builtins": "error",
"node/no-unsupported-features/es-syntax": "error",
"node/no-unsupported-features/node-builtins": "error",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
"node/prefer-global/text-decoder": "error",
"node/prefer-global/text-encoder": "error",
"node/prefer-global/url-search-params": "error",
"node/prefer-global/url": "error",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "error",
"node/shebang": "error",
},
};
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
name: clusterio_docker_image_testing
path: .
- name: ls
run: ls && ls clusterio_docker_image_testing && ls /home/runner/work/factorioClusterio && ls /home/runner/work/factorioClusterio/factorioClusterio
run: ls && ls clusterio_docker_image_testing && ls /home/runner/work/clusterio && ls /home/runner/work/clusterio/clusterio
- name: Load docker image
run: docker load -i clusterio_docker_image_testing
-1
View File
@@ -31,7 +31,6 @@ dist/
node_modules
Factorio*
factorio*
/factorio
/instances
/sharedMods
+3 -3
View File
@@ -1,14 +1,14 @@
language: node_js
node_js:
- 10
- 12
before_install:
- curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64
- tar -xf factorio.tar.gz
- npm install -g codecov
script:
- npx lerna bootstrap --no-ci
- npx lerna bootstrap --hoist --no-ci
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.6/subspace_storage_1.99.8.zip
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
- npx lerna run build
- npm run-script ci-cover
- codecov -f coverage/lcov.info
+6 -2
View File
@@ -19,6 +19,7 @@
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
- Added centralized logging for the cluster.
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
- Added remote save management with the ability to list, create, upload, download, delete and load saves for instances.
### Features
@@ -43,6 +44,7 @@
- Added an overview of the installed plugins to the web interface.
- Added clusterioctl command to list plugins on the master server.
- Added Player Auth plugin for logging in to the web interface by proving the ability to log in to a server as a given user.
- Added Node.js based installer to simplify setting up a cluster.
### Changes
@@ -76,6 +78,7 @@
UPS statistics is exported by the statistics exporter plugin.
- Master server now defaults to hosting on https on port 8443.
- Renamed client to slave.
- Removed rotation of factorio-current.log files.
### Breaking Changes
@@ -140,6 +143,7 @@
- Renamed clusterioMod plugin to subspace_storage.
- Master no longer creates secret-api-token.txt.
- Removed automatic creation of self-signed TLS certificate.
- Node.js versions below 12 are no longer supported.
## Version 1.2.4
@@ -183,8 +187,8 @@
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C on Linux ([#217][#217]).
- Fixed package.json incorrectly reporting the license as ISC.
[#217]: https://github.com/clusterio/factorioClusterio/issues/217
[#229]: https://github.com/clusterio/factorioClusterio/issues/229
[#217]: https://github.com/clusterio/clusterio/issues/217
[#229]: https://github.com/clusterio/clusterio/issues/229
### Breaking Changes
+4 -5
View File
@@ -1,10 +1,9 @@
FROM node:12 as subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/factorioClusterioMod.git
WORKDIR /factorioClusterioMod
RUN git checkout clusterio-2.0 \
&& npm install \
RUN git clone https://github.com/clusterio/subspace_storage.git
WORKDIR /subspace_storage
RUN npm install \
&& node build
FROM node:12 as clusterio_builder
@@ -24,7 +23,7 @@ RUN npm install \
#RUN npm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
COPY --from=subspace_storage_builder /factorioClusterioMod/dist/ /clusterio/sharedMods/
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
# Build submodules (web UI, libraries, plugins etc)
RUN npx lerna run build
+89 -142
View File
@@ -4,20 +4,16 @@
<br/>
<br/>
# factorioClusterio
# Clusterio
Discord for development/support/play: https://discord.gg/5XuDkje
## Important notice
This is the development branch for factorioClusterio 2.0 which is currently undergoing heavy restructuring and refactoring.
Expect plugins and existing installations to frequently break when using this branch.
If you don't want to be an alpha tester for 2.0 please use the stable [1.2.x branch][1.2.x] or [latest stable release](https://github.com/clusterio/factorioClusterio/releases/latest).
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
If you are starting a new cluster it's highly recommended to use the 2.0 alpha.
Installation instructions below are for the unstable master branch.
Go to the page for the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
[1.2.x]: https://github.com/clusterio/factorioClusterio/tree/1.2.x
### Ways to support me/the project:
@@ -32,17 +28,15 @@ Go to the page for the [1.2.x branch][1.2.x] for instructions on how to install
* [Introduction](#introduction)
* [Features](#features)
* [Plugins](#plugins)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [MacOS setup](#macos-setup)
* [Installing Plugins](#installing-plugins)
* [Installation](#installation)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [MacOS setup](#macos-setup)
* [Installing Plugins](#installing-plugins)
* [Configure Master Server](#configure-master-server)
* [Hosting Behind Proxy](#hosting-behind-proxy)
* [Setting up an admin account](#setting-up-an-admin-account)
* [Running Clusterio](#running-clusterio)
* [Master Server](#master-server)
* [Slaves](#slaves)
* [Instances](#instances)
* [Setting up remote slaves](#setting-up-remote-slaves)
* [Setting up clusterioctl](#setting-up-clusterioctl)
* [Common problems](#Common-problems)
@@ -50,7 +44,7 @@ Go to the page for the [1.2.x branch][1.2.x] for instructions on how to install
Clusterio is a clustered Factorio server manager that provides the tooling for implementing cross server interactions in Factorio.
It was previously best known for implementing cross server transfer and cloud storage of items via teleporter chests.
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/factorioClusterioMod).
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/subspace_storage).
By itself Clusterio doesn't change the gameplay in any way, you could even use Clusterio to manage completely vanilla Factorio servers.
Plugins do the work of modding in the visible changes into the game, see the [Plugins section](#plugins) for ready made plugins you can install into a Clusterio cluster.
@@ -76,40 +70,55 @@ These are the plugins supported and maintained by the Clusterio developers:
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the master server.
- [Subspace Storage](https://github.com/clusterio/factorioClusterioMod): Provide shared storage that can transport items between servers via teleport chests.
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
There's also plugins developed and maintained by the community:
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge): Bridges chat between instances and Discord.
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge) (@hornwitser/discord_bridge): Bridges chat between instances and Discord.
By Hornwitser.
- [Server Select](https://github.com/Hornwitser/server_select): In-game GUI for connecting to other server in the cluster.
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other server in the cluster.
Originally by Godmave, ported to 2.0 by Hornwitser.
Want to make your own plugin?
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
## Ubuntu setup
## Installation
**Warning**: These instructions are for the unstable master version and is not recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
Clusterio runs on Node.js v12 and up, it's distributed via npm and comes with a guided install script.
If you alread have a recent Node.js installed you can set it up in a new directory with.
Clusterio runs on Node.js v12 and up, and v10.16.0+.
Node.js itself is not supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
npm init "@clusterio"
Master and all slaves:
Otherwise see below for OS specific instructions.
wget -qO - https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt install -y nodejs
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.gz
downloads and installs nodejs, git and clusterio.
To specify a version, change "latest" in the link to a version number like 0.14.21.
### Ubuntu setup
1. Install Node.js v12 or higher.
For 20.04 LTS or below the version of Node.js provided by the Ubuntu repos are too old and you will have to use the nodesource PPA, otherwise you may skip the first line.
wget -qO - https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt install nodejs
2. Create a new directory and run the Clusterio installer:
mkdir clusterio
cd clusterio
npm init "@clusterio"
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use local factorio directory for the Factorio installation then download the headless build of Factorio and unpack it:
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.gz
To specify a version of Factorio to download replace "latest" in the URL with a version number like "1.0.0".
4. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
**Ubuntu with Docker**
@@ -119,80 +128,50 @@ If you need it open an issue about it.
<!--
Clusterio has *very* limited support for using docker.
sudo docker build -t clusterio --no-cache --force-rm factorioClusterio
sudo docker build -t clusterio --no-cache --force-rm clusterio
sudo docker run --name master -e MODE=master -p 1234:8080 -d -it --restart=unless-stopped danielvestol/clusterio
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/factorioClusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/clusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
The -v flag is used to specify the instance directory.
Your instances (save files etc) will be stored there.
-->
## Windows setup
### Windows setup
**Warning**: These instructions are for the unstable master version and is not recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
1. Download and install the latest LTS release from http://nodejs.org.
**Requirements**
2. Create a new empty directory for the installation and navigate into it.
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
download and install nodeJS 12 from http://nodejs.org.
Clusterio runs on Node.js v12 and up, and v10.16.0+.
npm init "@clusterio"
**Master**
Make sure to note down the admin authentication token it provides at the end as you will need it later.
1. Open PowerShell or Command prompt in the directory you want to install to and run the following commands.
3. If you chose to use local factorio directory for the Factorio installation then download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
### MacOS Setup
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
npm init "@clusterio"
2. Obtain Factorio by either of these two methods:
Make sure to note down the admin authentication token it provides at the end as you will need it later.
- Via the stand alone version on from their website
3. If you chose to use local factorio directory for the Factorio installation you will need to obtain and copy a mac version of Factorio and unpack it to to the `factorio` folder in your clusterio installation folder.
1. Create a new folder named "factorio" in the the factorioClusterio folder.
2. Download the MS Windows (64-bit zip package) from https://www.factorio.com/download .
### Installing Plugins
3. Open the zip file and drag the folder called "Factorio_x.y.z" into the factorio folder created in step 1.
- Via steam installation
1. Create a new folder named "factorio" in the the factorioClusterio folder.
2. Locate the game files by right clicking the game in steam, selecting properties, then Local Files, then Browse local files.
3. Go to the parent folder of the folder that Steam opened and copy the Factorio folder into the factorio folder created in step 1.
## MacOS Setup
**Requirements**
download and install nodeJS 12 from http://nodejs.org or brew (`brew install node`). Clusterio runs on Node.js v12 and
up, and v10.16.0+.
1. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
```bash
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
```
2. Obtain Factorio:
Install Factorio and copy the factorio.app/Contents in your clusterio install folder as factorio.
If you installed factorio on your mac you can run
```bash
cp -r /Applications/factorio.app/Contents/ factorio
```
## Installing Plugins
Installing plugins to make them work with Clusterio consists of two steps.
First install the package via npm, for example
For well known plugins you can select them during installation and no further steps are necessary to make them work.
Installing plugins that are not offered by the installer consists of two steps.
First install the package the plugin is provided by via npm, for example
npm install @clusterio/plugin-subspace_storage
@@ -202,7 +181,7 @@ Then tell clusterio that this plugin exists by adding it as a plugin.
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by master, slave and ctl.
If you have slaves or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriomaster, clusterioslave and clusterioctl commands has the plugin sub-command so you do not need to install clusteriomaster to add plugins.
The clusteriomaster, clusterioslave and clusterioctl commands has the plugin sub-command so you do not need to install clusteriomaster to add plugins, instead use the clusterio command you have available.
For development purposes the `plugin add` command supports adding plugins by the absolute path to them, or a relative path that must start with either . or .. (which will then be resolved to an absolute path).
@@ -214,6 +193,11 @@ You can change the port used with the command
npx clusteriomaster config set master.http_port 1234
When changing the port you will also need to change the address slaves connect with.
For the standalone installation mode you can use
npx clusterioslave config set slave.master_url http://localhost:1234/
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
@@ -226,67 +210,30 @@ You can list the config of the master server with the `npx clusteriomaster confi
See the [readme for @clusterio/master](/packages/master/README.md) for more information.
### Setting up an admin account
Before you can manage the cluster you need to bootstrap an admin account for it.
Replace `<username>` with your Factorio username (do not make up a new username here).
npx clusteriomaster bootstrap create-admin <username>
npx clusteriomaster bootstrap create-ctl-config <username>
The first command creates a user account with the given name and promotes it to a cluster admin.
The second one sets up a `config-control.json` config for clusterioctl to connect to the master server under the given user account.
## Running Clusterio
After following the installation and master configuration instructions you can use the following commands to run Clusterio.
After completing the installation start up the master server and at least one slave separately.
The installer provides the `run-master` and `run-slave` scripts to make this simple.
Once the master process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a slave and then click start.
### Master Server
## Setting up remote slaves
It's necessary to run the master server in order for anything to work.
Once you've completed the setup run the following command to start it up:
npx clusteriomaster run
Run the installer as described in the installation section and choose "Slave only" as the operating mode to install, it'll ask for a master URL and an authentication token.
The URL is the same as what is needed to connect to the web interface, and the athentication token can be generated on the Slaves page in the web interface.
Once you start up the slave it should show up in the Slaves list and be available for assigning and running instances on.
### Slaves
## Setting up clusterioctl
Slaves connect to the master server and are managed remotely from the master server.
In order for slaves to connect to the master server they need a valid authentication token, you can create a slave config with a valid token with the following command.
There's a command line interface available for Clusterio which is installed separately with the same installer as for the master and slave.
Run the installer as described in the installation section and choose "Ctl only" as the operating mode to install, you can do this in the same directory as you have installed other clusterio component(s) to.
The installer will ask for a master URL and an authentication token, these are the same as you would use to connect to the web interface.
If you want to use a different user for the command line interface you can generate an authentication token for an existing user with
npx clusterioctl slave create-config --name Local --generate-token
This will write a new `config-slave.json` file in the current directory (you can change the location with the `--output` option) with the name, token and url to connect to the master server with.
If you are making the config for a remote slave you will need to have set the `master.external_address` option to the URL the master server can be reached on.
You can list the config of a slave on the slave itself with the `npx clusterioslave config list` command.
See the [readme for @clusterio/slave](/packages/slave/README.md) for more information.
Once the config is set up run the slave with
npx clusterioslave run
### Instances
Instances are created, managed and started from the master server.
For now the only interface available is the `clusterioctl` command line tool included in Clusterio.
You can run this tool from any slave, or the master server without having to set up a config, if you want to manage the cluster from somewhere else you will need to set the `control.master_url` and `control.master_token` options with the `node clusterioctl control-config set` command:
The basic operations to start a new instance is the following
npx clusterioctl instance create "My instance"
npx clusterioctl instance assign "My instance" "Local"
npx clusterioctl instance start "My instance"
The first line creates the instance configuration on the master server.
The second assigns the instance to a slave which creates the instance directory and files needed to run the instance on the given slave.
The third line starts the instance, which creates a new save if there are no save games present.
There are many more commands available with clusterioctl.
See the [Managing a Cluster](/docs/managing-a-cluster.md) document or `npx clusterioctl --help` for a full list of them.
npx clusteriomaster bootstrap generate-user-token <username>
## Common problems
+8
View File
@@ -109,6 +109,14 @@ This should be less than both the timeout and collection interval configured for
Defaults to 8.
### master.stream_timeout
Timeout in seconds for proxy streams through the master that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Defaults to 15.
### master.default_role_id
Role to automatically grant to new users.
+40 -8
View File
@@ -41,7 +41,7 @@ If you already cloned the main GitHub repository you can change your clone to po
To keep your local repository up to to date with the main repository add the main repository as a remote.
git remote add upstream https://github.com/clusterio/factorioClusterio
git remote add upstream https://github.com/clusterio/clusterio
After this running `git fetch upstream` will get the updated branches from the main repository.
Do not run `git pull upstream` it will mess thing up!
@@ -53,16 +53,48 @@ The project is organized into several packages that are all managed through Lern
In order to get the development environment up and running you will need to run the following commands:
npm install
npx lerna bootstrap
npx lerna bootstrap --hoist
npx lerna run build
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
node packages/create --dev
To add plugins to use in the development environment use
node packages/ctl plugin add ./path/to/plugin
It's important that the path starts with `./`.
The `plugins/` directory contains the core plugins for the project and can be added using the above mentioned command.
The documentation uses the binaries provided by the packages, but for the development environment you will need to call the packages directly:
npx clusteriomaster -> node packages/master
npx clusterioslave -> node packages/slave
npx clusterioctl -> node packages/ctl
Note that to use `clusterioctl` you will have to create a control config first:
node packages/master bootstrap create-ctl-config [admin-user]
Once you've set up the cluster you can use `node packages/master run` to run the master server and `node packages/slave run` to run the slave which connects to the master server.
For web development on the master there are also the following flags.
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
These options can be combined and multiple plugins can be actived for dev mode.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `npx lerna run build` before you start the master without these flags again.
### Starting a Feature Branch
Development is done exclusively in feature branches.
This means the first thing to do before starting any development at all is to make a new branch starting from the master or 1.2.x branch of the main repository.
To do this check out that branch from the main repository (substitute master for 1.2.x if you're working on that branch).
This means the first thing to do before starting any development at all is to make a new branch starting from the master branch of the main repository.
To do this check out that branch from the main repository.
git fetch upstream
git checkout upstream/master
@@ -103,7 +135,7 @@ If there are more changes you want to have merged or mistakes you need to fix yo
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
If your feature branch has been merged however it's final and you will have to start a new feature branch and submit that in a new pull request.
The base usage of rebase assuming you are checked out on your feature branch is the following command (substitute master with 1.2.x if your feature branch is based on that)
The base usage of rebase assuming you are checked out on your feature branch is the following command
git rebase -i upstream/master
@@ -122,7 +154,7 @@ If you have a pull request that has not yet been merged submitted for this featu
### Resolving Merge Conflicts
The best way to resolve merge conflicts in feature braches is to rebase it on top of the updated upstream.
Run the following commands (substitute master with 1.2.x if your feature branch is based on that)
Run the following commands
git fetch upstream
git rebase upstream/master
@@ -150,8 +182,8 @@ The format of this log should be self explanatory, please follow it carefully.
## Supported Node.js and Factorio Versions
Clusterio run on Node.js v12 and up, and v10 from v10.16.0, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The Travis CI tests are runned on whatever version of Node.js v10 is the default, which at the time of writing is v10.18.0.
Clusterio run on Node.js v12 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The Travis CI tests are runned on whatever version of Node.js v12 is the default.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
+191 -152
View File
@@ -48,18 +48,20 @@ For a plugin the most important file is the `info.js` file.
Without it the plugin will not recognized by Clusterio.
Here's an example of it:
const libLink = require("@clusterio/lib/link"); // For messages
```js
const libLink = require("@clusterio/lib/link"); // For messages
module.exports = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance",
masterEntrypoint: "master",
messages: {
/* See below */
},
};
module.exports = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance",
masterEntrypoint: "master",
messages: {
/* See below */
},
};
```
The following properties are recognized:
@@ -117,20 +119,22 @@ For both instanceEntrypoint and masterEntrypoint the path should not end with .j
The plugin class should derive from its respective base class defined in `lib/plugin`.
For example, to define a MasterPlugin class the following code can be used:
const libPlugin = require("@clusterio/lib/plugin");
```js
const libPlugin = require("@clusterio/lib/plugin");
class MasterPlugin extends libPlugin.BaseMasterPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
}
// ...
class MasterPlugin extends libPlugin.BaseMasterPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
}
module.exports = {
MasterPlugin,
}
// ...
}
module.exports = {
MasterPlugin,
}
```
For the instance plugin it's exactly the same except "Master" is replaced with "Instance", and for the clusterioctl plugin "Control" is used.
The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js).
@@ -138,11 +142,13 @@ The available hooks that you can override are documented in the base class [in l
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
E.g.:
constructor(...args) {
super(...args);
```js
constructor(...args) {
super(...args);
// Code here
}
// Code here
}
```
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
The async init method is always called immediatly after the constructor, so there's little reason to do this.
@@ -154,9 +160,11 @@ The base plugin classes provide a winston logger for logging messages to the sha
For instances a copy of the log is also stored on the slave the instance is on.
To use it, pass a string to one of the log levels functions, for example:
async init() {
this.logger.info("Initializing frobbing");
}
```js
async init() {
this.logger.info("Initializing frobbing");
}
```
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
@@ -180,11 +188,13 @@ These levels have roughly the following meanings:
For guarding unexpected errors the best option is to log a short description along with the`stack` property of the Error:
try {
// Operation that should not throw but may end up throwing
} catch (err) {
this.logger.error(`Operation failed:\n${err.stack}`);
)
```js
try {
// Operation that should not throw but may end up throwing
} catch (err) {
this.logger.error(`Operation failed:\n${err.stack}`);
)
```
For plugin hooks expections thrown are automatically catched and logged, but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately.
@@ -195,30 +205,34 @@ Clusterio provides a configuration system that handles storing, distributing, ed
You can take advantage of it by subclassing `PluginConfigGroup`, setting `defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export.
For example in info.js:
const libConfig = require("@clusterio/lib/config");
```js
const libConfig = require("@clusterio/lib/config");
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = "foo_frobber";
MasterConfigGroup.define({
name: "level",
description: "Level of frobnication done",
type: "number",
initial_value: 2,
});
MasterConfigGroup.finalize();
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = "foo_frobber";
MasterConfigGroup.define({
name: "level",
description: "Level of frobnication done",
type: "number",
initial_value: 2,
});
MasterConfigGroup.finalize();
module.exports = {
...
MasterConfigGroup: MasterConfigGroup,
};
module.exports = {
...
MasterConfigGroup: MasterConfigGroup,
};
```
Code inside the `MasterPlugin` class will then be able to access the level config field through the `Config` object at `this.master.config`, for example in the MasterPluginClass:
async init() {
let level = this.master.config.get("foo_frobber.level");
this.logger.info(`I got a frobnication level of ${level}`);
}
```js
async init() {
let level = this.master.config.get("foo_frobber.level");
this.logger.info(`I got a frobnication level of ${level}`);
}
```
The same applies for instance configs, replace "master" with "instance" where appropriate.
See [Configuration System](config-system.md) for more details on how this system works.
@@ -230,11 +244,13 @@ If the plugin requires a certain feature to be enabled to function it should thr
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
For example:
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("foo_frobber plugin requires save patching.");
}
```js
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("foo_frobber plugin requires save patching.");
}
}
```
## Communicating with Factorio
@@ -243,14 +259,15 @@ For pushing data into Factorio there's RCON, which lets you send arbitrary Lua c
This is done by calling the `sendRcon` method on the plugin object.
For example:
async onStart() {
let response = await this.sendRcon(
"/sc rcon.print('data')"
);
// Do stuff with response.
}
```js
async onStart() {
let response = await this.sendRcon(
"/sc rcon.print('data')"
);
// Do stuff with response.
}
```
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
You can have lua code injected into the game via the module system and call that from RCON to avoid having to send code through the commands.
@@ -261,24 +278,28 @@ The `send_json` API allows sending JSON payloads on channels that plugins can li
From a plugin you listen for an event named `ipc-channel_name` in order to get data sent by `send_json`.
For example in the plugin code:
async init() {
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => this.logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
```js
async init() {
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => this.logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
async handleFoo(content) {
// Do stuff with content
}
async handleFoo(content) {
// Do stuff with content
}
```
And then in the module for the plugin:
local clusterio_api = require("modules/clusterio/api")
```lua
local clusterio_api = require("modules/clusterio/api")
-- inside some event handler
clusterio_api.send_json("my_plugin_foo", { data = 123 })
-- inside some event handler
clusterio_api.send_json("my_plugin_foo", { data = 123 })
```
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
@@ -310,18 +331,20 @@ Events are defined as properties of the messages object exported by `info.js` th
The name of the property correspond to the handler invoked on the plugin class.
The Event constructor takes an object of properties that define the event, for example the following could be defined in `info.js`:
messages: {
startFrobnication: new libLink.Event({
type: "foo_frobber:start_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
eventRequired: ["frobnication_type"],
eventProperties: {
"frobnication_type": { type: "string" },
"urgent": { type: "boolean" },
},
}),
},
```js
messages: {
startFrobnication: new libLink.Event({
type: "foo_frobber:start_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
eventRequired: ["frobnication_type"],
eventProperties: {
"frobnication_type": { type: "string" },
"urgent": { type: "boolean" },
},
}),
},
```
This specifies an event that can be sent from the master to a slave, and from a slave to an instance.
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
@@ -383,24 +406,26 @@ The name of the property corresponds to the handler invoked on the plugin class.
The Request constructor takes an object of properties that define the event.
For example, the following could be defined in `info.js`:
messages: {
reportFrobnication: new libLink.Request({
type: "foo_frobber:report_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
requestRequired: ["verbosity"],
requestProperties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
```js
messages: {
reportFrobnication: new libLink.Request({
type: "foo_frobber:report_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
requestRequired: ["verbosity"],
requestProperties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
},
responseProperties: {
"report": {
type: "array",
items: { type: "string" },
},
responseProperties: {
"report": {
type: "array",
items: { type: "string" },
},
},
}),
},
},
}),
},
```
This specifies a request that can be sent from the master to a slave, and from a slave to an instance.
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
@@ -459,10 +484,12 @@ For `InstancePlugin` code the link to the slave is the `instance` itself, which
The `.info` property of the plugin class exposes the data exported from the plugin's `info.js` module.
In other words:
// In an InstancePlugin class
async frobnicate() {
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
}
```js
// In an InstancePlugin class
async frobnicate() {
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
}
```
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
@@ -478,9 +505,11 @@ Plugins must respond to the prepare disconnect by stopping any processess it doe
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` or `closing` property of the connector for the master/slave connection.
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is closing by using the following code:
if (!this.slave.connector.closing) {
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
}
```js
if (!this.slave.connector.closing) {
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
}
```
If the event or request needs to be sent to the master it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `onMasterConnectionEvent` and `onSlaveConnectionEvent` hooks.
@@ -500,29 +529,33 @@ Clusterio comes with its own Prometheus client implementation, one part due to N
In its simplest form collecting data from plugins consists of defining the metric and updating it somewhere in the plugin code.
For example:
const { Counter } = require("@clusterio/lib/prometheus");
```js
const { Counter } = require("@clusterio/lib/prometheus");
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
);
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
);
// Somewhere in the master plugin code
fooMetric.inc();
// Somewhere in the master plugin code
fooMetric.inc();
```
This works for master plugins, and the metric will be automatically available through the /metric HTTP endpoint.
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
const { Counter } = require("@clusterio/lib/prometheus");
```js
const { Counter } = require("@clusterio/lib/prometheus");
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
{ labels: ["instance_id"] }
);
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
{ labels: ["instance_id"] }
);
// Somewhere in the instance plugin code
barMetric.labels(String(this.instance.id)).set(someValue);
// Somewhere in the instance plugin code
barMetric.labels(String(this.instance.id)).set(someValue);
```
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the master server on slaves.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a slave.
@@ -536,28 +569,32 @@ For statistics you need to update on collection there's an `onMetrics` hook on b
The control entrypoint for plugins allows you to extend clustectl with your own commands.
The creation of custom commands typically starts with defining a command tree for the plugin:
const { Command, CommandTree } = require("@clusterio/lib/command");
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
```js
const { Command, CommandTree } = require("@clusterio/lib/command");
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
```
Then commands are added to the the plugin's command tree:
const info = require("./info");
```js
const info = require("./info");
fooFrobberCommands.add(new Command({
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
yargs.positional("level", {
describe: "type of frobnication", type: "string"
});
}],
handler: async function(args, control) {
await info.messages.frobnicate.send(control, {
instance_name: "Console",
content: args.message,
});
},
}));
fooFrobberCommands.add(new Command({
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
yargs.positional("level", {
describe: "type of frobnication", type: "string"
});
}],
handler: async function(args, control) {
await info.messages.frobnicate.send(control, {
instance_name: "Console",
content: args.message,
});
},
}));
```
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
@@ -567,14 +604,16 @@ Note that messages sent from clusterioctl needs to have `"control-master"` as a
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Control plugin:
const libPlugin = require("@clusterio/lib/plugin");
```js
const libPlugin = require("@clusterio/lib/plugin");
class ControlPlugin extends libPlugin.BaseControlPlugin {
async addCommands(rootCommand) {
rootCommand.add(fooFrobberCommands);
}
class ControlPlugin extends libPlugin.BaseControlPlugin {
async addCommands(rootCommand) {
rootCommand.add(fooFrobberCommands);
}
}
module.exports = {
ControlPlugin,
}
module.exports = {
ControlPlugin,
}
```
+3 -2
View File
@@ -1,7 +1,8 @@
{
"packages": [
"packages/*",
"plugins/*"
"plugins/*",
"test"
],
"version": "2.0.0-alpha.5"
"version": "2.0.0-alpha.8"
}
+8 -19
View File
@@ -2,7 +2,7 @@
"name": "root",
"private": true,
"scripts": {
"test": "mocha --check-leaks -R spec --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/test",
"test": "mocha --check-leaks -R spec --exclude \"**/node_modules/**\" --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/test",
"fast-test": "FAST_TEST=y npm test",
"cover": "nyc npm test",
"fast-cover": "FAST_TEST=y nyc npm test",
@@ -18,25 +18,14 @@
]
},
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"devDependencies": {
"@clusterio/ctl": "file:./packages/ctl",
"@clusterio/lib": "file:./packages/lib",
"@clusterio/master": "file:packages/master",
"@clusterio/slave": "file:./packages/slave",
"body-parser": "^1.18.2",
"eslint": "^7.2.0",
"express": "^4.16.2",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.3",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"lerna": "^3.22.1",
"mocha": "^6.2.0",
"node-forge": "^0.10.0",
"nyc": "^14.1.1",
"phin": "^3.4.1",
"webpack": "^5.0.0"
"eslint": "^7.25.0",
"eslint-plugin-node": "^11.1.0",
"jsdoc": "^3.6.6",
"lerna": "^4.0.0",
"mocha": "^8.4.0",
"nyc": "^15.1.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
# Clusterio installer
Installer for Clusterio.
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env node
"use strict";
const child_process = require("child_process");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const phin = require("phin");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
const { levels, logger, setLogLevel } = require("./logging");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const availablePlugins = [
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
// Comunity plugins
{ name: "Discord Bridge", value: "@hornwitser/discord_bridge" },
{ name: "Server Select", value: "@hornwitser/server_select" },
];
const factorioLocations = {
win32: [
"C:\\Program Files\\Factorio",
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio",
],
darwin: [
"/Applications/factorio.app/Contents",
],
};
class LineSplitter extends stream.Transform {
constructor(options) {
super(options);
this._partial = null;
}
_transform(chunk, encoding, callback) {
if (this._partial) {
chunk = Buffer.concat([this._partial, chunk]);
this._partial = null;
}
while (chunk.length) {
let end = chunk.indexOf("\n");
if (end === -1) {
this._partial = chunk;
break;
}
let next = end + 1;
// Eat carriage return as well if present
if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) {
end -= 1;
}
let line = chunk.slice(0, end);
chunk = chunk.slice(next);
this.push(line);
}
callback();
}
_flush(callback) {
if (this._partial) {
this.push(this._partial);
this._partial = null;
}
callback();
}
}
class InstallError extends Error { }
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const asyncExec = util.promisify(child_process.execFile);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
});
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { logger.verbose(line.toString()); });
child.stdout.pipe(stdout);
let stderr = new LineSplitter({ readableObjectMode: true });
stderr.on("data", line => { logger.verbose(`err: ${line.toString()}`); });
child.stderr.pipe(stderr);
});
}
async function execMaster(args) {
if (dev) {
return await execFile("node", [path.join("packages", "master"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriomaster${scriptExt}`), args);
}
async function execSlave(args) {
if (dev) {
return await execFile("node", [path.join("packages", "slave"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
}
function validateSlaveToken(token) {
let parts = token.split(".");
if (parts.length !== 3) {
throw new InstallError("Invalid token");
}
let parsed;
try {
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
} catch (err) {
throw new InstallError("Invalid token");
}
if (parsed.aud !== "slave" || !Number.isInteger(parsed.slave)) {
throw new InstallError("Invalid token");
}
}
async function validateInstallDir() {
let entries = new Set(await fs.readdir("."));
if (entries.size) {
if (!entries.has("package.json")) {
throw new InstallError("Refusing to install to non-empty directory");
}
let packageData;
try {
packageData = JSON.parse(await fs.readFile("package.json"));
} catch (err) {
throw new InstallError(`Failed to read package.json: ${err.message}`);
}
if (packageData.name !== "clusterio-install" || !packageData.private) {
throw new InstallError("Refusing to run on non-clusterio installation");
}
}
}
async function installClusterio(mode, plugins) {
try {
await fs.writeFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
} catch (err) {
if (err.code !== "EEXIST") {
throw new InstallError(`Failed to write package.json: ${err.message}`);
}
}
let components = [];
if (["standalone", "master"].includes(mode)) {
components.push("@clusterio/master");
}
if (["standalone", "slave"].includes(mode)) {
components.push("@clusterio/slave");
}
if (mode === "ctl") {
components.push("@clusterio/ctl");
}
logger.info(`Please wait, installing ${mode}`);
try {
await execFile(`npm${scriptExt}`, ["install", ...components, ...plugins]);
} catch (err) {
throw new InstallError(`Failed to install: ${err.message}`);
}
if (plugins.length) {
logger.info("Setting up plugins");
let pluginList;
try {
pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json")));
} catch (err) {
if (err.code === "ENOENT") {
pluginList = new Map();
} else {
throw new InstallError(`Error loading plugin-list.json: ${err.message}`);
}
}
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
// eslint-disable-next-line node/global-require
let pluginInfo = require(require.resolve(path.posix.join(plugin, "info"), { paths: [process.cwd()] }));
pluginList.set(pluginInfo.name, plugin);
}
}
try {
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
} catch (err) {
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
}
}
}
async function writeScripts(mode) {
if (["standalone", "master"].includes(mode)) {
if (process.platform === "win32") {
await fs.writeFile(
"run-master.cmd",
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
);
} else {
await fs.writeFile(
"run-master.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
{ mode: 0o755 },
);
await fs.outputFile(
"systemd/clusteriomaster.service",
`[Unit]
Description=Clusterio Master
[Service]
User=${os.userInfo().username}
Group=nogroup
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn
[Install]
WantedBy=multi-user.target
`);
}
}
if (["standalone", "slave"].includes(mode)) {
if (process.platform === "win32") {
await fs.writeFile(
"run-slave.cmd",
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
);
} else {
await fs.writeFile(
"run-slave.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
{ mode: 0o755 },
);
await fs.outputFile(
"systemd/clusterioslave.service",
`[Unit]
Description=Clusterio Slave
[Service]
User=${os.userInfo().username}
Group=nogroup
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
ExecStart=${process.cwd()}/node_modules/.bin/clusterioslave run --log-level=warn
[Install]
WantedBy=multi-user.target
`);
}
}
}
async function inquirerMissingArgs(args) {
let answers = {};
if (args.mode) { answers.mode = args.mode; }
answers = await inquirer.prompt([
{
type: "list",
name: "mode",
message: "Operating mode to install",
default: "standalone",
choices: [
{ name: "Standalone (install both master and slave on this computer)", value: "standalone" },
{ name: "Master only", value: "master" },
{ name: "Slave only", value: "slave" },
{ name: "Ctl only", value: "ctl" },
{ name: "Plugins only", value: "plugins" },
],
},
], answers);
if (["standalone", "master"].includes(answers.mode)) {
if (args.admin) { answers.admin = args.admin; }
answers = await inquirer.prompt([
{
type: "input",
name: "admin",
message: "Admin account name",
validate: input => {
if (!input) {
return "May not be empty";
}
return true;
},
},
], answers);
}
if (answers.mode === "slave") {
if (args.slaveName) { answers.slaveName = args.slaveName; }
answers = await inquirer.prompt([
{
type: "input",
name: "slaveName",
message: "Name of slave",
},
], answers);
}
if (["slave", "ctl"].includes(answers.mode)) {
if (args.masterUrl) { answers.masterUrl = args.masterUrl; }
answers = await inquirer.prompt([
{
type: "input",
name: "masterUrl",
message: "Master server URL",
},
], answers);
if (args.masterToken) { answers.masterToken = args.masterToken; }
answers = await inquirer.prompt([
{
type: "input",
name: "masterToken",
message: "Master authentication Token",
},
], answers);
}
if (answers.mode === "slave") {
validateSlaveToken(answers.masterToken);
}
if (["standalone", "slave"].includes(answers.mode)) {
let myIp;
if (args.publicAddress) {
answers.publicAddress = args.publicAddress;
} else {
try {
let result = await phin("https://api.ipify.org/");
myIp = result.body.toString();
} catch (err) { /* ignore */ }
}
answers = await inquirer.prompt([
{
type: "input",
name: "publicAddress",
message: "Public DNS/IP address of this server",
default: myIp,
},
], answers);
if (args.factorioDir) { answers.factorioDir = args.factorioDir; }
let locations = factorioLocations[process.platform] || [];
let foundLocations = [];
for (let location of locations) {
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
foundLocations.push(location);
}
}
answers = await inquirer.prompt([
{
type: "list",
name: "factorioDir",
message: "Path to Factorio installation",
choices: [
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
], answers);
if (answers.factorioDir === null) {
answers = await inquirer.prompt([
{
type: "input",
name: "factorioDir",
message: "Path to Factorio installation",
askAnswered: true,
},
], answers);
} else if (answers.factorioDir === "factorio") {
await fs.ensureDir("factorio");
}
}
if (dev) { answers.plugins = []; }
answers = await inquirer.prompt([
{
type: "checkbox",
name: "plugins",
message: "Plugins to install",
choices: availablePlugins,
pageSize: 20,
},
], answers);
return answers;
}
async function main() {
const args = yargs
.option("log-level", {
nargs: 1, describe: "Log level to print to stdout", default: "info",
choices: ["none"].concat(Object.keys(levels)), type: "string",
})
.option("dev", {
nargs: 0, describe: "Initialize development repository", hidden: true, default: false, type: "boolean",
})
.option("mode", {
nargs: 1, describe: "Operating mode to install",
choices: ["standalone", "master", "slave", "ctl", "plugins"],
})
.option("admin", {
nargs: 1, describe: "Admin account name [standalone/master]", type: "string",
})
.option("slave-name", {
nargs: 1, describe: "Slave name [slave]", type: "string",
})
.option("master-url", {
nargs: 1, describe: "Master URL [slave/ctl]", type: "string",
})
.option("master-token", {
nargs: 1, describe: "Master authentication token [slave/ctl]", type: "string",
})
.option("public-address", {
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/slave]", type: "string",
})
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/slave]", type: "string",
})
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
})
.argv
;
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
dev = args.dev;
if (!dev) {
await validateInstallDir();
}
let answers = await inquirerMissingArgs(args);
logger.verbose(JSON.stringify(answers));
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
let adminToken = null;
if (["standalone", "master"].includes(answers.mode)) {
logger.info("Setting up master");
await execMaster(["bootstrap", "create-admin", answers.admin]);
let result = await execMaster(["bootstrap", "generate-user-token", answers.admin]);
adminToken = result.stdout.split("\n").slice(-2)[0];
}
if (answers.mode === "standalone") {
logger.info("Setting up slave");
await execSlave(["config", "set", "slave.name", "local"]);
let result = await execSlave(["config", "show", "slave.id"]);
let slaveId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
result = await execMaster(["bootstrap", "generate-slave-token", slaveId]);
let slaveToken = result.stdout.split("\n").slice(-2)[0];
await execSlave(["config", "set", "slave.master_token", slaveToken]);
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
}
if (answers.mode === "slave") {
logger.info("Setting up slave");
let slaveId = JSON.parse(Buffer.from(answers.masterToken.split(".")[1], "base64")).slave;
await execSlave(["config", "set", "slave.id", slaveId]);
await execSlave(["config", "set", "slave.name", answers.slaveName]);
await execSlave(["config", "set", "slave.master_url", answers.masterUrl]);
await execSlave(["config", "set", "slave.master_token", answers.masterToken]);
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
}
if (!dev && ["standalone", "master", "slave"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode);
}
/* eslint-disable no-console */
console.log(`Successfully installed ${answers.mode}`);
if (adminToken) {
console.log(`Admin authentication token: ${adminToken}`);
}
/* eslint-enable no-console */
}
if (module === require.main) {
main().catch(err => {
if (err instanceof InstallError) {
logger.error(err.message);
} else {
logger.fatal(`
+------------------------------------------------------------+
| Unexpected error occured installing clusterio, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
});
}
+51
View File
@@ -0,0 +1,51 @@
/* eslint-disable no-console */
"use strict";
const chalk = require("chalk");
const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
audit: 3,
info: 4,
verbose: 6,
});
const colors = {
fatal: "inverse red",
error: "bold redBright",
warn: "bold yellowBright",
audit: "bold greenBright",
info: "bold blueBright",
verbose: "bold grey",
};
let maxLogLevel = levels.info;
const logger = {};
for (let [name, level] of Object.entries(levels)) {
// eslint-disable-next-line no-loop-func
logger[name] = message => {
if (level > maxLogLevel) {
return;
}
let style = chalk;
for (let part of colors[name].split(" ")) {
style = style[part];
}
if (["fatal", "error"].includes(name)) {
console.error(`${style(name)} ${message}`);
} else if (["warn"].includes(name)) {
console.warn(`${style(name)} ${message}`);
} else {
console.log(`${style(name)} ${message}`);
}
};
}
module.exports = {
colors,
levels,
setLogLevel: (level) => { maxLogLevel = level; },
logger,
};
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@clusterio/create",
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.8",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"factorio",
"clusterio"
],
"engines": {
"node": ">=12"
},
"dependencies": {
"chalk": "^4.1.0",
"fs-extra": "^8.1.0",
"inquirer": "^8.1.0",
"phin": "^3.5.1",
"yargs": "^14.2.0"
},
"publishConfig": {
"access": "public"
}
}
+1 -1
View File
@@ -88,4 +88,4 @@ Lists up all configuration entries with their currently configured values.
[Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
[The Clusterio repository](https://github.com/clusterio/factorioClusterio) for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
+188 -11
View File
@@ -25,6 +25,8 @@ const libSharedCommands = require("@clusterio/lib/shared_commands");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
const libFactorio = require("@clusterio/lib/factorio");
const libFileOps = require("@clusterio/lib/file_ops");
function print(...content) {
@@ -135,10 +137,11 @@ slaveCommands.add(new libCommand.Command({
slaveCommands.add(new libCommand.Command({
definition: ["generate-token", "Generate token for a slave", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true });
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id" });
}],
handler: async function(args, control) {
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: args.id });
let slaveId = typeof args.id === "number" ? args.id : null;
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId });
print(response.token);
},
}));
@@ -314,15 +317,175 @@ instanceCommands.add(new libCommand.Command({
},
}));
async function loadMapSettings(args) {
let seed = args.seed !== undefined ? args.seed : null;
let mapGenSettings = null;
let mapSettings = null;
if (args.mapExchangeString) {
let parsed = libFactorio.readMapExchangeString(args.mapExchangeString);
mapGenSettings = parsed.map_gen_settings;
mapSettings = parsed.map_settings;
}
if (args.mapGenSettings) {
mapGenSettings = JSON.parse(await fs.readFile(args.mapGenSettings));
}
if (args.mapSettings) {
mapSettings = JSON.parse(await fs.readFile(args.mapSettings));
}
return {
seed,
mapGenSettings,
mapSettings,
};
}
instanceCommands.add(new libCommand.Command({
definition: ["create-save <instance>", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
definition: ["list-saves <instance>", "list saves on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId });
for (let entry of response.list) {
entry.mtime = new Date(entry.mtime_ms).toLocaleString();
delete entry.mtime_ms;
}
print(asTable(response.list));
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["create-save <instance> [name]", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.createSave.send(control, {
instance_id: instanceId,
name: args.name,
seed,
map_gen_settings: mapGenSettings,
map_settings: mapSettings,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["upload-save <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args, control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new libErrors.CommandError("Save name must end with .zip");
}
// phin doesn't support streaming requests :(
let content = await fs.readFile(args.filepath);
let instanceId = await libCommand.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.master_url"));
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", instanceId);
url.searchParams.append("filename", filename);
let result = await phin({
url, method: "POST",
headers: {
"X-Access-Token": control.config.get("control.master_token"),
"Content-Type": "application/zip",
},
core: { ca: control.tlsCa },
data: content,
parse: "json",
});
for (let error of result.body.errors || []) {
logger.error(error);
}
for (let requestError of result.body.request_errors || []) {
logger.error(error);
}
if (result.body.saves && result.body.saves.length) {
logger.info(`Successfully uploaded as ${result.body.saves[0]}`);
}
if ((result.body.errors || []).length || (result.body.request_errors || []).length) {
throw new libErrors.CommandError("Uploading save failed");
}
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["download-save <instance> <save>", "Download a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to download save from", type: "string" });
yargs.positional("save", { describe: "Save to download", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let result = await libLink.messages.downloadSave.send(control, {
instance_id: instanceId,
save: args.save,
});
let url = new URL(control.config.get("control.master_url"));
url.pathname += `api/stream/${result.stream_id}`;
let response = await phin({
url, method: "GET",
core: { ca: control.tlsCa },
stream: true,
});
let stream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
stream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(stream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
response.pipe(stream);
await events.once(stream, "finish");
let filename = await libFileOps.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["delete-save <instance> <save>", "Delete a save from an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete save from", type: "string" });
yargs.positional("save", { describe: "Save to delete", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.deleteSave.send(control, {
instance_id: instanceId,
save: args.save,
});
},
}));
@@ -364,15 +527,23 @@ instanceCommands.add(new libCommand.Command({
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"seed": { describe: "Seed to use, takes precedence over map-gen-settings", nargs: 1, type: "number" },
"map-exchange-string": { describe: "Map exchange string to use for the save", nargs: 1, type: "string" },
"map-gen-settings": { describe: "path to file to use for map-gen-settings", nargs: 1, type: "string" },
"map-settings": { describe: "path to file to use for map-settings", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let { seed, mapGenSettings, mapSettings } = await loadMapSettings(args);
let response = await libLink.messages.loadScenario.send(control, {
instance_id: instanceId,
scenario: args.scenario || null,
scenario: args.scenario,
seed,
map_gen_settings: mapGenSettings,
map_settings: mapSettings,
});
control.keepOpen = args.keepOpen;
},
@@ -748,6 +919,12 @@ class Control extends libLink.Link {
this.keepOpen = false;
}
async slaveUpdateEventHandler() { }
async instanceUpdateEventHandler() { }
async saveListUpdateEventHandler() { }
async setLogSubscriptions({
all = false,
master = false,
@@ -906,7 +1083,7 @@ async function startControl() {
await controlConfig.init();
} else {
throw err;
throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
@@ -962,7 +1139,7 @@ async function startControl() {
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line no-process-exit
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
@@ -1008,10 +1185,10 @@ I version of clusterio. Expect things to break. I
startControl().catch(err => {
if (!(err instanceof libErrors.StartupError)) {
logger.fatal(`
+----------------------------------------------------------------+
| Unexpected error occured while starting control, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,14 +15,14 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.5",
"@clusterio/lib": "^2.0.0-alpha.8",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"yargs": "^14.2.0"
},
+1 -1
View File
@@ -2,4 +2,4 @@
This package contains functionality shared between master, slave, ctl and/or plugins of Clusterio.
See [the Clusterio repository](https://github.com/clusterio/factorioClusterio) for more information on Clusterio.
See [the Clusterio repository](https://github.com/clusterio/clusterio) for more information on Clusterio.
+7
View File
@@ -101,6 +101,13 @@ MasterGroup.define({
type: "number",
initial_value: 8,
});
MasterGroup.define({
name: "proxy_stream_timeout",
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
+5 -3
View File
@@ -1,11 +1,13 @@
/* eslint-disable global-require */
/**
* Configuration System
* @author Hornwitser
* @module lib/config
*/
"use strict";
const classes = require("./classes");
const definitions = require("./definitions");
module.exports = {
...require("./classes"),
...require("./definitions"),
...classes,
...definitions,
};
+393
View File
@@ -0,0 +1,393 @@
"use strict";
const zlib = require("zlib");
class MapReaderState {
constructor(buf) {
this.pos = 0;
this.buf = buf;
this.last_position = { x: 0, y: 0 };
}
}
function readUInt8(state) {
let value = state.buf.readUInt8(state.pos);
state.pos += 1;
return value;
}
function readBool(state) {
let value = readUInt8(state) !== 0;
return value;
}
function readInt16(state) {
let value = state.buf.readInt16LE(state.pos);
state.pos += 2;
return value;
}
function readUInt16(state) {
let value = state.buf.readUInt16LE(state.pos);
state.pos += 2;
return value;
}
function readInt32(state) {
let value = state.buf.readInt32LE(state.pos);
state.pos += 4;
return value;
}
function readUInt32(state) {
let value = state.buf.readUInt32LE(state.pos);
state.pos += 4;
return value;
}
function readUInt32so(state) {
let value = readUInt8(state);
if (value === 0xff) {
return readUInt32(state);
}
return value;
}
function readFloat(state) {
let value = state.buf.readFloatLE(state.pos);
state.pos += 4;
return value;
}
function readDouble(state) {
let value = state.buf.readDoubleLE(state.pos);
state.pos += 8;
return value;
}
function readString(state) {
let size = readUInt32so(state);
let data = state.buf.slice(state.pos, state.pos + size).toString("utf-8");
state.pos += size;
return data;
}
function readOptional(state, readValue) {
let load = readUInt8(state) !== 0;
if (!load) {
return null;
}
return readValue(state);
}
function readArray(state, readItem) {
let size = readUInt32so(state);
let array = [];
for (let i = 0; i < size; i++) {
let item = readItem(state);
array.push(item);
}
return array;
}
function readDict(state, readKey, readValue) {
let size = readUInt32so(state);
let mapping = new Map();
for (let i = 0; i < size; i++) {
let key = readKey(state);
let value = readValue(state);
mapping.set(key, value);
}
return mapping;
}
function readVersion(state) {
let major = readUInt16(state);
let minor = readUInt16(state);
let patch = readUInt16(state);
let developer = readUInt16(state);
return [major, minor, patch, developer];
}
function readFrequencySizeRichness(state) {
return {
frequency: readFloat(state),
size: readFloat(state),
richness: readFloat(state),
};
}
function readAutoplaceSetting(state) {
return {
treat_missing_as_default: readBool(state),
settings: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
};
}
function readMapPosition(state) {
let x, y;
let x_diff = readInt16(state) / 256;
if (x_diff === 0x7fff / 256) {
x = readInt32(state) / 256;
y = readInt32(state) / 256;
} else {
let y_diff = readInt16(state) / 256;
x = state.last_position.x + x_diff;
y = state.last_position.y + y_diff;
}
state.last_position.x = x;
state.last_position.x = y;
return { x, y };
}
function readBoundingBox(state) {
return {
left_top: readMapPosition(state),
right_bottom: readMapPosition(state),
orientation: {
x: readInt16(state),
y: readInt16(state),
},
};
}
function readCliffSettings(state) {
return {
name: readString(state),
elevation_0: readFloat(state),
elevation_interval: readFloat(state),
richness: readFloat(state),
};
}
function readMapGenSettings(state) {
return {
terrain_segmentation: readFloat(state),
water: readFloat(state),
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
seed: readUInt32(state),
width: readUInt32(state),
height: readUInt32(state),
area_to_generate_at_start: readBoundingBox(state),
starting_area: readFloat(state),
peaceful_mode: readBool(state),
starting_points: readArray(state, readMapPosition),
property_expression_names: Object.fromEntries(readDict(state, readString, readString)),
cliff_settings: readCliffSettings(state),
};
}
function readPollution(state) {
return {
enabled: readOptional(state, readBool),
diffusion_ratio: readOptional(state, readDouble),
min_to_diffuse: readOptional(state, readDouble),
ageing: readOptional(state, readDouble),
expected_max_per_chunk: readOptional(state, readDouble),
min_to_show_per_chunk: readOptional(state, readDouble),
min_pollution_to_damage_trees: readOptional(state, readDouble),
pollution_with_max_forest_damage: readOptional(state, readDouble),
pollution_per_tree_damage: readOptional(state, readDouble),
pollution_restored_per_tree_damage: readOptional(state, readDouble),
max_pollution_to_restore_trees: readOptional(state, readDouble),
enemy_attack_pollution_consumption_modifier: readOptional(state, readDouble),
};
}
function readSteeringValue(state) {
return {
radius: readOptional(state, readDouble),
separation_factor: readOptional(state, readDouble),
separation_force: readOptional(state, readDouble),
force_unit_fuzzy_goto_behavior: readOptional(state, readBool),
};
}
function readSteering(state) {
return {
default: readSteeringValue(state),
moving: readSteeringValue(state),
};
}
function readEnemyEvolution(state) {
return {
enabled: readOptional(state, readBool),
time_factor: readOptional(state, readDouble),
destroy_factor: readOptional(state, readDouble),
pollution_factor: readOptional(state, readDouble),
};
}
function readEnemyExpansion(state) {
return {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
friendly_base_influence_radius: readOptional(state, readUInt32),
enemy_building_influence_radius: readOptional(state, readUInt32),
building_coefficient: readOptional(state, readDouble),
other_base_coefficient: readOptional(state, readDouble),
neighbouring_chunk_coefficient: readOptional(state, readDouble),
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
max_colliding_tiles_coefficient: readOptional(state, readDouble),
settler_group_min_size: readOptional(state, readUInt32),
settler_group_max_size: readOptional(state, readUInt32),
min_expansion_cooldown: readOptional(state, readUInt32),
max_expansion_cooldown: readOptional(state, readUInt32),
};
}
function readUnitGroup(state) {
return {
min_group_gathering_time: readOptional(state, readUInt32),
max_group_gathering_time: readOptional(state, readUInt32),
max_wait_time_for_late_members: readOptional(state, readUInt32),
max_group_radius: readOptional(state, readDouble),
min_group_radius: readOptional(state, readDouble),
max_member_speedup_when_behind: readOptional(state, readDouble),
max_member_slowdown_when_ahead: readOptional(state, readDouble),
max_group_slowdown_factor: readOptional(state, readDouble),
max_group_member_fallback_factor: readOptional(state, readDouble),
member_disown_distance: readOptional(state, readDouble),
tick_tolerance_when_member_arrives: readOptional(state, readUInt32),
max_gathering_unit_groups: readOptional(state, readUInt32),
max_unit_group_size: readOptional(state, readUInt32),
};
}
function readPathFinder(state) {
return {
fwd2bwd_ratio: readOptional(state, readInt32),
goal_pressure_ratio: readOptional(state, readDouble),
use_path_cache: readOptional(state, readBool),
max_steps_worked_per_tick: readOptional(state, readDouble),
max_work_done_per_tick: readOptional(state, readUInt32),
short_cache_size: readOptional(state, readUInt32),
long_cache_size: readOptional(state, readUInt32),
short_cache_min_cacheable_distance: readOptional(state, readDouble),
short_cache_min_algo_steps_to_cache: readOptional(state, readUInt32),
long_cache_min_cacheable_distance: readOptional(state, readDouble),
cache_max_connect_to_cache_steps_multiplier: readOptional(state, readUInt32),
cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
negative_cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
negative_cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
cache_path_start_distance_rating_multiplier: readOptional(state, readDouble),
cache_path_end_distance_rating_multiplier: readOptional(state, readDouble),
stale_enemy_with_same_destination_collision_penalty: readOptional(state, readDouble),
ignore_moving_enemy_collision_distance: readOptional(state, readDouble),
enemy_with_different_destination_collision_penalty: readOptional(state, readDouble),
general_entity_collision_penalty: readOptional(state, readDouble),
general_entity_subsequent_collision_penalty: readOptional(state, readDouble),
extended_collision_penalty: readOptional(state, readDouble),
max_clients_to_accept_any_new_request: readOptional(state, readUInt32),
max_clients_to_accept_short_new_request: readOptional(state, readUInt32),
direct_distance_to_consider_short_request: readOptional(state, readUInt32),
short_request_max_steps: readOptional(state, readUInt32),
short_request_ratio: readOptional(state, readDouble),
min_steps_to_check_path_find_termination: readOptional(state, readUInt32),
start_to_goal_cost_multiplier_to_terminate_path_find: readOptional(state, readDouble),
overload_levels: readOptional(state, (p) => readArray(p, readUInt32)),
overload_multipliers: readOptional(state, (p) => readArray(p, readDouble)),
negative_path_cache_delay_interval: readOptional(state, readUInt32),
};
}
function readDifficultySettings(state) {
return {
recipe_difficulty: readUInt8(state),
technology_difficulty: readUInt8(state),
technology_price_multiplier: readDouble(state),
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)],
};
}
function readMapSettings(state) {
return {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
};
}
/**
* @typedef {object} module:lib/factorio.MapExchangeResult
* @property {array} version -
* Version of Factorio the string was created with.
* @property {object} map_gen_settings -
* Decoded map generator settings in the format the --map-gen-settings
* command line option to Factorio expect.
* @property {object} map_settings -
* Decoded map settings in the format the --map-settings command line option
* to Factorio expects.
* @property {number} checksum - checksum for the exchange string.
*/
/**
* Parse a Map Exchange String
*
* Reads and decodes the data in the given map exchange string and returns
* data structures that can be fed into the Factorio server when creating a
* save to set the map gen settings and the map settings for the save.
*
* @param {string} exchangeString - Max Exchange String to parse.
* @returns {module:lib/factorio.MapExchangeResult} Parsed result.
* @memberof module:lib/factorio
*/
function readMapExchangeString(exchangeString) {
exchangeString = exchangeString.replace(/[ \t\n\r]+/g, "");
if (!/>>>[0-9a-zA-Z\/+]+={0,3}<<</.test(exchangeString)) {
throw new Error("Not a map exchange string");
}
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
} catch (err) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed map exchange string: zlib inflate failed");
}
}
let state = new MapReaderState(buf);
let data;
try {
data = {
version: readVersion(state),
unknown: readUInt8(state),
map_gen_settings: readMapGenSettings(state),
map_settings: readMapSettings(state),
checksum: readUInt32(state),
};
} catch (err) {
if (err.code === "ERR_OUT_OF_RANGE") {
throw new Error("Malformed map exchange string: reached end before finishing parsing");
}
throw err;
}
if (state.pos !== buf.length) {
throw new Error("Malformed map exchange string: data after end");
}
return data;
}
module.exports = {
readMapExchangeString,
};
+41 -2
View File
@@ -90,7 +90,16 @@ async function loadFile(server, modVersions, modPath) {
}
}
let zip = await loadZip(server, modVersions, mod);
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err) {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
let file = zip.file(filePath);
if (!file) {
return null;
@@ -310,7 +319,15 @@ async function exportLocale(server, modVersions, modOrder, languageCode) {
continue;
}
let zip = await loadZip(server, modVersions, mod);
let zip;
try {
zip = await loadZip(server, modVersions, mod);
} catch (err) {
if (err.code === "ENOENT") {
continue;
}
throw err;
}
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(ini.parse(content.toString("utf8")));
@@ -357,6 +374,28 @@ async function exportData(server) {
throw new Error("No items got exported");
}
// Some mod authors put leading zeros into the versions of their zip files.
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
let match = splitter.exec(entry);
if (!match) {
continue;
}
let modVersion = `${match[2]}.${match[3]}.${match[4]}`;
let normalizedVersion =
`${Number.parseInt(match[2], 10)}.${Number.parseInt(match[3], 10)}.${Number.parseInt(match[4], 10)}`
;
if (modVersion === normalizedVersion) {
continue;
}
if (modVersions.get(match[1]) === normalizedVersion) {
modVersions.set(match[1], modVersion);
}
}
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
let locale = await exportLocale(server, modVersions, modOrder, "en");
+9 -4
View File
@@ -1,12 +1,17 @@
/* eslint-disable global-require */
/**
* Library for interfacing with Factorio Servers and saves.
* @module lib/factorio
* @author Hornwitser
*/
"use strict";
const exchange_string = require("./exchange_string");
const export_ = require("./export");
const patch = require("./patch");
const server = require("./server");
module.exports = {
...require("./export"),
...require("./patch"),
...require("./server"),
...exchange_string,
...export_,
...patch,
...server,
};
+39 -4
View File
@@ -375,7 +375,7 @@ const outputHeuristics = [
// Message indicating the server is done starting up
{
filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
filter: { type: "log", message: /^(update|map)Tick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
action: function() {
this._notifyGameReady().catch((err) => { this.emit("error", err); });
},
@@ -605,7 +605,11 @@ class FactorioServer extends events.EventEmitter {
let type = line.slice(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
if (type === "j") {
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
try {
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
} catch (err) {
throw new Error(`Malformed JSON to ${channel}: ${line.slice(channelEnd + 2).toString("utf-8")}`);
}
} else if (type === "f") {
let fileName = line.slice(channelEnd + 2).toString("utf-8");
@@ -829,17 +833,26 @@ class FactorioServer extends events.EventEmitter {
*
* @param {string} name -
* Name of the save to create. Should end with ".zip".
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* Map setting to pass via --map-settings.
*/
async create(name) {
async create(name, seed = null, mapGenSettings = null, mapSettings = null) {
this._check(["init"]);
this._state = "create";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
...(seed !== null ? ["--map-gen-seed", seed] : []),
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
@@ -903,17 +916,26 @@ class FactorioServer extends events.EventEmitter {
* argument to start the given scenario.
*
* @param {string} scenario - Name of the scenario to run.
* @param {?number=} seed - Seed to pass via --map-gen-seed
* @param {?object=} mapGenSettings -
* Map get settings to pass via --map-gen-settings.
* @param {?object=} mapSettings -
* Map setting to pass via --map-settings.
*/
async startScenario(scenario) {
async startScenario(scenario, seed = null, mapGenSettings = null, mapSettings = null) {
this._check(["init"]);
this._state = "running";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
...(seed !== null ? ["--map-gen-seed", seed] : []),
...(mapGenSettings !== null ? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []),
...(mapSettings !== null ? ["--map-settings", this.writePath("map-settings.json")] : []),
"--port", this.gamePort,
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
@@ -1153,6 +1175,19 @@ class FactorioServer extends events.EventEmitter {
});
await fs.outputFile(this.writePath("config.ini"), content);
}
async _writeMapSettings(mapGenSettings, mapSettings) {
if (mapGenSettings) {
await fs.outputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
);
}
if (mapSettings) {
await fs.outputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
);
}
}
}
+38
View File
@@ -32,6 +32,44 @@ async function getNewestFile(directory, filter = (name) => true) {
return newestFile;
}
/**
* Modifies name in case it already exisist at the given directory
*
* Checks the directory passed if it already contains a file or folder with
* the given name, if it does not returns name unmodified, otherwise it
* returns a modified name that does not exist in the directory.
*
* Warning: this function should not be relied upon to be accurate for
* security sensitive applications. The selection process is inherently
* racy and a file or folder may have been created in the folder by the time
* this function returns.
*
* @param {string} directory - directory to check in.
* @param {string} name - file name to check for, may have extension.
* @param {string} extension - dot extension used for the file name.
* @returns {string} modified name with extension that likely does not exist
* in the folder
*/
async function findUnusedName(directory, name, extension = "") {
if (extension && name.endsWith(extension)) {
name = name.slice(0, -extension.length);
}
while (true) {
if (!await fs.pathExists(path.join(directory, `${name}${extension}`))) {
return `${name}${extension}`;
}
let match = /^(.*?)(-(\d+))?$/.exec(name);
if (!match[2]) {
name = `${match[1]}-2`;
} else {
name = `${match[1]}-${Number.parseInt(match[3], 10) + 1}`;
}
}
}
module.exports = {
getNewestFile,
findUnusedName,
};
+3 -2
View File
@@ -1,7 +1,8 @@
/* eslint-disable global-require */
/* eslint-disable node/global-require */
"use strict";
// eslint-disable-next-line no-process-env
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
// eslint-disable-next-line node/no-extraneous-require
module.exports = require("fast-deep-equal/es6");
} else {
module.exports = require("util").isDeepStrictEqual;
+1 -1
View File
@@ -249,7 +249,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
// Open WebSocket to master
logger.verbose(`SOCKET | connecting to ${url}`);
// eslint-disable-next-line no-process-env
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket = new WebSocket(url);
+7 -4
View File
@@ -1,12 +1,15 @@
/* eslint-disable global-require */
/**
* Library for message based communication between nodes
* @module lib/link
* @author Hornwitser
*/
"use strict";
const link = require("./link");
const messages = require("./messages");
const connectors = require("./connectors");
module.exports = {
...require("./link"),
...require("./messages"),
...require("./connectors"),
...link,
...messages,
...connectors,
};
+178 -19
View File
@@ -331,6 +331,14 @@ messages.setMasterConfigProp = new Request({
},
});
let slaveProperties = {
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
};
messages.listSlaves = new Request({
type: "list_slaves",
links: ["control-master"],
@@ -341,18 +349,45 @@ messages.listSlaves = new Request({
items: {
additionalProperties: false,
required: ["agent", "version", "name", "id", "connected"],
properties: {
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
},
properties: slaveProperties,
},
},
},
});
messages.setSlaveSubscriptions = new Request({
type: "set_slave_subscriptions",
links: ["control-master"],
permission: "core.slave.subscribe",
requestProperties: {
"all": { type: "boolean" },
"slave_ids": {
type: "array",
items: { type: "integer" },
},
},
});
let instanceProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]},
};
messages.getInstance = new Request({
type: "get_instance",
links: ["control-master"],
permission: "core.instance.get",
requestProperties: {
"id": { type: "integer" },
},
responseProperties: instanceProperties,
});
messages.listInstances = new Request({
type: "list_instances",
links: ["control-master"],
@@ -363,25 +398,31 @@ messages.listInstances = new Request({
items: {
additionalProperties: false,
required: ["name", "id", "assigned_slave", "status"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
properties: instanceProperties,
},
},
},
});
messages.setInstanceSubscriptions = new Request({
type: "set_instance_subscriptions",
links: ["control-master"],
permission: "core.instance.subscribe",
requestProperties: {
"all": { type: "boolean" },
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.generateSlaveToken = new Request({
type: "generate_slave_token",
links: ["control-master"],
permission: "core.slave.generate_token",
requestProperties: {
"slave_id": { type: "integer" },
"slave_id": { type: ["integer", "null"] },
},
responseProperties: {
"token": { type: "string" },
@@ -467,11 +508,103 @@ messages.startInstance = new Request({
forwardTo: "instance",
});
let saveList = {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["type", "name", "size", "mtime_ms", "loaded"],
properties: {
"type": { enum: ["file", "directory", "special"] },
"name": { type: "string" },
"size": { type: "integer" },
"mtime_ms": { type: "number" },
"loaded": { type: "boolean" },
"default": { type: "boolean" },
},
},
};
messages.listSaves = new Request({
type: "list_saves",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.save.list",
forwardTo: "instance",
responseProperties: {
"list": saveList,
},
});
messages.createSave = new Request({
type: "create_save",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.create_save",
permission: "core.instance.save.create",
forwardTo: "instance",
requestProperties: {
"name": { type: "string" },
"seed": { type: ["integer", "null"] },
"map_gen_settings": { type: ["object", "null"] },
"map_settings": { type: ["object", "null"] },
},
});
messages.deleteSave = new Request({
type: "delete_save",
links: ["control-master", "master-slave"],
permission: "core.instance.save.delete",
forwardTo: "instance",
requestProperties: {
"save": { type: "string" },
},
});
messages.downloadSave = new Request({
type: "download_save",
links: ["control-master"],
permission: "core.instance.save.download",
requestProperties: {
"instance_id": { type: "integer" },
"save": { type: "string" },
},
responseProperties: {
"stream_id": { type: "string" },
},
});
messages.pullSave = new Request({
type: "pull_save",
links: ["master-slave"],
requestProperties: {
"instance_id": { type: "integer" },
"stream_id": { type: "string" },
"filename": { type: "string" },
},
responseProperties: {
"save": { type: "string" },
},
});
messages.pushSave = new Request({
type: "push_save",
links: ["master-slave"],
requestProperties: {
"instance_id": { type: "integer" },
"stream_id": { type: "string" },
"save": { type: "string" },
},
});
messages.setSaveListSubscriptions = new Request({
type: "set_save_list_subscriptions",
links: ["control-master"],
permission: "core.instance.save.list_subscribe",
requestProperties: {
"all": { type: "boolean" },
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.loadScenario = new Request({
@@ -481,6 +614,9 @@ messages.loadScenario = new Request({
forwardTo: "instance",
requestProperties: {
"scenario": { type: "string" },
"seed": { type: ["integer", "null"] },
"map_gen_settings": { type: ["object", "null"] },
"map_settings": { type: ["object", "null"] },
},
});
@@ -747,7 +883,7 @@ messages.updateInstances = new Request({
properties: {
"serialized_config": { type: "object" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
},
},
@@ -962,6 +1098,13 @@ messages.logMessage = new Event({
},
});
messages.slaveUpdate = new Event({
type: "slave_update",
links: ["master-control"],
eventProperties: slaveProperties,
});
messages.instanceInitialized = new Event({
type: "instance_initialized",
links: ["instance-slave"],
@@ -979,11 +1122,27 @@ messages.instanceStatusChanged = new Event({
eventProperties: {
"instance_id": { type: "integer" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
},
});
messages.instanceUpdate = new Event({
type: "instance_update",
links: ["master-control"],
eventProperties: instanceProperties,
});
messages.saveListUpdate = new Event({
type: "save_list_update",
links: ["instance-slave", "slave-master", "master-control"],
forwardTo: "master",
eventProperties: {
"instance_id": { type: "integer" },
"list": saveList,
},
});
messages.masterConnectionEvent = new Event({
type: "master_connection_event",
links: ["slave-instance"],
+4 -3
View File
@@ -1,17 +1,18 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.5",
"repository": "https://github.com/clusterio/factorioClusterio",
"version": "2.0.0-alpha.8",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"keywords": [
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"ajv": "^6.12.6",
"chalk": "^4.1.0",
"fs-extra": "^8.1.0",
"ini": "^1.3.5",
"isomorphic-ws": "^4.0.1",
+38 -10
View File
@@ -191,7 +191,7 @@ class BaseInstancePlugin {
* Plugins must stop sending messages to the master, or are forwarded
* via the master server after the prepare disconnect has been handled.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection to the slave preparing to disconnect.
*/
async onPrepareMasterDisconnect(connection) { }
@@ -226,6 +226,11 @@ class BaseInstancePlugin {
class BaseMasterPlugin {
constructor(info, master, metrics, logger) {
this.info = info;
/**
* Master server
* @type {module:master/src/Master}
*/
this.master = master;
this.metrics = metrics;
@@ -247,15 +252,19 @@ class BaseMasterPlugin {
/**
* Called when the status of an instance changes
*
* Invoked when the master server has received notice from a slave that
* the running status of an instance has changed. The possible statuses
* that can be notified about are:
* - `stopped`: Instance is no longer running.
* Invoked when the master server has changed the status of an instance
* or received notice from a slave that the status of an instance has
* changed. The possible statuses that can be notified about are:
* - `unassigned:`: Instance is no longer asssigned to a slave.
* - `stopped`: Instance is no longer running or was just assigned to a
* slave.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance startup completed and is now running.
* - `stopping`: Instance is in the processing stopping.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting item
* icons and locale data.
* - `deleted`: Instance was deleted.
*
* On master startup all known instances gets the status `unknown` if
* they are assigned to a slave, when the slave then connects to the
@@ -263,15 +272,34 @@ class BaseMasterPlugin {
* is invoked for all those instances. You can detect this situation by
* checking if prev equals `unknown`.
*
* When instances are created on the master they will notify of a status
* change with prev set to null. While the status of new instances is
* in most cases `unassigned` it's possible for the created instance to
* start with any state in some slave connection corner cases.
*
* Note that it's possible for status change notification to get lost in
* the case of network outages if reconnect fails to re-establish the
* session between the master server and the slave.
*
* @param {Object} instance - the instance that changed.
* @param {string} prev - the previous status of the instance.
* @param {?string} prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance, prev) { }
/**
* Called when the value of an instance config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed on an instance.
*
* @param {Object} instance - The instance the config changed on.
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed on.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(instance, group, field, prev) { }
/**
* Called before collecting Prometheus metrics
*
@@ -323,7 +351,7 @@ class BaseMasterPlugin {
*
* Invoked when the connection to the slave has been closed.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, resume and close
*/
@@ -359,7 +387,7 @@ class BaseMasterPlugin {
*
* Invoked when the connection to the control has been closed.
*
* @param {module:master/master~ControlConnection} connection -
* @param {module:master/src/ControlConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, resume, and close.
*/
@@ -375,7 +403,7 @@ class BaseMasterPlugin {
* Plugins must stop sending messages to the slave in question after the
* prepare disconnect has been handled.
*
* @param {module:master/master~SlaveConnection} connection -
* @param {module:master/src/SlaveConnection} connection -
* The connection to the slave preparing to disconnect.
*/
async onPrepareSlaveDisconnect(connection) { }
@@ -404,7 +432,7 @@ class BaseMasterPlugin {
* @param {Object} data - Data ta pass with the event.
*/
broadcastEventToSlaves(event, data={}) {
for (let slaveConnection of this.master.slaveConnections.values()) {
for (let slaveConnection of this.master.wsServer.slaveConnections.values()) {
if (
!slaveConnection.connector.closing
&& (!event.plugin || slaveConnection.plugins.has(event.plugin))
+1 -1
View File
@@ -1,4 +1,4 @@
/* eslint-disable global-require */
/* eslint-disable node/global-require */
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
+1 -1
View File
@@ -55,7 +55,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
let pluginInfo;
try {
// eslint-disable-next-line global-require
// eslint-disable-next-line node/global-require
pluginInfo = require(path.posix.join(pluginPath, "info"));
} catch (err) {
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
+44 -1
View File
@@ -322,6 +322,12 @@ definePermission({
description: "Get the full list of slaves in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.slave.subscribe",
title: "Subscribe to slave updates",
description: "Subscribe to be notified on updates on the details of slaves.",
grantByDefault: true,
});
definePermission({
name: "core.slave.generate_token",
title: "Generate slave token",
@@ -333,12 +339,24 @@ definePermission({
description: "Create slave configs via the master server.",
});
definePermission({
name: "core.instance.get",
title: "Get instance",
description: "Get the details of an instance in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.list",
title: "List instances",
description: "Get the full list of instances in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.subscribe",
title: "Subscribe to instance updates",
description: "Subscribe to be notified on updates on the details and status of instances.",
grantByDefault: true,
});
definePermission({
name: "core.instance.create",
title: "Create instance",
@@ -360,10 +378,35 @@ definePermission({
description: "Assign or reassign instances to a slave.",
});
definePermission({
name: "core.instance.create_save",
name: "core.instance.save.list",
title: "List saves on instance",
description: "List the saves currently on the instance.",
});
definePermission({
name: "core.instance.save.create",
title: "Create new instance save",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.save.delete",
title: "Delete instance save",
description: "Delete savegames on instances.",
});
definePermission({
name: "core.instance.save.upload",
title: "Upload save to instance",
description: "Upload savegames to instances.",
});
definePermission({
name: "core.instance.save.download",
title: "Download save from instance",
description: "Download savegames from instances.",
});
definePermission({
name: "core.instance.save.list_subscribe",
title: "Subscribe to save list updatess",
description: "Subscribe to be notifed on updates to the list of saves of instances.",
});
definePermission({
name: "core.instance.export_data",
title: "Export locale and icons",
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+1 -1
View File
@@ -111,4 +111,4 @@ Runs the master server.
## See Also
[The Clusterio repository](https://github.com/clusterio/factorioClusterio) for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
+119 -1928
View File
File diff suppressed because it is too large. Load diff
+8 -9
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -16,15 +16,13 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.5",
"body-parser": "^1.18.2",
"@clusterio/lib": "^2.0.0-alpha.8",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
"express": "^4.16.2",
"express-fileupload": "1.2.0",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
@@ -35,12 +33,13 @@
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.5",
"@clusterio/web_ui": "^2.0.0-alpha.8",
"fast-deep-equal": "^3.1.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"webpack-cli": "^3.3.12",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-dev-middleware": "^4.1.0",
"webpack-merge": "^5.2.0"
},
+77
View File
@@ -0,0 +1,77 @@
"use strict";
const libErrors = require("@clusterio/lib/errors");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
/**
* Base class for master server connections
*
* @extends module:lib/link.Link
* @alias module:master/src/BaseConnection
*/
class BaseConnection extends libLink.Link {
constructor(target, connector, master) {
/** @member {module:master/src/WsServerConnector} module:master/src/BaseConnection#connector */
super("master", target, connector);
this._master = master;
libLink.attachAllMessages(this);
for (let masterPlugin of this._master.plugins.values()) {
libPlugin.attachPluginMessages(this, masterPlugin);
}
}
async forwardRequestToInstance(message, request) {
return await this._master.forwardRequestToInstance(request, message.data);
}
async forwardEventToInstance(message, event) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) { return; }
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) { return; }
let connection = this._master.wsServer.slaveConnections.get(slaveId);
if (!connection || connection.closing) { return; }
if (event.plugin && !connection.plugins.has(event.plugin)) { return; }
event.send(connection, message.data);
}
async broadcastEventToSlaves(message, event) {
for (let slaveConnection of this._master.wsServer.slaveConnections.values()) {
// Do not broadcast back to the source
if (slaveConnection === this) { continue; }
if (slaveConnection.connector.closing) { continue; }
if (event.plugin && !slaveConnection.plugins.has(event.plugin)) { continue; }
event.send(slaveConnection, message.data);
}
}
async broadcastEventToInstance(message, event) {
await this.broadcastEventToSlaves(message, event);
}
async prepareDisconnectRequestHandler(message, request) {
await libPlugin.invokeHook(this._master.plugins, "onPrepareSlaveDisconnect", this);
this.connector.setClosing();
return await super.prepareDisconnectRequestHandler(message, request);
}
async disconnect(code, reason) {
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
}
}
await this.connector.close(code, reason);
}
}
module.exports = BaseConnection;
+650
View File
@@ -0,0 +1,650 @@
"use strict";
const jwt = require("jsonwebtoken");
const util = require("util");
const winston = require("winston");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libLink = require("@clusterio/lib/link");
const { levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libPlugin = require("@clusterio/lib/plugin");
const libPrometheus = require("@clusterio/lib/prometheus");
const libUsers = require("@clusterio/lib/users");
const BaseConnection = require("./BaseConnection");
const routes = require("./routes");
const lastQueryLogTime = new libPrometheus.Gauge(
"clusterio_master_last_query_log_duration_seconds",
"Time in seconds the last log query took to execute."
);
/**
* Represents the connection to a control link
*
* @extends module:master/src/BaseConnection
* @alias module:master/src/ControlConnection
*/
class ControlConnection extends BaseConnection {
constructor(registerData, connector, master, user) {
super("control", connector, master);
this._agent = registerData.agent;
this._version = registerData.version;
/**
* The user making this connection.
* @type {module:lib/users.User}
*/
this.user = user;
this.slaveSubscriptions = {
all: false,
slave_ids: [],
};
this.instanceSubscriptions = {
all: false,
instance_ids: [],
};
this.saveListSubscriptions = {
all: false,
instance_ids: [],
};
this.logTransport = null;
this.logSubscriptions = {
all: false,
master: false,
slave_ids: [],
instance_ids: [],
};
this.ws_dumper = null;
this.connector.on("connect", () => {
this.connector._socket.clusterio_ignore_dump = Boolean(this.ws_dumper);
});
this.connector.on("close", () => {
if (this.logTransport) {
this.logTransport = null;
logger.remove(this.logTransport);
}
if (this.ws_dumper) {
this._master.debugEvents.off("message", this.ws_dumper);
}
});
for (let event of ["connect", "drop", "resume", "close"]) {
this.connector.on(event, () => {
for (let masterPlugin of this._master.plugins.values()) {
masterPlugin.onControlConnectionEvent(this, event);
}
});
}
}
async getMasterConfigRequestHandler() {
return { serialized_config: this._master.config.serialize("control") };
}
async setMasterConfigFieldRequestHandler(message) {
this._master.config.set(message.data.field, message.data.value, "control");
}
async setMasterConfigPropRequestHandler(message) {
let { field, prop, value } = message.data;
this._master.config.setProp(field, prop, value, "control");
}
async listSlavesRequestHandler(message) {
let list = [];
for (let slave of this._master.slaves.values()) {
list.push({
agent: slave.agent,
version: slave.version,
id: slave.id,
name: slave.name,
connected: this._master.wsServer.slaveConnections.has(slave.id),
});
}
return { list };
}
async setSlaveSubscriptionsRequestHandler(message) {
this.slaveSubscriptions = message.data;
}
slaveUpdated(slave, update) {
if (
this.slaveSubscriptions.all
|| this.slaveSubscriptions.slave_ids.includes(slave.id)
) {
libLink.messages.slaveUpdate.send(this, update);
}
}
generateSlaveToken(slaveId) {
return jwt.sign({ aud: "slave", slave: slaveId }, this._master.config.get("master.auth_secret"));
}
async generateSlaveTokenRequestHandler(message) {
let slaveId = message.data.slave_id;
if (slaveId === null) {
slaveId = Math.random() * 2**31 | 0;
}
return { token: this.generateSlaveToken(slaveId) };
}
async createSlaveConfigRequestHandler(message) {
let slaveConfig = new libConfig.SlaveConfig("control");
await slaveConfig.init();
slaveConfig.set("slave.master_url", this._master.getMasterUrl());
if (message.data.id !== null) {
slaveConfig.set("slave.id", message.data.id);
}
if (message.data.name !== null) {
slaveConfig.set("slave.name", message.data.name);
}
if (message.data.generate_token) {
this.user.checkPermission("core.slave.generate_token");
slaveConfig.set("slave.master_token", this.generateSlaveToken(slaveConfig.get("slave.id")));
}
return { serialized_config: slaveConfig.serialize() };
}
async getInstanceRequestHandler(message) {
let id = message.data.id;
let instance = this._master.instances.get(id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${id} does not exist`);
}
return {
id,
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
status: instance.status,
};
}
async listInstancesRequestHandler(message) {
let list = [];
for (let instance of this._master.instances.values()) {
list.push({
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
status: instance.status,
});
}
return { list };
}
async setInstanceSubscriptionsRequestHandler(message) {
this.instanceSubscriptions = message.data;
}
instanceUpdated(instance) {
if (
this.instanceSubscriptions.all
|| this.instanceSubscriptions.instance_ids.includes(instance.config.get("instance.id"))
) {
libLink.messages.instanceUpdate.send(this, {
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
status: instance.status,
});
}
}
// XXX should probably add a hook for slave reuqests?
async createInstanceRequestHandler(message) {
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(message.data.serialized_config);
let instanceId = instanceConfig.get("instance.id");
if (this._master.instances.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} already exists`);
}
// Add common settings for the Factorio server
let settings = {
"name": `${this._master.config.get("master.name")} - ${instanceConfig.get("instance.name")}`,
"description": `Clusterio instance for ${this._master.config.get("master.name")}`,
"tags": ["clusterio"],
"max_players": 0,
"visibility": { "public": true, "lan": true },
"username": "",
"token": "",
"game_password": "",
"require_user_verification": true,
"max_upload_in_kilobytes_per_second": 0,
"max_upload_slots": 5,
"ignore_player_limit_for_returning_players": false,
"allow_commands": "admins-only",
"autosave_interval": 10,
"autosave_slots": 5,
"afk_autokick_interval": 0,
"auto_pause": false,
"only_admins_can_pause_the_game": true,
"autosave_only_on_server": true,
...instanceConfig.get("factorio.settings"),
};
instanceConfig.set("factorio.settings", settings);
let instance = { config: instanceConfig, status: "unassigned" };
this._master.instances.set(instanceId, instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, null);
this._master.addInstanceHooks(instance);
}
async deleteInstanceRequestHandler(message, request) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
if (instance.config.get("instance.assigned_slave") !== null) {
await this.forwardRequestToInstance(message, request);
}
this._master.instances.delete(message.data.instance_id);
let prev = instance.status;
instance.status = "deleted";
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
}
async getInstanceConfigRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
return {
serialized_config: instance.config.serialize("control"),
};
}
async updateInstanceConfig(instance) {
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId) {
let connection = this._master.wsServer.slaveConnections.get(slaveId);
if (connection) {
await libLink.messages.assignInstance.send(connection, {
instance_id: instance.config.get("instance.id"),
serialized_config: instance.config.serialize("slave"),
});
}
}
}
async setInstanceConfigFieldRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
if (message.data.field === "instance.assigned_slave") {
throw new libErrors.RequestError("instance.assigned_slave must be set through the assign-slave interface");
}
if (message.data.field === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new libErrors.RequestError("Setting instance.id is not supported");
}
instance.config.set(message.data.field, message.data.value, "control");
await this.updateInstanceConfig(instance);
}
async setInstanceConfigPropRequestHandler(message) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let { field, prop, value } = message.data;
instance.config.setProp(field, prop, value, "control");
await this.updateInstanceConfig(instance);
}
async assignInstanceCommandRequestHandler(message, request) {
let { slave_id, instance_id } = message.data;
let instance = this._master.instances.get(instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
// Check if target slave is connected
let newSlaveConnection;
if (slave_id !== null) {
newSlaveConnection = this._master.wsServer.slaveConnections.get(slave_id);
if (!newSlaveConnection) {
// The case of the slave not getting the assign instance message
// still have to be handled, so it's not a requirement that the
// target slave be connected to the master while doing the
// assignment, but it is IMHO a better user experience if this
// is the case.
throw new libErrors.RequestError("Target slave is not connected to the master server");
}
}
// Unassign from currently assigned slave if it is connected.
let currentAssignedSlave = instance.config.get("instance.assigned_slave");
if (currentAssignedSlave !== null && slave_id !== currentAssignedSlave) {
let oldSlaveConnection = this._master.wsServer.slaveConnections.get(currentAssignedSlave);
if (oldSlaveConnection && !oldSlaveConnection.connector.closing) {
await libLink.messages.unassignInstance.send(oldSlaveConnection, { instance_id });
}
}
// Assign to target
instance.config.set("instance.assigned_slave", slave_id);
if (slave_id !== null) {
await libLink.messages.assignInstance.send(newSlaveConnection, {
instance_id,
serialized_config: instance.config.serialize("slave"),
});
}
}
async setSaveListSubscriptionsRequestHandler(message) {
this.saveListSubscriptions = message.data;
}
saveListUpdate(data) {
if (
this.saveListSubscriptions.all
|| this.saveListSubscriptions.instance_ids.includes(data.instance_id)
) {
libLink.messages.saveListUpdate.send(this, data);
}
}
async downloadSaveRequestHandler(message) {
let { instance_id, save } = message.data;
let stream = await routes.createProxyStream(this._master.app);
stream.filename = save;
let ready = new Promise((resolve, reject) => {
stream.events.on("source", resolve);
stream.events.on("timeout", () => reject(
new libErrors.RequestError("Timed out establishing stream from slave")
));
});
ready.catch(() => {});
let result = await this._master.forwardRequestToInstance(libLink.messages.pushSave, {
instance_id,
stream_id: stream.id,
save,
});
await ready;
return { stream_id: stream.id };
}
async setLogSubscriptionsRequestHandler(message) {
this.logSubscriptions = message.data;
this.updateLogSubscriptions();
}
static logFilter({ all, master, slave_ids, instance_ids, max_level }) {
return info => {
// Note: reversed to filter out undefined levels
if (max_level && !(levels[info.level] <= levels[max_level])) {
return false;
}
if (all) {
return true;
}
if (master && info.slave_id === undefined) {
return true;
}
if (info.slave_id && slave_ids.includes(info.slave_id)) {
return true;
}
if (info.instance_id && instance_ids.includes(info.instance_id)) {
return true;
}
return false;
};
}
updateLogSubscriptions() {
let { all, master, slave_ids, instance_ids } = this.logSubscriptions;
if (all || master || slave_ids.length || instance_ids.length) {
if (!this.logTransport) {
this.logTransport = new libLoggingUtils.LinkTransport({ link: this });
this._master.clusterLogger.add(this.logTransport);
}
this.logTransport.filter = this.constructor.logFilter(this.logSubscriptions);
} else if (this.logTransport) {
this._master.clusterLogger.remove(this.logTransport);
this.logTransport = null;
}
}
async queryLogRequestHandler(message) {
let transport = new winston.transports.File({ filename: "cluster.log" });
let query = util.promisify((options, callback) => transport.query(options, callback));
// Available options and defaults inferred from reading the source
// rows: number = limit || 10
// limit: number // alias of rows.
// start: number = 0
// until: Date = now
// from: Date = until - 24h
// order: "asc"|"desc" = "desc"
// fields: Array<string>
// level: string
// This interface is junk:
// - The level option filters by exact match
// - No way to add your own filter
// - No way to stream results
// - The log file is read starting from the beginning
let setDuration = lastQueryLogTime.startTimer();
let log = await query({
order: "asc",
limit: Infinity,
from: new Date(0),
});
log = log.filter(this.constructor.logFilter(message.data));
setDuration();
return { log };
}
async listPermissionsRequestHandler(message) {
let list = [];
for (let permission of libUsers.permissions.values()) {
list.push({
name: permission.name,
title: permission.title,
description: permission.description,
});
}
return { list };
}
async listRolesRequestHandler(message) {
let list = [];
for (let role of this._master.userManager.roles.values()) {
list.push({
id: role.id,
name: role.name,
description: role.description,
permissions: [...role.permissions],
});
}
return { list };
}
async createRoleRequestHandler(message) {
let lastId = Math.max.apply(null, [...this._master.userManager.roles.keys()]);
// Start at 5 to leave space for future default roles
let id = Math.max(5, lastId+1);
this._master.userManager.roles.set(id, new libUsers.Role({ id, ...message.data }));
return { id };
}
async updateRoleRequestHandler(message) {
let { id, name, description, permissions } = message.data;
let role = this._master.userManager.roles.get(id);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${id} does not exist`);
}
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
}
async grantDefaultRolePermissionsRequestHandler(message) {
let role = this._master.userManager.roles.get(message.data.id);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${message.data.id} does not exist`);
}
role.grantDefaultPermissions();
}
async deleteRoleRequestHandler(message) {
let id = message.data.id;
let role = this._master.userManager.roles.get(id);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${id} does not exist`);
}
this._master.userManager.roles.delete(id);
for (let user of this._master.userManager.users.values()) {
user.roles.delete(role);
}
}
async listUsersRequestHandler(message) {
let list = [];
for (let user of this._master.userManager.users.values()) {
list.push({
name: user.name,
roles: [...user.roles].map(role => role.id),
is_admin: user.isAdmin,
is_banned: user.isBanned,
is_whitelisted: user.isWhitelisted,
instances: [...user.instances],
});
}
return { list };
}
async createUserRequestHandler(message) {
this._master.userManager.createUser(message.data.name);
}
async updateUserRolesRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
let resolvedRoles = new Set();
for (let roleId of message.data.roles) {
let role = this._master.userManager.roles.get(roleId);
if (!role) {
throw new libErrors.RequestError(`Role with ID ${roleId} does not exist`);
}
resolvedRoles.add(role);
}
user.roles = resolvedRoles;
}
async setUserAdminRequestHandler(message) {
let { name, create, admin } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isAdmin = admin;
this.broadcastEventToSlaves({ data: { name, admin }}, libLink.messages.adminlistUpdate);
}
async setUserBannedRequestHandler(message) {
let { name, create, banned, reason } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isBanned = banned;
user.banReason = reason;
this.broadcastEventToSlaves({ data: { name, banned, reason }}, libLink.messages.banlistUpdate);
}
async setUserWhitelistedRequestHandler(message) {
let { name, create, whitelisted } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
user = this._master.userManager.createUser(name);
} else {
throw new libErrors.RequestError(`User '${name}' does not exist`);
}
}
user.isWhitelisted = whitelisted;
this.broadcastEventToSlaves({ data: { name, whitelisted }}, libLink.messages.whitelistUpdate);
}
async deleteUserRequestHandler(message) {
let user = this._master.userManager.users.get(message.data.name);
if (!user) {
throw new libErrors.RequestError(`User '${message.data.name}' does not exist`);
}
if (user.is_admin) {
this.broadcastEventToSlaves({ data: { name, admin: false }}, libLink.messages.adminlistUpdate);
}
if (user.is_whitelisted) {
this.broadcastEventToSlaves({ data: { name, whitelisted: false }}, libLink.messages.whitelistUpdate);
}
if (user.is_banned) {
this.broadcastEventToSlaves({ data: { name, banned: false, reason: "" }}, libLink.messages.banlistUpdate);
}
this._master.userManager.users.delete(message.data.name);
}
async debugDumpWsRequestHandler(message) {
this.ws_dumper = data => {
if (this.connector.connected) {
libLink.messages.debugWsMessage.send(this, data);
}
};
this.connector._socket.clusterio_ignore_dump = true;
this._master.debugEvents.on("message", this.ws_dumper);
}
}
module.exports = ControlConnection;
+549
View File
@@ -0,0 +1,549 @@
"use strict";
const compression = require("compression");
const events = require("events");
const express = require("express");
const fs = require("fs-extra");
const http = require("http");
const https = require("https");
const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
const { logger } = require("@clusterio/lib/logging");
const HttpCloser = require("./HttpCloser");
const metrics = require("./metrics");
const routes = require("./routes");
const UserManager = require("./UserManager");
const WsServer = require("./WsServer");
const hitCounter = new libPrometheus.Counter(
"clusterio_master_http_hits_total",
"How many HTTP requests in total have been received"
);
/**
* Manages all master related operations
* @alias module:master/src/Master
*/
class Master {
constructor(clusterLogger, pluginInfos, configPath, config) {
this.clusterLogger = clusterLogger;
/**
* Mapping of plugin name to info objects for known plugins
* @type {Map<string, Object>}
*/
this.pluginInfos = pluginInfos;
this.configPath = configPath;
/**
* Master config.
* @type {module:lib/config.MasterConfig}
*/
this.config = config;
this.app = express();
this.app.locals.master = this;
this.app.locals.streams = new Map();
/**
* Mapping of slave id to slave info
* @type {Map<number, Object>}
*/
this.slaves = null;
/**
* Mapping of instance id to instance info
* @type {Map<number, Object>}
*/
this.instances = null;
/**
* User and roles manager for the cluster
* @type {module:master/src/UserManager}
*/
this.userManager = null;
this.httpServer = null;
this.httpServerCloser = null;
this.httpsServer = null;
this.httpsServerCloser = null;
/**
* Mapping of plugin name to loaded plugin
* @type {Map<string, module:lib/plugin.BaseMasterPlugin>}
*/
this.plugins = new Map();
/**
* WebSocket server
* @type {module:master/src/WsServer}
*/
this.wsServer = new WsServer(this);
this.debugEvents = new events.EventEmitter();
this._events = new events.EventEmitter();
// Possible states are new, starting, running, stopping, stopped
this._state = "new";
this._shouldStop = false;
this.devMiddleware = null;
}
async start(args) {
if (!this._state === "new") {
throw new Error(`Cannot start in state ${this._state}`);
}
this._state = "starting";
try {
await this._startInternal(args);
} catch (err) {
await this._stopInternal();
this._state = "stopped";
this._events.emit("stop");
throw err;
}
if (this._shouldStop) {
this.stop();
}
}
async _startInternal(args) {
// Start webpack development server if enabled
if (args.dev || args.devPlugin) {
logger.warn("Webpack development mode enabled");
/* eslint-disable node/global-require, node/no-unpublished-require */
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfigs = [];
if (args.dev) {
webpackConfigs.push(require("../webpack.config")({}));
}
if (args.devPlugin) {
for (let name of args.devPlugin) {
let info = this.pluginInfos.find(i => i.name === name);
if (!info) {
throw new libErrors.StartupError(`No plugin named ${name}`);
}
let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
config.output.publicPath = `/plugins/${name}/`;
webpackConfigs.push(config);
}
}
/* eslint-enable node/global-require, node/no-unpublished-require */
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, {});
this.app.use(this.devMiddleware);
}
let databaseDirectory = this.config.get("master.database_directory");
await fs.ensureDir(databaseDirectory);
this.slaves = await Master.loadSlaves(path.join(databaseDirectory, "slaves.json"));
this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json"));
this.userManager = new UserManager(this.config);
await this.userManager.load(path.join(databaseDirectory, "users.json"));
for (let instance of this.instances.values()) {
this.addInstanceHooks(instance);
}
// Make sure we're actually going to listen on a port
let httpPort = this.config.get("master.http_port");
let httpsPort = this.config.get("master.https_port");
let bindAddress = this.config.get("master.bind_address") || "";
if (!httpPort && !httpsPort) {
logger.fatal("Error: at least one of http_port and https_port must be configured");
process.exitCode = 1;
return;
}
let tls_cert = this.config.get("master.tls_certificate");
let tls_key = this.config.get("master.tls_private_key");
if (httpsPort && (!tls_cert || !tls_key)) {
throw new libErrors.StartupError(
"tls_certificate and tls_private_key must be configure in order to use https_port"
);
}
Master.addAppRoutes(this.app, this.pluginInfos);
// Load plugins
await this.loadPlugins();
this.wsServer = new WsServer(this);
// Only start listening for connections after all plugins have loaded
if (httpPort) {
this.httpServer = http.createServer(this.app);
this.httpServerCloser = new HttpCloser(this.httpServer);
await this.listen(this.httpServer, httpPort, bindAddress);
logger.info(`Listening for HTTP on port ${this.httpServer.address().port}`);
}
if (httpsPort) {
let certificate, privateKey;
try {
certificate = await fs.readFile(tls_cert);
privateKey = await fs.readFile(tls_key);
} catch (err) {
throw new libErrors.StartupError(
`Error loading ssl certificate: ${err.message}`
);
}
this.httpsServer = https.createServer({
key: privateKey,
cert: certificate,
}, this.app);
this.httpsServerCloser = new HttpCloser(this.httpsServer);
await this.listen(this.httpsServer, httpsPort, bindAddress);
logger.info(`Listening for HTTPS on port ${this.httpsServer.address().port}`);
}
logger.info("Started master");
this._state = "running";
}
/**
* Stops the master server.
*
* Save data and bring down the active connections. This is the reverse
* of start and is valid at any point in time.
*/
async stop() {
if (this._state === "starting") {
this._shouldStop = true;
await events.once(this._events, "stop");
return;
} else if (this._state === "running") {
await this._stopInternal();
}
this._state = "stopped";
this._events.emit("stop");
}
async _stopInternal() {
// This function should never throw.
this._state = "stopping";
logger.info("Stopping master");
logger.info("Saving config");
await fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
}
let databaseDirectory = this.config.get("master.database_directory");
if (this.slaves) {
await Master.saveSlaves(path.join(databaseDirectory, "slaves.json"), this.slaves);
}
if (this.instances) {
await Master.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances);
}
if (this.userManager) {
await this.userManager.save(path.join(databaseDirectory, "users.json"));
}
await libPlugin.invokeHook(this.plugins, "onShutdown");
if (this.wsServer) {
await this.wsServer.stop();
}
let stopTasks = [];
logger.info("Stopping HTTP(S) server");
if (this.httpServer && this.httpServer.listening) { stopTasks.push(this.httpServerCloser.close()); }
if (this.httpsServer && this.httpsServer.listening) { stopTasks.push(this.httpsServerCloser.close()); }
await Promise.all(stopTasks);
logger.info("Goodbye");
}
static async loadSlaves(filePath) {
let serialized;
try {
serialized = JSON.parse(await fs.readFile(filePath));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
// TODO: Remove after release.
if (serialized.length && !(serialized[0] instanceof Array)) {
return new Map(); // Discard old format.
}
return new Map(serialized);
}
static async saveSlaves(filePath, slaves) {
await fs.outputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
}
static async loadInstances(filePath) {
logger.info(`Loading ${filePath}`);
let instances = new Map();
try {
let serialized = JSON.parse(await fs.readFile(filePath));
for (let serializedConfig of serialized) {
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(serializedConfig);
let status = instanceConfig.get("instance.assigned_slave") === null ? "unassigned" : "unknown";
let instance = { config: instanceConfig, status };
instances.set(instanceConfig.get("instance.id"), instance);
}
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
return instances;
}
static async saveInstances(filePath, instances) {
let serialized = [];
for (let instance of instances.values()) {
serialized.push(instance.config.serialize());
}
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
}
static addAppRoutes(app, pluginInfos) {
app.use((req, res, next) => {
hitCounter.inc();
next();
});
app.use(compression());
// Set folder to serve static content from (the website)
app.use(express.static(path.join(__dirname, "..", "dist", "web")));
app.use(express.static("static")); // Used for data export files
// Add API routes
routes.addRouteHandlers(app);
// Add routes for the web interface
for (let route of routes.webRoutes) {
app.get(route, Master.serveWeb(route));
}
for (let pluginInfo of pluginInfos) {
for (let route of pluginInfo.routes || []) {
app.get(route, Master.serveWeb(route));
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web");
app.use(`/plugins/${pluginInfo.name}`, express.static(webPath));
}
}
slaveUpdated(slave) {
let update = {
agent: slave.agent,
version: slave.version,
id: slave.id,
name: slave.name,
connected: this.wsServer.slaveConnections.has(slave.id),
};
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.slaveUpdated(slave, update);
}
}
addInstanceHooks(instance) {
instance.config.on("fieldChanged", (group, field, prev) => {
if (group.name === "instance" && field === "name") {
this.instanceUpdated(instance);
}
libPlugin.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, group, field, prev);
});
this.instanceUpdated(instance);
}
instanceUpdated(instance) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.instanceUpdated(instance);
}
}
saveListUpdate(data) {
for (let controlConnection of this.wsServer.controlConnections) {
if (controlConnection.connector.closing) {
continue;
}
controlConnection.saveListUpdate(data);
}
}
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
continue;
}
let MasterPluginClass = libPlugin.BaseMasterPlugin;
try {
if (pluginInfo.masterEntrypoint) {
MasterPluginClass = await libPluginLoader.loadMasterPluginClass(pluginInfo);
}
let masterPlugin = new MasterPluginClass(pluginInfo, this, metrics, logger);
await masterPlugin.init();
this.plugins.set(pluginInfo.name, masterPlugin);
} catch (err) {
throw new libErrors.PluginError(pluginInfo.name, err);
}
logger.info(`Loaded plugin ${pluginInfo.name}`);
}
}
/**
* Calls listen on server capturing any errors that occurs
* binding to the port. Also adds handler for WebSocket
* upgrade event.
*
* @param {module:net.Server} server - Server to start the listening on.
* @param {*} args - Arguments to the .listen() call on the server.
* @returns {Promise} promise that resolves the server is listening.
*/
listen(server, ...args) {
return new Promise((resolve, reject) => {
server.on("upgrade", (req, socket, head) => {
logger.verbose("handling WebSocket upgrade");
this.wsServer.handleUpgrade(req, socket, head);
});
function wrapError(err) {
reject(new libErrors.StartupError(
`Server listening failed: ${err.message}`
));
}
server.once("error", wrapError);
server.listen(...args, () => {
server.off("error", wrapError);
resolve();
});
});
}
/**
* Returns the URL needed to connect to the master server.
*
* @returns {string} master URL.
*/
getMasterUrl() {
return Master.calculateMasterUrl(this.config);
}
static calculateMasterUrl(config) {
let url = config.get("master.external_address");
if (!url) {
if (config.get("master.https_port")) {
url = `https://localhost:${config.get("master.https_port")}/`;
} else {
url = `http://localhost:${config.get("master.http_port")}/`;
}
}
return url;
}
/**
* Servers the web interface with the root path set apropriately
*
* @param {string} route - route the interface is served under.
* @returns {function} Experess.js route handler.
*/
static serveWeb(route) {
// The depth is is the number of slashes in the route minus one, but due
// to lenient matching on trailing slashes in the route we need to
// compensate if the request path contains a slash but not the route,
// and vice versa.
let routeDepth = (route.match(/\//g) || []).length - 1 - (route.slice(-1) === "/");
return function(req, res, next) {
let depth = routeDepth + (req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
fs.readFile(path.join(__dirname, "..", "web", "index.html"), "utf8").then((content) => {
res.type("text/html");
res.send(content.replace(/__WEB_ROOT__/g, webRoot));
}).catch(err => {
next(err);
});
};
}
/**
* Forward the given request to the slave contaning the instance
*
* Finds the slave which the instance with the given instance ID is
* currently assigned to and forwards the request to that instance.
* The request data must have an instance_id parameter.
*
* @param {module:lib/link.Request} request - The request to send.
* @param {object} data - Data to pass with the request.
* @param {number} data.instance_id -
* ID of instance to send this request towards
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.instances.get(data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`);
}
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) {
throw new libErrors.RequestError("Instance is not assigned to a slave");
}
let connection = this.wsServer.slaveConnections.get(slaveId);
if (!connection) {
throw new libErrors.RequestError("Slave containing instance is not connected");
}
if (request.plugin && !connection.plugins.has(request.plugin)) {
throw new libErrors.RequestError(`Slave containing instance does not have ${request.plugin} plugin`);
}
return await request.send(connection, data);
}
}
module.exports = Master;
+179
View File
@@ -0,0 +1,179 @@
"use strict";
const libConfig = require("@clusterio/lib/config");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
const libPlugin = require("@clusterio/lib/plugin");
const BaseConnection = require("./BaseConnection");
/**
* Represents the connection to a slave
*
* @extends module:master/src/BaseConnection
* @alias module:master/src/SlaveConnection
*/
class SlaveConnection extends BaseConnection {
constructor(registerData, connector, master) {
super("slave", connector, master);
this._agent = registerData.agent;
this._id = registerData.id;
this._name = registerData.name;
this._version = registerData.version;
this.plugins = new Map(Object.entries(registerData.plugins));
this._master.slaves.set(this._id, {
agent: this._agent,
id: this._id,
name: this._name,
version: this._version,
plugins: registerData.plugins,
});
for (let event of ["connect", "drop", "resume", "close"]) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._master.plugins.values()) {
plugin.onSlaveConnectionEvent(this, event);
}
});
}
}
/**
* ID of the slave this connection is connected to
*
* @returns {number} slave ID.
*/
get id() {
return this._id;
}
async instanceStatusChangedEventHandler(message, event) {
let instance = this._master.instances.get(message.data.instance_id);
// It's possible to get updates from an instance that does not exist
// or is not assigned to the slave it originated from if it was
// reassigned or deleted while the connection to the slave it was
// originally on was down at the time.
if (!instance || instance.config.get("instance.assigned_slave") !== this._id) {
logger.warn(`Got bogus update for instance id ${message.data.instance_id}`);
return;
}
// We may receive status changed where the status hasn't changed
// from our perspective if the connection was down at the time it
// changed. Slaves also send status updates on assignInstance which
// for hacky reason is also used to push config changes and
// restablish status after a connection loss.
if (instance.status === message.data.status) {
return;
}
let prev = instance.status;
instance.status = message.data.status;
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
}
async updateInstancesRequestHandler(message) {
// Push updated instance configs
for (let instance of this._master.instances.values()) {
if (instance.config.get("instance.assigned_slave") === this._id) {
await libLink.messages.assignInstance.send(this, {
instance_id: instance.config.get("instance.id"),
serialized_config: instance.config.serialize("slave"),
});
}
}
// Assign instances the slave has but master does not
for (let instanceData of message.data.instances) {
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(instanceData.serialized_config, "slave");
let masterInstance = this._master.instances.get(instanceConfig.get("instance.id"));
if (masterInstance) {
// Check if this instance is assigned somewhere else.
if (masterInstance.config.get("instance.assigned_slave") !== this._id) {
await libLink.messages.unassignInstance.send(this, {
instance_id: masterInstance.config.get("instance.id"),
});
continue;
}
// Already have this instance, update state instead
if (masterInstance.status !== instanceData.status) {
let prev = masterInstance.status;
masterInstance.status = instanceData.status;
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", masterInstance, prev);
}
continue;
}
instanceConfig.set("instance.assigned_slave", this._id);
let newInstance = { config: instanceConfig, status: instanceData.status };
this._master.instances.set(instanceConfig.get("instance.id"), newInstance);
this._master.addInstanceHooks(newInstance);
await libLink.messages.assignInstance.send(this, {
instance_id: instanceConfig.get("instance.id"),
serialized_config: instanceConfig.serialize("slave"),
});
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", newInstance, null);
}
// Push lists to make sure they are in sync.
let adminlist = [];
let banlist = [];
let whitelist = [];
for (let user of this._master.userManager.users.values()) {
if (user.isAdmin) {
adminlist.push(user.name);
}
if (user.isBanned) {
banlist.push([user.name, user.banReason]);
}
if (user.isWhitelisted) {
whitelist.push(user.name);
}
}
libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist });
}
async saveListUpdateEventHandler(message) {
this._master.saveListUpdate(message.data);
}
async logMessageEventHandler(message) {
this._master.clusterLogger.log({
...message.data.info,
slave_id: this._id,
slave_name: this._name,
});
}
async playerEventEventHandler(message) {
let { instance_id, name, type } = message.data;
let user = this._master.userManager.users.get(name);
if (!user) {
user = this._master.userManager.createUser(name);
}
if (type === "join") {
user.notifyJoin(instance_id);
} else if (type === "leave") {
user.notifyLeave(instance_id);
}
let instance = this._master.instances.get(instance_id);
await libPlugin.invokeHook(this._master.plugins, "onPlayerEvent", instance, message.data);
}
}
module.exports = SlaveConnection;
+88
View File
@@ -0,0 +1,88 @@
"use strict";
const fs = require("fs-extra");
const libUsers = require("@clusterio/lib/users");
/**
* Manages users and roles
* @alias module:master/src/UserManager
*/
class UserManager {
constructor(config) {
this._config = config;
this.roles = null;
this.users = null;
}
async load(filePath) {
let loadedRoles = new Map();
let loadedUsers = new Map();
try {
let content = JSON.parse(await fs.readFile(filePath));
for (let serializedRole of content.roles) {
let role = new libUsers.Role(serializedRole);
loadedRoles.set(role.id, role);
}
for (let serializedUser of content.users) {
let user = new libUsers.User(serializedUser, loadedRoles);
loadedUsers.set(user.name, user);
}
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
// Create default roles if loading failed
libUsers.ensureDefaultAdminRole(loadedRoles);
libUsers.ensureDefaultPlayerRole(loadedRoles);
}
this.roles = loadedRoles;
this.users = loadedUsers;
}
async save(filePath) {
if (!this.roles || !this.users) {
return;
}
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.serialize());
}
let serializedUsers = [];
for (let user of this.users.values()) {
serializedUsers.push(user.serialize());
}
let serialized = {
users: serializedUsers,
roles: serializedRoles,
};
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
}
/**
* Create a new user
*
* Creates a new user and add it to the user database.
*
* @param {string} name - Name of the user to create.
* @returns {module:lib/users.User} newly created user.
*/
createUser(name) {
if (this.users.has(name)) {
throw new Error(`User '${name}' already exists`);
}
let defaultRoleId = this._config.get("master.default_role_id");
let user = new libUsers.User({ name, roles: [defaultRoleId] }, this.roles);
this.users.set(name, user);
return user;
}
}
module.exports = UserManager;
+304
View File
@@ -0,0 +1,304 @@
"use strict";
const jwt = require("jsonwebtoken");
const WebSocket = require("ws");
const { logger } = require("@clusterio/lib/logging");
const libPrometheus = require("@clusterio/lib/prometheus");
const libSchema = require("@clusterio/lib/schema");
const ControlConnection = require("./ControlConnection");
const packageVersion = require("../package").version;
const SlaveConnection = require("./SlaveConnection");
const WsServerConnector = require("./WsServerConnector");
const wsMessageCounter = new libPrometheus.Counter(
"clusterio_master_websocket_message_total",
"How many messages have been received over WebSocket on the master server",
{ labels: ["direction"] }
);
const wsConnectionsCounter = new libPrometheus.Counter(
"clusterio_master_websocket_connections_total",
"How many WebSocket connections have been initiated on the master server"
);
const wsRejectedConnectionsCounter = new libPrometheus.Counter(
"clusterio_master_websocket_rejected_connections_total",
"How many WebSocket connections have been rejected during the handshake on the master server"
);
/**
* WebSocket server
* @alias module:master/src/WsServer
*/
class WsServer {
constructor(master) {
this.master = master;
this.stopAcceptingNewSessions = false;
/** @type {Array<module:master/src/ControlConnection>} */
this.controlConnections = [];
/** @type {Map<number, module:master/src/SlaveConnection>} */
this.slaveConnections = new Map();
/** @type {Map<number, module:master/src/WsServerConnector>} */
this.activeConnectors = new Map();
/** @type {Set<module:ws>} */
this.pendingSockets = new Set();
// Unique string for the session token audience
this.sessionAud = `session-${Date.now()}`;
this.nextSessionId = 1;
/** @type {module:ws.Server} */
this.wss = new WebSocket.Server({
noServer: true,
path: "/api/socket",
});
this.wss.on("connection", (socket, req) => this.handleConnection(socket, req));
}
async stop() {
this.stopAcceptingNewSessions = true;
let disconnectTasks = [];
for (let controlConnection of this.controlConnections) {
controlConnection.connector.setTimeout(this.master.config.get("master.connector_shutdown_timeout"));
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
}
for (let slaveConnection of this.slaveConnections.values()) {
slaveConnection.connector.setTimeout(this.master.config.get("master.connector_shutdown_timeout"));
disconnectTasks.push(slaveConnection.disconnect(1001, "Server Quit"));
}
logger.info(`WsServer | Waiting for ${disconnectTasks.length} connectors to close`);
for (let task of disconnectTasks) {
try {
await task;
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
logger.warn(`Unexpected error disconnecting connector:\n${err.stack}`);
}
}
}
for (let socket of this.pendingSockets) {
socket.close(1001, "Server Quit");
}
}
handleUpgrade(req, socket, head) {
// For reasons that defy common sense, the connection event has
// to be emitted explictly when using noServer.
this.wss.handleUpgrade(req, socket, head, (ws) => {
this.wss.emit("connection", ws, req);
});
}
handleConnection(socket, req) {
logger.verbose(`WsServer | new connection from ${req.socket.remoteAddress}`);
// Track statistics
wsConnectionsCounter.inc();
socket.on("message", (message) => {
wsMessageCounter.labels("in").inc();
if (!socket.clusterio_ignore_dump) {
this.master.debugEvents.emit("message", { direction: "in", content: message });
}
});
let originalSend = socket.send;
socket.send = (...args) => {
wsMessageCounter.labels("out").inc();
if (typeof args[0] === "string" && !socket.clusterio_ignore_dump) {
this.master.debugEvents.emit("message", { direction: "out", content: args[0] });
}
return originalSend.call(socket, ...args);
};
// Start connection handshake.
let loadedPlugins = {};
for (let [name, plugin] of this.master.plugins) {
loadedPlugins[name] = plugin.info.version;
}
socket.send(JSON.stringify({ seq: null, type: "hello", data: {
version: packageVersion,
plugins: loadedPlugins,
}}));
this.attachHandler(socket, req);
}
attachHandler(socket, req) {
this.pendingSockets.add(socket);
let timeoutId = setTimeout(() => {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1008, "Handshake timeout");
this.pendingSockets.delete(socket);
}, 30*1000);
socket.once("message", (message) => {
clearTimeout(timeoutId);
this.pendingSockets.delete(socket);
this.handleHandshake(
message, socket, req
).catch(err => {
logger.error(`
+------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
});
});
}
async handleHandshake(message, socket, req) {
try {
message = JSON.parse(message);
} catch (err) {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid JSON`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Invalid JSON");
return;
}
if (!libSchema.clientHandshake(message)) {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress} after receiving invalid handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Bad handshake");
return;
}
let { seq, type, data } = message;
if (type === "resume") {
let connector;
try {
let payload = jwt.verify(
data.session_token,
this.master.config.get("master.auth_secret"),
{ audience: this.sessionAud }
);
connector = this.activeConnectors.get(payload.sid);
if (!connector) {
throw new Error();
}
} catch (err) {
socket.send(JSON.stringify({ seq: null, type: "invalidate", data: {}}));
this.attachHandler(socket, req);
return;
}
connector.continue(socket, data.last_seq);
return;
}
if (this.stopAcceptingNewSessions) {
logger.verbose(`WsServer | closing ${req.socket.remoteAddress}, server is shutting down`);
wsRejectedConnectionsCounter.inc();
socket.close(1001, "Shutting down");
return;
}
// Validate token
let user;
try {
if (type === "register_slave") {
let tokenPayload = jwt.verify(
data.token,
this.master.config.get("master.auth_secret"),
{ audience: "slave" }
);
if (tokenPayload.slave !== data.id) {
throw new Error("missmatched slave id");
}
} else if (type === "register_control") {
let tokenPayload = jwt.verify(
data.token,
this.master.config.get("master.auth_secret"),
{ audience: "user" }
);
user = this.master.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
user.checkPermission("core.control.connect");
}
} catch (err) {
logger.verbose(`WsServer | authentication failed for ${req.socket.remoteAddress}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
return;
}
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId }, this.master.config.get("master.auth_secret")
);
let heartbeatInterval = this.master.config.get("master.heartbeat_interval");
let connector = new WsServerConnector(socket, sessionId, heartbeatInterval);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(connector);
});
let additionalReadyData = {};
if (type === "register_slave") {
let connection = this.slaveConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for slave ${data.id}`);
connection.connector.setTimeout(15); // Slave connection is likely stalled
await connection.disconnect(1008, "Registered from another connection");
}
logger.verbose(`WsServer | registered slave ${data.id} version ${data.version}`);
connection = new SlaveConnection(data, connector, this.master);
connector.on("close", () => {
if (this.slaveConnections.get(data.id) === connection) {
this.slaveConnections.delete(data.id);
this.master.slaveUpdated(this.master.slaves.get(data.id));
}
});
this.slaveConnections.set(data.id, connection);
this.master.slaveUpdated(this.master.slaves.get(data.id));
} else if (type === "register_control") {
logger.verbose(`WsServer | registered control from ${req.socket.remoteAddress}`);
let connection = new ControlConnection(data, connector, this.master, user);
connector.on("close", () => {
let index = this.controlConnections.indexOf(this);
if (index !== -1) {
this.controlConnections.splice(index, 1);
}
});
this.controlConnections.push(connection);
additionalReadyData["account"] = {
name: user.name,
};
}
connector.ready(sessionToken, additionalReadyData);
}
}
module.exports = WsServer;
+218
View File
@@ -0,0 +1,218 @@
"use strict";
const events = require("events");
const libLink = require("@clusterio/lib/link");
const { logger } = require("@clusterio/lib/logging");
/**
* Connector for master server connections
*
* @extends module:lib/link.WebSocketBaseConnector
* @alias module:master/src/WsServerConnector
*/
class WsServerConnector extends libLink.WebSocketBaseConnector {
constructor(socket, sessionId, heartbeatInterval) {
super();
this._socket = socket;
this._sessionId = sessionId;
this._heartbeatInterval = heartbeatInterval;
// The following states are used in the server connector
// handshake: Waiting for client to (re)connect.
// connected: Connection is online
// closing: Connection is in the process of being closed.
// closed: Connection has been closed
this._state = "handshake";
this._timeout = 15 * 60;
}
_check(expectedStates) {
if (!expectedStates.includes(this._state)) {
throw new Error(
`Expected state ${expectedStates} but state is ${this._state}`
);
}
}
/**
* Send ready over the socket
*
* Sends the ready message over the socket to initiate the session.
*
* @param {string} sessionToken -
* the session token to send to the client.
* @param {Object=} additionalData -
* extra properties to send along the ready message.
*/
ready(sessionToken, additionalData) {
this._socket.send(JSON.stringify({
seq: null,
type: "ready",
data: {
session_token: sessionToken,
heartbeat_interval: this._heartbeatInterval,
...additionalData,
},
}));
this._state = "connected";
this._attachSocketHandlers();
this.emit("connect");
}
/**
* Continue connection with the given socket
*
* Terminates the current socket and contiunes the session over the
* socket given from the message sequence given.
*
* @param {module:net.Socket} socket - New socket to continue on.
* @param {number} lastSeq - The last message the client received.
*/
continue(socket, lastSeq) {
// It's possible the previous connection hasn't closed yet due to a
// stale connection. If this is the case the connector will have to
// wait for the previous socket to close down before it can continue
// from a new socket, or there will be overlapping events from both.
if (["connected", "closing"].includes(this._state)) {
this._socket.close(4003, "Session Hijacked");
// Correctly waiting for the connector to close and then
// handling all the edge cases like multiple continues happening
// in parallel or connections going stale is far too difficult.
// Kill the connection here and let the client reconnect later
// when the connector is not held up by an active connection.
socket.close(1013, "Session Busy");
socket.terminate();
return;
}
this._socket = socket;
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
this._socket.send(JSON.stringify({
seq: null,
type: "continue",
data: {
last_seq: this._lastReceivedSeq,
heartbeat_interval: this._heartbeatInterval,
},
}));
this._state = "connected";
this._attachSocketHandlers();
this._dropSendBufferSeq(lastSeq);
for (let message of this._sendBuffer) {
this._socket.send(JSON.stringify(message));
}
this.emit("resume");
}
setTimeout(timeout) {
if (this._timeoutId) {
clearTimeout(this._timeoutId);
}
this._timeoutId = setTimeout(() => { this._timedOut(); }, timeout * 1000);
this._timeout = timeout;
}
_timedOut() {
logger.verbose("SOCKET | Connection timed out");
this._timeoutId = null;
this._lastReceivedSeq = null;
this._sendBuffer.length = 0;
this._state = "closed";
this.emit("close");
this.emit("invalidate");
}
_attachSocketHandlers() {
this.startHeartbeat();
this._socket.on("close", (code, reason) => {
logger.verbose(`SOCKET | Close (code: ${code}, reason: ${reason})`);
if (this._state === "closing") {
this._lastReceivedSeq = null;
this._sendBuffer.length = 0;
this._state = "closed";
this.emit("close");
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
} else {
this._state = "handshake";
this.emit("drop");
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._timeout * 1000);
}
this.stopHeartbeat();
});
this._socket.on("error", err => {
// It's assumed that close is always called by ws
logger.verbose("SOCKET | Error:", err);
});
this._socket.on("open", () => {
logger.verbose("SOCKET | Open");
});
this._socket.on("ping", data => {
logger.verbose(`SOCKET | Ping (data: ${data}`);
});
this._socket.on("pong", data => {
logger.verbose(`SOCKET | Pong (data: ${data}`);
});
// Handle messages
this._socket.on("message", data => {
let message = JSON.parse(data);
if (["connected", "closing"].includes(this._state)) {
if (message.seq !== null) {
this._lastReceivedSeq = message.seq;
}
if (message.type === "heartbeat") {
this._processHeartbeat(message);
} else {
this.emit("message", message);
}
} else {
throw new Error(`Received message in unexpected state ${this._state}`);
}
});
}
/**
* Close the connection with the given reason.
*
* Sends a close frame and disconnects the connector.
*
* @param {number} code - WebSocket close code.
* @param {string} reason - WebSocket close reason.
*/
async close(code, reason) {
if (this._state === "closed") {
return;
}
this.stopHeartbeat();
this._state = "closing";
this._socket.close(code, reason);
await events.once(this, "close");
}
}
module.exports = WsServerConnector;
+9
View File
@@ -0,0 +1,9 @@
// Shared metrics
"use strict";
const libPrometheus = require("@clusterio/lib/prometheus");
exports.endpointHitCounter = new libPrometheus.Counter(
"clusterio_master_http_endpoint_hits_total",
"How many requests a particular HTTP endpoint has gotten",
{ labels: ["route"] }
);
+417 -1
View File
@@ -1,6 +1,416 @@
"use strict";
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const jwt = require("jsonwebtoken");
const path = require("path");
const util = require("util");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPrometheus = require("@clusterio/lib/prometheus");
const { logger } = require("@clusterio/lib/logging");
const { endpointHitCounter } = require("./metrics");
// Merges samples from sourceResult to destinationResult
function mergeSamples(destinationResult, sourceResult) {
let receivedSamples = new Map(sourceResult.samples);
for (let [suffix, suffixSamples] of destinationResult.samples) {
if (receivedSamples.has(suffix)) {
suffixSamples.push(...receivedSamples.get(suffix));
receivedSamples.delete(suffix);
}
}
for (let entry of receivedSamples) {
sourceResult.samples.push(entry);
}
}
// Prometheus polling endpoint
async function getMetrics(req, res, next) {
endpointHitCounter.labels(req.route.path).inc();
let results = [];
let pluginResults = await libPlugin.invokeHook(req.app.locals.master.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
}
}
let requests = [];
let timeout = req.app.locals.master.config.get("master.metrics_timeout") * 1000;
for (let slaveConnection of req.app.locals.master.wsServer.slaveConnections.values()) {
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), timeout, null));
}
for await (let result of await libPrometheus.defaultRegistry.collect()) {
results.push(result);
}
let resultMap = new Map();
for (let response of await Promise.all(requests)) {
if (!response) {
// TODO: Log timeout occured?
continue;
}
for (let result of response.results) {
if (!resultMap.has(result.metric.name)) {
resultMap.set(result.metric.name, result);
} else {
// Merge metrics received by multiple slaves
mergeSamples(resultMap.get(result.metric.name), result);
}
}
}
for (let result of resultMap.values()) {
results.push(libPrometheus.deserializeResult(result));
}
let text = await libPrometheus.exposition(results);
res.set("Content-Type", libPrometheus.exposition.contentType);
res.send(text);
}
function getPlugins(req, res) {
let plugins = [];
for (let pluginInfo of req.app.locals.master.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.master.plugins.has(name);
let enabled = loaded && req.app.locals.master.config.group(pluginInfo.name).get("load_plugin");
plugins.push({ name, version: pluginInfo.version, enabled, loaded });
}
res.send(plugins);
}
function validateSlaveToken(req, res, next) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
jwt.verify(token, req.app.locals.master.config.get("master.auth_secret"), { audience: "slave" });
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
function validateUserToken(req, res, next) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
let tokenPayload = jwt.verify(
token,
req.app.locals.master.config.get("master.auth_secret"),
{ audience: "user" }
);
let user = req.app.locals.master.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
res.locals.user = user;
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
// Handle an uploaded export package.
async function uploadExport(req, res) {
endpointHitCounter.labels(req.route.path).inc();
if (req.get("Content-Type") !== "application/zip") {
res.sendStatus(415);
return;
}
let data = [];
for await (let chunk of req) {
data.push(chunk);
}
data = Buffer.concat(data);
let zip = await JSZip.loadAsync(data);
data = null;
// This is hardcoded to prevent path expansion attacks
let exportFiles = [
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
];
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
let name = path.posix.basename(filePath);
await fs.outputFile(path.join("static", "export", name), await file.async("nodebuffer"));
}
res.sendStatus(200);
}
async function createProxyStream(app) {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
let stream = {
id,
flowing: false,
size: null,
mime: null,
filename: null,
events: new events.EventEmitter(),
timeout: setTimeout(() => {
stream.events.emit("timeout");
}, app.locals.master.config.get("master.proxy_stream_timeout") * 1000),
};
stream.events.on("close", () => {
clearTimeout(stream.timeout);
app.locals.streams.delete(id);
});
stream.events.on("timeout", () => {
stream.events.emit("close");
});
app.locals.streams.set(id, stream);
return stream;
}
async function putStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
return;
}
stream.source = req;
stream.mime = req.get("Content-Type");
stream.size = req.get("Content-Length");
stream.events.emit("source");
stream.events.on("close", () => {
if (!stream.flowing) {
req.resume();
res.sendStatus(500);
} else {
res.sendStatus(200);
}
});
}
async function getStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
return;
}
function startStream() {
res.append("Content-Type", stream.mime);
if (stream.size) {
res.append("Content-Length", stream.size);
}
if (stream.filename) {
res.append("Content-Disposition", `attachment; filename="${stream.filename}"`);
} else {
res.append("Content-Disposition", "attachment");
}
stream.source.pipe(res);
stream.flowing = true;
res.on("finish", () => {
stream.events.emit("close");
});
clearTimeout(stream.timeout);
}
if (stream.source) {
startStream();
} else {
stream.events.on("source", startStream);
stream.events.on("timeout", () => {
res.sendStatus(500);
});
}
}
const zipMimes = [
"application/zip",
"application/x-zip-compressed",
];
async function uploadSave(req, res) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
} catch (err) {
res.status(403).json({ request_errors: [err.message] });
return;
}
let contentType = req.get("Content-Type");
let match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let mimeType = match[1].toLowerCase();
let tasks = [];
let errors = [];
let requestErrors = [];
let saves = [];
async function handleFile(instanceId, stream, filename, streamMime) {
let proxyStream = await createProxyStream(req.app);
proxyStream.source = stream;
proxyStream.mime = streamMime;
let timeout = new Promise((_, reject) => {
proxyStream.events.on("timeout", () => {
stream.resume();
reject(new Error("Timed out establishing stream to slave"));
});
});
try {
let result = await Promise.race([
req.app.locals.master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: instanceId,
stream_id: proxyStream.id,
filename,
}),
timeout,
]);
saves.push(result.save);
} catch (err) {
proxyStream.events.emit("close");
logger.error(`Error uploading save: ${err.message}`);
errors.push(err.message);
stream.resume();
}
}
if (mimeType === "multipart/form-data") {
await new Promise(resolve => {
let fields = {};
let busboy = new Busboy({ headers: req.headers });
busboy.on("file", (fieldname, stream, filename, transferEncoding, fileMime) => {
if (fields.instanceId === undefined) {
requestErrors.push("instance_id must come before files uploaded");
}
if (!zipMimes.includes(fileMime)) {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(fields.instanceId, stream, filename, fileMime));
});
busboy.on("field", (fieldname, value, fieldnameTruncated, valueTruncated, transferEncoding, valueMime) => {
if (fieldname === "instance_id") {
fields.instanceId = Number.parseInt(value, 10);
if (Number.isNaN(fields.instanceId)) {
requestErrors.push("invalid instance_id");
}
}
});
busboy.on("finish", resolve);
busboy.on("error", (err) => {
logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(busboy);
});
} else if (zipMimes.includes(mimeType)) {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
let instanceId = Number.parseInt(req.query.instance_id, 10);
if (Number.isNaN(instanceId)) {
requestErrors.push("Missing or invalid instance_id parameter");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(instanceId, req, filename, mimeType));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ saves });
}
function addRouteHandlers(app) {
app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.put("/api/upload-export",
validateSlaveToken,
(req, res, next) => uploadExport(req, res).catch(next)
);
app.put("/api/stream/:id", (req, res, next) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req, res, next) => uploadSave(req, res).catch(next)
);
}
// Routes used in the web interface and served by the master server
module.exports = [
const webRoutes = [
"/",
"/master",
"/slaves",
@@ -14,3 +424,9 @@ module.exports = [
"/plugins",
"/plugins/:name/view",
];
module.exports = {
addRouteHandlers,
webRoutes,
createProxyStream,
};
+1 -1
View File
@@ -90,4 +90,4 @@ Runs the slave.
## See Also
[The Clusterio repository](https://github.com/clusterio/factorioClusterio) for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
+1 -1
View File
@@ -16,7 +16,7 @@
],
"title": "Clusterio Library (Alpha)",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"homepage": "https://github.com/clusterio/clusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"additional_files": {
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
@@ -46,11 +46,14 @@ end
-- no game.table_to_json here :(
local function table_to_json(data)
if type(data) == "number" then
if data == math.huge then return "1e500" end -- Inf is not representable in JSON, but 1e500 will overflow to it.
if data == -math.huge then return "-1e500" end
if data ~= data then return "0" end -- NaN is not representable in JSON, treat as 0 instead.
return string.format("%.17g", data)
elseif type(data) == "string" then
data = data:gsub('([\x00-\x1f\\"])', function(match)
return "\\x" .. string.format("%02x", match:byte())
return "\\u00" .. string.format("%02x", match:byte())
end)
return '"' .. data .. '"'
+1 -1
View File
@@ -3,6 +3,6 @@
"version": "0.0.0",
"title": "Icon exporter",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"homepage": "https://github.com/clusterio/clusterio",
"description": "Exports item prototypes on stdout during the data stage."
}
+35 -2
View File
@@ -2,18 +2,44 @@
-- Based on code from playerManager and trainTeleports
local serialize = {}
local function version_to_table(version)
local t = {}
for p in string.gmatch(version, "%d+") do
t[#t + 1] = tonumber(p)
end
return t
end
-- 0.17 compatibility
local supports_bar, get_bar, has_bar
local supports_bar, get_bar, has_bar, version
if (pcall(function() local mods = script.active_mods end)) then
supports_bar = "supports_bar"
get_bar = "get_bar"
set_bar = "set_bar"
version = version_to_table(script.active_mods.base)
else
supports_bar = "hasbar"
get_bar = "getbar"
set_bar = "setbar"
version = version_to_table("0.17.69")
end
-- returns true if the game version is greater than or equal to the given version
local function version_ge(comp)
comp = version_to_table(comp)
for i=1, 3 do
if comp[i] > version[i] then
return false
elseif comp[i] < version[i] then
return true
end
end
return true
end
local has_create_grid = version_ge("1.1.7")
-- Equipment Grids are serialized into an array of equipment entries
-- where ench entry is a table with the following fields:
-- n: name
@@ -173,7 +199,14 @@ function serialize.deserialize_item_stack(slot, entry)
slot.allow_manual_label_change = label.a
end
if entry.g then
serialize.deserialize_equipment_grid(slot.grid, entry.g)
if slot.grid then
serialize.deserialize_equipment_grid(slot.grid, entry.g)
elseif slot.type == "item-with-entity-data" and has_create_grid then
slot.create_grid()
serialize.deserialize_equipment_grid(slot.grid, entry.g)
else
print("Error: Attempt to deserialize equipment grid on an unsupported entity")
end
end
if entry.i then
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -15,12 +15,12 @@
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.5",
"@clusterio/lib": "^2.0.0-alpha.8",
"fs-extra": "^8.1.0",
"phin": "^3.4.1",
"phin": "^3.5.1",
"pidusage": "^2.0.18",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
+249 -41
View File
@@ -37,6 +37,7 @@ const libConfig = require("@clusterio/lib/config");
const libSharedCommands = require("@clusterio/lib/shared_commands");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
const instanceRconCommandDuration = new libPrometheus.Histogram(
@@ -169,6 +170,7 @@ class Instance extends libLink.Link {
this._status = "stopped";
this._running = false;
this._loadedSave = null;
this.server = new libFactorio.FactorioServer(
factorioDir, this._dir, serverOptions
);
@@ -209,9 +211,50 @@ class Instance extends libLink.Link {
}
}
static async listSaves(savesDir, loadedSave) {
let defaultSave = null;
if (loadedSave === null) {
defaultSave = await libFileOps.getNewestFile(
savesDir, (name) => !name.endsWith(".tmp.zip")
);
}
let list = [];
for (let name of await fs.readdir(savesDir)) {
let type;
let stat = await fs.stat(path.join(savesDir, name));
if (stat.isFile()) {
type = "file";
} else if (stat.isDirectory()) {
type = "directory";
} else {
type = "special";
}
list.push({
name,
type,
size: stat.size,
mtime_ms: stat.mtimeMs,
loaded: name === loadedSave,
default: name === defaultSave,
});
}
return list;
}
async sendSaveListUpdate() {
libLink.messages.saveListUpdate.send(this, {
instance_id: this.id,
list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave),
});
}
async _autosave(name) {
let stat = await fs.stat(this.path("saves", `${name}.zip`));
instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size);
await this.sendSaveListUpdate();
}
notifyStatus(status) {
@@ -224,7 +267,7 @@ class Instance extends libLink.Link {
/**
* Current state of the instance
*
* One of stopped, starting, running, creating_save and exporting_data
* One of stopped, starting, running, stopping, creating_save and exporting_data
*
* @returns {string} instance status.
*/
@@ -234,6 +277,7 @@ class Instance extends libLink.Link {
notifyExit() {
this._running = false;
this._loadedSave = null;
this.notifyStatus("stopped");
this.config.off("fieldChanged", this._configFieldChanged);
@@ -252,6 +296,10 @@ class Instance extends libLink.Link {
for (let pluginInstance of this.plugins.values()) {
pluginInstance.onExit();
}
this.sendSaveListUpdate().catch(err => {
this.logger.error(`Unexpected error updating save list:\n${err.stack}`);
});
}
async _loadPlugin(pluginInfo, slave) {
@@ -267,7 +315,12 @@ class Instance extends libLink.Link {
async init(pluginInfos) {
this.notifyStatus("starting");
await this.server.init();
try {
await this.server.init();
} catch (err) {
this.notifyExit();
throw err;
}
// load plugins
for (let pluginInfo of pluginInfos) {
@@ -384,20 +437,6 @@ class Instance extends libLink.Link {
);
}
this.logger.verbose("Rotating old logs...");
// clean old log file to avoid crash
try {
let logPath = this.path("factorio-current.log");
let stat = await fs.stat(logPath);
if (stat.isFile()) {
let logFilename = `factorio-${Math.floor(Date.parse(stat.mtime)/1000)}.log`;
await fs.rename(logPath, this.path(logFilename));
this.logger.verbose(`Log rotated as ${logFilename}`);
}
} catch (err) {
this.logger.error(`Error rotating logs:\n${err.stack}`);
}
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
}
@@ -427,6 +466,23 @@ class Instance extends libLink.Link {
saveName = "world.zip";
}
// Load a copy if it's autosave to prevent overwriting the autosave
if (saveName.startsWith("_autosave")) {
this.logger.info("Copying autosave");
let now = new Date();
let newName = util.format(
"%s-%s-%s %s%s %s",
now.getUTCFullYear(),
(now.getUTCMonth() + 1).toLocaleString("en", { minimumIntegerDigits: 2 }),
now.getUTCDate().toLocaleString("en", { minimumIntegerDigits: 2 }),
now.getUTCHours().toLocaleString("en", { minimumIntegerDigits: 2 }),
now.getUTCMinutes().toLocaleString("en", { minimumIntegerDigits: 2 }),
saveName,
);
await fs.copy(this.path("saves", saveName), this.path("saves", newName));
saveName = newName;
}
if (!this.config.get("factorio.enable_save_patching")) {
return saveName;
}
@@ -467,7 +523,7 @@ class Instance extends libLink.Link {
// Find stand alone modules to load
// XXX for now only the included clusterio module is loaded
for (let entry of await fs.readdir(path.join(__dirname, "modules"), { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!entry.isFile()) {
if (modules.has(entry.name)) {
throw new Error(`Module with name ${entry.name} already exists in a plugin`);
}
@@ -510,6 +566,7 @@ class Instance extends libLink.Link {
});
this.server.on("exit", () => this.notifyExit());
this._loadedSave = saveName;
await this.server.start(saveName);
if (this.config.get("factorio.enable_save_patching")) {
@@ -517,6 +574,7 @@ class Instance extends libLink.Link {
await this.updateInstanceData();
}
await this.sendSaveListUpdate();
await libPlugin.invokeHook(this.plugins, "onStart");
this._running = true;
@@ -530,14 +588,17 @@ class Instance extends libLink.Link {
* scenario.
*
* @param {String} scenario - Name of scenario to load.
* @param {?number} seed - seed to use.
* @param {?object} mapGenSettings - MapGenSettings to use.
* @param {?object} mapSettings - MapSettings to use.
*/
async startScenario(scenario) {
async startScenario(scenario, seed, mapGenSettings, mapSettings) {
this.server.on("rcon-ready", () => {
this.logger.verbose("RCON connection established");
});
this.server.on("exit", () => this.notifyExit());
await this.server.startScenario(scenario);
await this.server.startScenario(scenario, seed, mapGenSettings, mapSettings);
await libPlugin.invokeHook(this.plugins, "onStart");
@@ -623,6 +684,7 @@ class Instance extends libLink.Link {
*/
async stop() {
this._running = false;
this.notifyStatus("stopping");
// XXX this needs more thought to it
if (this.server._state === "running") {
@@ -691,15 +753,22 @@ class Instance extends libLink.Link {
throw err;
}
let { scenario, seed, map_gen_settings, map_settings } = message.data;
try {
await this.startScenario(message.data.scenario);
await this.startScenario(scenario, seed, map_gen_settings, map_settings);
} catch (err) {
await this.stop();
throw err;
}
}
async createSaveRequestHandler() {
async listSavesRequestHandler(message) {
return {
list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave),
};
}
async createSaveRequestHandler(message) {
this.notifyStatus("creating_save");
try {
this.logger.verbose("Writing server-settings.json");
@@ -715,7 +784,8 @@ class Instance extends libLink.Link {
}
this.server.on("exit", () => this.notifyExit());
await this.server.create("world");
let { name, seed, map_gen_settings, map_settings } = message.data;
await this.server.create(name, seed, map_gen_settings, map_settings);
this.logger.info("Successfully created save");
}
@@ -1055,13 +1125,8 @@ class Slave extends libLink.Link {
throw new Error(`Instance name ${err.message}`);
}
// For now add dashes until an unused directory name is found
let dir = path.join(this.config.get("slave.instances_directory"), name);
while (await fs.pathExists(dir)) {
dir += "-";
}
return dir;
let instancesDir = this.config.get("slave.instances_directory");
return path.join(instancesDir, await libFileOps.findUnusedName(instancesDir, name));
}
async forwardRequestToInstance(message, request) {
@@ -1309,12 +1374,147 @@ class Slave extends libLink.Link {
return await request.send(instanceConnection, message.data);
}
async listSavesRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = this.instanceConnections.get(instanceId);
if (instanceConnection) {
return await request.send(instanceConnection, message.data);
}
let instanceInfo = this.instanceInfos.get(instanceId);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
}
return {
list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null),
};
}
async createSaveRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
await request.send(instanceConnection, message.data);
}
async sendSaveListUpdate(instance_id, savesDir) {
let instanceConnection = this.instanceConnections.get(instance_id);
let saveList;
if (instanceConnection) {
saveList = (await libLink.messages.listSaves.send(instanceConnection, { instance_id })).list;
} else {
saveList = await Instance.listSaves(savesDir, null);
}
libLink.messages.saveListUpdate.send(this, { instance_id, list: saveList });
}
async deleteSaveRequestHandler(message) {
let { instance_id, save } = message.data;
try {
checkFilename(save);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
try {
await fs.unlink(path.join(instanceInfo.path, "saves", save));
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${save} does not exist`);
}
throw err;
}
await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves"));
}
async pullSaveRequestHandler(message) {
let { instance_id, stream_id, filename } = message.data;
try {
checkFilename(filename);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa },
stream: true,
});
if (response.statusCode !== 200) {
let content = await libHelpers.readStream(response);
throw new libErrors.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
}
let savesDir = path.join(instanceInfo.path, "saves");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let stream;
while (true) {
try {
stream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(stream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(savesDir, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
response.pipe(stream);
await events.once(stream, "finish");
filename = await libFileOps.findUnusedName(savesDir, filename, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename));
await this.sendSaveListUpdate(instance_id, savesDir);
return { save: filename };
}
async pushSaveRequestHandler(message) {
let { instance_id, stream_id, save } = message.data;
try {
checkFilename(save);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
let content;
try {
// phin doesn't support streaming requests :(
content = await fs.readFile(path.join(instanceInfo.path, "saves", save));
} catch (err) {
if (err.code === "ENOENT") {
throw new libErrors.RequestError(`${save} does not exist`);
}
throw err;
}
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
phin({
url, method: "PUT",
core: { ca: this.tlsCa },
data: content,
}).catch(err => logger.error(`Error pushing save to master:\n${err.stack}`));
}
async exportDataRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
@@ -1420,13 +1620,13 @@ class Slave extends libLink.Link {
} catch (err) {
setBlocking(true);
logger.error(`
+--------------------------------------------------------------------+
| Unexpected error occured while shutting down slave, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+--------------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while shutting down slave, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line no-process-exit
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
}
@@ -1577,7 +1777,7 @@ async function startSlave() {
await slaveConfig.init();
} else {
throw err;
throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`);
}
}
@@ -1632,7 +1832,7 @@ async function startSlave() {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line no-process-exit
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -1645,7 +1845,7 @@ async function startSlave() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line no-process-exit
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -1687,6 +1887,14 @@ I version of clusterio. Expect things to break. I
if (err instanceof libErrors.AuthenticationFailed) {
logger.fatal(err.message);
} else if (err instanceof libErrors.StartupError) {
logger.fatal(`
+-----------------------------------+
| Unable to to start clusterioslave |
+-----------------------------------+
${err.stack}`
);
} else if (err instanceof libErrors.PluginError) {
logger.fatal(`
${err.pluginName} plugin threw an unexpected error
@@ -1697,10 +1905,10 @@ ${err.original.stack}`
} else {
logger.fatal(`
+--------------------------------------------------------------+
| Unexpected error occured while starting slave, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+--------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting slave, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
}
+9 -5
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"browser": "src/index.jsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -12,15 +12,17 @@
"clusterio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
"node": ">=12"
},
"dependencies": {
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.13.8",
"@babel/preset-react": "^7.12.13",
"@clusterio/lib": "^2.0.0-alpha.5",
"antd": "^4.6.5",
"@clusterio/lib": "^2.0.0-alpha.8",
"antd": "^4.16.0",
"assert": "^2.0.0",
"babel-loader": "^8.2.2",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.2",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.1.0",
@@ -33,8 +35,10 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"stream-browserify": "^3.0.0",
"style-loader": "^2.0.0",
"util": "^0.12.3"
"util": "^0.12.3",
"webpack": "^5.36.2"
},
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -90,5 +90,5 @@ export default async function bootstrap() {
controlConnector.setTimeout(15);
let control = new Control(controlConnector, plugins);
ReactDOM.render(<App control={control} plugins={plugins}/>, document.getElementById("root"));
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
}
+3 -6
View File
@@ -7,7 +7,6 @@ import { logger } from "@clusterio/lib/logging";
import ErrorBoundary from "./ErrorBoundary";
import SiteLayout from "./SiteLayout";
import ControlContext from "./ControlContext";
import PluginsContext from "./PluginsContext";
import LoginForm from "./LoginForm";
import { Card, Spin, Typography } from "antd";
@@ -95,11 +94,9 @@ export default function App(props) {
return (
<ErrorBoundary Component={ErrorCard}>
<ControlContext.Provider value={props.control}>
<PluginsContext.Provider value={props.plugins}>
<BrowserRouter basename={webRoot}>
{page}
</BrowserRouter>
</PluginsContext.Provider>
<BrowserRouter basename={webRoot}>
{page}
</BrowserRouter>
</ControlContext.Provider>
</ErrorBoundary>
);
@@ -5,24 +5,20 @@ import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
import { useSlaveList } from "../model/slave";
const { Paragraph } = Typography;
export default function AssignInstanceModal(props) {
let [visible, setVisible] = useState(false);
let [slaves, setSlaves] = useState([]);
let [loading, setLoading] = useState(true);
let [slaveList] = useSlaveList();
let [applying, setApplying] = useState(false);
let [form] = Form.useForm();
let control = useContext(ControlContext);
function open() {
setVisible(true);
libLink.messages.listSlaves.send(control).then((result) => {
setSlaves(result.list);
setLoading(false);
});
}
function handleAssign() {
@@ -73,8 +69,8 @@ export default function AssignInstanceModal(props) {
</Paragraph>
<Form form={form} initialValues={{ slave: props.slaveId }}>
<Form.Item name="slave" label="Slave">
<Select loading={loading}>
{slaves.map((slave) => <Select.Option
<Select>
{slaveList.map((slave) => <Select.Option
key={slave["id"]}
value={slave["id"]}
disabled={!slave["connected"]}
@@ -0,0 +1,141 @@
import React, { useContext, useState } from "react";
import { Button, Col, Form, Input, Modal, Row, Typography } from "antd";
import libLink from "@clusterio/lib/link";
import { readMapExchangeString } from "@clusterio/lib/factorio/exchange_string";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
export default function CreateSaveModal(props) {
let [visible, setVisible] = useState(false);
let [creatingSave, setCreatingSave] = useState(false);
let [form] = Form.useForm();
let control = useContext(ControlContext);
function createSave() {
let values = form.getFieldsValue();
let name = values.saveName || "world.zip";
let seed = values.seed === undefined ? null : Number.parseInt(values.seed, 10);
let mapGenSettings = null;
let mapSettings = null;
if (values.exchangeString && values.exchangeString.trim()) {
let result;
try {
result = readMapExchangeString(values.exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
}
form.setFields([{ name: "exchangeString", errors: [] }]);
mapGenSettings = result.map_gen_settings;
mapSettings = result.map_settings;
}
if (values.mapGenSettings && values.mapGenSettings.trim()) {
try {
mapGenSettings = JSON.parse(values.mapGenSettings);
} catch (err) {
form.setFields([{ name: "mapGenSettings", errors: [err.message] }]);
return;
}
form.setFields([{ name: "mapGenSettings", errors: [] }]);
}
if (values.mapSettings && values.mapSettings.trim()) {
try {
mapSettings = JSON.parse(values.mapSettings);
} catch (err) {
form.setFields([{ name: "mapSettings", errors: [err.message] }]);
return;
}
form.setFields([{ name: "mapSettings", errors: [] }]);
}
setCreatingSave(true);
libLink.messages.createSave.send(control, {
instance_id: props.instance.id,
name,
seed,
map_gen_settings: mapGenSettings,
map_settings: mapSettings,
}).then(() => {
form.resetFields();
setVisible(false);
}).catch(
notifyErrorHandler("Error creating save")
).finally(() => {
setCreatingSave(false);
});
}
function convertExchangeString() {
let exchangeString = form.getFieldValue("exchangeString");
let result;
try {
result = readMapExchangeString(exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
}
form.setFields([{ name: "exchangeString", errors: [], value: "" }]);
form.setFieldsValue({
mapGenSettings: JSON.stringify(result.map_gen_settings, null, 4),
mapSettings: JSON.stringify(result.map_settings, null, 4),
});
}
function handleCancel() {
setVisible(false);
}
return <>
<Button
loading={creatingSave}
disabled={props.instance.status !== "stopped"}
onClick={() => {
setVisible(true);
}}
>
Create save
</Button>
<Modal
title="Create Save"
width={560}
okText="Create"
visible={visible}
confirmLoading={creatingSave}
onOk={createSave}
onCancel={handleCancel}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="saveName" label="Save Name">
<Input placeholder="world.zip" />
</Form.Item>
<Form.Item name="seed" label="Seed">
<Input type="number" />
</Form.Item>
<Form.Item name="exchangeString" label="Map Exchange String">
<Input.TextArea allowClear={true} autoSize={{ minRows: 3 }} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: -8 }}>
<Button onClick={() => convertExchangeString()}>Convert to settings</Button>
</div>
<Form.Item
name="mapGenSettings"
label="Map Gen Settings"
tooltip="JSON data to pass to Factorio with --map-gen-settings, overrides Map Exchange String"
>
<Input.TextArea allowClear={true} autoSize={{ minRows: 1 }} />
</Form.Item>
<Form.Item
name="mapSettings"
label="Map Settings"
tooltip="JSON data to pass to Factorio with --map-settings, overrides Map Exchange String"
>
<Input.TextArea allowClear={true} autoSize={{ minRows: 1 }} />
</Form.Item>
</Form>
</Modal>
</>;
}
@@ -12,6 +12,8 @@ import InstanceConsole from "./InstanceConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
import LoadScenarioModal from "./LoadScenarioModal";
import SavesList from "./SavesList";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
@@ -26,10 +28,10 @@ export default function InstanceViewPage(props) {
let history = useHistory();
let control = useContext(ControlContext);
let [instance, updateInstance] = useInstance(instanceId);
let [instance] = useInstance(instanceId);
let [slave] = useSlave(Number(instance["assigned_slave"]));
let [creatingSave, setCreatingSave] = useState(false);
let [exportingData, setExportingData] = useState(false);
let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }];
if (instance.loading) {
@@ -44,24 +46,24 @@ export default function InstanceViewPage(props) {
}
let instanceButtons = <Space>
<StartStopInstanceButton
instance={instance}
onFinish={updateInstance}
/>
{instance.status === "stopped" && <Button
loading={creatingSave}
<StartStopInstanceButton instance={instance} />
<LoadScenarioModal instance={instance} />
<Button
loading={exportingData}
disabled={instance.status !== "stopped"}
onClick={() => {
setCreatingSave(true);
libLink.messages.createSave.send(
setExportingData(true);
libLink.messages.exportData.send(
control, { instance_id: instanceId }
).catch(notifyErrorHandler("Error creating save")).finally(() => {
updateInstance();
setCreatingSave(false);
).catch(
notifyErrorHandler("Error exporting data")
).finally(() => {
setExportingData(false);
});
}}
>
Create save
</Button>}
Export data
</Button>
<Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
@@ -107,16 +109,16 @@ export default function InstanceViewPage(props) {
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
onFinish={updateInstance}
/>
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
<SavesList instance={instance} />
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
<InstanceConfigTree id={instanceId} onApply={updateInstance} />
<InstanceConfigTree id={instanceId} />
</PageLayout>;
}
@@ -1,52 +1,105 @@
import React, { useContext } from "react";
import React, { useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useInstanceList } from "../model/instance";
import { notifyErrorHandler } from "../util/notify";
import { useSlaveList } from "../model/slave";
export default function InstancesPage() {
function CreateInstanceButton(props) {
let control = useContext(ControlContext);
let history = useHistory();
let [visible, setVisible] = useState(false);
let [form] = Form.useForm();
async function listInstances() {
let result = await libLink.messages.listInstances.send(control);
return result["list"].map(item => ({
key: item["id"],
"Name": item["name"],
"Assigned Slave": item["assigned_slave"],
"Status": item["status"],
}));
async function createInstance() {
let values = form.getFieldsValue();
if (!values.instanceName) {
form.setFields([{ name: "instanceName", errors: ["Name is required"] }]);
return;
}
let instanceConfig = new libConfig.InstanceConfig("control");
await instanceConfig.init();
instanceConfig.set("instance.name", values.instanceName);
let serialized_config = instanceConfig.serialize("master");
let result = await libLink.messages.createInstance.send(control, { serialized_config });
setVisible(false);
history.push(`/instances/${instanceConfig.get("instance.id")}/view`);
}
return <>
<Button
type="primary"
onClick={() => {
setVisible(true);
}}
>Create</Button>
<Modal
title="Create Instance"
okText="Create"
visible={visible}
onOk={() => { createInstance().catch(notifyErrorHandler("Error creating instance")); }}
onCancel={() => { setVisible(false); }}
destroyOnClose
>
<Form form={form}>
<Form.Item name="instanceName" label="Name">
<Input/>
</Form.Item>
</Form>
</Modal>
</>;
}
export default function InstancesPage() {
let history = useHistory();
let [slaveList] = useSlaveList();
let [instanceList] = useInstanceList();
return <PageLayout nav={[{ name: "Instances" }]}>
<h2>Instances</h2>
<DataTable
DataFunction={listInstances}
AddRecord={{
fields: [{
dataIndex: "name",
<PageHeader
className="site-page-header"
title="Instances"
extra=<CreateInstanceButton />
/>
<Table
columns={[
{
title: "Name",
}],
insert: async args => {
let instanceConfig = new libConfig.InstanceConfig("control");
await instanceConfig.init();
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize("master");
let response = await libLink.messages.createInstance.send(control, { serialized_config });
dataIndex: "name",
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.key}/view`);
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave) {
return slave.name;
}
return instance.assigned_slave;
},
}),
}}
},
{
title: "Status",
dataIndex: "status",
},
]}
dataSource={instanceList}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>
</PageLayout>;
}
@@ -0,0 +1,140 @@
import React, { useContext, useState } from "react";
import { Button, Col, Form, Input, Modal, Row, Typography } from "antd";
import libLink from "@clusterio/lib/link";
import { readMapExchangeString } from "@clusterio/lib/factorio/exchange_string";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
export default function LoadScenarioModal(props) {
let [visible, setVisible] = useState(false);
let [loadingScenario, setLoadingScenario] = useState(false);
let [form] = Form.useForm();
let control = useContext(ControlContext);
function loadScenario() {
let values = form.getFieldsValue();
let scenario = values.scenario || "base/freeplay";
let seed = values.seed === undefined ? null : Number.parseInt(values.seed, 10);
let mapGenSettings = null;
let mapSettings = null;
if (values.exchangeString && values.exchangeString.trim()) {
let result;
try {
result = readMapExchangeString(values.exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
}
form.setFields([{ name: "exchangeString", errors: [] }]);
mapGenSettings = result.map_gen_settings;
mapSettings = result.map_settings;
}
if (values.mapGenSettings && values.mapGenSettings.trim()) {
try {
mapGenSettings = JSON.parse(values.mapGenSettings);
} catch (err) {
form.setFields([{ name: "mapGenSettings", errors: [err.message] }]);
return;
}
form.setFields([{ name: "mapGenSettings", errors: [] }]);
}
if (values.mapSettings && values.mapSettings.trim()) {
try {
mapSettings = JSON.parse(values.mapGenSettings);
} catch (err) {
form.setFields([{ name: "mapSettings", errors: [err.message] }]);
return;
}
form.setFields([{ name: "mapSettings", errors: [] }]);
}
setLoadingScenario(true);
libLink.messages.loadScenario.send(control, {
instance_id: props.instance.id,
scenario,
seed,
map_gen_settings: mapGenSettings,
map_settings: mapSettings,
}).then(() => {
setVisible(false);
}).catch(
notifyErrorHandler("Error creating save")
).finally(() => {
setLoadingScenario(false);
});
}
function convertExchangeString() {
let exchangeString = form.getFieldValue("exchangeString");
let result;
try {
result = readMapExchangeString(exchangeString);
} catch (err) {
form.setFields([{ name: "exchangeString", errors: [err.message] }]);
return;
}
form.setFields([{ name: "exchangeString", errors: [], value: "" }]);
form.setFieldsValue({
mapGenSettings: JSON.stringify(result.map_gen_settings, null, 4),
mapSettings: JSON.stringify(result.map_settings, null, 4),
});
}
function handleCancel() {
setVisible(false);
}
return <>
<Button
loading={loadingScenario}
disabled={props.instance.status !== "stopped"}
onClick={() => {
setVisible(true);
}}
>
Load scenario
</Button>
<Modal
title="Load Scenario"
width={560}
okText="Load"
visible={visible}
confirmLoading={loadingScenario}
onOk={loadScenario}
onCancel={handleCancel}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="scenario" label="Scenario">
<Input placeholder="base/freeplay" />
</Form.Item>
<Form.Item name="seed" label="Seed">
<Input type="number" />
</Form.Item>
<Form.Item name="exchangeString" label="Map Exchange String">
<Input.TextArea allowClear={true} autoSize={{ minRows: 3 }} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: -8 }}>
<Button onClick={() => convertExchangeString()}>Convert to settings</Button>
</div>
<Form.Item
name="mapGenSettings"
label="Map Gen Settings"
tooltip="JSON data to pass to Factorio with --map-gen-settings, overrides Map Exchange String"
>
<Input.TextArea allowClear={true} autoSize={{ minRows: 1 }} />
</Form.Item>
<Form.Item
name="mapSettings"
label="Map Settings"
tooltip="JSON data to pass to Factorio with --map-settings, overrides Map Exchange String"
>
<Input.TextArea allowClear={true} autoSize={{ minRows: 1 }} />
</Form.Item>
</Form>
</Modal>
</>;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { Button, Card, Divider, Form, Image, Input, List, Space, Spin, Typograph
import LockOutlined from "@ant-design/icons/LockOutlined";
import logo from "../images/logo.png";
import PluginsContext from "./PluginsContext";
import ControlContext from "./ControlContext";
const { Paragraph, Text } = Typography;
@@ -33,7 +33,7 @@ function TokenAuth(props) {
}
export default function LoginForm(props) {
let plugins = useContext(PluginsContext);
let plugins = useContext(ControlContext).plugins;
let loginForms = [];
for (let plugin of plugins.values()) {
@@ -2,14 +2,14 @@ import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Alert, Descriptions, Spin } from "antd";
import PluginsContext from "./PluginsContext";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
export default function PluginViewPage() {
let params = useParams();
let plugins = useContext(PluginsContext);
let plugins = useContext(ControlContext).plugins;
let [pluginList, setPluginList] = useState(null);
let pluginName = params.name;
@@ -3,12 +3,12 @@ import { useHistory } from "react-router-dom";
import { Table } from "antd";
import notify from "../util/notify";
import PluginsContext from "./PluginsContext";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
export default function PluginsPage() {
let plugins = useContext(PluginsContext);
let plugins = useContext(ControlContext).plugins;
let history = useHistory();
let [pluginList, setPluginList] = useState([]);
@@ -0,0 +1,178 @@
import React, { useContext, useState } from "react";
import { Button, List, Popconfirm, Progress, Space, Table, Tooltip, Upload } from "antd";
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
import { useSaves } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
function formatBytes(bytes) {
if (bytes === 0) {
return "0 Bytes";
}
let units = [" Bytes", " kB", " MB", " GB", " TB"];
let factor = 1000;
let power = Math.min(Math.floor(Math.log(bytes) / Math.log(factor)), units.length);
return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power];
}
export default function SavesList(props) {
let control = useContext(ControlContext);
let saves = useSaves(props.instance.id);
let [starting, setStarting] = useState(false);
let [uploadingFiles, setUploadingFiles] = useState([]);
const saveTable = <Table
size="small"
columns={[
{
title: "Name",
render: save => <>
{save.name}
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined/></Tooltip>}
{save.default && <Tooltip title="Save loaded by default"><LeftOutlined/></Tooltip>}
</>,
sorter: (a, b) => a.name.localeCompare(b.name),
},
{
title: "Size",
key: "size",
responsive: ["sm"],
render: save => formatBytes(save.size),
align: "right",
sorter: (a, b) => a.size - b.size,
},
{
title: "Last Modified",
key: "mtime_ms",
render: save => new Date(save.mtime_ms).toLocaleString(),
sorter: (a, b) => a.mtime_ms - b.mtime_ms,
defaultSortOrder: "descend",
},
]}
dataSource={saves}
rowKey={save => save.name}
pagination={false}
expandable={{
columnWidth: 33,
expandRowByClick: true,
expandedRowRender: save => <Space wrap style={{marginBottom: 0}}>
<Button
loading={starting}
disabled={props.instance.status !== "stopped"}
onClick={() => {
setStarting(true);
libLink.messages.startInstance.send(
control, { instance_id: props.instance.id, save: save.name }
).catch(
notifyErrorHandler("Error loading save")
).finally(
() => { setStarting(false); }
);
}}
>Load save</Button>
<Button
onClick={() => {
libLink.messages.downloadSave.send(
control, { instance_id: props.instance.id, save: save.name }
).then(response => {
let url = new URL(webRoot, document.location);
url.pathname += `api/stream/${response.stream_id}`;
document.location = url;
}).catch(
notifyErrorHandler("Error downloading save")
);
}}
>Download</Button>
<Popconfirm
title="Permanently delete save?"
okText="Delete"
placement="top"
okButtonProps={{ danger: true }}
onConfirm={() => {
libLink.messages.deleteSave.send(
control, { instance_id: props.instance.id, save: save.name }
).catch(notifyErrorHandler("Error deleting save"));
}}
>
<Button danger>Delete</Button>
</Popconfirm>
</Space>,
}}
/>;
function onChange(changeEvent) {
if (["done", "error"].includes(changeEvent.file.status)) {
if (changeEvent.file.status === "error") {
notifyErrorHandler("Error uploading file")(changeEvent.file.error);
}
let newUploadingFiles = [...uploadingFiles];
let index = newUploadingFiles.findIndex(f => f.uid === changeEvent.file.uid);
if (index !== -1) {
newUploadingFiles.splice(index, 1);
setUploadingFiles(newUploadingFiles);
}
return;
}
let file = {
uid: changeEvent.file.uid,
name: changeEvent.file.name,
percent: changeEvent.file.percent,
status: changeEvent.file.status,
};
let newUploadingFiles = [...uploadingFiles];
let index = newUploadingFiles.findIndex(f => f.uid === changeEvent.file.uid);
if (index !== -1) {
newUploadingFiles[index] = file;
} else {
newUploadingFiles.push(file);
}
setUploadingFiles(newUploadingFiles);
}
let uploadProps = {
name: "file",
accept: ".zip",
headers: {
"X-Access-Token": control.connector.token,
},
data: {
instance_id: props.instance.id,
},
showUploadList: false,
action: `${webRoot}api/upload-save`,
onChange,
};
return <div>
<SectionHeader title="Saves" extra=<Space>
<Upload {...uploadProps} >
<Button>Upload save</Button>
</Upload>
<CreateSaveModal instance={props.instance} />
</Space> />
<Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
</Upload.Dragger>
{uploadingFiles.length ? <List>
{uploadingFiles.map(file => <List.Item key={file.uid}>
{file.name}
<Progress
percent={file.percent}
format={percent => `${Math.floor(percent)}%`}
status={file.status === "error" ? "exception" : "normal"}
/>
</List.Item>)}
</List> : null}
</div>;
}
@@ -0,0 +1,8 @@
import React, { useContext, useState } from "react";
export default function SectionHeader(props) {
return <div className="section-header">
<div className="section-header-title">{props.title}</div>
{props.extra && <div className="section-header-extra">{props.extra}</div>}
</div>;
}
@@ -7,7 +7,7 @@ import webUiPackage from "../../package.json";
import { useAccount } from "../model/account";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import PluginsContext from "./PluginsContext";
import ControlContext from "./ControlContext";
import { pages } from "../pages";
const { Header, Sider } = Layout;
@@ -17,7 +17,7 @@ export default function SiteLayout(props) {
let history = useHistory();
let [currentSidebarPath, setCurrentSidebarPath] = useState(null);
let account = useAccount();
let plugins = useContext(PluginsContext);
let plugins = useContext(ControlContext).plugins;
function SetSidebar(setSidebarProps) {
useEffect(() => {
+95 -15
View File
@@ -1,28 +1,108 @@
import React, { useContext } from "react";
import React, { useContext, useRef, useState } from "react";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import libLink from "@clusterio/lib/link";
import DataTable from "./data-table";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useSlaveList } from "../model/slave";
import { notifyErrorHandler } from "../util/notify";
function GenerateSlaveTokenButton(props) {
let control = useContext(ControlContext);
let [visible, setVisible] = useState(false);
let [token, setToken] = useState(null);
let [form] = Form.useForm();
let tokenTextAreaRef = useRef(null);
async function generateToken() {
let values = form.getFieldsValue();
let slaveId = null;
if (values.slaveId) {
slaveId = Number.parseInt(values.slaveId, 10);
if (Number.isNaN(slaveId)) {
form.setFields([{ name: "slaveId", errors: ["Must be an integer"] }]);
return;
}
form.setFields([{ name: "slaveId", errors: [] }]);
}
let result = await libLink.messages.generateSlaveToken.send(control, { slave_id: slaveId });
setToken(result.token);
}
return <>
<Button
onClick={() => { setVisible(true); }}
>Generate Token</Button>
<Modal
title="Generate Slave Token"
visible={visible}
footer={null}
onCancel={() => {
setVisible(false);
setToken(null);
form.resetFields();
}}
>
<Form form={form} layout="vertical" requiredMark="optional">
<Form.Item name="slaveId" label="Slave ID">
<Input/>
</Form.Item>
<Form.Item>
<Button
onClick={() => { generateToken().catch(notifyErrorHandler("Error generating token")); }}
>Generate</Button>
</Form.Item>
<Form.Item label="Token" required>
<Input.TextArea value={token} autoSize ref={tokenTextAreaRef} />
</Form.Item>
<Button disabled={token === null} onClick={() => {
let textArea = tokenTextAreaRef.current.resizableTextArea.textArea;
textArea.select();
document.execCommand("copy");
window.getSelection().removeAllRanges();
}}>Copy</Button>
</Form>
</Modal>
</>;
}
export default function SlavesPage() {
let control = useContext(ControlContext);
async function listSlaves() {
let result = await libLink.messages.listSlaves.send(control);
return result["list"].map((item) => ({
key: item["id"],
"Name": item["name"],
"Agent": item["agent"],
"Version": item["version"],
"Connected": item["connected"] && "Yes",
}));
}
let [slaveList] = useSlaveList();
return <PageLayout nav={[{ name: "Slaves" }]}>
<h2>Slaves</h2>
<DataTable DataFunction={listSlaves} />
<PageHeader
className="site-page-header"
title="Slaves"
extra=<GenerateSlaveTokenButton />
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
},
{
title: "Agent",
dataIndex: "agent",
},
{
title: "Version",
dataIndex: "version",
},
{
title: "Connected",
key: "connected",
render: slave => slave["connected"] && "Yes",
},
]}
dataSource={slaveList}
rowKey={slave => slave["id"]}
pagination={false}
/>
</PageLayout>;
};
@@ -218,7 +218,7 @@ class DataTable extends Component {
onCancel={this.hideEditModal.bind(this)}
footer={null}
title={
<h2>{this.state.inserting ? "Legg til" : "Rediger"}</h2>
<h2>{this.state.inserting ? "Add" : "Edit"}</h2>
}
>
{this.state.showEditModal && <Form
@@ -249,7 +249,7 @@ class DataTable extends Component {
</Form.Item>)}
<Form.Item>
<Button type="primary" htmlType="submit">
{this.state.inserting ? "Legg til" : "Lagre"}
{this.state.inserting ? "Save" : "Save"}
</Button>
</Form.Item>
</Form>}
+29
View File
@@ -67,6 +67,31 @@ body {
padding-right: 64px;
}
.site-page-header {
padding: 0 0 16px;
}
.section-header {
display: flex;
align-items: center;
margin-top: 16px;
margin-bottom: 8px;
}
.section-header-title {
flex: auto;
overflow: hidden;
font-weight: bold;
font-size: 16px;
line-height: 1.5715;
white-space: nowrap;
text-overflow: ellipsis;
}
.section-header-extra {
margin-left: auto;
}
.site-name {
position: absolute;
top: -10px;
@@ -143,6 +168,10 @@ body {
white-space: pre-wrap;
}
.save-list-dragger .ant-upload.ant-upload-btn {
padding: 0;
}
.rcon-result code {
display: block;
background: #000;
+2 -1
View File
@@ -8,7 +8,6 @@ export * from "./model/item_metadata.jsx";
export { default as ControlContext } from "./components/ControlContext";
export { default as PluginsContext } from "./components/PluginsContext";
export { default as BaseConfigTree } from "./components/BaseConfigTree";
export { default as InstanceConfigTree } from "./components/InstanceConfigTree";
@@ -19,3 +18,5 @@ export { default as InstanceConsole } from "./components/InstanceConsole";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as PageLayout } from "./components/PageLayout";
export { default as SavesList } from "./components/SavesList";
export { default as SectionHeader } from "./components/SectionHeader";
+60 -12
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import libLink from "@clusterio/lib/link";
import { logger } from "@clusterio/lib/logging";
export function useInstance(id) {
@@ -9,25 +10,72 @@ export function useInstance(id) {
let [instance, setInstance] = useState({ loading: true });
function updateInstance() {
if (!Number.isInteger(id)) {
libLink.messages.getInstance.send(control, { id }).then(result => {
setInstance({ ...result, present: true });
}).catch(err => {
logger.error(`Failed to get instance: ${err}`);
setInstance({ missing: true });
return;
}
// XXX optimize by requesting only the instance in question
libLink.messages.listInstances.send(control).then(result => {
let match = result.list.find(i => i.id === id);
if (!match) {
setInstance({ missing: true });
} else {
setInstance({ ...match, present: true });
}
});
}
useEffect(() => {
if (!Number.isInteger(id)) {
setInstance({ missing: true });
return undefined;
}
updateInstance();
function updateHandler(newInstance) {
setInstance({ ...newInstance, present: true });
}
control.onInstanceUpdate(id, updateHandler);
return () => {
control.offInstanceUpdate(id, updateHandler);
};
}, [id]);
return [instance, updateInstance];
}
export function useInstanceList() {
let control = useContext(ControlContext);
let [instanceList, setInstanceList] = useState([]);
function updateInstanceList() {
libLink.messages.listInstances.send(control).then(result => {
setInstanceList(result.list);
}).catch(err => {
logger.error(`Failed to list instances:\n${err}`);
});
}
useEffect(() => {
updateInstanceList();
function updateHandler(newInstance) {
setInstanceList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(s => s.id === newInstance.id);
if (newInstance.status !== "deleted") {
if (index !== -1) {
newList[index] = newInstance;
} else {
newList.push(newInstance);
}
} else if (index !== -1) {
newList.splice(index, 1);
}
return newList;
});
}
control.onInstanceUpdate(null, updateHandler);
return () => {
control.offInstanceUpdate(null, updateHandler);
};
}, []);
return [instanceList];
}
+34
View File
@@ -0,0 +1,34 @@
import React, { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import libLink from "@clusterio/lib/link";
import { logger } from "@clusterio/lib/logging";
export function useSaves(instanceId) {
let control = useContext(ControlContext);
let [saves, setSaves] = useState([]);
function updateSaves() {
libLink.messages.listSaves.send(control, { instance_id: instanceId }).then(result => {
setSaves(result.list);
}).catch(err => {
logger.error(`Failed to list instance saves: ${err}`);
});
}
useEffect(() => {
updateSaves();
function updateHandler(data) {
setSaves(data.list);
}
control.onSaveListUpdate(instanceId, updateHandler);
return () => {
control.offSaveListUpdate(instanceId, updateHandler);
};
}, [instanceId]);
return saves;
}
+49 -5
View File
@@ -9,11 +9,6 @@ export function useSlave(id) {
let [slave, setSlave] = useState({ loading: true });
function updateSlave() {
if (!Number.isInteger(id)) {
setSlave({ missing: true });
return;
}
// XXX optimize by requesting only the slave in question
libLink.messages.listSlaves.send(control).then(result => {
let match = result.list.find(i => i.id === id);
@@ -26,8 +21,57 @@ export function useSlave(id) {
}
useEffect(() => {
if (!Number.isInteger(id)) {
setSlave({ missing: true });
return undefined;
}
updateSlave();
function updateHandler(newSlave) {
setSlave({ ...newSlave, present: true });
}
control.onSlaveUpdate(id, updateHandler);
return () => {
control.offSlaveUpdate(id, updateHandler);
};
}, [id]);
return [slave, updateSlave];
}
export function useSlaveList() {
let control = useContext(ControlContext);
let [slaveList, setSlaveList] = useState([]);
function updateSlaveList() {
libLink.messages.listSlaves.send(control).then(result => {
setSlaveList(result.list);
});
}
useEffect(() => {
updateSlaveList();
function updateHandler(newSlave) {
setSlaveList(oldList => {
let newList = oldList.concat();
let index = newList.findIndex(s => s.id === newSlave.id);
if (index !== -1) {
newList[index] = newSlave;
} else {
newList.push(newSlave);
}
return newList;
});
}
control.onSlaveUpdate(null, updateHandler);
return () => {
control.offSlaveUpdate(null, updateHandler);
};
}, []);
return [slaveList];
}
+180
View File
@@ -62,10 +62,22 @@ export class Control extends libLink.Link {
*/
this.accountName = null;
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.instanceLogHandlers = new Map();
this.connector.on("connect", data => {
this.accountName = data.account.name;
this.updateSlaveSubscriptions().catch(err => logger.error(
`Unexpected error updating slave subscriptions:\n${err.stack}`
));
this.updateInstanceSubscriptions().catch(err => logger.error(
`Unexpected error updating instance subscriptions:\n${err.stack}`
));
this.updateSaveListSubscriptions().catch(err => logger.error(
`Unexpected error updating save list subscriptions:\n${err.stack}`
));
this.updateLogSubscriptions().catch(err => logger.error(
`Unexpected error updating log subscriptions:\n${err.stack}`
));
@@ -84,6 +96,174 @@ export class Control extends libLink.Link {
}
}
async slaveUpdateEventHandler(message) {
let handlers = [].concat(
this.slaveUpdateHandlers.get(null) || [],
this.slaveUpdateHandlers.get(message.data.id) || [],
);
for (let handler of handlers) {
handler(message.data);
}
}
async onSlaveUpdate(id, handler) {
if (id !== null && !Number.isInteger(id)) {
throw new Error("Invalid slave id");
}
let handlers = this.slaveUpdateHandlers.get(id);
if (!handlers) {
handlers = [];
this.slaveUpdateHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateSlaveSubscriptions();
}
}
async offSlaveUpdate(id, handler) {
let handlers = this.slaveUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for slave ${id} exists`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for slave ${id}`);
}
handlers.slice(index, 1);
if (!handlers.length) {
this.slaveUpdateHandlers.delete(id);
await this.updateSlaveSubscriptions();
}
}
async updateSlaveSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setSlaveSubscriptions.send(this, {
all: this.slaveUpdateHandlers.has(null),
slave_ids: [...this.slaveUpdateHandlers.keys()].filter(e => e !== null),
});
}
async instanceUpdateEventHandler(message) {
let handlers = [].concat(
this.instanceUpdateHandlers.get(null) || [],
this.instanceUpdateHandlers.get(message.data.id) || [],
);
for (let handler of handlers) {
handler(message.data);
}
};
async onInstanceUpdate(id, handler) {
if (id !== null && !Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
let handlers = this.instanceUpdateHandlers.get(id);
if (!handlers) {
handlers = [];
this.instanceUpdateHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateInstanceSubscriptions();
}
}
async offInstanceUpdate(id, handler) {
let handlers = this.instanceUpdateHandlers.get(id);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for instance ${id} exist`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for instance ${id}`);
}
handlers.splice(index, 1);
if (!handlers.length) {
this.instanceUpdateHandlers.delete(id);
await this.updateInstanceSubscriptions();
}
}
async updateInstanceSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setInstanceSubscriptions.send(this, {
all: this.instanceUpdateHandlers.has(null),
instance_ids: [...this.instanceUpdateHandlers.keys()].filter(e => e !== null),
});
}
async saveListUpdateEventHandler(message) {
let handlers = this.saveListUpdateHandlers.get(message.data.instance_id);
for (let handler of handlers || []) {
handler(message.data);
}
};
async onSaveListUpdate(id, handler) {
if (!Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
let handlers = this.saveListUpdateHandlers.get(id);
if (!handlers) {
handlers = [];
this.saveListUpdateHandlers.set(id, handlers);
}
handlers.push(handler);
if (handlers.length === 1) {
await this.updateSaveListSubscriptions();
}
}
async offSaveListUpdate(id, handler) {
let handlers = this.saveListUpdateHandlers.get(id);
if (!handlers) {
throw new Error(`No handlers for instance ${id} exists`);
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for instance ${id}`);
handlers.splice(index, 1);
if (!handlers.length) {
this.saveListUpdateHandlers.delete(id);
await this.updateSaveListSubscriptions();
}
}
}
async updateSaveListSubscriptions() {
if (!this.connector.connected) {
return;
}
await libLink.messages.setSaveListSubscriptions.send(this, {
all: false,
instance_ids: [...this.saveListUpdateHandlers.keys()],
});
}
async logMessageEventHandler(message) {
let info = message.data.info;
+5
View File
@@ -92,8 +92,13 @@ module.exports = (env = {}) => ({
"util": require.resolve("util/"),
"os": require.resolve("os-browserify/browser"),
// Required for zlib
"assert": require.resolve("assert"),
"stream": require.resolve("stream-browserify"),
"events": require.resolve("events/"),
"path": require.resolve("path-browserify"),
"zlib": require.resolve("browserify-zlib"),
},
},
});
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+12 -7
View File
@@ -1,24 +1,29 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"repository": "clusterio/clusterio",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/clusterio/factorioClusterio/issues"
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.5",
"webpack-cli": "^3.3.12",
"@clusterio/lib": "^2.0.0-alpha.8",
"@clusterio/web_ui": "^2.0.0-alpha.8",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+5 -4
View File
@@ -3,6 +3,7 @@
*/
"use strict";
const crypto = require("crypto");
const express = require("express");
const util = require("util");
const jwt = require("jsonwebtoken");
@@ -46,7 +47,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
this.metrics.endpointHitCounter.labels(req.route.path).inc();
let servers = [];
for (let instance of this.master.db.instances.values()) {
for (let instance of this.master.instances.values()) {
if (instance.status === "running" && instance.config.get("player_auth.load_plugin")) {
servers.push(instance.config.get("factorio.settings")["name"] || "unnamed server");
}
@@ -54,12 +55,12 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
res.send(servers);
});
this.master.app.post("/api/player_auth/player_code", (req, res, next) => {
this.master.app.post("/api/player_auth/player_code", express.json(), (req, res, next) => {
this.metrics.endpointHitCounter.labels(req.route.path).inc();
this.handlePlayerCode(req, res).catch(next);
});
this.master.app.post("/api/player_auth/verify", (req, res, next) => {
this.master.app.post("/api/player_auth/verify", express.json(), (req, res, next) => {
this.metrics.endpointHitCounter.labels(req.route.path).inc();
this.handleVerify(req, res).catch(next);
});
@@ -142,7 +143,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
for (let [player, entry] of this.players) {
if (entry.playerCode === playerCode && entry.expires > Date.now()) {
if (entry.verifyCode === verifyCode) {
let user = this.master.db.users.get(player);
let user = this.master.userManager.users.get(player);
if (!user) {
res.send({ error: true, message: "invalid user" });
return;
+15 -9
View File
@@ -1,31 +1,37 @@
{
"name": "@clusterio/plugin-player_auth",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"description": "Clusterio plugin authenticating logged in players to the web interface",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"repository": "clusterio/clusterio",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/clusterio/factorioClusterio/issues"
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
},
"dependencies": {
"express": "^4.16.2",
"jsonwebtoken": "^8.2.1"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.5",
"antd": "^4.6.5",
"phin": "^3.4.1",
"@clusterio/lib": "^2.0.0-alpha.8",
"@clusterio/web_ui": "^2.0.0-alpha.8",
"antd": "^4.16.0",
"phin": "^3.5.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"webpack-cli": "^3.3.12",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+2 -2
View File
@@ -53,7 +53,7 @@ describe("player_auth", function() {
describe("/api/player_auth/servers", function() {
it("should return a list of running servers with player_auth loaded", async function() {
function addInstance(id, status, load, name) {
masterPlugin.master.db.instances.set(id, {
masterPlugin.master.instances.set(id, {
config: {
get(field) {
if (field === "player_auth.load_plugin") {
@@ -78,7 +78,7 @@ describe("player_auth", function() {
});
assert.deepEqual(result.body, ["running loaded", "unnamed server"]);
for (let id of [1, 2, 3, 4, 5]) {
masterPlugin.master.db.instances.delete(id);
masterPlugin.master.instances.delete(id);
}
});
});
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+12 -7
View File
@@ -1,17 +1,20 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"repository": "clusterio/clusterio",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/clusterio/factorioClusterio/issues"
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
@@ -20,8 +23,10 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.5",
"webpack-cli": "^3.3.12",
"@clusterio/lib": "^2.0.0-alpha.8",
"@clusterio/web_ui": "^2.0.0-alpha.8",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+12 -7
View File
@@ -1,24 +1,29 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"repository": "clusterio/clusterio",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/clusterio/factorioClusterio/issues"
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.5",
"webpack-cli": "^3.3.12",
"@clusterio/lib": "^2.0.0-alpha.8",
"@clusterio/web_ui": "^2.0.0-alpha.8",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+1
View File
@@ -0,0 +1 @@
webpack.config.js
+22 -2
View File
@@ -10,11 +10,19 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async init() {
this.stopping = false;
this.pendingTasks = new Set();
this.instance.server.on("ipc-subspace_storage:output", (output) => {
this.provideItems(output).catch(err => this.unexpectedError(err));
});
this.instance.server.on("ipc-subspace_storage:orders", (orders) => {
this.requestItems(orders).catch(err => this.unexpectedError(err));
if (this.stopping) {
return;
}
let task = this.requestItems(orders).catch(err => this.unexpectedError(err));
this.pendingTasks.add(task);
task.finally(() => { this.pendingTasks.delete(task); });
});
}
@@ -31,6 +39,12 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
await this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}", true)`, true);
}
async onStop() {
clearInterval(this.pingId);
this.stopping = true;
await Promise.all(this.pendingTasks);
}
onExit() {
clearInterval(this.pingId);
}
@@ -71,13 +85,19 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
// combinator signals ---------------------------------------------------------
async updateStorageEventHandler(message, event) {
if (this.stopping) {
return;
}
let items = message.data.items;
// XXX this should be moved to instance/clusterio api
items.push(["signal-unixtime", Math.floor(Date.now()/1000)]);
let itemsJson = libLuaTools.escapeString(JSON.stringify(items));
await this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
let task = this.sendRcon(`/sc __subspace_storage__ UpdateInvData("${itemsJson}")`, true);
this.pendingTasks.add(task);
task.finally(() => { this.pendingTasks.delete(task); });
await task;
}
}
+2 -2
View File
@@ -137,8 +137,8 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
}
} else {
let instanceConfig = this.master.db.instances.get(instanceId);
let instanceName = instanceConfig ? instanceConfig.get("instance.name") : "unkonwn";
let instance = this.master.instances.get(instanceId);
let instanceName = instance ? instance.config.get("instance.name") : "unkonwn";
// use fancy neural net to calculate a "fair" dole division rate.
if (method === "neural_dole") {
+12 -7
View File
@@ -1,17 +1,20 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.5",
"version": "2.0.0-alpha.8",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/factorioClusterio",
"repository": "clusterio/clusterio",
"scripts": {
"build": "webpack-cli --env.production",
"build": "webpack-cli --env production",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/clusterio/factorioClusterio/issues"
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@clusterio/lib": "^2.0.0-alpha.0"
@@ -20,11 +23,13 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.5",
"antd": "^4.6.5",
"@clusterio/lib": "^2.0.0-alpha.8",
"@clusterio/web_ui": "^2.0.0-alpha.8",
"antd": "^4.16.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"webpack-cli": "^3.3.12",
"webpack": "^5.36.2",
"webpack-cli": "^4.7.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+204 -4
View File
@@ -7,9 +7,10 @@ const events = require("events");
const libLink = require("@clusterio/lib/link");
const libUsers = require("@clusterio/lib/users");
const testStrings = require("../lib/factorio/test_strings");
const {
TestControl, TestControlConnector, url, controlToken, slowTest,
get, exec, execCtl, sendRcon, getControl, instancesDir,
get, exec, execCtl, sendRcon, getControl, spawn, instancesDir,
} = require("./index");
@@ -61,6 +62,65 @@ describe("Integration of Clusterio", function() {
});
});
describe("clusterioslave", function() {
describe("slaveUpdateEventHandler()", function() {
it("should trigger when a new slave is added", async function() {
// On windows there's currently no way to automate graceful shutdown of the slave
// process as CTRL+C is some weird terminal thing and SIGINT isn't a thing.
if (process.platform === "win32") {
this.skip();
}
slowTest(this);
getControl().slaveUpdates = [];
let config = "alt-slave-config.json";
let configPath = path.join("temp", "test", config);
await fs.remove(configPath);
await execCtl(`slave create-config --id 5 --name alt-slave --generate-token --output ${config}`);
await exec(
`node ../../packages/slave --config ${config} config set slave.tls_ca ../../test/file/tls/cert.pem`
);
let slaveProcess;
try {
slaveProcess = await spawn(
"alt-slave:", `node ../../packages/slave run --config ${config}`, /SOCKET \| registering slave/
);
} finally {
if (slaveProcess) {
slaveProcess.kill("SIGINT");
await events.once(slaveProcess, "exit");
}
}
let sawUpdate = false;
let sawConnected = false;
let sawDisconnected = false;
for (let update of getControl().slaveUpdates) {
if (update.name !== "alt-slave") {
continue;
}
sawUpdate = true;
if (update.connected) {
sawConnected = true;
} else {
sawDisconnected = true;
}
}
assert(sawUpdate, "No slave update was sent");
assert(sawConnected, "No slave update with status connected was sent");
assert(sawDisconnected, "No slave update with status disconnected was sent");
let result = await libLink.messages.listSlaves.send(getControl());
let slaves = new Map(result.list.map(instance => [instance.id, instance]));
assert(slaves.has(5), "Slave list was not updated");
});
});
});
describe("clusterioctl", function() {
describe("master config list", function() {
it("runs", async function() {
@@ -113,6 +173,9 @@ describe("Integration of Clusterio", function() {
it("runs", async function() {
await execCtl("slave generate-token --id 42");
});
it("runs without an id", async function() {
await execCtl("slave generate-token");
});
});
describe("instance list", function() {
it("runs", async function() {
@@ -122,10 +185,18 @@ describe("Integration of Clusterio", function() {
describe("instance create", function() {
it("creates the instance", async function() {
this.timeout(6000);
await execCtl("instance create test --id 44");
let instances = await getInstances();
assert(instances.has(44), "instance was not created");
assert.equal(instances.get(44).status, "unassigned", "incorrect instance status");
// Make sure the following tests does not fail due to not having internet
let value = JSON.stringify({ lan: true, public: false }).replace(
/"/g, process.platform === "win32" ? '""' : '\\"'
);
await execCtl(`instance config set-prop test factorio.settings visibility "'${value}'"`);
await execCtl("instance config set-prop test factorio.settings require_user_verification false");
});
});
@@ -145,6 +216,21 @@ describe("Integration of Clusterio", function() {
});
});
describe("saveListUpdateEventHandler()", function() {
it("should have triggered for the created save", function() {
slowTest(this);
assert.equal(getControl().saveListUpdates.slice(-1)[0].list[0].name, "world.zip");
});
});
describe("instance list-saves", function() {
it("lists the created save", async function() {
slowTest(this);
let result = await execCtl("instance list-saves test");
assert(/world\.zip/.test(result.stdout), "world.zip not present in list save output");
});
});
describe("instance export-data", function() {
it("exports the data", async function() {
slowTest(this);
@@ -157,11 +243,31 @@ describe("Integration of Clusterio", function() {
});
describe("instance start", function() {
it("starts the instance", async function() {
it("should not hang if factorio version does not exist", async function() {
slowTest(this);
await execCtl("instance start test");
try {
await execCtl("instance config set 44 factorio.version 0.1.2");
await assert.rejects(execCtl("instance start test"));
} finally {
await execCtl("instance config set 44 factorio.version latest");
}
});
it("starts the given save", async function() {
slowTest(this);
await execCtl("instance start test --save world.zip");
await checkInstanceStatus(44, "running");
});
it("copies the save if an autosave is the target", async function() {
slowTest(this);
await execCtl("instance stop 44");
let savesDir = path.join("temp", "test", "instances", "test", "saves");
await fs.copy(path.join(savesDir, "world.zip"), path.join(savesDir, "_autosave1.zip"));
await execCtl("instance start test");
let result = await libLink.messages.listSaves.send(getControl(), { instance_id: 44 });
let running = result.list.find(s => s.loaded);
assert(running.name !== "_autosave1.zip");
});
});
describe("instance send-rcon", function() {
@@ -184,7 +290,7 @@ describe("Integration of Clusterio", function() {
"afk-auto-kick", "Kick if AFK for more than 2 minutes.",
],
[
"allow_commands", true,
"allow_commands", "true",
"allow-commands", "Allow Lua commands: Yes.",
],
[
@@ -315,6 +421,83 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance load-scenario", function() {
it("starts the instance with the given settings", async function() {
slowTest(this);
await execCtl("instance config set 44 factorio.enable_save_patching false");
await execCtl("instance config set 44 player_auth.load_plugin false");
await execCtl("instance config set 44 research_sync.load_plugin false");
await execCtl("instance config set 44 statistics_exporter.load_plugin false");
await execCtl("instance config set 44 subspace_storage.load_plugin false");
try {
let exchangeString = testStrings.modified.replace(/[\n\r]+/g, "");
let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`;
await execCtl(`instance load-scenario test ${args}`);
await checkInstanceStatus(44, "running");
await sendRcon(44, '/c game.print("disable achievements")');
await sendRcon(44, '/c game.print("disable achievements")');
assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n");
assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n");
assert.equal(
await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n"
);
} finally {
await execCtl("instance stop test");
}
});
});
describe("instance upload-save", function() {
it("should upload a zip file", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await execCtl("instance upload-save 44 upload.zip");
assert(
await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
"file not uploaded to saves directory"
);
});
it("should reject non-zip files", async function() {
await fs.outputFile(path.join("temp", "test", "invalid"), "a test");
await assert.rejects(execCtl("instance upload-save 44 invalid"));
});
it("should reject path traversal attacks", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await assert.rejects(execCtl("instance upload-save 44 upload.zip --name ../traversal.zip"));
});
});
describe("instance download-save", function() {
it("should download a save", async function() {
await fs.remove(path.join("temp", "test", "upload.zip"));
await execCtl("instance download-save 44 upload.zip");
assert(await fs.pathExists(path.join("temp", "test", "upload.zip")));
});
it("should error if save does not exist", async function() {
await assert.rejects(execCtl("instance download-save 44 invalid"));
});
it("should error on path traversal attacks", async function() {
await assert.rejects(execCtl("instance download-save 44 ../factorio-current.log"));
});
});
describe("instance delete-save", function() {
it("should delete a save", async function() {
await execCtl("instance delete-save 44 upload.zip");
assert(
!await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
"file not deleted"
);
});
it("should error if save does not exist", async function() {
await assert.rejects(execCtl("instance delete-save 44 upload.zip"));
});
it("should error on path traversal attacks", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await assert.rejects(execCtl("instance delete-save 44 ../../upload.zip"));
});
});
describe("instance delete", function() {
it("deletes the instance", async function() {
slowTest(this);
@@ -325,6 +508,23 @@ describe("Integration of Clusterio", function() {
});
});
describe("instanceUpdateEventHandler()", function() {
it("should have triggered for the previous instance status updates", function() {
let statusesToCheck = new Set([
"unassigned", "stopped", "creating_save", "exporting_data",
"starting", "running", "stopping", "deleted",
]);
let statusesNotSeen = new Set(statusesToCheck);
for (let update of getControl().instanceUpdates) {
assert(statusesToCheck.has(update.status), `Missing check for status ${update.status}`);
statusesNotSeen.delete(update.status);
}
assert(statusesNotSeen.size === 0, `Did not see the statuses ${[...statusesNotSeen]}`);
});
});
describe("permission list", function() {
it("runs", async function() {
await execCtl("permission list");
+33 -3
View File
@@ -25,6 +25,21 @@ class TestControl extends libLink.Link {
constructor(connector) {
super("control", "master", connector);
libLink.attachAllMessages(this);
this.slaveUpdates = [];
this.instanceUpdates = [];
this.saveListUpdates = [];
this.connector.on("connect", () => {
libLink.messages.setSlaveSubscriptions.send(
this, { all: true, slave_ids: [] }
).catch(err => logger.error(`Error setting slave subscriptions:\n${err.stack}`));
libLink.messages.setInstanceSubscriptions.send(
this, { all: true, instance_ids: [] }
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
libLink.messages.setSaveListSubscriptions.send(
this, { all: true, instance_ids: [] }
).catch(err => logger.error(`Error setting save list subscriptions:\n${err.stack}`));
});
}
async prepareDisconnectRequestHandler(message, request) {
@@ -34,6 +49,18 @@ class TestControl extends libLink.Link {
async debugWsMessageEventHandler() { }
async slaveUpdateEventHandler(message) {
this.slaveUpdates.push(message.data);
}
async instanceUpdateEventHandler(message) {
this.instanceUpdates.push(message.data);
}
async saveListUpdateEventHandler(message) {
this.saveListUpdates.push(message.data);
}
async logMessageEventHandler() { }
}
@@ -46,7 +73,7 @@ class TestControlConnector extends libLink.WebSocketClientConnector {
// Mark that this test takes a lot of time, or depeneds on a test
// that takes a lot of time.
function slowTest(test) {
// eslint-disable-next-line no-process-env
// eslint-disable-next-line node/no-process-env
if (process.env.FAST_TEST) {
test.skip();
}
@@ -156,7 +183,7 @@ before(async function() {
await exec("node ../../packages/master bootstrap create-ctl-config test");
await exec("node ../../packages/ctl control-config set control.tls_ca ../../test/file/tls/cert.pem");
masterProcess = await spawn("master:", "node ../../packages/master run", /All plugins loaded/);
masterProcess = await spawn("master:", "node ../../packages/master run", /Started master/);
await execCtl("slave create-config --id 4 --name slave --generate-token");
await exec(`node ../../packages/slave config set slave.factorio_directory ${path.join("..", "..", "factorio")}`);
@@ -183,7 +210,9 @@ after(async function() {
masterProcess.kill("SIGINT");
await events.once(masterProcess, "exit");
}
await control.connector.close();
if (control) {
await control.connector.close();
}
});
// Ensure the test processes are stopped.
@@ -202,6 +231,7 @@ module.exports = {
execCtl,
sendRcon,
getControl,
spawn,
url,
controlToken,
+16
View File
@@ -6,6 +6,7 @@ const path = require("path");
const libFactorio = require("@clusterio/lib/factorio");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
const { slowTest } = require("./index");
@@ -34,6 +35,15 @@ describe("Integration of lib/factorio/server", function() {
await fs.ensureDir(writePath);
logFile = fs.createWriteStream(path.join(writePath, "log.txt"), "utf8");
server.on("output", function(output) {
if (output.source === "stderr") {
// Factorio doesn't print on stderr but sometimes on
// Travis a BrokenPipeError is printed on stderr after
// the server has closed for mysterious reasons.
// I have no idea what causes it, but it causes a write
// after end error if not handled.
logger.error(`Factorio stderr: ${JSON.stringify(output)}`);
return;
}
logFile.write(`${JSON.stringify(output)}\n`);
});
@@ -93,6 +103,12 @@ describe("Integration of lib/factorio/server", function() {
slowTest(this);
log(".start()");
// Make sure the test does not fail due to not having internet
let settings = await server.exampleSettings();
settings.visibility = { public: false, lan: true };
settings.require_user_verification = false;
await fs.outputFile(server.writePath("server-settings.json"), JSON.stringify(settings, null, 4));
// Make sure the test does not fail due to create() failing.
let mapPath = server.writePath("saves", "test.zip");
assert(await fs.exists(mapPath), "save is missing");
+73
View File
@@ -0,0 +1,73 @@
"use strict";
const assert = require("assert").strict;
const zlib = require("zlib");
const libFactorio = require("@clusterio/lib/factorio");
const testStrings = require("./test_strings");
// Simple linear congruential random number generator
class Random {
constructor(seed) {
this.prev = seed;
}
next() {
let curr = (1103515245 * this.prev + 12345) & 0x7fffffff;
this.prev = curr;
return curr >> 16;
}
}
describe("lib/factorio/exchange_string", function() {
describe("readMapExchangeString", function() {
it("should parse a valid string", function() {
let result = libFactorio.readMapExchangeString(testStrings.default);
assert.equal(result.map_gen_settings.seed, 1234567890);
result = libFactorio.readMapExchangeString(testStrings.modified);
assert.equal(result.checksum, 4092204126);
});
it("should handle malformed strings", function() {
assert.throws(
() => libFactorio.readMapExchangeString("<<blah>>"),
new Error("Not a map exchange string")
);
assert.throws(
() => libFactorio.readMapExchangeString(testStrings.default.slice(0, 100)),
new Error("Not a map exchange string")
);
assert.throws(
() => libFactorio.readMapExchangeString(`>>>${Buffer.from("abk430ia404ah3b4").toString("base64")}<<<`),
new Error("Malformed map exchange string: zlib inflate failed")
);
for (let i = 0; i < 100; i++) {
let gen = new Random(i);
let size = gen.next() % 200 + 100;
let data = Buffer.alloc(size);
for (let j = 0; j < size; j++) {
data[j] = gen.next() % 256;
}
// eslint-disable-next-line node/no-sync
data = zlib.deflateSync(data);
assert.throws(
() => libFactorio.readMapExchangeString(`>>>${data.toString("base64")}<<<`),
new Error("Malformed map exchange string: reached end before finishing parsing")
);
}
let pastEnd = Buffer.from(testStrings.default.replace(/><\n/g, ""), "base64");
// eslint-disable-next-line node/no-sync
pastEnd = zlib.deflateSync(Buffer.concat([zlib.inflateSync(pastEnd), Buffer.from("junk")]));
assert.throws(
() => libFactorio.readMapExchangeString(`>>>${pastEnd.toString("base64")}<<<`),
new Error("Malformed map exchange string: data after end")
);
});
});
});
+38
View File
@@ -0,0 +1,38 @@
"use strict";
// Exchange strings for testing with
let default_ = `
>>>eNpjZGBkUGQAgwZ7EOZgSc5PzIHxQJgrOb+gILVIN78oFVmYM
7moNCVVNz8TVXFqXmpupW5SYjGKYo7Movw8dBNYi0vy81BFSopSU
4uRRbhLixLzMktz0fUyMF5imubZ0CLHAML/6xkU/v8HYSDrAdAvI
MzA2ABRCRSDAdbknMy0NAYGBUcgdgJJMzIyVousc39YNcWeEaJGz
wHK+AAVOZAEE/GEMfwccEqpwBgmSOYYg8FnJAbE0hKgFVBVHA4IB
kSyBSTJyNj7duuC78cu2DH+Wfnxkm9Sgj2joavIuw9G6+yAkuwgf
zLBiVkzQWAnzCsMMDMf2EOlbtoznj0DAm/sGVlBOkRAhIMFkDjgz
czAKMAHZC3oARIKMgwwp9nBjBFxYEwDg28wnzyGMS7bo/sDGBA2I
MPlQMQJEAG2EO4yRgjTod+B0UEeJiuJUALUb8SA7IYUhA9Pwqw9j
GQ/mkMwIwLZH2giKg5YooELZGEKnHjBDHcNMDwvsMN4DvMdGJlBD
JCqL0AxCA8kAzMKQgs4gIObmQEBgGmjrO/lHgDxa6G4<<<
`;
let modified = `
>>>eNp1UzFs00AUvZ8mbRIEypCBSlCCyFAhOQqBKULxlQUxMCCxgnCc
S2rh2On5LAgMZOjAgISEurQLXSmCBTGwRWKhEkgIJraiMgBiaGmFOiC
FO5/PsUz6pfv+9/6/99+/kwEBOo2k6c83NhrZtOkaNkIDXa286fZ6hG
ouJaJIwTmT+i2iuZYdQ3GeOKTb15qGJ4oxQsMglbWo60iGYcSQ8Zjrh
GUhwighHpcRSBHIEZ8ajuV35dmBwIPK1DRxmMX6ADnqmre1Rb/D86UF
hNYX1lZncZpZNkGpmQ41PE87J6RFTe4YjND4hPDq943NwfIcEmv0AJV
GI7F4tMXViYVgECgCjoWWPm66DqOurXmEMcvp1A3/br1pGV5Oq1ZqVW
Hzk0ralCz5xDH79a5vM6tnW4Rma5XgQPVk8kTXtTzmU5Jg1g6tm0hfr
ZwPLGPaVrstL6p0SYwEAPeLLy9/vbeigxysgsPgIESGTYVcCYMnL/Bh
qbRKoQsq2NFBdt+LBbIp4y3CqiweBzK5LJIAu4vbD18f7Dfg77PdT1e
bt3S4/q2w5J39IbQfFW+TitzaqrA3ahSkOLf0MPVFhw/vhf3SYUqcOC
PczkoKQfXaNILCMb5df8Rd6QRS0hqKpoihHdgfNcm2Cj7ryTnKGC4K8
jnh3gmXQRElVwYyxI8x4FMqOzsu4edrKK6hNZ5wU7V9G+ufEFL+7yHi
cySQMp7wDHnRsBW571ORGn6fH2fUDj/FwV0iUbXPMbmTP7akkt8ChiL
/BPceEs3j1M3iz71/ObT9tA==<<<`;
module.exports = {
default: default_,
modified,
};
+35
View File
@@ -13,6 +13,13 @@ describe("lib/file_ops", function() {
await fs.outputFile(path.join(baseDir, "test", "file.txt"), "contents");
await fs.outputFile(path.join(baseDir, "test", "another file.txt"), "more contents");
await fs.outputFile(path.join(baseDir, "find", "file"), "contents");
await fs.outputFile(path.join(baseDir, "find", "file.txt"), "contents");
await fs.outputFile(path.join(baseDir, "find", "foo-1"), "contents");
await fs.outputFile(path.join(baseDir, "find", "foo-2"), "contents");
await fs.outputFile(path.join(baseDir, "find", "bar-1.txt"), "contents");
await fs.outputFile(path.join(baseDir, "find", "bar-2.txt"), "contents");
}
before(setupTestingEnv);
@@ -31,4 +38,32 @@ describe("lib/file_ops", function() {
assert.equal(newest, null);
});
});
describe("findUnusedName()", function() {
it("should return named unchanged if it does not exist", async function() {
let cases = [
[["file"], "file"],
[["file", ".txt"], "file.txt"],
[["file.txt", ".txt"], "file.txt"],
];
for (let [args, expected] of cases) {
let actual = await libFileOps.findUnusedName(path.join(baseDir, "test", "folder"), ...args);
assert.equal(actual, expected);
}
});
it("should return changed name if it does exist", async function() {
let cases = [
[["file"], "file-2"],
[["file", ".txt"], "file-2.txt"],
[["file.txt", ".txt"], "file-2.txt"],
[["foo-1"], "foo-3"],
[["bar-1", ".txt"], "bar-3.txt"],
[["bar-1.txt", ".txt"], "bar-3.txt"],
];
for (let [args, expected] of cases) {
let actual = await libFileOps.findUnusedName(path.join(baseDir, "find"), ...args);
assert.equal(actual, expected);
}
});
});
});
-9
View File
@@ -1,9 +0,0 @@
"use strict";
const assert = require("assert").strict;
const mock = require("./mock");
const master = require("@clusterio/master/master");
describe("Master testing", function() {
});
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more