Compare commits

..
Author SHA1 Message Date
Hornwitser 9869d92ead v2.0.0-alpha.4 2021-02-26 15:52:18 +01:00
Hornwitser a248a67d10 Merge pull request #360 from Hornwitser/edit-master-config
Remote master config management
2021-02-26 15:40:24 +01:00
Hornwitser bec30c9927 Add remote editing of master config
Add commands and web UI for editing the master configuration remotely.
2021-02-24 22:16:03 +01:00
Hornwitser 38b7d0f600 Fix web config trying to set the new prop value as field
Fix the BaseConfigTree treating the nex property name and value boxes as
fields to be applied to the config when pressing Apply.
2021-02-24 20:18:41 +01:00
Hornwitser 0366334e7c Generalize InstanceConfigTree
Pull out the instance specific logic from InstanceConfigTree and turn it
into a general BaseConfigTree component that can be used for master and
slave configs.
2021-02-24 20:17:29 +01:00
Hornwitser b538960592 Add ErrorBoundary to page content
Prevent the page from turning into a black screen when page rendering
fails by adding an error boundary to the display of the page content in
SiteLayout.jsx.
2021-02-24 19:48:43 +01:00
Hornwitser ac17b8497a Split InstanceViewPage into separate components
Pull out the components in InstanceViewPage.jsx into separate source
files.
2021-02-24 18:47:03 +01:00
Hornwitser 2c74a67738 Add location access control to config class
Add a per field access parameter to the config class which specifies
which of master, slave and control can access the given field.  A
location is added to the config class to specify which party owns it and
fields which do not have an access parameter are not present in the
config and will throw if attempted to be read from or writen to.

The serialize, load, update, get, set and setProp are ammended with a
location parameter which specifiy where the data is from or destined to
and the access checks are performed to ensure remote access to the
config is still limited by the access parameter of field.

With this system in place the master config can be shared with control
and slave connections without compromising secrets that should stay
private.  This is primarily the master authentication secret, but other
plugins may define secrets it does not want to have shared with slave
and/or control connections.
2021-02-24 15:46:19 +01:00
Hornwitser 77cf4947b6 Merge pull request #359 from Hornwitser/small-things-3
Small things 3
2021-02-20 18:33:21 +01:00
Hornwitser c6a0435751 Fix missing space 2021-02-20 18:10:46 +01:00
Hornwitser 7e9f1562e4 Add Instance.id shortcut
Add shortcut for instance.config.get("instance.id") similar to the
already existing shortcut for its name.
2021-02-19 14:57:27 +01:00
Hornwitser 1de8b76303 Emit resume instead of connect on resumed connections
Raise "resume" instead of "connect" when a connection is re-established
after a connection drop.  This prevents logic meant to resynchronize
state after a full reconnection from being invoked whenever the
connection was temporarily dropped.
2021-02-19 14:28:19 +01:00
Hornwitser b4cbe2355d Add add/remove perms option to ctl role edit
Implement --add-perms and --remove-perms to add and remove permissions
from the role without having to list up all of the permissions in the
role again.

Rename --permissions to --set-perms to be consistent with the two new
options added.
2021-02-17 21:21:18 +01:00
Hornwitser 09c3f82226 Fix test failures on Windows
Fix broken regex, too tight time check, and lack of shell expansion on
Windows causing test cases to fail or not run at all.
2021-02-17 21:21:18 +01:00
Hornwitser 01e442c42e Mention WebSocket interface in docs
Replace old paragraph in the developing for clusterio document about the
removed HTTP interface with a notice of the new protocol running over
WebSocket.
2021-02-17 21:21:18 +01:00
Hornwitser 75dfc66e9a Document event_handler requiremenet for modules
Add note in the docs for modules that the event_handler interface has to
be used.
2021-02-17 21:21:18 +01:00
Hornwitser f8153bd784 Remove redundant parameter from attachPluginMessages
Remove the pluginInfo parameter from lib/plugin attachPluginMessages as
the plugin info object is available in the `.info` property of the
plugin.  While a plugin instance doesn't exist for the slave side and
was the reason for these to be separate arguments, I expect slave
plugins to be implemented eventually.
2021-02-17 21:21:18 +01:00
Hornwitser b7c7e79a86 Move distributed static/ folder to dist/web/
Move the target folder for the built web interface to dist/web/ instead
of static/.  This better follows the norm for npm packages to include
built files in the dist/ folder and also allows putting other built
files into that folder in the future.
2021-02-17 21:21:18 +01:00
Hornwitser 207a28cb44 Merge pull request #358 from Hornwitser/shutdown-logic
Shutdown logic changes.
2021-02-17 21:18:11 +01:00
Hornwitser 9660fa0801 Handle SIGHUP gracefully
Invoke shutdown instead of exiting immediately when SIGHUP is received,
and do the same on both slave and master for signals received.
2021-02-17 17:49:54 +01:00
Hornwitser 4ca092f351 Prevent shutdown running more than once
Exit immediately if shutdown has already been called.  This is due to
the possibility of it being called multiple times from combinations of
signals and fatal errors.
2021-02-17 17:48:05 +01:00
Hornwitser 99fa6b4b12 Set blocking on logging before process.exit
On windows the terminal output is asynchronous by default which leads to
log lines being lost if text is written to stdout before calling
process.exit.  Fix by using setBlocking before doing the log call.
2021-02-17 17:45:43 +01:00
Hornwitser fb30499d80 Fix Factorio config test failure
Fix response for allow-commands config having changed in the latest
experimental of Factorio.
2021-02-17 17:12:04 +01:00
Hornwitser 2a2c188aec Merge pull request #357 from furgin/handle-term-signal
Add SIGTERM handler to master and slave.
2021-02-17 16:55:39 +01:00
Matthew Jensen c101fa7a32 Added term signal handling to slave process 2021-02-03 10:22:31 +11:00
Matthew Jensen 40bd3edb32 Added TERM signal handler 2021-02-03 10:00:40 +11:00
Hornwitser e944364fc2 Merge pull request #354 from Hornwitser/improve-config
Improve config
2021-01-26 12:31:22 +01:00
Hornwitser a2c72b7fd1 Implement add and remove properties in web config
Implement removing properties for object config fields by setting them
to an empty string and adding new properties with a corresponding value.
2021-01-24 20:41:37 +01:00
Hornwitser 2117271992 Add option to read config value from stdin
Add --stdin option to the config set commands which reads the value to
set via stdin instead of on the command line.  This allows a more secure
way of entering values and avoids having to deal with multiple layers of
quoting and escapes for special characters.
2021-01-23 13:40:17 +01:00
Hornwitser 0e21060ea3 Allow removing properties with set-prop
Implement removal of properties in ConfigGroup.setProp,
set_instance_config_prop, and the set-prop clusterioctl command.
2021-01-22 12:53:53 +01:00
Hornwitser dea3a30cae Allow optional top level properties in messages
Add optional arguments to Request and Event to control which properties
are required to be present in the payload.  This allows gracefully
extending the protocol, reducing overhead for optional data, and leaving
properties out for additional behaviour.
2021-01-22 12:20:27 +01:00
Hornwitser b84c5b4293 Add common settings for Factorio servers
Add settings of interest to server administrators that appear in
server-settings.json to the factorio.settings config.  This makes it
easier to see which settings can be set there.
2021-01-18 08:17:14 +01:00
Hornwitser 201eb89dcd Assume bad JSON is string in set-prop
Assume value is supposed to be a string if it fails to parse as JSON and
it does not start and end in a [], {}, or "" pair.
2021-01-16 11:01:15 +01:00
Hornwitser 1b901f5d1c Fix the remaning linter warnings (#353)
Refactor the code base to remove all of the remaining warnings from
ESLint and convert them into errors.
2021-01-05 22:28:08 +01:00
Hornwitser 9243731646 Merge pull request #352 from Hornwitser/rework-tls
Remove automatic creation of TLS certificate
2021-01-04 21:27:15 +01:00
Hornwitser 06a0255547 Increase integration test set up timeout
Double time alloted for the setting up phase of the integration tests
from 20 seconds to 40 seconds after a suspected timeout during the
travis build.
2021-01-04 09:04:04 +01:00
Hornwitser b89688597a Fix ctl control-config not being recognized
When parsing using the .argv interface to yargs the parser state is not
restored back to its pre-parsing state after the operation completes.
This causes a susequent reparse with argv to forget its commands and
respond with an unknown arguments error when trying to use the
control-config command to clusterioctl.  Fix by switching to use the
parse() interface which does restore the parser state after completing.
2021-01-04 08:22:05 +01:00
Hornwitser 3592ff3269 Remove automatic creation of TLS certificate
The automatic creation of a self-signed certificate while nice presents
several problems.  To make connections not reject the self-signed
certificate the validation of TLS certificates are wholesale disabled,
decreasing the security even when properly set up and trusted
certificates are used.

To make the created self-signed certificate pass validation it is
necessary for it to list the host names it's valid for and to copy it to
each machine that intends to connect to the server.  These two steps
complicate automating the process in a reliable way and will likely lead
to confused users seeking to disable TLS certificate validation as the
easy way out to problems.

For these reasons, remove the automating creation of a broke self-signed
certificate, fix validation of certificates and document how to set up
TLS properly.  This makes HTTPS recommended when accessing over the
internet, but still something which must be explicitly configured.
2021-01-04 08:22:05 +01:00
Hornwitser a094a06cd1 Merge pull request #351 from Hornwitser/alpha-bugfixes-3
Alpha bugfixes 3
2020-12-29 17:57:48 +01:00
Hornwitser aa3dfdbc0a Fix crash on race condition stopping Factorio server
If the server decides to exit on its own during the stop operation to
the FactorioServer class after the operation has started but before the
signal is sent to the process then the exit handler will have wiped the
server child process and cause the kill call to fail.  This in turn
would let the hang timer running which would bring down the Node.js
process due to an unhandled error for the same reason.

Fix by making sure the stop operation watches for the server exiting and
returning early if the server already quit before getting to the send
stop signal part.
2020-12-29 03:34:38 +01:00
Hornwitser 16525ae07c Add graceful closure of HTTP connections
When closing down the HTTP and HTTPS servers existing connections are
not closed by Node.js, and may live on forever if they are actively in
use.  A common case where this happens is if Prometheus is set up with a
short interval for collections.

To close down the active connections they need to be tracked and closed
when the server is closed, add the HttpCloser class which encapsulates
the tracking and closure process behind a simple interface and use this
to close down the HTTP and HTTPS server on the master.
2020-12-28 07:05:04 +01:00
Hornwitser e8405cbf77 Fix message validation for request responses
Fix the validation loging for sending responses to requests not
functioning due to the message data property being passed incorrectly to
the validator.
2020-12-28 07:03:32 +01:00
Hornwitser 6eef4bd300 Make metrics gathering timeout configurable
When reducing the collection interval in Prometheus it may be necessary
to to reduce the metrics gathering timeout to ensure it's less than the
interval.  Add config options to adjust the timeouts used for metrics
gathering so that these can be set accordingly.
2020-12-28 07:03:32 +01:00
Hornwitser 699e3994df Add missing plugin error printing in slave.js
Special case PluginError printing during startup in slave.js in the same
way it's done in master.js.  This makes it so that the stack of the
error thrown by a plugin is printed instead of the stack trace of the
place it was wrapped into a PluginError.
2020-12-28 07:03:32 +01:00
Hornwitser b681ea7243 Add RateLimiter helper
Add helper class for implementing rate limited operations.
2020-12-28 07:03:32 +01:00
Hornwitser 29a3c26b44 Fix usage of logger in FactorioServer
Fix incorrect usage of the global logger, and typo'd use of the _logger
property in FactorioServer.
2020-12-28 07:03:32 +01:00
tovernaar123 35cb9f8698 Add bind_address option (#349)
Add option to set the IP address the master server binds its HTTP and
HTTPS ports to.
2020-12-28 03:40:28 +01:00
Daniel 6204c73ef2 Merge pull request #346 from Danielv123/github-actions
Added automated build and test infrastructure
2020-12-23 17:15:16 +01:00
Danielv123 95e1b3e349 Run actions for all branches 2020-12-23 14:26:56 +00:00
Danielv123 30f345be55 Added automated build and test infrastructure
Based on docker and github actions
Builds a docker image for each commit as well as the lua lib mod
Builds the master branch of subspace_storage for use with the tests
Tests are based on latest stable factorio branch
2020-12-23 14:13:02 +00:00
Arvids Godjuks 231505c6fe Merge pull request #345 from BlizzardBlue/patch-1
Fix typo in README.md
2020-12-22 12:08:21 +02:00
BlizzardBlue d87252d52f Fix typo in README.md 2020-12-22 12:07:45 +09:00
105 changed files with 3766 additions and 1630 deletions

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# No need to invalidate cache just because we try speeding up the build
.dockerignore
Dockerfile
docker-compose*
# Install dependencies during the build
node_modules
packages/**/node_modules
plugins/**/node_modules
# Download factorio during the build
factorio
factorio.tar.gz
# We build internal mods during build, mods from local test env should not be applied
sharedMods
sharedModsDisabled
# These files should never be required during the build
.git
.github
dist
temp
+1 -2
View File
@@ -1,2 +1 @@
packages/master/static
plugins/*/static
dist/
+20 -20
View File
@@ -45,7 +45,7 @@ module.exports = {
"arrow-spacing": ["error", { "after": true, "before": true }],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["warn", "1tbs", { "allowSingleLine": true }],
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
@@ -60,18 +60,18 @@ module.exports = {
"functions": "only-multiline",
},
],
"comma-spacing": "warn",
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": "warn",
"complexity": "error",
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
"curly": "warn",
"curly": "error",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "error",
"dot-location": ["error", "property"],
"dot-notation": ["warn", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
@@ -81,7 +81,7 @@ module.exports = {
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"global-require": "warn",
"global-require": "error",
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"handle-callback-err": "error",
@@ -94,22 +94,22 @@ module.exports = {
"init-declarations": "off",
"jsx-quotes": "error",
"key-spacing": "error",
"keyword-spacing": "warn",
"keyword-spacing": "error",
"line-comment-position": "off",
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always"],
"max-classes-per-file": "off",
"max-depth": "warn",
"max-len": ["warn", { "code": 120 }],
"max-depth": "error",
"max-len": ["error", { "code": 120 }],
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "off",
"multiline-comment-style": ["warn", "separate-lines"],
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": "error",
"new-parens": "error",
@@ -130,7 +130,7 @@ module.exports = {
"no-continue": "off",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "warn",
"no-else-return": "error",
"no-empty-function": "off",
"no-eq-null": "error",
"no-eval": "error",
@@ -143,7 +143,7 @@ module.exports = {
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-invalid-this": "warn",
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-lone-blocks": "error",
@@ -179,7 +179,7 @@ module.exports = {
"no-path-concat": "error",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-process-env": "error",
"no-process-exit": "warn",
"no-process-exit": "error",
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
@@ -205,7 +205,7 @@ module.exports = {
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": "warn",
"no-use-before-define": "error",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
@@ -213,7 +213,7 @@ module.exports = {
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "warn",
"no-var": "error",
"no-void": ["error", { "allowAsStatement": true }],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
@@ -234,7 +234,7 @@ module.exports = {
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "warn",
"prefer-arrow-callback": "error",
"prefer-const": "off",
"prefer-destructuring": "off",
"prefer-exponentiation-operator": "error",
@@ -246,7 +246,7 @@ module.exports = {
"prefer-regex-literals": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "warn",
"prefer-template": "error",
"quote-props": "off",
"quotes": ["error", "double", { "avoidEscape": true }],
"radix": ["error", "always"],
@@ -261,12 +261,12 @@ module.exports = {
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "error",
"space-before-blocks": "warn",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["warn", "always"],
"spaced-comment": ["error", "always"],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
@@ -274,7 +274,7 @@ module.exports = {
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", { "requireReturn": false }],
"vars-on-top": "warn",
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
+159
View File
@@ -0,0 +1,159 @@
name: Docker
on:
push:
# Publish `master` as Docker `latest` image.
branches:
- '**'
# Publish `v1.2.3` tags as releases.
tags:
- v*
# Run tests for any PRs.
pull_request:
release:
types: [released, prereleased]
env:
# TODO: Change variable to your image's name.
IMAGE_NAME: clusterio
jobs:
# Build dependencies and clusterio image
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build docker image
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_final
- name: Export docker image
run: docker save -o clusterio_docker_image $IMAGE_NAME
- name: Upload docker image
uses: actions/upload-artifact@master
with:
name: clusterio_docker_image
path: clusterio_docker_image
# Build dependencies and clusterio image with test tooling
build_testing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build docker image
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_testing
- name: Export docker image
run: docker save -o clusterio_docker_image_testing $IMAGE_NAME
- name: Upload docker image
uses: actions/upload-artifact@master
with:
name: clusterio_docker_image_testing
path: clusterio_docker_image_testing
# Upload clusterio_lib and subspace_storage versions
upload_mods:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download docker image
uses: actions/download-artifact@master
with:
name: clusterio_docker_image
path: .
- name: Load docker image
run: docker load -i clusterio_docker_image
- name: Create container
run: docker create --name clusterio_container $IMAGE_NAME
- name: Move mods out of container
run: docker cp clusterio_container:/clusterio/sharedMods ./mods
- name: Remove container
run: docker rm clusterio_container
- name: List extracted mods
run: ls mods
- name: Upload mods
uses: actions/upload-artifact@master
with:
name: mods
path: mods
# Run tests.
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
test:
needs: build_testing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Download docker image
uses: actions/download-artifact@master
with:
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
- name: Load docker image
run: docker load -i clusterio_docker_image_testing
- name: Run tests
run: |
if [ -f docker-compose.test.yml ]; then
docker-compose --file docker-compose.test.yml run unit_test
else
docker build . --file Dockerfile
fi
# Push image to GitHub Packages.
# See also https://docs.docker.com/docker-hub/builds/
push:
# Ensure test job passes before pushing image.
needs: [test, build]
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v2
- name: Download artifact
uses: actions/download-artifact@master
with:
name: clusterio_docker_image
path: .
- name: Load docker image
run: docker load -i clusterio_docker_image
- name: Log into registry
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image
run: |
IMAGE_ID=ghcr.io/${{ github.actor }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "master" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION
+1 -4
View File
@@ -31,6 +31,7 @@ dist/
node_modules
Factorio*
factorio*
/factorio
/instances
/sharedMods
@@ -45,10 +46,6 @@ static/export
saves
/temp/
# Web build
/packages/master/static
/plugins/*/static
# IDE
.idea/
.vs/
+3 -2
View File
@@ -53,11 +53,11 @@ Version 2.0.0
- Added option to auto start instances on slave startup.
- Added metric mapping of slave and instance ids to their names, allowing
them to be displayed by name in queries that join with the mapping.
- Added option to configure timeout for exported Prometheus metrics.
- Added option to set address to bind HTTP(S) port to.
### Changes
- Fixed sslCert and sslPrivKey entries being ignored in the config.
- Changed ssl key creation to be done at startup instead of on npm install.
- Added error handling during master startup.
- Factorio game and rcon port now defaults to a random port above 49151.
- Removed unimplemented mods update command.
@@ -179,6 +179,7 @@ Version 2.0.0
- Removed usage of socket.io entirely in favor of a plain WebSocket connection.
- Renamed clusterioMod plugin to subspace_storage.
- Master no longer creates secret-api-token.txt.
- Removed automatic creation of self-signed TLS certificate.
Version 1.2.4
+58 -17
View File
@@ -1,23 +1,64 @@
# Warning: This is currently broken as a result of
# changes done in the 2.0 refactoring.
FROM node:12
RUN apt-get update && apt install git curl tar -y
RUN mkdir factorioClusterio
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 \
&& node build
RUN git clone -b master https://github.com/clusterio/factorioClusterio.git && cd factorioClusterio && npm install --only=production
RUN cd factorioClusterio && curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz
FROM node:12 as clusterio_builder
RUN apt update \
&& apt install -y wget \
&& mkdir /clusterio
WORKDIR /clusterio
RUN wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
# Copy project files into the container
COPY . .
WORKDIR factorioClusterio
RUN mkdir instances sharedMods
RUN cp config.json.dist config.json
RUN npm install \
&& npx lerna bootstrap --hoist
RUN node client manage shared mods add clusterio
# Install plugins. This is intended as a reasonable default, enabling plugins to make for fun gameplay.
# If you want a different set of plugins, consider using this as the base image for your own.
#RUN npm install @clusterio/plugin-subspace_storage
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
LABEL maintainer "Sir3lit@gmail.com"
COPY --from=subspace_storage_builder /factorioClusterioMod/dist/ /clusterio/sharedMods/
EXPOSE 8080 34167
VOLUME /factorioClusterio/instances
VOLUME /factorioClusterio/sharedMods
VOLUME /factorioClusterio/sharedPlugins
# Build submodules (web UI, libraries, plugins etc)
RUN npx lerna run build
# Build Lua library mod
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
&& mv dist/* sharedMods/ \
&& mkdir temp \
&& mkdir temp/test \
&& cp sharedMods/ temp/test/ -r \
&& ls sharedMods
CMD MODE="$MODE" node $MODE start $INSTANCE
# Remove node_modules
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
FROM frolvlad/alpine-glibc AS clusterio_final
RUN apk add --update bash nodejs npm
COPY --from=clusterio_builder /clusterio /clusterio
WORKDIR /clusterio
# Install runtime dependencies
RUN npm install --production
RUN npx lerna bootstrap -- --production --no-optional
LABEL maintainer "danielv@danielv.no"
FROM frolvlad/alpine-glibc AS clusterio_testing
RUN apk add --update bash nodejs npm
COPY --from=clusterio_builder /clusterio /clusterio
WORKDIR /clusterio
# Install runtime dependencies
RUN npm install
RUN npx lerna bootstrap
RUN npm install chalk semver
+9 -46
View File
@@ -80,7 +80,7 @@ into a Clusterio cluster.
## Plugins
The heart of Clusterio is its plugin syste. Plugins add functionality
The heart of Clusterio is its plugin system. Plugins add functionality
to Factorio servers, Clusterio itself or both. These are the plugins
supported and maintained by the Clusterio developers:
@@ -206,21 +206,21 @@ with either . or .. (which will then be resolved to an absolute path).
## Configure Master Server
By default the master server will create a self signed TLS certificate
and listen on HTTPS on port 8443. You can change the port used with the
command
By default the master server will listen for HTTP on port 8080. You can
change the port used with the command
npx clusteriomaster config set master.https_port 1234
npx clusteriomaster config set master.http_port 1234
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
npx clusteriomaster config set master.external_address https://192.0.2.4:8443/
npx clusteriomaster config set master.external_address http://203.0.113.4:1234/
Change the url to reflect the IP, protocol, and port the master server
is accessible under, dns names are also supported. If proxyed under a
sub-path include the sub path as well and make sure the URL ends with a
slash.
is accessible under, dns names are also supported. If you're planning
on making the master server accessible on the internet it's recommended
to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md)
document for more details.
You can list the config of the master server with the `npx
clusteriomaster config list` command. See the [readme for
@@ -228,43 +228,6 @@ clusteriomaster config list` command. See the [readme for
information.
### Hosting Behind Proxy
If you want to host the master server behind a reverse proxy the
recommended setup is to disable HTTPS and host it on a high port
over HTTP to which you point your proxy to. For example to configure
port 8080 as the HTTP port for the master server.
npx clusteriomaster config set master.https_port # unsets https_port
npx clusteriomaster config set master.http_port 8080
The proxy can then forward requests to a sub-path like `/cluster/` to
`http://localhost:8080/`.
Additianlly WebSocket connections to api/socket relative to the sub-path
the interface is hosted under also needs to be forwarded. See below for
the specifics of setting this up on Apache.
#### Apache Config
To proxy with Apache make sure that proxy_module, proxy_http_module and
proxy_wstunnel_module is loaded in the config, then add the following
directives to wherever is the approriate place for your flavour of
Apache config organization.
```apache
<Location /cluster/>
ProxyPass "http://localhost:8080/"
</location>
<Location /cluster/api/socket>
ProxyPass "ws://localhost:8080/api/socket"
</Location>
```
Pay attention to the use of trailing slashes, they are important.
### Setting up an admin account
Before you can manage the cluster you need to bootstrap an admin account
+7
View File
@@ -0,0 +1,7 @@
version: '3'
services:
unit_test:
image: clusterio
build: .
command: npm run test
+9
View File
@@ -0,0 +1,9 @@
version: '3'
services:
master:
build: .
command: npx clusteriomaster run
slave:
build: .
command: npx clusterioslave run
+10
View File
@@ -28,6 +28,16 @@ after the groupName have been set and before the class has been
finalized. The `define` method takes an object as argument with the
following properties:
**access**:
The locations this config field can be read and modified from. This
is an array of strings which can contain the values `"master"`,
`"slave"` and `"control"`. If a party is missing in this array then
that party will not receive the value of this field when the config
is shared, and will be unable to modify the field. Takes the value
of `defaultAccess` set on the config group class if not passed.
Note that instance config groups should have both `"master"` and
`"slave"` for all of its fields or unexpected behaviour may occur.
**type**:
The type of config value this field will support. The supported
values are boolean, string, number, and object, and if an attept is
+91 -17
View File
@@ -5,6 +5,14 @@ Configuration
Master Configuration
--------------------
### master.name
Name of the cluster. Used to distinguish it from other clusters and is
prepended to the name of servers in the server list.
Defaluts to "Your Cluster".
### master.database_directory
Directory used to store cluster data to. Needs to be writeable by the
@@ -19,7 +27,7 @@ Port to host HTTP server on. If set to null no HTTP server will be
exposed. At least one of this and master.https_port needs to be set to
a port.
Defaults to null.
Defaults to 8080.
### master.https_port
@@ -29,7 +37,21 @@ master.tls_private_key as the certificate and private key for the HTTPS
server. If set to null no HTTPS server will be exposed. At least one
of this and master.http_port needs to be set to a port.
Defaults to 8443.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide
to setting up HTTPS with Clusterio.
Defaults to null.
### master.bind_address
IP address to bind the HTTP and HTTPS ports on. Useful to limit which
interface to accept connections from. If set to null the unspecified
IPv6 address will be used, which on most systems means it will also
listen on the unspecified IPv4 address and accept connections from all
interfaces.
Defaults to null.
### master.external_address
@@ -43,29 +65,20 @@ Defaults to null meaning assume localhost.
### master.tls_certificate
TLS certificate to use for the HTTPS server when master.https_port is
configured. If neither this nor the configured master.tls_private_key
exists a self signed certificate is automatically created.
Path to TLS certificate to use for the HTTPS server when
master.https_port is configured.
Defaults to "database/certificates/cert.crt".
### master.tls_private_key
TLS private key to use for the HTTPS server when master.https_port is
configured. If neither this nor the configured master.tls_certificate
exists a self signed certificate is automatically created.
Path to TLS private key to use for the HTTPS server when
master.https_port is configured.
Defaults to "database/certificates/cert.key".
### master.tls_bits
Number of bits to use when creating a self signed certificate.
Defaults to 2048.
### master.auth_secret
Base64 encoded authentication secret used to verify tokens issued to
@@ -101,6 +114,15 @@ unavoidable.
Defaults to 30.
### master.metrics_timeout
Timeout in seconds before a call to gather metrics from a slave times
out. This should be less than both the timeout and collection interval
configured for the collection job in Prometheus.
Defaults to 8.
### master.default_role_id
Role to automatically grant to new users. If null no role is granted.
@@ -158,7 +180,7 @@ Defaults to "instances".
URL to connect to the master server to.
Defaults to "https://localhost:8443/".
Defaults to "http://localhost:8080/".
### slave.master_token
@@ -171,6 +193,17 @@ create a new slave config with the correct url and token in it.
Defaults to "enter token here".
### slave.tls_ca
Path to certificate in PEM format to use for validating a TLS connection
to the master server. If you have a self signed certificate on the
master server you will need to copy the certificate to your slaves and
set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted
certificate authorities.
### slave.public_address
External address instances hosted on this slave can be accessed on.
@@ -191,7 +224,7 @@ Defaults to 5.
Instance Configuration
======================
----------------------
### instance.name
@@ -399,3 +432,44 @@ need to be enabled on the master server in order for them to be loaded
on instances.
Defaults to true
Control Configuration
---------------------
### control.master_url
URL to connect to the master server to.
Defaults to null meaning complain about it not being set and exit.
### control.master_token
Access token used for authenticating with the master server. You can
generate an access token with `clusteriomaster bootstrap
generate-user-token <username>`, or use the `clusteriomaster bootstrap
create-ctl-config <username>` to create a new ctl config with the
correct url and token in it.
Defaults to null meaning complain about it not being set and exit.
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection
to the master server. If you have a self signed certificate on the
master server you will need to copy the certificate to the computer you
run clusterctl from and set it as the certificate authority with this
option.
Defaults to null meaning use Node.js's default set of trusted
certificate authorities.
### control.reconnect_delay
Duration in seconds to wait before attempting to reconnect with the
master server after the connection is dropped.
Defaults to 2.
+10 -6
View File
@@ -161,11 +161,13 @@ which accepts a table and calls `add_lib` for each value in the table.
Communicating with Clusterio
----------------------------
There's a poorly to virtuarly undocumented HTTP interface that can be
used to communicate with Clusterio to do things like get the status of
instances, grab items from the store, run commands on instances, etc,
etc. For now you can read the source code, and maybe ask on the Discord
for some pointers if you get stuck.
Clusterio uses a homebrew protocol based on sending JSON payloads over
a WebSocket connection. See [socket.md](socket.md) for the
implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom
interface over HTTP, WebSocket or any other technology supported by
Node.js.
Clusterio Modules
@@ -177,7 +179,9 @@ modules that are loaded into the game if you don't need the capabilites
of the plugin system. The save patcher puts modules into the `modules`
folder of the Factorio save and adds code to `control.lua` to load the
module according to the `load` and `require` options to the module.json
file.
file. Like with scenarios for Clusterio the [event_handler
interface](#event_handler-interface) has to be used for any event
subscriptions in modules.
Stand alone modules are placed into the modules folder of Clusterio,
plugin modules are located in the module folder of the plugin. In
+112
View File
@@ -0,0 +1,112 @@
Setting up TLS
==============
By default Clusterio will listen for connections over HTTP which is fine
for local networks but over the Internet it should idealy be over HTTPS
as there are some credentials that could be exposed to eavesdroppers.
The recommended setup is to have an ordinary HTTPS server software like
Apache or Nginx to act as a reverse proxy in front of the Clusterio
master server. The reverse proxy would then be set up with a TLS
certificate from a Certificate Authoritiy like [Let's
Encrypt](https://letsencrypt.org). Alternatively you can have Clusterio
listen for connections over HTTPS directly by providing it with a TLS
certificate and private key.
Note that a TLS certificate signed by a recognized Certificate Authority
in general requires that you own a domain. See the last section for how
to create and use a self-signed certificate for an IP address if you
don't have a domain.
Using a reverse proxy
---------------------
Configure the proxy to forward requests over HTTP to the port the master
server is listening to so that for example a request to
`https://example.com/instances/` is forwarded to
`http://localhost:8080/instances/`. It is possible to host the
interface under a sub-path, in that case a request under the sub-path
should be mapped to the master server with the sub-path stripped out.
For example `https://example.com/sub/path/instances/` would be mapped to
`http://localhost:8080/instances`.
Additianlly WebSocket connections to `/api/socket` also needs to be
forwarded. Typically this requires explicit configuration and support
for forwarding WebSocket connections. If the interface hosted under a
sub-path the `/api/socket` address is relative to the sub-path and the
sub-path also needs to be stripped out when forwarded.
You can also optionally set the master server to listen only on the
loopback interface to prevent it being directly connected to from the
outside.
npx clusteriomaster config set master.bind_address 127.0.0.1
### Apache Config
To proxy with Apache make sure that `proxy_module`, `proxy_http_module`
and `proxy_wstunnel_module` is loaded in the config, then add the
following directives to wherever is the approriate place for your
flavour of Apache config organization.
```apache
<Location /sub/path/>
ProxyPass "http://localhost:8080/"
</location>
<Location /sub/path/api/socket>
ProxyPass "ws://localhost:8080/api/socket"
</Location>
```
Replace `/sub/path/` with the location on the proxy you want to host the
Clusterio interface under. If the whole domain is to be dedicated to
the Clusterio interface then `/sub/path/` should be just `/`. Pay
attention to the use of trailing slashes, as they are important.
Hosting HTTPS directly from Node.js
-----------------------------------
Listening on HTTPS is supported by the Clusterio master server via the
`master.https_port` option provided paths to a certificate file and a
private key file is configured for the `master.tls_certificate` and
`master.tls_private_key` options. Both of these are expected to be
unencrypted PEM files.
Creating and using a self-signed certificate
--------------------------------------------
Create a self-signed certificate with OpenSSL by running
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3650 -subj /CN=localhost -addext "subjectAltName=IP:<ip>"
substituting `<ip>` with the IP address the server will be accessed
under, for example `IP:203.0.113.4`. Multiple addresses can be given by
supplying a comma sepparated list of `IP:address` pairs and DNS names
can also be specified using the `DNS:example.com` format. You need to
supply every name you want to connect to the server under the
subjectAltName field, for example to connect to the HTTPS port locally
via an address like `https://localhost:4443/` you need to add
`DNS:localhost`.
This creates a private key file named `key.pem` and a public certificate
file named `cert.pem` and these are used for the
`master.tls_certificate` and `master.tls_private_key` config options
respectively on the master server:
npx clusteriomaster config set master.tls_certificate cert.pem
npx clusteriomaster config set master.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the
computers you want to set up as slaves and configure it as the
`slave.tls_ca` option:
npx clusterioslave config set slave.tls_ca cert.pem
You will also need to copy the `cert.pem` file to all of the computers
you want to remotely manage the cluster via clusterctl and configure it
as `control.tls_ca` option:
npx clusterioctl control-config set control.tls_ca cert.pem
+37 -11
View File
@@ -224,12 +224,13 @@ but for event handlers registered on EventEmitters it's critical that
exceptions are catched and handled appropriately.
Defining Configuration
----------------------
Plugin Configuration
--------------------
Clusterio provides a configuration system that handles storing,
distributing, editing and validating config fields for you. You can
take advantage of it by subclassing `PluginConfigGroup`, setting the
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:
@@ -237,6 +238,7 @@ the `info.js` export. For example in info.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",
@@ -261,7 +263,7 @@ for example in the MasterPluginClass:
}
The same applies for instance configs, replace "master" with "instance"
where appropriate. See [Configuration System](configuration-system.md)
where appropriate. See [Configuration System](config-system.md)
for more details on how this system works.
@@ -381,8 +383,10 @@ the event, for example the following could be defined in `info.js`:
type: "foo_frobber:start_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
eventRequired: ["frobnication_type"],
eventProperties: {
"frobnication_type": { type: "string" },
"urgent": { type: "boolean" },
},
}),
},
@@ -390,8 +394,8 @@ the event, for example the following could be defined in `info.js`:
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. It will also be forwarded by slaves to a specific
instance.
data payload and that it may optionally contain a boolean `urgent`
property. It will also be forwarded by slaves to a specific instance.
The following properties are recognized by the Event constructor:
@@ -434,12 +438,19 @@ it's sent to, but not back from where it came from. Currently, only
that slave except for the instance it came from. from an instance will
cause it to be broadcast to all instances of
#### eventRequired
By default all properties defined in `eventProperties` are required to
be present in the payload for the event. This can be overriden by
specifying an array of properties which are required here. This is
equivalent to using the `required` keyword in JSON schema.
#### eventProperties
Object with properties mapping to a JSON schema of that property that
specifies what's valid to send in the event. This is equivalent to
using the `properties` keyword in JSON schema, except that the
properties specified are implicitly required and additional properties
properties specified are by default required and additional properties
are not allowed. See [this guide][guide] for an introduction to writing
JSON schemas.
@@ -466,8 +477,10 @@ the event. For example, the following could be defined in `info.js`:
type: "foo_frobber:report_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
requestRequired: ["verbosity"],
requestProperties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
},
responseProperties: {
"report": {
@@ -481,7 +494,8 @@ the event. For example, the following could be defined in `info.js`:
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 the
`instance_id` property (implied by `forwardTo: "instance"`) and it may
also contain a boolean `special` property. It also defines that the
response sent must contain a `report` property mapping to an array of
strings. When received by a slave, it will also be forwarded to the
instance specified by `instance_id`.
@@ -520,15 +534,27 @@ instance, or `"instance"` to indicate it should be forwarded to the
instances specified by the `instance_id` request property. This works
by using a default handler for the request by the links that forward it.
#### requestRequired
By default all properties defined in `requestProperties` are required to
be present in the payload for the request. This can be overriden by
specifying an array of properties which are required here. This is
equivalent to using the `required` keyword in JSON schema.
#### requestProperties
Object with properties mapping to a JSON schema of that property that
specifies what's valid to send in the request. This is equivalent to
using the `properties` keyword in JSON schema, except that the
properties specified are implicitly required and additional properties
properties specified are by default required and additional properties
are not allowed. See [this guide][guide] for an introduction to writing
JSON schemas
#### responseRequired
Same as the requestRequired only for the response sent back by the
target.
#### responseProperties
Same as the requestProperties only for the response sent back by the
@@ -597,7 +623,7 @@ until the connection is re-established. This means that if your plugin
sends a lot of events or requests, they can end up being queued up in a
buffer for a long time and sent out all at once. To avoid this you
should be throtteling and/or stopping your requests/events after `drop`
has been raised, and continue back as normal when `connect` is raised.
has been raised, and continue back as normal when `resume` is raised.
Collecting Statistics
@@ -636,7 +662,7 @@ set it accordingly, for example:
);
// Somewhere in the instance plugin code
barMetric.labels(String(this.instance.config.get("instance.id"))).set(someValue);
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.
+1 -1
View File
@@ -3,5 +3,5 @@
"packages/*",
"plugins/*"
],
"version": "2.0.0-alpha.3"
"version": "2.0.0-alpha.4"
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "root",
"private": true,
"scripts": {
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/*/test",
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test",
"fast-test": "FAST_TEST=y npm test",
"cover": "nyc npm test",
"fast-cover": "FAST_TEST=y nyc npm test",
+198 -49
View File
@@ -12,6 +12,7 @@ const asTable = require("as-table").configure({ delimiter: " | " });
const events = require("events");
const path = require("path");
const winston = require("winston");
const setBlocking = require("set-blocking");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
@@ -22,6 +23,7 @@ const libCommand = require("@clusterio/lib/command");
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");
function print(...content) {
@@ -29,6 +31,79 @@ function print(...content) {
console.log(...content);
}
const masterCommands = new libCommand.CommandTree({ name: "master", description: "Master management" });
const masterConfigCommands = new libCommand.CommandTree({
name: "config", alias: ["c"], description: "master config management",
});
masterConfigCommands.add(new libCommand.Command({
definition: ["list", "List master configuration"],
handler: async function(args, control) {
let response = await libLink.messages.getMasterConfig.send(control);
for (let group of response.serialized_config.groups) {
for (let [name, value] of Object.entries(group.fields)) {
print(`${group.name}.${name} ${JSON.stringify(value)}`);
}
}
},
}));
masterConfigCommands.add(new libCommand.Command({
definition: ["set <field> [value]", "Set field in master config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args, control) {
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await libLink.messages.setMasterConfigField.send(control, {
field: args.field,
value: args.value,
});
},
}));
masterConfigCommands.add(new libCommand.Command({
definition: ["set-prop <field> <prop> [value]", "Set property of field in master config", (yargs) => {
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args, control) {
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let request = {
field: args.field,
prop: args.prop,
};
try {
if (args.value !== undefined) {
request.value = JSON.parse(args.value);
}
} catch (err) {
// See note for the instance version of set-prop
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) {
throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
request.value = args.value;
}
await libLink.messages.setMasterConfigProp.send(control, request);
},
}));
masterCommands.add(masterConfigCommands);
const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" });
slaveCommands.add(new libCommand.Command({
definition: [["list", "l"], "List slaves connected to the master"],
@@ -102,13 +177,13 @@ instanceCommands.add(new libCommand.Command({
});
}],
handler: async function(args, control) {
let instanceConfig = new libConfig.InstanceConfig();
let instanceConfig = new libConfig.InstanceConfig("control");
await instanceConfig.init();
if (args.id) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let serialized_config = instanceConfig.serialize("master");
let response = await libLink.messages.createInstance.send(control, { serialized_config });
},
}));
@@ -133,13 +208,21 @@ instanceConfigCommands.add(new libCommand.Command({
}));
instanceConfigCommands.add(new libCommand.Command({
definition: ["set <instance> <field> <value>", "Set field in instance config", (yargs) => {
definition: ["set <instance> <field> [value]", "Set field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("value", { describe: "Value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = "";
}
await libLink.messages.setInstanceConfigField.send(control, {
instance_id: instanceId,
field: args.field,
@@ -149,20 +232,49 @@ instanceConfigCommands.add(new libCommand.Command({
}));
instanceConfigCommands.add(new libCommand.Command({
definition: ["set-prop <instance> <field> <prop> <value>", "Set property of field in instance config", (yargs) => {
definition: ["set-prop <instance> <field> <prop> [value]", "Set property of field in instance config", (yargs) => {
yargs.positional("instance", { describe: "Instance to set config on", type: "string" });
yargs.positional("field", { describe: "Field to set", type: "string" });
yargs.positional("prop", { describe: "Property to set", type: "string" });
yargs.positional("value", { describe: "JSON parsed value to set", type: "string" });
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceConfigProp.send(control, {
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
}
let request = {
instance_id: instanceId,
field: args.field,
prop: args.prop,
value: JSON.parse(args.value),
});
};
try {
if (args.value !== undefined) {
request.value = JSON.parse(args.value);
}
} catch (err) {
// If this is from stdin or looks like an array, object or string
// literal throw the parse error, otherwise assume this is a string.
// The resoning behind this is that correctly quoting the string
// with the all the layers of quote removal at play is difficult.
// See the following table for how to pass "That's a \" quote" in
// different environments:
// cmd : """""That's a \\"" quote"""""
// cmd + npx : """""""""""That's a \\\\"""" quote"""""""""""
// PowerShell : '"""""That''s a \\"" quote"""""'
// PowerShell + npx : '"""""""""""That''s a \\\\"""" quote"""""""""""'
// bash : '""That'\''s a \" quote""'
// bash + npx : '""That'\''s a \" quote""'
// bash + npx -s sh : "'\"\"That'\\''s a \\\" quote\"\"'"
if (args.stdin || /^(\[.*]|{.*}|".*")$/.test(args.value)) {
throw new libErrors.CommandError(`In parsing value '${args.value}': ${err.message}`);
}
request.value = args.value;
}
await libLink.messages.setInstanceConfigProp.send(control, request);
},
}));
instanceCommands.add(instanceConfigCommands);
@@ -329,7 +441,11 @@ roleCommands.add(new libCommand.Command({
yargs.options({
"name": { describe: "New name for role", nargs: 1, type: "string" },
"description": { describe: "New description for role", nargs: 1, type: "string" },
"permissions": { describe: "New permissions for role", array: true, type: "string" },
"set-perms": { describe: "Set permissions for role", array: true, type: "string" },
"add-perms": { describe: "Add permissions to role", array: true, type: "string", conflicts: "set-perms" },
"remove-perms": {
describe: "Remove permissions from role", array: true, type: "string", conflicts: "set-perms",
},
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
});
}],
@@ -342,8 +458,18 @@ roleCommands.add(new libCommand.Command({
if (args.description !== undefined) {
role.description = args.description;
}
if (args.permissions !== undefined) {
role.permissions = args.permissions;
if (args.addPerms) {
role.permissions = role.permissions.concat(args.addPerms);
}
if (args.removePerms) {
let perms = new Set(role.permissions);
for (let perm of args.removePerms) {
perms.delete(perm);
}
role.permissions = [...perms];
}
if (args.setPerms !== undefined) {
role.permissions = args.setPerms;
}
await libLink.messages.updateRole.send(control, role);
@@ -549,8 +675,8 @@ debugCommands.add(new libCommand.Command({
* @private
*/
class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
super(url, reconnectDelay);
constructor(url, reconnectDelay, tlsCa, token) {
super(url, reconnectDelay, tlsCa);
this._token = token;
}
@@ -582,7 +708,7 @@ class Control extends libLink.Link {
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
libPlugin.attachPluginMessages(this, controlPlugin);
}
/**
@@ -627,6 +753,48 @@ class Control extends libLink.Link {
}
}
async function loadPlugins(pluginList) {
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let controlPlugins = new Map();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.controlEntrypoint) {
continue;
}
let ControlPluginClass = await libPluginLoader.loadControlPluginClass(pluginInfo);
let controlPlugin = new ControlPluginClass(pluginInfo, logger);
controlPlugins.set(pluginInfo.name, controlPlugin);
await controlPlugin.init();
}
return controlPlugins;
}
async function registerCommands(controlPlugins, yargs) {
const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
rootCommands.add(masterCommands);
rootCommands.add(slaveCommands);
rootCommands.add(instanceCommands);
rootCommands.add(permissionCommands);
rootCommands.add(roleCommands);
rootCommands.add(userCommands);
rootCommands.add(logCommands);
rootCommands.add(debugCommands);
for (let controlPlugin of controlPlugins.values()) {
await controlPlugin.addCommands(rootCommands);
}
for (let [name, command] of rootCommands.subCommands) {
if (name === command.name) {
command.register(yargs);
}
}
return rootCommands;
}
async function startControl() {
yargs
@@ -659,7 +827,7 @@ async function startControl() {
;
// Parse the args first to get the configured plugin list.
let args = yargs.argv;
let args = yargs.parse();
// Log stream for the ctl session.
logger.add(new ConsoleTransport({
@@ -684,49 +852,21 @@ async function startControl() {
return;
}
logger.verbose("Loading Plugins");
let controlPlugins = await loadPlugins(pluginList);
// Add all cluster management commands including ones from plugins
const rootCommands = new libCommand.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
rootCommands.add(slaveCommands);
rootCommands.add(instanceCommands);
rootCommands.add(permissionCommands);
rootCommands.add(roleCommands);
rootCommands.add(userCommands);
rootCommands.add(logCommands);
rootCommands.add(debugCommands);
logger.verbose("Loading Plugin info");
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let controlPlugins = new Map();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.controlEntrypoint) {
continue;
}
let { ControlPlugin } = require(path.posix.join(pluginInfo.requirePath, pluginInfo.controlEntrypoint));
let controlPlugin = new ControlPlugin(pluginInfo, logger);
controlPlugins.set(pluginInfo.name, controlPlugin);
await controlPlugin.init();
await controlPlugin.addCommands(rootCommands);
}
for (let [name, command] of rootCommands.subCommands) {
if (name === command.name) {
command.register(yargs);
}
}
let rootCommands = await registerCommands(controlPlugins, yargs);
// Reparse after commands have been added with help and strict checking.
args = yargs
.help()
.strict()
.argv
.parse()
;
logger.verbose(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig();
let controlConfig = new libConfig.ControlConfig("control");
try {
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
@@ -765,15 +905,22 @@ async function startControl() {
return;
}
let tlsCa = null;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath);
}
let controlConnector = new ControlConnector(
controlConfig.get("control.master_url"),
controlConfig.get("control.reconnect_delay"),
tlsCa,
controlConfig.get("control.master_token")
);
let control = new Control(controlConnector, controlPlugins);
try {
await controlConnector.connect();
} catch(err) {
} catch (err) {
if (err instanceof libErrors.AuthenticationFailed) {
throw new libErrors.StartupError(err.message);
}
@@ -783,7 +930,9 @@ async function startControl() {
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
});
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.3",
"@clusterio/lib": "^2.0.0-alpha.4",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
+104
View File
@@ -0,0 +1,104 @@
"use strict";
/**
* Helper class for rate limiting actions
*
* Provides a simple interface for implementing actions that are rate
* limited on invocations.
*
* @example
* const rateLimiter = RateLimiter({
* action: () => { console.log("action"); },
* maxRate: 1,
* });
*
* for (let i = 0; i < 100; i++) {
* rateLimiter.activate();
* // Prints action twice to the console, once for the first activation
* // and one more time 1 second later for 99 other activations that were
* // exceeding the rate limit.
* }
* @alias module:lib/RateLimiter
*/
class RateLimiter {
/**
* Construct RateLimiter helper
*
* The options passed may also be modified on the instance at runtime.
*
* @param {object=} options - Options for this rate limiter
* @param {number=} options.maxRate -
* Maximum rate of activations per second.
* @param {function()=} options.action -
* Callback invoked at maste `maxRate` per second on activation.
*/
constructor(options = {}) {
/**
* Maximum rate of activations per second.
* @type {number}
*/
this.maxRate = options.maxRate || 1;
/**
* Callback invoked at most `maxRate` per second on activation.
* @type {function()}
*/
this.action = options.action || null;
this._lastRun = 0;
this._runTimeout = null;
this.lastProgressBroadcast = Date.now();
}
/**
* Activate the rate limited action
*
* Checks if the invocation of the rate limited action now would fall
* within the rate limit. If it is within the limit then the action is
* invoked immediatly, otherwise a timeout is registered to invoke the
* action at next point in time where it will be within the rate limit.
*
* @returns {boolean} true if activation was within the rate limit.
*/
activate() {
let now = Date.now();
if (now < this._lastRun + 1000 / this.maxRate) {
if (!this._runTimeout && this.action !== null) {
this._runTimeout = setTimeout(() => {
this._runTimeout = null;
this._lastRun = Date.now();
this.action();
}, this._lastRun + 1000 / this.maxRate - now);
}
return false;
}
// We may end up activating before the timeout does.
if (this._runTimeout) {
clearTimeout(this._runTimeout);
this._runTimeout = null;
}
this._lastRun = now;
if (this.action !== null) {
this.action();
}
return true;
}
/**
* Cancel pending action
*
* Cancel pending action invocation. This is necessary when shutting
* down the use of the rate limit as a timeout is registered to invoke
* the next action when activated faster than the rate limit allows.
*/
cancel() {
if (this._runTimeout) {
clearTimeout(this._runTimeout);
}
}
}
module.exports = RateLimiter;
+59 -58
View File
@@ -8,64 +8,6 @@ const path = require("path");
const yargs = require("yargs");
async function main() {
const args = yargs
.scriptName("build")
.options({
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
})
.strict()
.argv
;
await build(args);
}
async function build(args) {
let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json")));
if (args.factorioVersion) {
info.factorio_version = args.factorioVersion;
}
if (args.dependencies) {
info.dependencies = args.dependencies;
}
if (args.clean) {
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
let [, name, version] = match;
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
await fs.remove(modPath);
}
}
}
}
if (info.variants) {
for (let [variant, variantOverrides] of Object.entries(info.variants)) {
let variantInfo = {
...info,
...variantOverrides,
};
delete variantInfo.variants;
await buildMod(args, variantInfo);
}
} else {
await buildMod(args, info);
}
}
async function buildMod(args, info) {
if (args.build) {
await fs.ensureDir(args.outputDir);
@@ -121,6 +63,65 @@ async function buildMod(args, info) {
}
}
async function build(args) {
let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json")));
if (args.factorioVersion) {
info.factorio_version = args.factorioVersion;
}
if (args.dependencies) {
info.dependencies = args.dependencies;
}
if (args.clean) {
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
let [, name, version] = match;
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
await fs.remove(modPath);
}
}
}
}
if (info.variants) {
for (let [variant, variantOverrides] of Object.entries(info.variants)) {
let variantInfo = {
...info,
...variantOverrides,
};
delete variantInfo.variants;
await buildMod(args, variantInfo);
}
} else {
await buildMod(args, info);
}
}
async function main() {
const args = yargs
.scriptName("build")
.options({
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
})
.strict()
.argv
;
await build(args);
}
if (module === require.main) {
main().catch(err => { console.log(err); });
}
+168 -42
View File
@@ -7,6 +7,17 @@ const isDeepStrictEqual = require("../is_deep_strict_equal");
const { basicType } = require("@clusterio/lib/helpers");
/**
* Invalid Access exception
*
* Exception class for when an access attempt is made to a field that
* does not permitt this access is done.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidAccess extends Error { };
/**
* Invalid Value exception
*
@@ -69,13 +80,24 @@ function splitOn(separator, string) {
* @memberof module:lib/config
*/
class Config extends events.EventEmitter {
constructor() {
/**
* Create a new instance of the given config
*
* @param {string} location -
* Location to evaluate access for this instance from
*/
constructor(location) {
super();
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`);
}
if (typeof location !== "string") {
throw new Error("location must be a string");
}
this.location = location;
this._groups = new Map();
this._unknownGroups = new Map();
}
@@ -83,8 +105,8 @@ class Config extends events.EventEmitter {
async init() {
for (let [name, GroupClass] of this.constructor._groups) {
if (!this._groups.has(name)) {
let group = new GroupClass();
await group.init(this);
let group = new GroupClass(this);
await group.init();
this._groups.set(name, group);
}
}
@@ -101,7 +123,13 @@ class Config extends events.EventEmitter {
}
}
async load(serializedConfig) {
/**
* Load config from a serialized object
*
* @param {object} serializedConfig - Serialized config to load.
* @param {string=} location - Location used for access control.
*/
async load(serializedConfig, location = this.location) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let GroupClass = this.constructor._groups.get(serializedGroup.name);
@@ -110,8 +138,8 @@ class Config extends events.EventEmitter {
continue;
}
let group = new GroupClass();
await group.load(this, serializedGroup);
let group = new GroupClass(this);
await group.load(serializedGroup, location);
this._groups.set(group.name, group);
}
@@ -121,12 +149,13 @@ class Config extends events.EventEmitter {
/**
* Serialize the config to a plain JavaScript object
*
* @param {string=} location - Location used for access control.
* @returns {Object} JSON serializable representation of the config.
*/
serialize() {
serialize(location = this.location) {
let groups = [...this._unknownGroups.values()];
for (let group of this._groups.values()) {
groups.push(group.serialize());
groups.push(group.serialize(location));
}
return { groups };
@@ -140,28 +169,42 @@ class Config extends events.EventEmitter {
*
* @param {Object} serializedConfig - Output from .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
* @param {string=} location - Location used for access control.
*/
update(serializedConfig, notify) {
update(serializedConfig, notify, location = this.location) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let group = this._groups.get(serializedGroup.name);
if (group) {
group.update(serializedGroup, notify);
group.update(serializedGroup, notify, location);
} else {
this._unknownGroups.set(serializedGroup.name, serializedGroup);
}
}
}
/**
* Check if field can be accessed from the given location
*
* @param {string} name - Name of the field to check.
* @param {string=} location - Location used for access control.
* @returns {boolean} true if field is accessible
*/
canAccess(name, location = this.location) {
let [group, field] = splitOn(".", name);
return this.group(group).canAccess(field, location);
}
/**
* Get the value for a config field
*
* @param {string} name - Name of field to get.
* @param {string=} location - Location used for access control.
* @returns {*} the value for the field.
*/
get(name) {
get(name, location = this.location) {
let [group, field] = splitOn(".", name);
return this.group(group).get(field);
return this.group(group).get(field, location);
}
/**
@@ -169,10 +212,11 @@ class Config extends events.EventEmitter {
*
* @param {string} name - Name of field to set.
* @param {*} value - the value to set on field
* @param {string=} location - Location used for access control.
*/
set(name, value) {
set(name, value, location = this.location) {
let [group, field] = splitOn(".", name);
this.group(group).set(field, value);
this.group(group).set(field, value, location);
}
/**
@@ -181,10 +225,11 @@ class Config extends events.EventEmitter {
* @param {string} name - Name of field to set property on.
* @param {string} prop - Name of property to set on field.
* @param {*} value - the value to set the property to.
* @param {string=} location - Location used for access control.
*/
setProp(name, prop, value) {
setProp(name, prop, value, location = this.location) {
let [group, field] = splitOn(".", name);
this.group(group).setProp(field, prop, value);
this.group(group).setProp(field, prop, value, location);
}
/**
@@ -280,15 +325,42 @@ class ConfigGroup {
*
* After the creation of the new config group you have to call
* either .init() or .load() on it in order to fully initialize it.
*
* @param {module:lib/config.Config} config -
* Parent config for this group instance.
*/
constructor() {
constructor(config) {
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`);
}
this._fields = new Map();
this._unknownFields = new Map();
this._config = null;
this.config = config;
}
/**
* Check access for a field
*
* @param {object} def - definition for for the field to check.
* @param {string} remote - location of remote access to check for.
* @param {boolean} error - throw on failure to pass check.
* @throws {module:lib/config.InvalidAccess}
* if access is not granted and error is true.
* @returns {boolean} true if access is granted
* @private
*/
_checkAccess(def, remote, error) {
for (let loc of [remote, this.config.location]) {
if (!def.access.includes(loc)) {
if (error) {
throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`);
} else {
return false;
}
}
}
return true;
}
/**
@@ -296,16 +368,15 @@ class ConfigGroup {
*
* Computes and assigns the initial values for all fields of the
* config group.
*
* @param {module:lib/config.Config} config - Config group belongs to.
*/
async init(config) {
this._config = config;
async init() {
for (let [name, def] of this.constructor._definitions) {
if (this._fields.has(name)) {
continue;
}
if (!def.access.includes(this.config.location)) {
continue;
}
let value = null;
if (typeof def.initial_value === "function") {
@@ -326,13 +397,13 @@ class ConfigGroup {
* group and then initializes any possible missing fields from it to
* their default values.
*
* @param {module:lib/config.Config} config - Config group belongs to.
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {string=} location - Location used for access control.
*/
async load(config, serializedGroup) {
this.update(serializedGroup, false);
await this.init(config);
async load(serializedGroup, location = this.config.location) {
this.update(serializedGroup, false, location);
await this.init();
}
/**
@@ -344,16 +415,34 @@ class ConfigGroup {
return this.constructor.groupName;
}
/**
* Check if field can be accessed from the given location
*
* @param {string} name - Name of field to check.
* @param {string=} location - Location used for access control.
* @returns {boolean} true if field is accessible
*/
canAccess(name, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
return this._checkAccess(def, location, false);
}
/**
* Get value for field
*
* @param {string} name - Name of field to get.
* @param {string=} location - Location used for access control.
* @returns {*} Value stored for the field.
*/
get(name) {
if (!this.constructor._definitions.has(name)) {
get(name, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
return this._fields.get(name);
}
@@ -366,14 +455,16 @@ class ConfigGroup {
*
* @param {string} name - Name of field to set.
* @param {*} value - Value to set for field.
* @param {string=} location - Location used for access control.
* @throws {module:lib/config.InvalidField} if field is not defined.
* @throws {module:lib/config.InvalidValue} if value is not allowed for the field.
*/
set(name, value) {
set(name, value, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
// Empty strings are treates as null
if (value === "") {
@@ -386,8 +477,11 @@ class ConfigGroup {
if (basicType(value) === "string") {
if (def.type === "boolean") {
if (value === "true") { value = true; }
else if (value === "false") { value = false; }
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
@@ -415,8 +509,8 @@ class ConfigGroup {
let prev = this._fields.get(name);
this._fields.set(name, value);
if (this._config && !isDeepStrictEqual(value, prev)) {
this._config.emit("fieldChanged", this, name, prev);
if (this.config && !isDeepStrictEqual(value, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
@@ -429,14 +523,16 @@ class ConfigGroup {
* @param {string} name - Name of field to set property on.
* @param {string} prop - Name of property to set on field.
* @param {*} value - the value to set the property to.
* @param {string=} location - Location used for access control.
* @throws {module:lib/config.InvalidField} if field is not defined.
* @throws {module:lib/config.InvalidValue} if field is not an object.
*/
setProp(name, prop, value) {
setProp(name, prop, value, location = this.config.location) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(def, location, true);
if (def.type !== "object") {
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
@@ -445,10 +541,14 @@ class ConfigGroup {
let prev = this._fields.get(name);
let updated = {...prev || {}};
updated[prop] = value;
if (value !== undefined) {
updated[prop] = value;
} else {
delete updated[prop];
}
this._fields.set(name, updated);
if (this._config && !isDeepStrictEqual(updated, prev)) {
this._config.emit("fieldChanged", this, name, prev);
if (this.config && !isDeepStrictEqual(updated, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
@@ -461,8 +561,9 @@ class ConfigGroup {
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
* @param {string=} location - Location used for access control.
*/
update(serializedGroup, notify) {
update(serializedGroup, notify, location = this.config.location) {
if (basicType(serializedGroup) !== "object") {
throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`);
}
@@ -482,6 +583,10 @@ class ConfigGroup {
continue;
}
if (!this._checkAccess(def, location, false)) {
continue;
}
if (def.optional) {
if (value !== null && basicType(value) !== def.type) {
continue;
@@ -497,8 +602,8 @@ class ConfigGroup {
let prev = this._fields.get(name);
this._fields.set(name, value);
if (notify && this._config && !isDeepStrictEqual(value, prev)) {
this._config.emit("fieldChanged", this, name, prev);
if (notify && this.config && !isDeepStrictEqual(value, prev)) {
this.config.emit("fieldChanged", this, name, prev);
}
}
}
@@ -506,11 +611,17 @@ class ConfigGroup {
/**
* Serialize the config to a plain JavaScript object
*
* @param {string=} location - Location used for access control.
* @returns {Object} JSON serializable representation of the group.
*/
serialize() {
serialize(location = this.config.location) {
let fields = {};
for (let [name, value] of this._fields) {
let def = this.constructor._definitions.get(name);
if (!this._checkAccess(def, location, false)) {
continue;
}
fields[name] = value;
}
@@ -550,6 +661,9 @@ class ConfigGroup {
* Define a new configuration field
*
* @param {Object} item - Field definition.
* @param {Array<string>=} item.access -
* Array of strings declaring where this config entry can be read
* and modified from.
* @param {string} item.type -
* string declaring the type of the config value, supports
* boolean, string, number and object.
@@ -602,6 +716,12 @@ class ConfigGroup {
}
break;
case "access":
if (valueType !== "array") {
throw new Error("access must be an array");
}
break;
case "enum":
if (valueType !== "array") {
throw new Error("enum must be an array");
@@ -648,11 +768,16 @@ class ConfigGroup {
}
let computed = {
access: this.defaultAccess,
optional: false,
fullName: `${this.groupName}.${item.name}`,
...item,
};
if (!computed.access) {
throw new Error(`access not set and no defaultAccess defined for ${this.name}`);
}
if (!computed.optional && computed.initial_value === undefined) {
throw new Error(`Non-optional field ${item.name} needs an initial_value`);
}
@@ -699,6 +824,7 @@ class PluginConfigGroup extends ConfigGroup {
module.exports = {
InvalidAccess,
InvalidField,
InvalidValue,
+44 -15
View File
@@ -10,7 +10,15 @@ const classes = require("./classes");
* @memberof module:lib/config
*/
class MasterGroup extends classes.ConfigGroup { }
MasterGroup.defaultAccess = ["master", "slave", "control"];
MasterGroup.groupName = "master";
MasterGroup.define({
name: "name",
title: "Name",
description: "Name of the cluster",
type: "string",
initial_value: "Your Cluster",
});
MasterGroup.define({
name: "database_directory",
title: "Database directory",
@@ -24,6 +32,7 @@ MasterGroup.define({
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
type: "number",
optional: true,
initial_value: 8080,
});
MasterGroup.define({
name: "https_port",
@@ -31,7 +40,13 @@ MasterGroup.define({
description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.",
type: "number",
optional: true,
initial_value: 8443,
});
MasterGroup.define({
name: "bind_address",
title: "Bind Address",
description: "IP address to bind the HTTP and HTTPS ports on.",
type: "string",
optional: true,
});
MasterGroup.define({
name: "external_address",
@@ -46,7 +61,6 @@ MasterGroup.define({
description: "Path to the certificate to use for HTTPS.",
type: "string",
optional: true,
initial_value: "database/certificates/cert.crt",
});
MasterGroup.define({
name: "tls_private_key",
@@ -54,16 +68,9 @@ MasterGroup.define({
description: "Path to the private key to use for HTTPS.",
type: "string",
optional: true,
initial_value: "database/certificates/cert.key",
});
MasterGroup.define({
name: "tls_bits",
title: "TLS Bits",
description: "Number of bits to use for auto generated TLS certificate.",
type: "number",
initial_value: 2048,
});
MasterGroup.define({
access: ["master"],
name: "auth_secret",
title: "Master Authentication Secret",
description:
@@ -87,6 +94,13 @@ MasterGroup.define({
type: "number",
initial_value: 30,
});
MasterGroup.define({
name: "metrics_timeout",
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from slaves",
type: "number",
initial_value: 8,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
@@ -112,6 +126,7 @@ MasterConfig.registerGroup(MasterGroup);
* @memberof module:lib/config
*/
class SlaveGroup extends classes.ConfigGroup {}
SlaveGroup.defaultAccess = ["master", "slave", "control"];
SlaveGroup.groupName = "slave";
SlaveGroup.define({
name: "name",
@@ -141,7 +156,7 @@ SlaveGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
type: "string",
initial_value: "https://localhost:8443/",
initial_value: "http://localhost:8080/",
});
SlaveGroup.define({
name: "master_token",
@@ -149,6 +164,12 @@ SlaveGroup.define({
type: "string",
initial_value: "enter token here",
});
SlaveGroup.define({
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
type: "string",
optional: true,
});
SlaveGroup.define({
name: "public_address",
description: "Public facing address players should connect to in order to join instances on this slave",
@@ -179,6 +200,7 @@ SlaveConfig.registerGroup(SlaveGroup);
* @memberof module:lib/config
*/
class InstanceGroup extends classes.ConfigGroup { }
InstanceGroup.defaultAccess = ["master", "slave", "control"];
InstanceGroup.groupName = "instance";
InstanceGroup.define({
name: "name",
@@ -210,6 +232,7 @@ InstanceGroup.finalize();
* @memberof module:lib/config
*/
class FactorioGroup extends classes.ConfigGroup { }
FactorioGroup.defaultAccess = ["master", "slave", "control"];
FactorioGroup.groupName = "factorio";
FactorioGroup.define({
name: "version",
@@ -251,10 +274,7 @@ FactorioGroup.define({
name: "settings",
description: "Settings overridden in server-settings.json",
type: "object",
initial_value: {
"tags": ["clusterio"],
"auto_pause": false,
},
initial_value: {}, // See create instance handler in master.
});
FactorioGroup.define({
name: "verbose_logging",
@@ -310,6 +330,7 @@ InstanceConfig.registerGroup(FactorioGroup);
* @memberof module:lib/config
*/
class ControlGroup extends classes.ConfigGroup {}
ControlGroup.defaultAccess = ["control"];
ControlGroup.groupName = "control";
ControlGroup.define({
name: "master_url",
@@ -323,6 +344,12 @@ ControlGroup.define({
type: "string",
optional: true,
});
ControlGroup.define({
name: "tls_ca",
description: "Path to Certificate Authority to validate TLS connection to master against.",
type: "string",
optional: true,
});
ControlGroup.define({
name: "reconnect_delay",
title: "Reconnect Delay",
@@ -369,6 +396,7 @@ function registerPluginConfigGroups(pluginInfos) {
} else {
class MasterConfigGroup extends classes.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = pluginInfo.name;
MasterConfigGroup.finalize();
MasterConfig.registerGroup(MasterConfigGroup);
@@ -381,6 +409,7 @@ function registerPluginConfigGroups(pluginInfos) {
} else {
class InstanceConfigGroup extends classes.PluginConfigGroup { }
InstanceConfigGroup.defaultAccess = ["master", "slave", "control"];
InstanceConfigGroup.groupName = pluginInfo.name;
InstanceConfigGroup.finalize();
InstanceConfig.registerGroup(InstanceConfigGroup);
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable global-require */
/**
* Configuration System
* @author Hornwitser
+2 -4
View File
@@ -59,9 +59,8 @@ async function loadJsonAsMap(filePath) {
// File does not exist, return empty map
return new Map();
} else {
throw err;
}
throw err;
}
}
@@ -121,9 +120,8 @@ async function loadJsonArrayAsMap(filePath) {
// File does not exist, return empty map
return new Map();
} else {
throw err;
}
throw err;
}
}
+101 -88
View File
@@ -99,6 +99,105 @@ async function loadFile(server, modVersions, modPath) {
return await file.async("nodebuffer");
}
async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, iconCache) {
let icon = iconCache.get(iconPath);
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
return icon;
}
async function loadSimpleIcon(server, modVersions, item, size, iconCache) {
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size, 0, iconCache);
if (icon) {
let iconScale = size / item.icon_size;
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
}
}
return icon;
}
async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
let icon = await Jimp.create(size, size);
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
// The size in pixels of one unit
let baseUnit = size / (baseLayerSize * baseLayerScale);
for (let layer of item.icons) {
let layerSize = layer.icon_size || item.icon_size;
let iconLayer = await loadIcon(server, modVersions, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache);
if (!iconLayer) {
continue;
}
iconLayer = iconLayer.clone();
let tint;
if (layer.tint) {
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
tint = {
r: (layer.tint.r || 0) / divisor,
g: (layer.tint.g || 0) / divisor,
b: (layer.tint.b || 0) / divisor,
a: layer.tint.a || 1,
};
} else {
tint = { r: 1, b: 1, g: 1, a: 1 };
}
let layerScale = layer.scale || 32 / layerSize;
let [xs, ys] = layer.shift || [0, 0];
let realScale = layerScale * baseUnit;
let sizeShift = (size - layerSize * realScale) / 2;
xs = xs * baseUnit + sizeShift;
ys = ys * baseUnit + sizeShift;
if (realScale !== 1) {
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
x += xs;
y += ys;
if (x < 0 || x >= size || y < 0 || y >= size) {
return;
}
let sdata = iconLayer.bitmap.data;
let ddata = icon.bitmap.data;
let didx = icon.getPixelIndex(x, y);
let sa = sdata[sidx + 3] / 255;
let da = ddata[didx + 3] / 255;
let db = da * (1 - sa * tint.a);
let cb = sa + da * (1 - sa);
ddata[didx + 0] = Math.min(255, (sdata[sidx + 0] * sa * tint.r + ddata[didx + 0] * db) / cb);
ddata[didx + 1] = Math.min(255, (sdata[sidx + 1] * sa * tint.g + ddata[didx + 1] * db) / cb);
ddata[didx + 2] = Math.min(255, (sdata[sidx + 2] * sa * tint.b + ddata[didx + 2] * db) / cb);
ddata[didx + 3] = Math.min(255, sdata[sidx + 3] + ddata[didx + 3] * (1 - sa));
});
}
return icon;
}
/**
* Export item icons and data
*
@@ -130,107 +229,21 @@ async function exportItems(server, modVersions, items) {
let pos = 0;
let iconCache = new Map();
async function loadIcon(iconPath, iconSize, iconMipmaps) {
let icon = iconCache.get(iconPath);
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
iconCache.set(iconPath, icon);
} else {
icon = null;
server._logger.warn(`${iconPath} not found`);
}
iconCache.set(iconPath, icon);
}
return icon;
}
let simpleIcons = new Map();
for (let item of items) {
let icon;
let iconPos;
if (item.icons) {
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
icon = await Jimp.create(size, size);
icon = await loadLayeredIcon(server, modVersions, item, size, iconCache);
iconPos = pos;
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
// The size in pixels of one unit
let baseUnit = size / (baseLayerSize * baseLayerScale);
for (let layer of item.icons) {
let layerSize = layer.icon_size || item.icon_size;
let iconLayer = await loadIcon(layer.icon, layerSize, layer.icon_mipmaps || 0);
if (!iconLayer) {
continue;
}
iconLayer = iconLayer.clone();
let tint;
if (layer.tint) {
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
tint = {
r: (layer.tint.r || 0) / divisor,
g: (layer.tint.g || 0) / divisor,
b: (layer.tint.b || 0) / divisor,
a: layer.tint.a || 1,
};
} else {
tint = { r: 1, b: 1, g: 1, a: 1 };
}
let layerScale = layer.scale || 32 / layerSize;
let [xs, ys] = layer.shift || [0, 0];
let realScale = layerScale * baseUnit;
let sizeShift = (size - layerSize * realScale) / 2;
xs = xs * baseUnit + sizeShift;
ys = ys * baseUnit + sizeShift;
if (realScale !== 1) {
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, function(x, y, sidx) {
x += xs;
y += ys;
if (x < 0 || x >= size || y < 0 || y >= size) {
return;
}
let sdata = this.bitmap.data;
let ddata = icon.bitmap.data;
let didx = icon.getPixelIndex(x, y);
let sa = sdata[sidx + 3] / 255;
let da = ddata[didx + 3] / 255;
let db = da * (1 - sa * tint.a);
let cb = sa + da * (1 - sa);
ddata[didx + 0] = Math.min(255, (sdata[sidx + 0] * sa * tint.r + ddata[didx + 0] * db) / cb);
ddata[didx + 1] = Math.min(255, (sdata[sidx + 1] * sa * tint.g + ddata[didx + 1] * db) / cb);
ddata[didx + 2] = Math.min(255, (sdata[sidx + 2] * sa * tint.b + ddata[didx + 2] * db) / cb);
ddata[didx + 3] = Math.min(255, sdata[sidx + 3] + ddata[didx + 3] * (1 - sa));
});
}
} else {
iconPos = simpleIcons.get(item.icon);
if (iconPos === undefined) {
icon = await loadIcon(item.icon, item.icon_size, 0);
icon = await loadSimpleIcon(server, modVersions, item, size, iconCache);
if (icon) {
iconPos = pos;
simpleIcons.set(item.icon, pos);
let iconScale = size / item.icon_size;
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
}
}
}
}
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable global-require */
/**
* Library for interfacing with Factorio Servers and saves.
* @module lib/factorio
+3 -3
View File
@@ -78,7 +78,7 @@ function generateLoader(patchInfo) {
];
for (let moduleName of patchInfo["scenario"]["modules"]) {
lines.push('event_handler.add_lib(require("'+moduleName+'"))');
lines.push(`event_handler.add_lib(require("${moduleName}"))`);
}
lines.push(...[
@@ -90,10 +90,10 @@ function generateLoader(patchInfo) {
for (let file of module["files"]) {
let requirePath = `modules/${module.name}/${file["path"].slice(0, -4)}`;
if (file["load"]) {
lines.push('event_handler.add_lib(require("'+requirePath+'"))');
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
}
if (file["require"]) {
lines.push('require("'+requirePath+'")');
lines.push(`require("${requirePath}")`);
}
}
}
+33 -25
View File
@@ -11,24 +11,9 @@ const ini = require("ini");
const rconClient = require("rcon-client");
const libErrors = require("@clusterio/lib/errors");
const { logger } = require("@clusterio/lib/logging");
const { escapeRegExp } = require("@clusterio/lib/helpers");
/**
* Escapes text for inclusion in a RegExp
*
* Adds \ character in front of special meta characters in the passsed in
* text so that it can be embedded into a RegExp and only match the text.
*
* See https://stackoverflow.com/a/9310752
*
* @param {string} text - Text to escape RegExp meta chars in.
* @returns {string} escaped text.
* @inner
*/
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
/**
* Determines the version of Factorio the datadir is pointing to by
* reading the changelog.txt in it.
@@ -522,6 +507,8 @@ class FactorioServer extends events.EventEmitter {
* @param {number} options.maxConcurrentCommands -
* Maximum number of RCON commands transmitted in parallel on the
* RCON connection.
* @param {number} options.hangTimeout -
* Timeout in ms to wait for a response to a shutdown command.
*/
constructor(factorioDir, writeDir, options) {
super();
@@ -547,6 +534,8 @@ class FactorioServer extends events.EventEmitter {
this.verboseLogging = options.verboseLogging;
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
/** Timeout in ms to wait for a response to a shutdown command */
this.hangTimeout = options.hangTimeout || 5000;
this._state = "new";
this._server = null;
this._rconClient = null;
@@ -574,12 +563,12 @@ class FactorioServer extends events.EventEmitter {
// Track autosaving
this._runningAutosave = null;
this.on("_autosave", function(name) {
this.on("_autosave", name => {
this.emit("autosave-start", name);
this._runningAutosave = name;
});
this.on("_saved", function() {
this.on("_saved", () => {
if (this._runningAutosave) {
this.emit("autosave-finished", this._runningAutosave);
this._runningAutosave = null;
@@ -637,7 +626,7 @@ class FactorioServer extends events.EventEmitter {
}
if (!this.emit(`ipc-${channel}`, content)) {
this.logger.warn(`Warning: Unhandled ipc-${channel}`, { content });
this._logger.warn(`Warning: Unhandled ipc-${channel}`, { content });
}
}
@@ -704,8 +693,6 @@ class FactorioServer extends events.EventEmitter {
this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); });
this._rconClient.on("end", () => {
this._rconClient = null;
this._rconReady = false;
this._startRcon().catch(() => { /* Ignore */ });
});
// XXX: Workaround to suppress bogus event listener warning
if (this._rconClient.emitter instanceof events.EventEmitter) {
@@ -796,12 +783,16 @@ class FactorioServer extends events.EventEmitter {
} else {
this.emit("error", new Error(
"Factorio server unexpectedly shut down. Possible causes:\n- "+
this._unexpected.join("\n- ")
"Factorio server unexpectedly shut down. Possible causes:"+
`\n- ${this._unexpected.join("\n- ")}`
));
}
}
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
// Reset server state
this._state = "init";
this._server = null;
@@ -954,6 +945,9 @@ class FactorioServer extends events.EventEmitter {
if (!this._rconReady) {
await events.once(this, "rcon-ready");
}
if (!this._rconClient) {
throw new Error("RCON connection lost");
}
let response = await this._rconClient.send(message);
if (expectEmpty && response !== "") {
@@ -972,17 +966,25 @@ class FactorioServer extends events.EventEmitter {
this._check(["running"]);
let hanged = true;
let stopped = false;
function setAlive() { hanged = false; }
let timeoutId = setTimeout(() => {
if (hanged) {
logger.error("Factorio appears to have hanged, sending SIGKILL");
this._logger.error("Factorio appears to have hanged, sending SIGKILL");
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._server.kill("SIGKILL");
}
}, 5000);
}, this.hangTimeout);
// It's possible the server decides to exit on its own, in which
// case the stop operation has to be cancelled.
this._server.once("exit", () => {
stopped = true;
clearTimeout(timeoutId);
});
this._state = "stopping";
// On Windows we need to save the map as there's no graceful shutdown.
@@ -1014,6 +1016,12 @@ class FactorioServer extends events.EventEmitter {
await this._rconClient.end();
}
// The Factorio server may have decided to get ahead of us and
// stop by itself before we had the chance to signal it.
if (stopped) {
return;
}
// On linux this sends SIGTERM to the process. On windows this
// will terminate the process with no cleanup. You can send your
// complaint for the lack of graceful shutdown to the Factorio
+3 -3
View File
@@ -14,14 +14,14 @@ const fs = require("fs");
* @returns {Promise<string>} hash of the stream.
*/
function hashStream(stream) {
return new Promise(function(resolve, reject) {
return new Promise((resolve, reject) => {
let hasher = crypto.createHash("sha1");
hasher.setEncoding("hex");
hasher.on("finish", function() {
hasher.on("finish", () => {
resolve(hasher.read());
});
stream.on("error", function(error) {
stream.on("error", (error) => {
// the docs doesn't say anything about what's passed on error
reject(error);
});
+41
View File
@@ -52,8 +52,49 @@ async function timeout(promise, time, timeoutResult) {
}
}
/**
* Read stream to the end and return its content
*
* Reads the stream given asynchronously until the end is reached and
* returns all the data which was read from the stream.
*
* @param {Readable} stream - byte stream to read to the end.
* @returns {Buffer} content of the stream.
*/
async function readStream(stream) {
let chunks = [];
for await (let chunk of stream) {
// Support using ^Z to end input on Windows
if (process.platform === "win32" && stream.isTTY && chunk.toString() === "\x1a\r\n") {
break;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
/**
* Escapes text for inclusion in a RegExp
*
* Adds \ character in front of special meta characters in the passsed in
* text so that it can be embedded into a RegExp and only match the text.
*
* See https://stackoverflow.com/a/9310752
*
* @param {string} text - Text to escape RegExp meta chars in.
* @returns {string} escaped text.
*/
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
module.exports = {
basicType,
wait,
timeout,
readStream,
escapeRegExp,
};
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable global-require */
"use strict";
// eslint-disable-next-line no-process-env
if (process.env.APP_ENV === "browser") {
+19 -8
View File
@@ -144,10 +144,11 @@ class WebSocketBaseConnector extends events.EventEmitter {
* @memberof module:lib/link
*/
class WebSocketClientConnector extends WebSocketBaseConnector {
constructor(url, reconnectDelay) {
constructor(url, reconnectDelay, tlsCa = null) {
super();
this._url = url;
this._tlsCa = tlsCa;
// The following states are used in the client connector
// new: Not connected
@@ -243,11 +244,9 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._socket = new WebSocket(url);
} else {
this._socket = new WebSocket(url, {
// For now we do not verify TLS certificates since the default setup is
// to create a self-signed certificate.
rejectUnauthorized: false,
});
let options = {};
if (this._tlsCa) { options.ca = this._tlsCa; }
this._socket = new WebSocket(url, options);
}
this._attachSocketHandlers();
@@ -317,7 +316,19 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._socket.onerror = event => {
// It's assumed that close is always called by ws
logger.error(`SOCKET | Error: ${event.message || "unknown error"}`);
let code = !event.error ? "" : `, code: ${event.error.code}`;
logger.error(`SOCKET | Error: ${event.message || "unknown error"}${code}`);
if (event.error) {
// Abort connection if certificate validation failed
if ([
"CERT_HAS_EXPIRED",
"DEPTH_ZERO_SELF_SIGNED_CERT",
"ERR_TLS_CERT_ALTNAME_INVALID",
].includes(event.error.code)) {
this.emit("error", new libErrors.AuthenticationFailed(event.error.message));
this._state = "closing";
}
}
};
this._socket.onopen = () => {
@@ -393,7 +404,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
this._startedReconnect = null;
this._connected = true;
this.emit("connect");
this.emit("resume");
} else if (type === "invalidate") {
this._heartbeatInterval = null;
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable global-require */
/**
* Library for message based communication between nodes
* @module lib/link
+1 -2
View File
@@ -124,9 +124,8 @@ class Link {
if (handler) {
handler(message);
return true;
} else {
return false;
}
return false;
}
/**
+73 -12
View File
@@ -70,16 +70,24 @@ class Request extends Message {
* Optional target to forward this request to. 'instance' add
* instance_id into the requestProperties and forward to the given
* instance. 'master' forwards it to the master server.
* @param {?Array<string>} requestRequired -
* List of properties that are required to be present in the data
* payload of this request. Defaults to all.
* @param {Object<string, Object>} requestProperties -
* Mapping of property values to JSON schema specifications for the
* properties that are valid in the data payload of this requst.
* @param {?Array<string>} responseRequired -
* List of properties that are required to be present in the data
* payload of the repsonse to this request. Defaults to all.
* @param {Object<string, Object>} responseProperties -
* Mapping of property values to JSON schema specifications for the
* properties that are valid in the data payload of the response to
* this requst.
*/
constructor({
type, permission, links, forwardTo = null, requestProperties = {}, responseProperties = {},
type, permission, links, forwardTo = null,
requestRequired = null, requestProperties = {},
responseRequired = null, responseProperties = {},
}) {
super();
this.type = type;
@@ -88,8 +96,8 @@ class Request extends Message {
this.forwardTo = forwardTo;
this.handlerSuffix = "RequestHandler";
this.requestType = type + "_request";
this.responseType = type + "_response";
this.requestType = `${type}_request`;
this.responseType = `${type}_response`;
if (permission === undefined && links.includes("control-master")) {
throw new Error(`permission is required for ${this.type} request over control-master links`);
@@ -98,7 +106,12 @@ class Request extends Message {
throw new Error(`permission is not allowed on ${this.type} request as it is not over control-master link`);
}
if (!requestRequired) {
requestRequired = Object.keys(requestProperties);
}
if (forwardTo === "instance") {
requestRequired = ["instance_id", ...requestRequired];
requestProperties = {
"instance_id": { type: "integer" },
...requestProperties,
@@ -114,12 +127,16 @@ class Request extends Message {
"type": { const: this.requestType },
"data": {
additionalProperties: false,
required: Object.keys(requestProperties),
required: requestRequired,
properties: requestProperties,
},
},
});
if (!responseRequired) {
responseRequired = Object.keys(responseProperties);
}
this._responseValidator = libSchema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
@@ -128,7 +145,7 @@ class Request extends Message {
anyOf: [
{
additionalProperties: false,
required: ["seq", ...Object.keys(responseProperties)],
required: ["seq", ...responseRequired],
properties: {
"seq": { type: "integer" },
...responseProperties,
@@ -195,8 +212,11 @@ class Request extends Message {
if (this.permission !== null && `${link.source}-${link.target}` === "master-control") {
let origHandler = handler;
handler = async function(message, request) {
this.user.checkPermission(request.permission);
return await origHandler.call(this, message, request);
// Abuse this binding to get a hold of the ControlConnection instance
// eslint-disable-next-line no-invalid-this, consistent-this
let controlConnection = this;
controlConnection.user.checkPermission(request.permission);
return await origHandler.call(controlConnection, message, request);
};
}
@@ -207,12 +227,13 @@ class Request extends Message {
response = {};
}
if (!this._responseValidator({ seq: 0, type: this.responseType, response })) {
let data = { ...response, seq: message.seq };
if (!this._responseValidator({ seq: 0, type: this.responseType, data })) {
logger.error(JSON.stringify(this._responseValidator.errors, null, 4));
throw new Error(`Validation failed responding to ${this.requestType}`);
}
link.connector.send(this.responseType, { ...response, seq: message.seq });
link.connector.send(this.responseType, data);
}).catch(err => {
if (!(err instanceof libErrors.RequestError)) {
@@ -279,6 +300,37 @@ messages.debugDumpWs = new Request({
// Management requests
messages.getMasterConfig = new Request({
type: "get_master_config",
links: ["control-master"],
permission: "core.master.get_config",
responseProperties: {
"serialized_config": { type: "object" },
},
});
messages.setMasterConfigField = new Request({
type: "set_master_config_field",
links: ["control-master"],
permission: "core.master.update_config",
requestProperties: {
"field": { type: "string" },
"value": { type: "string" },
},
});
messages.setMasterConfigProp = new Request({
type: "set_master_config_prop",
links: ["control-master"],
permission: "core.master.update_config",
requestRequired: ["field", "prop"],
requestProperties: {
"field": { type: "string" },
"prop": { type: "string" },
"value": {},
},
});
messages.listSlaves = new Request({
type: "list_slaves",
links: ["control-master"],
@@ -386,6 +438,7 @@ messages.setInstanceConfigProp = new Request({
type: "set_instance_config_prop",
links: ["control-master"],
permission: "core.instance.update_config",
requestRequired: ["instance_id", "field", "prop"],
requestProperties: {
"instance_id": { type: "integer" },
"field": { type: "string" },
@@ -756,11 +809,14 @@ class Event extends Message {
* links that are downstream, so sending an event set to instance
* broadcast that's sent to a slave will only be broadcast to that
* slave's instances.
* @param {?Array<string>} eventRequired -
* List of properties required to be present in the event payload.
* Defaults to all.
* @param {Object<string, Object>} eventProperties -
* Mapping of property values to JSON schema specifications for the
* properties that are valid in the data payload of this event.
*/
constructor({ type, links, forwardTo = null, broadcastTo = null, eventProperties = {} }) {
constructor({ type, links, forwardTo = null, broadcastTo = null, eventRequired = null, eventProperties = {} }) {
super();
this.type = type;
this.links = links;
@@ -768,7 +824,12 @@ class Event extends Message {
this.broadcastTo = broadcastTo;
this.handlerSuffix = "EventHandler";
if (!eventRequired) {
eventRequired = Object.keys(eventProperties);
}
if (forwardTo === "instance") {
eventRequired = ["instance_id", ...eventRequired];
eventProperties = {
"instance_id": { type: "integer" },
...eventProperties,
@@ -782,14 +843,14 @@ class Event extends Message {
throw new Error(`Invalid broadcastTo value ${broadcastTo}`);
}
this.eventType = type + "_event";
this.eventType = `${type}_event`;
this._eventValidator = libSchema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.eventType },
"data": {
additionalProperties: false,
required: Object.keys(eventProperties),
required: eventRequired,
properties: eventProperties,
},
},
+4 -4
View File
@@ -21,9 +21,9 @@ const libErrors = require("@clusterio/lib/errors");
function formatServerOutput(parsed) {
let time = "";
if (parsed.format === "seconds") {
time = chalk.yellow(parsed.time.padStart(8)) + " ";
time = `${chalk.yellow(parsed.time.padStart(8))} `;
} else if (parsed.format === "date") {
time = chalk.yellow(parsed.time) + " ";
time = `${chalk.yellow(parsed.time)} `;
}
let info = "";
@@ -41,10 +41,10 @@ function formatServerOutput(parsed) {
level = chalk.bold.redBright(level);
}
info = level + " " + chalk.gray(parsed.file) + ": ";
info = `${level} ${chalk.gray(parsed.file)}: `;
} else if (parsed.type === "action") {
info = "[" + chalk.yellow(parsed.action) + "] ";
info = `[${chalk.yellow(parsed.action)}] `;
}
return time + info + parsed.message;
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"repository": "https://github.com/clusterio/factorioClusterio",
"license": "MIT",
"keywords": [
@@ -22,6 +22,7 @@
"node-forge": "^0.10.0",
"rcon-client": "^4.2.0",
"semver": "^7.1.1",
"set-blocking": "^2.0.0",
"triple-beam": "^1.3.0",
"winston": "^3.3.3",
"winston-transport": "^4.4.0",
+27 -22
View File
@@ -148,13 +148,12 @@ class BaseInstancePlugin {
/**
* Called when an event on the master connection happens
*
* The event param may be one of connect, drop and close and has the
* following meaning:
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the master has been established, or
* the existing one that have previously been dropped is re-established.
* Invoked when a new connection to the master has been established.
*
* ##### drop
*
@@ -166,6 +165,11 @@ class BaseInstancePlugin {
* memory on the slave and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when the connection that had previously dropped is
* re-established.
*
* ##### close
*
* Invoked when the connection to the master has been closed. This
@@ -173,7 +177,7 @@ class BaseInstancePlugin {
* send any messages that goes to or via the master server after the
* connection has been closed and before a new one is established.
*
* @param {string} event - one of connect, drop, and close
* @param {string} event - one of connect, drop, resume and close
*/
onMasterConnectionEvent(event) { }
@@ -293,13 +297,12 @@ class BaseMasterPlugin {
/**
* Called when an event on a slave connection happens
*
* The event param may be one of connect, drop and close and has the
* following meaning:
* The event param may be one of connect, drop, resume and close and has
* the following meaning:
*
* ##### connect
*
* Invoked when a new connection to the slave has been established, or
* an existing one that had previously been dropped is re-established.
* Invoked when a new connection to the slave has been established.
*
* ##### drop
*
@@ -311,13 +314,18 @@ class BaseMasterPlugin {
* memory on the master and sent all in one go when the connection is
* re-established again.
*
* ##### resume
*
* Invoked when a connection that had previously been dropped is
* re-established
*
* ##### close
*
* Invoked when the connection to the slave has been closed.
*
* @param {module:master/master~SlaveConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, and close
* @param {string} event - one of connect, drop, resume and close
*/
onSlaveConnectionEvent(connection, event) { }
@@ -418,19 +426,18 @@ class BaseControlPlugin {
/**
* Attach plugin messages
*
* Attaches all messages defined in the `.message` property of the
* `pluginInfo` passed with handlers taken from `pluin`.
* Attaches all messages defined in the `.info.message` property of the
* `plugin` passed with handlers taken from the `pluin`.
*
* @param {module:lib/link.Link} link - Link to attach handlers to.
* @param {object} pluginInfo - Plugin info object.
* @param {module:lib/plugin.BasePlugin} plugin -
* The instance of the plugin to use handlers from.
* The instance of the plugin attach messages and handlers from.
*/
function attachPluginMessages(link, pluginInfo, plugin) {
let messageDefinitions = pluginInfo.messages || [];
function attachPluginMessages(link, plugin) {
let messageDefinitions = plugin.info.messages || [];
for (let [name, messageFormat] of Object.entries(messageDefinitions)) {
if (messageFormat.plugin !== pluginInfo.name) {
throw new Error(`Type of ${name} message must start with "${pluginInfo.name}:"`);
if (messageFormat.plugin !== plugin.info.name) {
throw new Error(`Type of ${name} message must start with "${plugin.info.name}:"`);
}
let handler = name + messageFormat.handlerSuffix;
@@ -439,14 +446,12 @@ function attachPluginMessages(link, pluginInfo, plugin) {
messageFormat.attach(link);
} else {
messageFormat.attach(link, async function(message, format) {
return await plugin[handler](message, format, this);
});
messageFormat.attach(link, async (message, format) => await plugin[handler](message, format));
}
} catch (err) {
if (err.code === "MISSING_LINK_HANDLER") {
err.plugin = pluginInfo.name;
err.plugin = plugin.info.name;
err.handler = handler;
}
+46
View File
@@ -1,3 +1,4 @@
/* eslint-disable global-require */
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
@@ -47,6 +48,51 @@ async function loadPluginInfos(pluginList) {
return plugins;
}
/**
* Load master plugin class of a plugin
*
* @param {Object} pluginInfo -
* Plugin info object returned from {@link
* module:lib/plugin_loader.loadPluginInfos} to load class from.
* @returns {function} plugin class
* @static
*/
async function loadMasterPluginClass(pluginInfo) {
let entrypoint = require(path.posix.join(pluginInfo.requirePath, pluginInfo.masterEntrypoint));
return entrypoint.MasterPlugin;
}
/**
* Load instance plugin class of a plugin
*
* @param {Object} pluginInfo -
* Plugin info object returned from {@link
* module:lib/plugin_loader.loadPluginInfos} to load class from.
* @returns {function} plugin class
* @static
*/
async function loadInstancePluginClass(pluginInfo) {
let entrypoint = require(path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint));
return entrypoint.InstancePlugin;
}
/**
* Load control plugin class of a plugin
*
* @param {Object} pluginInfo -
* Plugin info object returned from {@link
* module:lib/plugin_loader.loadPluginInfos} to load class from.
* @returns {function} plugin class
* @static
*/
async function loadControlPluginClass(pluginInfo) {
let entrypoint = require(path.posix.join(pluginInfo.requirePath, pluginInfo.controlEntrypoint));
return entrypoint.ControlPlugin;
}
module.exports = {
loadPluginInfos,
loadMasterPluginClass,
loadInstancePluginClass,
loadControlPluginClass,
};
+2 -1
View File
@@ -3,7 +3,8 @@
* @module lib/schema
*/
"use strict";
const ajv = new (require("ajv"))({
const Ajv = require("ajv");
const ajv = new Ajv({
verbose: true,
format: "full",
extendRefs: "fail",
+11 -2
View File
@@ -8,6 +8,7 @@ const path = require("path");
const libConfig = require("@clusterio/lib/config");
const { logger } = require("@clusterio/lib/logging");
const libHelpers = require("@clusterio/lib/helpers");
function print(...content) {
@@ -54,6 +55,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
let pluginInfo;
try {
// eslint-disable-next-line global-require
pluginInfo = require(path.posix.join(pluginPath, "info"));
} catch (err) {
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
@@ -99,7 +101,11 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
*/
function configCommand(yargs) {
yargs
.command("set <field> [value]", "Set config field")
.command("set <field> [value]", "Set config field", yargs => {
yargs.options({
"stdin": { describe: "read value from stdin", nargs: 0, type: "boolean" },
});
})
.command("show <field>", "Show value of the given config field")
.command("list", "List all configuration fields and their values")
.demandCommand(1, "You need to specify a command to run")
@@ -140,7 +146,10 @@ async function handleConfigCommand(args, instance, configPath) {
}
} else if (command === "set") {
if (args.value === undefined) {
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
} else if (args.value === undefined) {
args.value = null;
}
+10
View File
@@ -302,6 +302,16 @@ definePermission({
grantByDefault: true,
});
definePermission({
name: "core.master.get_config",
title: "Get master config",
description: "Get the config of master.",
});
definePermission({
name: "core.master.update_config",
title: "Modify master config",
description: "Modify the master config or entries of the master config.",
});
definePermission({
name: "core.slave.list",
title: "List slaves",
+286 -202
View File
@@ -31,6 +31,8 @@ const JSZip = require("jszip");
const version = require("./package").version;
const util = require("util");
const winston = require("winston");
const http = require("http");
const https = require("https");
// ugly globals
let masterConfig;
@@ -46,9 +48,13 @@ let db = {
instances: new Map(),
slaves: new Map(),
};
let slaveConnections = new Map();
let controlConnections = [];
let activeConnectors = new Map();
let pendingSockets = new Set();
// homebrew modules
const generateSslCert = require("./src/generate_ssl_cert");
const HttpCloser = require("./src/HttpCloser");
const routes = require("./src/routes");
const libDatabase = require("@clusterio/lib/database");
const libSchema = require("@clusterio/lib/schema");
@@ -72,7 +78,9 @@ const bodyParser = require("body-parser");
const fileUpload = require("express-fileupload");
const app = express();
let httpServer;
let httpServerCloser;
let httpsServer;
let httpsServerCloser;
app.use(cookieParser());
app.use(bodyParser.json({
@@ -107,7 +115,7 @@ function serveWeb(route) {
}
// Set folder to serve static content from (the website)
app.use(express.static(path.join(__dirname, "static")));
app.use(express.static(path.join(__dirname, "dist", "web")));
app.use(express.static("static")); // Used for data export files
const slaveMappingGauge = new libPrometheus.Gauge(
@@ -177,6 +185,22 @@ const wsActiveSlavesGauge = new libPrometheus.Gauge(
"How many slaves are currently connected to the master"
);
// Merges samples from sourceResult to destinationResult
function mergeSamples(destinationResult, sourceResult) {
// Merge metrics received by multiple slaves
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();
@@ -190,8 +214,9 @@ async function getMetrics(req, res, next) {
}
let requests = [];
let timeout = masterConfig.get("master.metrics_timeout") * 1000;
for (let slaveConnection of slaveConnections.values()) {
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), 8e3, null));
requests.push(libHelpers.timeout(libLink.messages.getMetrics.send(slaveConnection), timeout, null));
}
for await (let result of await libPrometheus.defaultRegistry.collect()) {
@@ -211,17 +236,7 @@ async function getMetrics(req, res, next) {
} else {
// Merge metrics received by multiple slaves
let receivedSamples = new Map(result.samples);
for (let [suffix, suffixSamples] of resultMap.get(result.metric.name).samples) {
if (receivedSamples.has(suffix)) {
suffixSamples.push(...receivedSamples.get(suffix));
receivedSamples.delete(suffix);
}
}
for (let entry of receivedSamples) {
result.samples.push(entry);
}
mergeSamples(resultMap.get(result.metric.name), result);
}
}
}
@@ -319,6 +334,23 @@ const masterConnectedClientsCount = new libPrometheus.Gauge(
},
);
/**
* Returns the URL needed to connect to the master server.
*
* @returns {string} master URL.
*/
function getMasterUrl() {
let url = masterConfig.get("master.external_address");
if (!url) {
if (masterConfig.get("master.https_port")) {
url = `https://localhost:${masterConfig.get("master.https_port")}/`;
} else {
url = `http://localhost:${masterConfig.get("master.http_port")}/`;
}
}
return url;
}
/**
* Load Map from JSON file in the database directory.
*
@@ -340,7 +372,7 @@ async function loadInstances(databaseDirectory, file) {
try {
let serialized = JSON.parse(await fs.readFile(filePath));
for (let serializedConfig of serialized) {
let instanceConfig = new libConfig.InstanceConfig();
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(serializedConfig);
let status = instanceConfig.get("instance.assigned_slave") === null ? "unassigned" : "unknown";
instances.set(instanceConfig.get("instance.id"), { config: instanceConfig, status });
@@ -445,11 +477,16 @@ async function saveUsers(databaseDirectory, file) {
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
}
let shuttingDown = false;
/**
* Innitiate shutdown of master server
*/
async function shutdown() {
logger.info("Shutting down");
if (shuttingDown) {
return;
}
shuttingDown = true;
let exitStartTime = Date.now();
try {
logger.info("Saving configs");
@@ -495,13 +532,13 @@ async function shutdown() {
let stopTasks = [];
logger.info("Stopping HTTP(S) server");
if (httpServer) { stopTasks.push(new Promise(resolve => httpServer.close(resolve))); }
if (httpsServer) { stopTasks.push(new Promise(resolve => httpsServer.close(resolve))); }
if (httpServer) { stopTasks.push(httpServerCloser.close()); }
if (httpsServer) { stopTasks.push(httpsServerCloser.close()); }
await Promise.all(stopTasks);
logger.info(`Clusterio cleanly exited in ${Date.now() - exitStartTime}ms`);
} catch(err) {
} catch (err) {
setBlocking(true);
logger.fatal(`
+--------------------------------------------------------------------+
@@ -510,6 +547,7 @@ async function shutdown() {
+--------------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line no-process-exit
process.exit(1);
}
}
@@ -524,7 +562,7 @@ class BaseConnection extends libLink.Link {
super("master", target, connector);
libLink.attachAllMessages(this);
for (let masterPlugin of masterPlugins.values()) {
libPlugin.attachPluginMessages(this, masterPlugin.info, masterPlugin);
libPlugin.attachPluginMessages(this, masterPlugin);
}
}
@@ -603,7 +641,6 @@ const lastQueryLogTime = new libPrometheus.Gauge(
"Time in seconds the last log query took to execute."
);
let controlConnections = [];
class ControlConnection extends BaseConnection {
constructor(registerData, connector, user) {
super("control", connector);
@@ -647,6 +684,19 @@ class ControlConnection extends BaseConnection {
});
}
async getMasterConfigRequestHandler() {
return { serialized_config: masterConfig.serialize("control") };
}
async setMasterConfigFieldRequestHandler(message) {
masterConfig.set(message.data.field, message.data.value, "control");
}
async setMasterConfigPropRequestHandler(message) {
let { field, prop, value } = message.data;
masterConfig.setProp(field, prop, value, "control");
}
async listSlavesRequestHandler(message) {
let list = [];
for (let slave of db.slaves.values()) {
@@ -670,7 +720,7 @@ class ControlConnection extends BaseConnection {
}
async createSlaveConfigRequestHandler(message) {
let slaveConfig = new libConfig.SlaveConfig();
let slaveConfig = new libConfig.SlaveConfig("control");
await slaveConfig.init();
slaveConfig.set("slave.master_url", getMasterUrl());
@@ -702,13 +752,40 @@ class ControlConnection extends BaseConnection {
// XXX should probably add a hook for slave reuqests?
async createInstanceRequestHandler(message) {
let instanceConfig = new libConfig.InstanceConfig();
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(message.data.serialized_config);
let instanceId = instanceConfig.get("instance.id");
if (db.instances.has(instanceId)) {
throw new libErrors.RequestError(`Instance with ID ${instanceId} already exists`);
}
// Add common settings for the Factorio server
let settings = {
"name": `${masterConfig.get("master.name")} - ${instanceConfig.get("instance.name")}`,
"description": `Clusterio instance for ${masterConfig.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);
db.instances.set(instanceId, { config: instanceConfig, status: "unassigned" });
}
@@ -731,7 +808,7 @@ class ControlConnection extends BaseConnection {
}
return {
serialized_config: instance.config.serialize(),
serialized_config: instance.config.serialize("control"),
};
}
@@ -742,7 +819,7 @@ class ControlConnection extends BaseConnection {
if (connection) {
await libLink.messages.assignInstance.send(connection, {
instance_id: instance.config.get("instance.id"),
serialized_config: instance.config.serialize(),
serialized_config: instance.config.serialize("slave"),
});
}
}
@@ -763,7 +840,7 @@ class ControlConnection extends BaseConnection {
throw new libErrors.RequestError("Setting instance.id is not supported");
}
instance.config.set(message.data.field, message.data.value);
instance.config.set(message.data.field, message.data.value, "control");
await this.updateInstanceConfig(instance);
}
@@ -774,7 +851,7 @@ class ControlConnection extends BaseConnection {
}
let { field, prop, value } = message.data;
instance.config.setProp(field, prop, value);
instance.config.setProp(field, prop, value, "control");
await this.updateInstanceConfig(instance);
}
@@ -813,7 +890,7 @@ class ControlConnection extends BaseConnection {
if (slave_id !== null) {
await libLink.messages.assignInstance.send(newSlaveConnection, {
instance_id,
serialized_config: instance.config.serialize(),
serialized_config: instance.config.serialize("slave"),
});
}
}
@@ -1075,7 +1152,6 @@ class ControlConnection extends BaseConnection {
}
}
let slaveConnections = new Map();
/**
* Represents the connection to a slave
*
@@ -1105,7 +1181,7 @@ class SlaveConnection extends BaseConnection {
}
});
for (let event of ["connect", "drop", "close"]) {
for (let event of ["connect", "drop", "resume", "close"]) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let masterPlugin of masterPlugins.values()) {
@@ -1139,15 +1215,15 @@ class SlaveConnection extends BaseConnection {
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(),
serialized_config: instance.config.serialize("slave"),
});
}
}
// Assign instances the slave has but master does not
for (let instance of message.data.instances) {
let instanceConfig = new libConfig.InstanceConfig();
await instanceConfig.load(instance.serialized_config);
let instanceConfig = new libConfig.InstanceConfig("master");
await instanceConfig.load(instance.serialized_config, "slave");
let masterInstance = db.instances.get(instanceConfig.get("instance.id"));
if (masterInstance) {
@@ -1176,7 +1252,7 @@ class SlaveConnection extends BaseConnection {
});
await libLink.messages.assignInstance.send(this, {
instance_id: instanceConfig.get("instance.id"),
serialized_config: instanceConfig.serialize(),
serialized_config: instanceConfig.serialize("slave"),
});
}
@@ -1334,7 +1410,7 @@ class WebSocketServerConnector extends libLink.WebSocketBaseConnector {
for (let message of this._sendBuffer) {
this._socket.send(JSON.stringify(message));
}
this.emit("connect");
this.emit("resume");
}
setTimeout(timeout) {
@@ -1443,67 +1519,6 @@ class WebSocketServerConnector extends libLink.WebSocketBaseConnector {
}
}
let pendingSockets = new Set();
let activeConnectors = new Map();
wss.on("connection", function (socket, req) {
logger.verbose(`SOCKET | new connection from ${req.socket.remoteAddress}`);
// Track statistics
wsConnectionsCounter.inc();
socket.on("message", (message) => {
wsMessageCounter.labels("in").inc();
if (!socket.clusterio_ignore_dump) {
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) {
debugEvents.emit("message", { direction: "out", content: args[0] });
}
return originalSend.call(socket, ...args);
};
// Start connection handshake.
socket.send(JSON.stringify({ seq: null, type: "hello", data: {
version,
plugins: loadedPlugins,
}}));
function attachHandler() {
pendingSockets.add(socket);
let timeoutId = setTimeout(() => {
logger.verbose(`SOCKET | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1008, "Handshake timeout");
pendingSockets.delete(socket);
}, 30*1000);
socket.once("message", (message) => {
clearTimeout(timeoutId);
pendingSockets.delete(socket);
handleHandshake(
message, socket, req, attachHandler
).catch(err => {
logger.error(`
+----------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
});
});
}
attachHandler();
});
let nextSessionId = 1;
// Unique string for the session token audience
@@ -1543,7 +1558,7 @@ async function handleHandshake(message, socket, req, attachHandler) {
throw new Error();
}
} catch(err) {
} catch (err) {
socket.send(JSON.stringify({ seq: null, type: "invalidate", data: {}}));
attachHandler();
return;
@@ -1623,12 +1638,63 @@ async function handleHandshake(message, socket, req, attachHandler) {
connector.ready(sessionToken);
}
// handle plugins on the master
async function pluginManagement() {
let startPluginLoad = Date.now();
masterPlugins = await loadPlugins();
logger.info(`All plugins loaded in ${Date.now() - startPluginLoad}ms`);
}
wss.on("connection", (socket, req) => {
logger.verbose(`SOCKET | new connection from ${req.socket.remoteAddress}`);
// Track statistics
wsConnectionsCounter.inc();
socket.on("message", (message) => {
wsMessageCounter.labels("in").inc();
if (!socket.clusterio_ignore_dump) {
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) {
debugEvents.emit("message", { direction: "out", content: args[0] });
}
return originalSend.call(socket, ...args);
};
// Start connection handshake.
socket.send(JSON.stringify({ seq: null, type: "hello", data: {
version,
plugins: loadedPlugins,
}}));
function attachHandler() {
pendingSockets.add(socket);
let timeoutId = setTimeout(() => {
logger.verbose(`SOCKET | closing ${req.socket.remoteAddress} after timing out on handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1008, "Handshake timeout");
pendingSockets.delete(socket);
}, 30*1000);
socket.once("message", (message) => {
clearTimeout(timeoutId);
pendingSockets.delete(socket);
handleHandshake(
message, socket, req, attachHandler
).catch(err => {
logger.error(`
+----------------------------------------------------------------+
| Unexpected error occured in WebSocket handshake, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
});
});
}
attachHandler();
});
async function loadPlugins() {
let plugins = new Map();
@@ -1640,13 +1706,13 @@ async function loadPlugins() {
loadedPlugins[pluginInfo.name] = pluginInfo.version;
let pluginLoadStarted = Date.now();
let MasterPlugin = libPlugin.BaseMasterPlugin;
let MasterPluginClass = libPlugin.BaseMasterPlugin;
try {
if (pluginInfo.masterEntrypoint) {
({ MasterPlugin } = require(path.posix.join(pluginInfo.requirePath, pluginInfo.masterEntrypoint)));
MasterPluginClass = await libPluginLoader.loadMasterPluginClass(pluginInfo);
}
let masterPlugin = new MasterPlugin(
let masterPlugin = new MasterPluginClass(
pluginInfo, { app, config: masterConfig, db, slaveConnections }, { endpointHitCounter }, logger
);
await masterPlugin.init();
@@ -1661,6 +1727,13 @@ async function loadPlugins() {
return plugins;
}
// handle plugins on the master
async function pluginManagement() {
let startPluginLoad = Date.now();
masterPlugins = await loadPlugins();
logger.info(`All plugins loaded in ${Date.now() - startPluginLoad}ms`);
}
/**
* Calls listen on server capturing any errors that occurs
* binding to the port. Also adds handler for WebSocket
@@ -1700,28 +1773,61 @@ function _setConfig(config) {
masterConfig = config;
}
/**
* Returns the URL needed to connect to the master server.
*
* @returns {string} master URL.
*/
function getMasterUrl() {
let url = masterConfig.get("master.external_address");
if (!url) {
if (masterConfig.get("master.https_port")) {
url = `https://localhost:${masterConfig.get("master.https_port")}/`;
async function handleBootstrapCommand(args) {
let subCommand = args._[1];
await loadUsers(masterConfig.get("master.database_directory"), "users.json");
if (subCommand === "create-admin") {
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
return;
}
let admin = db.users.get(args.name);
if (!admin) {
admin = createUser(args.name);
}
let adminRole = libUsers.ensureDefaultAdminRole(db.roles);
admin.roles.add(adminRole);
admin.isAdmin = true;
await saveUsers(masterConfig.get("master.database_directory"), "users.json");
} else if (subCommand === "generate-user-token") {
let user = db.users.get(args.name);
if (!user) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
// eslint-disable-next-line no-console
console.log(user.createToken(masterConfig.get("master.auth_secret")));
} else if (subCommand === "create-ctl-config") {
let admin = db.users.get(args.name);
if (!admin) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
let controlConfig = new libConfig.ControlConfig("control");
await controlConfig.init();
controlConfig.set("control.master_url", getMasterUrl());
controlConfig.set("control.master_token", admin.createToken(masterConfig.get("master.auth_secret")));
let content = JSON.stringify(controlConfig.serialize(), null, 4);
if (args.output === "-") {
// eslint-disable-next-line no-console
console.log(content);
} else {
url = `http://localhost:${masterConfig.get("master.http_port")}/`;
logger.info(`Writing ${args.output}`);
await fs.outputFile(args.output, content);
}
}
return url;
}
async function startServer() {
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
process.title = "clusterioMaster";
async function initialize() {
// argument parsing
let args = yargs
.scriptName("master")
@@ -1792,9 +1898,6 @@ async function startServer() {
}));
}
// add better stack traces on promise rejection
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
@@ -1809,7 +1912,7 @@ async function startServer() {
let command = args._[0];
if (command === "plugin") {
await libSharedCommands.handlePluginCommand(args, pluginList, args.pluginList);
return;
return [false, args];
}
logger.info("Loading Plugin info");
@@ -1819,7 +1922,7 @@ async function startServer() {
masterConfigPath = args.config;
logger.info(`Loading config from ${masterConfigPath}`);
masterConfig = new libConfig.MasterConfig();
masterConfig = new libConfig.MasterConfig("master");
try {
await masterConfig.load(JSON.parse(await fs.readFile(masterConfigPath)));
@@ -1843,72 +1946,26 @@ async function startServer() {
if (command === "config") {
await libSharedCommands.handleConfigCommand(args, masterConfig, masterConfigPath);
return;
return [false, args];
} else if (command === "bootstrap") {
let subCommand = args._[1];
await loadUsers(masterConfig.get("master.database_directory"), "users.json");
if (subCommand === "create-admin") {
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
return;
}
let admin = db.users.get(args.name);
if (!admin) {
admin = createUser(args.name);
}
let adminRole = libUsers.ensureDefaultAdminRole(db.roles);
admin.roles.add(adminRole);
admin.isAdmin = true;
await saveUsers(masterConfig.get("master.database_directory"), "users.json");
} else if (subCommand === "generate-user-token") {
let user = db.users.get(args.name);
if (!user) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
// eslint-disable-next-line no-console
console.log(user.createToken(masterConfig.get("master.auth_secret")));
} else if (subCommand === "create-ctl-config") {
let admin = db.users.get(args.name);
if (!admin) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
let controlConfig = new libConfig.ControlConfig();
await controlConfig.init();
controlConfig.set("control.master_url", getMasterUrl());
controlConfig.set("control.master_token", admin.createToken(masterConfig.get("master.auth_secret")));
let content = JSON.stringify(controlConfig.serialize(), null, 4);
if (args.output === "-") {
// eslint-disable-next-line no-console
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await fs.outputFile(args.output, content);
}
}
return;
await handleBootstrapCommand(args);
return [false, args];
}
// If we get here the command was run
return [true, args];
}
async function startServer(args) {
// Start webpack development server if enabled
if (args.dev) {
logger.warn("Webpack development mode enabled");
/* eslint-disable global-require */
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfig = require("./webpack.config");
/* eslint-enable global-require */
const compiler = webpack(webpackConfig({}));
devMiddleware = webpackDevMiddleware(compiler, {});
@@ -1918,7 +1975,9 @@ async function startServer() {
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line no-process-exit
process.exit(1);
}
@@ -1926,12 +1985,22 @@ async function startServer() {
logger.info("Caught interrupt signal, shutting down");
shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line no-process-exit
process.exit(1);
}
// terminal closed
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
shutdown();
});
process.on("SIGHUP", () => {
// No graceful cleanup, no warning out (stdout is likely closed.)
// Don't close the terminal with the clusterio master in it.
process.exit(1);
logger.info("Terminal closed, shutting down");
shutdown();
});
await fs.ensureDir(masterConfig.get("master.database_directory"));
@@ -1943,21 +2012,20 @@ async function startServer() {
// Make sure we're actually going to listen on a port
let httpPort = masterConfig.get("master.http_port");
let httpsPort = masterConfig.get("master.https_port");
let bindAddress = masterConfig.get("master.bind_address") || "";
if (!httpPort && !httpsPort) {
logger.fatal("Error: at least one of http_port and https_port must be configured");
process.exit(1);
process.exitCode = 1;
return;
}
let tls_cert = masterConfig.get("master.tls_certificate");
let tls_key = masterConfig.get("master.tls_private_key");
// Create a self signed certificate if the certificate files doesn't exist
if (httpsPort && !await fs.exists(tls_cert) && !await fs.exists(tls_key)) {
await generateSslCert({
bits: masterConfig.get("master.tls_bits"),
sslCertPath: tls_cert,
sslPrivKeyPath: tls_key,
doLogging: true,
});
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"
);
}
// Add routes for the web interface
@@ -1970,8 +2038,8 @@ async function startServer() {
}
let pluginPackagePath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
let staticPath = path.join(path.dirname(pluginPackagePath), "static");
app.use(`/plugin/${pluginInfo.name}`, express.static(staticPath));
let webPath = path.join(path.dirname(pluginPackagePath), "dist", "web");
app.use(`/plugin/${pluginInfo.name}`, express.static(webPath));
}
// Load plugins
@@ -1979,8 +2047,9 @@ async function startServer() {
// Only start listening for connections after all plugins have loaded
if (httpPort) {
httpServer = new (require("http").Server)(app);
await listen(httpServer, httpPort);
httpServer = http.createServer(app);
httpServerCloser = new HttpCloser(httpServer);
await listen(httpServer, httpPort, bindAddress);
logger.info(`Listening for HTTP on port ${httpServer.address().port}`);
}
@@ -1996,15 +2065,30 @@ async function startServer() {
);
}
httpsServer = require("https").createServer({
httpsServer = https.createServer({
key: privateKey,
cert: certificate,
}, app);
await listen(httpsServer, httpsPort);
httpsServerCloser = new HttpCloser(httpsServer);
await listen(httpsServer, httpsPort, bindAddress);
logger.info(`Listening for HTTPS on port ${httpsServer.address().port}`);
}
}
async function startup() {
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
process.title = "clusterioMaster";
// add better stack traces on promise rejection
process.on("unhandledRejection", err => logger.error(`Unhandled rejection:\n${err.stack}`));
let [shouldRun, args] = await initialize();
if (shouldRun) {
await startServer(args);
}
}
module.exports = {
app,
@@ -2027,7 +2111,7 @@ I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startServer().catch(err => {
startup().catch(err => {
if (err instanceof libErrors.StartupError) {
logger.fatal(`
+----------------------------------+
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env.production",
@@ -19,7 +19,7 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.3",
"@clusterio/lib": "^2.0.0-alpha.4",
"body-parser": "^1.18.2",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
@@ -36,7 +36,7 @@
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.3",
"@clusterio/web_ui": "^2.0.0-alpha.4",
"babel-loader": "^8.1.0",
"css-loader": "^4.3.0",
"fast-deep-equal": "^3.1.3",
+109
View File
@@ -0,0 +1,109 @@
"use strict";
const http = require("http");
const https = require("https");
const util = require("util");
/**
* Gracefully close a Node.js HTTP(S) server
*
* Helper class to properly close down a Node.js HTTP(S) server. This is
* necessary due to connections being able to linger around forever with the
* keep-alive logic in HTTP.
*
* @example
* // during server initialization
* const server = http.createServer();
* const serverCloser = new HttpCloser(server);
*
* // in the shutdown logic
* await serverCloser.close();
*
* @alias module:master/src/HttpCloser
*/
class HttpCloser {
/**
* Server to attach this closer to.
* @param {https.Server | http.Server} server - HTTP(S) to attach to
*/
constructor(server) {
this._server = server;
this._sockets = new Set();
this._responses = new Set();
if (this._server instanceof http.Server) {
this._server.on("connection", this._addSocket.bind(this));
} else if (this._server instanceof https.Server) {
this._server.on("secureConnection", this._addSocket.bind(this));
} else {
throw Error("server must be an instance of either http.Server or https.Server.");
}
this._server.on("request", this._addRequest.bind(this));
}
_addSocket(socket) {
this._sockets.add(socket);
socket.once("close", () => {
this._sockets.delete(socket);
});
}
_addRequest(request, response) {
this._responses.add(response);
response.once("close", () => this._responses.delete(response));
response.once("finish", () => this._responses.delete(response));
}
/**
* Gracefully close down the attached HTTP(S) server.
*
* Closes open connections that do not have a request in flight, signals
* connections to pending requests should close, and initiates the
* close operation on the attached server.
*
* If there's requests in progress that still haven't been completed
* after the timeout value passed they will be aborted.
*
* @param {number} timeout -
* Time in ms to wait for connections to close.
*/
async close(timeout = 5000) {
let activeSockets = new Set();
for (let response of this._responses) {
if (!response.headersSent) {
response.setHeader("Connection", "close");
} else {
let socket = response.socket;
response.once("finish", () => {
socket.end();
});
}
activeSockets.add(response.socket);
}
for (let socket of this._sockets) {
if (!activeSockets.has(socket)) {
socket.end();
}
}
return new Promise((resolve, reject) => {
let timer = setTimeout(() => {
for (let socket of this._sockets) {
// Ideally a RST would be sent here to indicate the the
// transfer failed, but we can't reliably send that from
// Node.js, see nodejs/node#27428.
socket.destroy();
}
}, timeout);
this._server.close(err => {
clearTimeout(timer);
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
};
module.exports = HttpCloser;
-43
View File
@@ -1,43 +0,0 @@
"use strict";
const forge = require("node-forge");
const fs = require("fs-extra");
const util = require("util");
const { logger } = require("@clusterio/lib/logging");
const hrtime = process.hrtime.bigint;
module.exports = async function({
bits = 2048,
sslCertPath,
sslPrivKeyPath,
} = {}){
logger.info(`Generating SSL certificate with ${bits} bits at ${sslCertPath}`);
let startNs = hrtime();
let keypair = await util.promisify(forge.pki.rsa.generateKeyPair)({ bits: bits, e: 0x10001 });
logger.info(`Generated ${bits}bit keypair in: ${Number(hrtime()-startNs) / 1e6} ms`);
startNs = hrtime();
let cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date(Date.now()+(1000*60*60*24*365)); // valid for one year
// XXX This should probably be limited to the actual hostname
let attrs = [{ name: "commonName", value: "*" }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
// self-sign certificate
cert.sign(keypair.privateKey);
// Write certificate and private key to pem files
await fs.outputFile(sslPrivKeyPath, forge.pki.privateKeyToPem(keypair.privateKey));
await fs.outputFile(sslCertPath, forge.pki.certificateToPem(cert));
logger.info(`Generated certificate in: ${Number(hrtime()-startNs) / 1e6} ms`);
};
+3 -4
View File
@@ -2,15 +2,14 @@
// Routes used in the web interface and served by the master server
module.exports = [
"/",
"/instances",
"/instances/:id/view",
"/master",
"/slaves",
"/slaves/:id/view",
"/instances",
"/instances/:id/view",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/permissions",
"/permissions/:id/view",
"/settings",
];
+2 -2
View File
@@ -9,11 +9,11 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
devServer: {
contentBase: "./static",
contentBase: "./dist/web",
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,7 +18,7 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.3",
"@clusterio/lib": "^2.0.0-alpha.4",
"fs-extra": "^8.1.0",
"phin": "^3.4.1",
"pidusage": "^2.0.18",
+147 -105
View File
@@ -118,7 +118,7 @@ const serverSettingsActions = {
/**
* Keeps track of the runtime parameters of an instance
*/
class Instance extends libLink.Link{
class Instance extends libLink.Link {
constructor(slave, connector, dir, factorioDir, instanceConfig) {
super("instance", "slave", connector);
libLink.attachAllMessages(this);
@@ -128,9 +128,15 @@ class Instance extends libLink.Link{
this.plugins = new Map();
this.config = instanceConfig;
/**
* ID of this instance, equivalenet to `instance.config.get("instance.id")`.
* @constant {number}
*/
this.id = this.config.get("instance.id");
this.logger = logger.child({
instance_id: this.config.get("instance.id"),
instance_name: this.config.get("instance.name"),
instance_id: this.id,
instance_name: this.name,
});
this._configFieldChanged = (group, field, prev) => {
@@ -168,7 +174,7 @@ class Instance extends libLink.Link{
);
this.server.on("output", (parsed, line) => {
this.logger.log("server", { message: line, instance_id: this.config.get("instance.id"), parsed });
this.logger.log("server", { message: line, instance_id: this.id, parsed });
libPlugin.invokeHook(this.plugins, "onOutput", parsed, line);
});
@@ -185,7 +191,7 @@ class Instance extends libLink.Link{
this.server.on("ipc-player_event", event => {
libLink.messages.playerEvent.send(this, {
instance_id: this.config.get("instance.id"),
instance_id: this.id,
...event,
});
libPlugin.invokeHook(this.plugins, "onPlayerEvent", event);
@@ -193,7 +199,7 @@ class Instance extends libLink.Link{
}
async sendRcon(message, expectEmpty, plugin = "") {
let instanceId = String(this.config.get("instance.id"));
let instanceId = String(this.id);
let observeDuration = instanceRconCommandDuration.labels(instanceId).startTimer();
try {
return await this.server.sendRcon(message, expectEmpty);
@@ -205,13 +211,13 @@ class Instance extends libLink.Link{
async _autosave(name) {
let stat = await fs.stat(this.path("saves", `${name}.zip`));
instanceFactorioAutosaveSize.labels(String(this.config.get("instance.id"))).set(stat.size);
instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size);
}
notifyStatus(status) {
this._status = status;
libLink.messages.instanceStatusChanged.send(this, {
instance_id: this.config.get("instance.id"), status,
instance_id: this.id, status,
});
}
@@ -238,7 +244,7 @@ class Instance extends libLink.Link{
collector instanceof libPrometheus.ValueCollector
&& collector.metric.labels.includes("instance_id")
) {
collector.removeAll({ instance_id: String(this.config.get("instance.id")) });
collector.removeAll({ instance_id: String(this.id) });
}
}
@@ -250,11 +256,11 @@ class Instance extends libLink.Link{
async _loadPlugin(pluginInfo, slave) {
let pluginLoadStarted = Date.now();
let { InstancePlugin } = require(path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint));
let instancePlugin = new InstancePlugin(pluginInfo, this, slave);
let InstancePluginClass = await libPluginLoader.loadInstancePluginClass(pluginInfo);
let instancePlugin = new InstancePluginClass(pluginInfo, this, slave);
this.plugins.set(pluginInfo.name, instancePlugin);
await instancePlugin.init();
libPlugin.attachPluginMessages(this, pluginInfo, instancePlugin);
libPlugin.attachPluginMessages(this, instancePlugin);
this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStarted}ms`);
}
@@ -285,7 +291,7 @@ class Instance extends libLink.Link{
for (let [name, plugin] of this.plugins) {
plugins[name] = plugin.info.version;
}
libLink.messages.instanceInitialized.send(this, { instance_id: this.config.get("instance.id"), plugins });
libLink.messages.instanceInitialized.send(this, { instance_id: this.id, plugins });
}
/**
@@ -380,16 +386,19 @@ class Instance extends libLink.Link{
this.logger.verbose("Rotating old logs...");
// clean old log file to avoid crash
try{
try {
let logPath = this.path("factorio-current.log");
let stat = await fs.stat(logPath);
if(stat.isFile()){
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(e){}
} catch (err) {
this.logger.error(`Error rotating logs:\n${err.stack}`);
}
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
}
@@ -540,9 +549,8 @@ class Instance extends libLink.Link{
* Update instance information on the Factorio side
*/
async updateInstanceData() {
let name = libLuaTools.escapeString(this.config.get("instance.name"));
let id = this.config.get("instance.id");
await this.sendRcon(`/sc clusterio_private.update_instance(${id}, "${name}")`, true);
let name = libLuaTools.escapeString(this.name);
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true);
}
async updateFactorioSettings(current, previous) {
@@ -645,8 +653,8 @@ class Instance extends libLink.Link{
let pid = this.server.pid;
if (pid) {
let stats = await pidusage(pid);
instanceFactorioCpuTime.labels(String(this.config.get("instance.id"))).set(stats.ctime / 1000);
instanceFactorioMemoryUsage.labels(String(this.config.get("instance.id"))).set(stats.memory);
instanceFactorioCpuTime.labels(String(this.id)).set(stats.ctime / 1000);
instanceFactorioMemoryUsage.labels(String(this.id)).set(stats.memory);
}
return { results };
@@ -698,6 +706,7 @@ class Instance extends libLink.Link{
await this.writeServerSettings();
this.logger.verbose("Creating save .....");
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
} catch (err) {
@@ -717,6 +726,7 @@ class Instance extends libLink.Link{
await this.writeServerSettings();
this.logger.info("Exporting data .....");
// eslint-disable-next-line no-use-before-define
await symlinkMods(this, "sharedMods");
let zip = await libFactorio.exportData(this.server);
@@ -789,7 +799,7 @@ async function discoverInstances(instancesDir) {
let instanceInfos = new Map();
for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
let instanceConfig = new libConfig.InstanceConfig();
let instanceConfig = new libConfig.InstanceConfig("slave");
let configPath = path.join(instancesDir, entry.name, "instance.json");
try {
@@ -825,7 +835,7 @@ class InstanceConnection extends libLink.Link {
libLink.attachAllMessages(this);
for (let pluginInfo of slave.pluginInfos) {
libPlugin.attachPluginMessages(this, pluginInfo, null);
libPlugin.attachPluginMessages(this, { info: pluginInfo });
}
}
@@ -890,8 +900,12 @@ class InstanceConnection extends libLink.Link {
}
class SlaveConnector extends libLink.WebSocketClientConnector {
constructor(slaveConfig, pluginInfos) {
super(slaveConfig.get("slave.master_url"), slaveConfig.get("slave.reconnect_delay"));
constructor(slaveConfig, tlsCa, pluginInfos) {
super(
slaveConfig.get("slave.master_url"),
slaveConfig.get("slave.reconnect_delay"),
tlsCa
);
this.slaveConfig = slaveConfig;
this.pluginInfos = pluginInfos;
}
@@ -914,6 +928,46 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
}
}
function checkFilename(name) {
// All of these are bad in Windows only, except for /, . and ..
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badNames = [
// Relative path components
".", "..",
// Reserved filenames in Windows
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
if (typeof name !== "string") {
throw new Error("must be a string");
}
if (name === "") {
throw new Error("cannot be empty");
}
if (badChars.test(name)) {
throw new Error('cannot contain <>:"\\/|=* or control characters');
}
if (badNames.includes(name.toUpperCase())) {
throw new Error(
"cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9"
);
}
if (badEnd.test(name)) {
throw new Error("cannot end with . or space");
}
}
/**
* Handles running the slave
*
@@ -928,7 +982,7 @@ class Slave extends libLink.Link {
this.pluginInfos = pluginInfos;
for (let pluginInfo of pluginInfos) {
libPlugin.attachPluginMessages(this, pluginInfo, null);
libPlugin.attachPluginMessages(this, { info: pluginInfo });
}
this.config = slaveConfig;
@@ -958,11 +1012,6 @@ class Slave extends libLink.Link {
this.updateInstances().catch((err) => {
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
return this.shutdown();
}).catch((err) => {
setBlocking(true);
logger.fatal(`Unexpected error during shutdown:\n${err.stack}`);
process.exit(1);
});
});
@@ -976,24 +1025,15 @@ class Slave extends libLink.Link {
this.connector.connect().catch((err) => {
logger.fatal(`Unexpected error reconnecting to master:\n${err.stack}`);
return this.shutdown();
}).catch((err) => {
setBlocking(true);
logger.fatal(`Unexpected error during shutdown:\n${err.stack}`);
process.exit(1);
});
} else {
logger.fatal("Master connection was unexpectedly closed");
this.shutdown().catch((err) => {
setBlocking(true);
logger.fatal(`Unexpected error during shutdown:\n${err.stack}`);
process.exit(1);
});
this.shutdown();
}
});
for (let event of ["connect", "drop", "close"]) {
for (let event of ["connect", "drop", "resume", "close"]) {
this.connector.on(event, () => {
for (let instanceConnection of this.instanceConnections.values()) {
libLink.messages.masterConnectionEvent.send(instanceConnection, { event });
@@ -1135,17 +1175,17 @@ class Slave extends libLink.Link {
let { instance_id, serialized_config } = message.data;
let instanceInfo = this.instanceInfos.get(instance_id);
if (instanceInfo) {
instanceInfo.config.update(serialized_config, true);
instanceInfo.config.update(serialized_config, true, "master");
logger.verbose(`Updated config for ${instanceInfo.path}`);
} else {
instanceInfo = this.discoveredInstanceInfos.get(instance_id);
if (instanceInfo) {
instanceInfo.config.update(serialized_config, true);
instanceInfo.config.update(serialized_config, true, "master");
} else {
let instanceConfig = new libConfig.InstanceConfig();
await instanceConfig.load(serialized_config);
let instanceConfig = new libConfig.InstanceConfig("slave");
await instanceConfig.load(serialized_config, "master");
// XXX: race condition on multiple simultanious calls
let instanceDir = await this._findNewInstanceDir(instanceConfig.get("instance.name"));
@@ -1308,7 +1348,7 @@ class Slave extends libLink.Link {
for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) {
let instanceConnection = this.instanceConnections.get(instanceId);
list.push({
serialized_config: instanceInfo.config.serialize(),
serialized_config: instanceInfo.config.serialize("master"),
status: instanceConnection ? instanceConnection.status : "stopped",
});
}
@@ -1349,6 +1389,9 @@ class Slave extends libLink.Link {
* Stops all instances and closes the connection
*/
async shutdown() {
if (this._shuttingDown) {
return;
}
this._shuttingDown = true;
this.connector.setTimeout(30);
@@ -1360,53 +1403,26 @@ class Slave extends libLink.Link {
}
}
for (let instanceId of this.instanceConnections.keys()) {
await this.stopInstance(instanceId);
try {
for (let instanceId of this.instanceConnections.keys()) {
await this.stopInstance(instanceId);
}
await this.connector.close(1001, "Slave Shutdown");
// Clear silly interval in pidfile library.
pidusage.clear();
} catch (err) {
setBlocking(true);
logger.error(`
+--------------------------------------------------------------------+
| Unexpected error occured while shutting down slave, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+--------------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line no-process-exit
process.exit(1);
}
await this.connector.close(1001, "Slave Shutdown");
// Clear silly interval in pidfile library.
pidusage.clear();
}
}
function checkFilename(name) {
// All of these are bad in Windows only, except for /, . and ..
// See: https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
const badChars = /[<>:"\/\\|?*\x00-\x1f]/g;
const badEnd = /[. ]$/;
const oneToNine = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const badNames = [
// Relative path components
".", "..",
// Reserved filenames in Windows
"CON", "PRN", "AUX", "NUL",
...oneToNine.map(n => `COM${n}`),
...oneToNine.map(n => `LPT${n}`),
];
if (typeof name !== "string") {
throw new Error("must be a string");
}
if (name === "") {
throw new Error("cannot be empty");
}
if (badChars.test(name)) {
throw new Error('cannot contain <>:"\\/|=* or control characters');
}
if (badNames.includes(name.toUpperCase())) {
throw new Error(
"cannot be named any of . .. CON PRN AUX NUL COM1-9 and LPT1-9"
);
}
if (badEnd.test(name)) {
throw new Error("cannot end with . or space");
}
}
@@ -1449,6 +1465,7 @@ async function symlinkMods(instance, sharedMods) {
let target = path.join(sharedMods, entry.name);
let link = instance.path("mods", entry.name);
/* eslint-disable max-depth */
if (process.platform !== "win32") {
await fs.symlink(path.relative(path.dirname(link), target), link);
@@ -1460,6 +1477,7 @@ async function symlinkMods(instance, sharedMods) {
} else {
await fs.link(target, link);
}
/* eslint-enable max-depth */
}
} else {
@@ -1543,7 +1561,7 @@ async function startSlave() {
libConfig.finalizeConfigs();
logger.info(`Loading config from ${args.config}`);
let slaveConfig = new libConfig.SlaveConfig();
let slaveConfig = new libConfig.SlaveConfig("slave");
try {
await slaveConfig.load(JSON.parse(await fs.readFile(args.config)));
@@ -1593,29 +1611,45 @@ async function startSlave() {
return;
}
let slaveConnector = new SlaveConnector(slaveConfig, pluginInfos);
let tlsCa = null;
let tlsCaPath = slaveConfig.get("slave.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath);
}
let slaveConnector = new SlaveConnector(slaveConfig, tlsCa, pluginInfos);
let slave = new Slave(slaveConnector, slaveConfig, pluginInfos);
// Handle interrupts
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line no-process-exit
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
slave.shutdown().catch(err => {
logger.error(`
+---------------------------------------------------------------+
| Unexpected error occured while stopping slave, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+---------------------------------------------------------------+
${err.stack}`
);
slave.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line no-process-exit
process.exit(1);
});
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
slave.shutdown();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
slave.shutdown();
});
slaveConnector.once("connect", () => {
@@ -1647,6 +1681,14 @@ I version of clusterio. Expect things to break. I
if (err instanceof libErrors.AuthenticationFailed) {
logger.fatal(err.message);
} else if (err instanceof libErrors.PluginError) {
logger.fatal(`
${err.pluginName} plugin threw an unexpected error
during startup, please report it to the plugin author.
------------------------------------------------------
${err.original.stack}`
);
} else {
logger.fatal(`
+--------------------------------------------------------------+
@@ -1657,6 +1699,6 @@ ${err.stack}`
);
}
process.exit(1);
process.exitCode = 1;
});
}
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
@@ -15,7 +15,7 @@
},
"dependencies": {
"@ant-design/icons": "^4.2.2",
"@clusterio/lib": "^2.0.0-alpha.3",
"@clusterio/lib": "^2.0.0-alpha.4",
"antd": "^4.6.5",
"buffer": "^6.0.2",
"clean-webpack-plugin": "^3.0.0",
@@ -0,0 +1,91 @@
import React, { useContext, useState } from "react";
import { Button, Form, Modal, Select, Typography } from "antd";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
const { Paragraph } = Typography;
export default function AssignInstanceModal(props) {
let [visible, setVisible] = useState(false);
let [slaves, setSlaves] = useState([]);
let [loading, setLoading] = useState(true);
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() {
let slaveId = form.getFieldValue("slave");
if (slaveId === undefined) {
setVisible(false);
return;
}
setApplying(true);
libLink.messages.assignInstanceCommand.send(control, {
instance_id: props.id,
slave_id: slaveId,
}).then(() => {
setVisible(false);
if (props.onFinish) {
props.onFinish();
}
}).catch(
notifyErrorHandler("Error assigning instance")
).finally(
() => setApplying(false)
);
}
function handleCancel() {
setVisible(false);
}
return <>
<Button {...props.buttonProps} onClick={open}>
{props.buttonContent || "Assign"}
</Button>
<Modal
title="Assign Instance"
okText="Assign"
visible={visible}
confirmLoading={applying}
onOk={handleAssign}
onCancel={handleCancel}
destroyOnClose
>
<Paragraph style={{ maxWidth: "30em" }}>
Select a Slave to assign this instance to. Assignment
creates the necessary files on the slave to start up
the instance. Note that reassigning an instance from
one slave to another will not move the server save over.
</Paragraph>
<Form form={form} initialValues={{ slave: props.slaveId }}>
<Form.Item name="slave" label="Slave">
<Select loading={loading}>
{slaves.map((slave) => <Select.Option
key={slave["id"]}
value={slave["id"]}
disabled={!slave["connected"]}
>
{slave["name"]}
{!slave["connected"] && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
</Form>
</Modal>
</>;
}
@@ -0,0 +1,252 @@
import React, { useEffect, useContext, useState } from "react";
import { Button, Card, Checkbox, Form, Input, InputNumber, Space, Spin, Tree, Typography } from "antd";
const { Title } = Typography;
export default function BaseConfigTree(props) {
let [config, setConfig] = useState(null);
let [form] = Form.useForm();
let [changedFields, setChangedFields] = useState(new Set());
let [errorFields, setErrorFields] = useState(new Map());
let [applying, setApplying] = useState(false);
async function updateConfig() {
let serializedConfig = await props.retrieveConfig();
let newConfig = new props.ConfigClass("control");
await newConfig.load(serializedConfig);
setConfig(newConfig);
}
useEffect(() => { updateConfig(); }, [props.id]);
function renderInput(def) {
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
}
if (config === null) {
return <Spin/>;
}
let initialValues = {};
function onValuesChange(changedValues) {
let newChangedFields = new Set(changedFields);
let changed = false;
for (let [key, value] of Object.entries(changedValues)) {
if (key.endsWith(":add.name") || key.endsWith(":add.value")) {
continue;
}
if (initialValues[key] === value) {
changed = newChangedFields.delete(key) || changed;
} else {
changed = !newChangedFields.has(key) || changed;
newChangedFields.add(key);
}
}
if (changed) {
setChangedFields(newChangedFields);
}
}
let treeData = [];
let propsMap = new Map();
for (let [name, GroupClass] of props.ConfigClass.groups) {
let group = config.group(name);
let treeNode = {
key: group.name,
title: group.name,
children: [],
};
for (let def of GroupClass.definitions.values()) {
if (!group.canAccess(def.name)) {
continue;
}
let value = group.get(def.name);
let fieldName = `${group.name}.${def.name}`;
let childNode = {
key: fieldName,
children: [],
};
if (def.type === "object") {
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
childNode.children.push({
key: propPath,
title: <Form.Item
name={propPath}
label={`"${prop}"`}
rules={[{ validator: (_, fieldValue) => {
if (!fieldValue.length) {
return Promise.reject(new Error("Will be removed"));
}
try {
JSON.parse(fieldValue);
} catch (err) {
return Promise.reject(new Error("Must be valid json"));
}
return Promise.resolve();
}}]}
>
<Input className="json-input" />
</Form.Item>,
});
initialValues[propPath] = JSON.stringify(value[prop]);
propsMap.set(propPath, [fieldName, prop]);
}
let newPropPath = `${group.name}.${def.name}:add`;
childNode.children.push({
key: newPropPath,
title: <Space>
<Form.Item
name={`${newPropPath}.name`}
noStyle={true}
>
<Input />
</Form.Item>:
<Form.Item
name={`${newPropPath}.value`}
noStyle={true}
>
<Input className="json-input" />
</Form.Item>
<Button
size="small"
onClick={async () => {
let propName = form.getFieldValue(`${newPropPath}.name`);
let propValue = form.getFieldValue(`${newPropPath}.value`);
if (!Object.prototype.hasOwnProperty.call(value, propName)) {
let newConfig = new props.ConfigClass("control");
await newConfig.load(config.serialize());
newConfig.setProp(fieldName, propName, null);
setConfig(newConfig);
}
let propPath = `${group.name}.${def.name}.${propName}`;
form.setFieldsValue({
[`${newPropPath}.name`]: "",
[`${newPropPath}.value`]: "",
[propPath]: propValue,
});
form.validateFields([propPath]);
onValuesChange({ [propPath]: propValue });
}}
>
Add
</Button>
</Space>,
});
} else {
childNode.title = <Form.Item
name={fieldName}
label={def.title || def.name}
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
valuePropName={def.type === "boolean" ? "checked" : "value"}
>
{renderInput(def)}
</Form.Item>;
initialValues[fieldName] = value;
}
treeNode.children.push(childNode);
}
treeData.push(treeNode);
}
return <>
<Title level={5} style={{ marginTop: 16 }}>
Config
<Space style={{ float: "right" }}>
<Button
type="primary"
size="small"
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
request = props.setProp(fieldName, prop, value);
} else {
request = props.setField(field, value);
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
setApplying(false);
if (props.onApply) {
props.onApply();
}
}}
disabled={changedFields.size === 0}
>
Apply
</Button>
<Button
size="small"
onClick={() => {
form.resetFields();
setChangedFields(new Set());
setErrorFields(new Map());
}}
>
Revert
</Button>
</Space>
</Title>
<Card size="small" className="config-container">
<Form
size="small"
form={form}
initialValues={initialValues}
onValuesChange={onValuesChange}
>
<Tree
treeData={treeData}
selectable={false}
defaultExpandAll={true}
filterTreeNode={(node) => changedFields.has(node.key)}
/>
</Form>
</Card>
</>;
}
@@ -0,0 +1,20 @@
import React from "react";
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return <this.props.Component error={this.state.error} />;
}
return this.props.children;
}
}
@@ -0,0 +1,16 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import PageLayout from "./PageLayout";
const { Paragraph } = Typography;
export default function ErrorPage(props) {
return <PageLayout nav={[{ name: "Error" }]}>
<h2>An unexpected error occured</h2>
<Paragraph code className="error-traceback">{props.error.stack}</Paragraph>
</PageLayout>;
};
@@ -0,0 +1,52 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
export default function InstanceConfigTree(props) {
let control = useContext(ControlContext);
async function retrieveConfig() {
let result = await libLink.messages.getInstanceConfig.send(control, {
instance_id: props.id,
});
return result.serialized_config;
}
async function setField(field, value) {
await libLink.messages.setInstanceConfigField.send(control, {
instance_id: props.id,
field,
value: String(value),
});
}
async function setProp(field, prop, value) {
let data = {
instance_id: props.id,
field,
prop,
};
if (value) {
try {
data.value = JSON.parse(value);
} catch (err) {
return;
}
}
await libLink.messages.setInstanceConfigProp.send(control, data);
}
return <BaseConfigTree
ConfigClass={libConfig.InstanceConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
id={props.id}
/>;
}
@@ -0,0 +1,94 @@
import React, { useEffect, useContext, useRef, useState } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
const { Paragraph } = Typography;
function formatParsedOutput(parsed, key) {
let time = "";
if (parsed.format === "seconds") {
time = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
} else if (parsed.format === "date") {
time = <span className="factorio-time">{parsed.time} </span>;
}
let info = "";
if (parsed.type === "log") {
let level = parsed.level;
if (level === "Script") {
level = <span className="factorio-script">{level}</span>;
} else if (level === "Verbose") {
level = <span className="factorio-verbose">{level}</span>;
} else if (level === "Info") {
level = <span className="factorio-info">{level}</span>;
} else if (parsed.level === "Warning") {
level = <span className="factorio-warning">{level}</span>;
} else if (parsed.level === "Error") {
level = <span className="factorio-error">{level}</span>;
}
info = <>{level} <span className="factorio-filename">{parsed.file}: </span></>;
} else if (parsed.type === "action") {
info = <>[<span className="factorio-action">{parsed.action}</span>] </>;
}
return <span key={key}>{time}{info}{parsed.message}<br/></span>;
}
function formatLog(info, key) {
if (info.level === "server" && info.parsed) {
return formatParsedOutput(info.parsed, key);
}
let level = <span className={`log-${info.level}`}>{info.level}</span>;
return <span key={key}>[{level}] {info.message}<br/></span>;
}
export default function InstanceConsole(props) {
let control = useContext(ControlContext);
let anchor = useRef();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
let [lines, setLines] = useState([]);
useEffect(() => {
// Scroll view to the anchor so it sticks to the bottom
let parent = anchor.current.parentElement;
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
libLink.messages.queryLog.send(control, {
all: false,
master: false,
slave_ids: [],
instance_ids: [props.id],
max_level: null,
}).then(result => {
setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index)));
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
function logHandler(info) {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
}
control.onInstanceLog(props.id, logHandler);
return () => {
control.offInstanceLog(props.id, logHandler);
};
}, [props.id]);
return <>
<Paragraph code className="instance-console">
{pastLines}
{lines}
<div className="scroll-anchor" ref={anchor} />
</Paragraph>
</>;
}
@@ -0,0 +1,49 @@
import React, { useContext, useState } from "react";
import { Input, Typography } from "antd";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
const { Title, Paragraph } = Typography;
export default function InstanceRcon(props) {
let control = useContext(ControlContext);
let [output, setOutput] = useState(null);
let [running, setRunning] = useState(false);
async function sendCommand(command) {
if (!command) {
setOutput(null);
return;
}
setRunning(true);
try {
let result = await libLink.messages.sendRcon.send(control, {
instance_id: props.id,
command: command,
});
setOutput(result.result);
} finally {
setRunning(false);
}
}
return <>
{output && <>
<Title level={5}>Rcon result</Title>
<Paragraph code className="rcon-result">{output}</Paragraph>
</>}
<Input.Search
disabled={props.disabled}
placeholder="Send RCON Command"
enterButton="Send"
onSearch={(command) => sendCommand(command).catch(notifyErrorHandler("Error sending command"))}
loading={running}
/>
</>;
}
@@ -1,539 +1,24 @@
import React, { useEffect, useContext, useRef, useState } from "react";
import React, { useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
notification, Button, Card, Checkbox, Col, Descriptions, Dropdown, Form, Input, InputNumber, Modal,
Popconfirm, Row, Select, Space, Spin, Tree, Typography,
} from "antd";
import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import InstanceConfigTree from "./InstanceConfigTree";
import InstanceConsole from "./InstanceConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
const { Title, Paragraph } = Typography;
function useInstance(id) {
let control = useContext(ControlContext);
let [instance, setInstance] = useState({ loading: true });
function updateInstance() {
if (!Number.isInteger(id)) {
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(() => {
updateInstance();
}, [id]);
return [instance, updateInstance];
}
function useSlave(id) {
let control = useContext(ControlContext);
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);
if (!match) {
setSlave({ missing: true });
} else {
setSlave({ ...match, present: true });
}
});
}
useEffect(() => {
updateSlave();
}, [id]);
return [slave, updateSlave];
}
function formatParsedOutput(parsed, key) {
let time = "";
if (parsed.format === "seconds") {
time = <span className="factorio-time">{parsed.time.padStart(8)} </span>;
} else if (parsed.format === "date") {
time = <span className="factorio-time">{parsed.time} </span>;
}
let info = "";
if (parsed.type === "log") {
let level = parsed.level;
if (level === "Script") {
level = <span className="factorio-script">{level}</span>;
} else if (level === "Verbose") {
level = <span className="factorio-verbose">{level}</span>;
} else if (level === "Info") {
level = <span className="factorio-info">{level}</span>;
} else if (parsed.level === "Warning") {
level = <span className="factorio-warning">{level}</span>;
} else if (parsed.level === "Error") {
level = <span className="factorio-error">{level}</span>;
}
info = <>{level} <span className="factorio-filename">{parsed.file}: </span></>;
} else if (parsed.type === "action") {
info = <>[<span className="factorio-action">{parsed.action}</span>] </>;
}
return <span key={key}>{time}{info}{parsed.message}<br/></span>;
}
function formatLog(info, key) {
if (info.level === "server" && info.parsed) {
return formatParsedOutput(info.parsed, key);
}
let level = <span className={`log-${info.level}`}>{info.level}</span>;
return <span key={key}>[{level}] {info.message}<br/></span>;
}
function InstanceConsole(props) {
let control = useContext(ControlContext);
let anchor = useRef();
let [pastLines, setPastLines] = useState([<span key={0}>{"Loading past entries..."}<br/></span>]);
let [lines, setLines] = useState([]);
useEffect(() => {
// Scroll view to the anchor so it sticks to the bottom
let parent = anchor.current.parentElement;
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
libLink.messages.queryLog.send(control, {
all: false,
master: false,
slave_ids: [],
instance_ids: [props.id],
max_level: null,
}).then(result => {
setPastLines(result.log.slice(-400).map((info, index) => formatLog(info, index)));
}).catch(err => {
setPastLines([<span key={0}>{`Error loading log: ${err.message}`}<br/></span>]);
});
function logHandler(info) {
setLines(currentLines => currentLines.concat(
[formatLog(info, currentLines.length)]
));
}
control.onInstanceLog(props.id, logHandler);
return () => {
control.offInstanceLog(props.id, logHandler);
};
}, [props.id]);
return <>
<Paragraph code className="instance-console">
{pastLines}
{lines}
<div className="scroll-anchor" ref={anchor} />
</Paragraph>
</>;
}
function InstanceRcon(props) {
let control = useContext(ControlContext);
let [output, setOutput] = useState(null);
let [running, setRunning] = useState(false);
async function sendCommand(command) {
if (!command) {
setOutput(null);
return;
}
setRunning(true);
try {
let result = await libLink.messages.sendRcon.send(control, {
instance_id: props.id,
command: command,
});
setOutput(result.result);
} finally {
setRunning(false);
}
}
return <>
{output && <>
<Title level={5}>Rcon result</Title>
<Paragraph code className="rcon-result">{output}</Paragraph>
</>}
<Input.Search
disabled={props.disabled}
placeholder="Send RCON Command"
enterButton="Send"
onSearch={(command) => sendCommand(command).catch(notifyErrorHandler("Error sending command"))}
loading={running}
/>
</>;
}
function AssignInstanceModal(props) {
let [visible, setVisible] = useState(false);
let [slaves, setSlaves] = useState([]);
let [loading, setLoading] = useState(true);
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() {
let slaveId = form.getFieldValue("slave");
if (slaveId === undefined) {
setVisible(false);
return;
}
setApplying(true);
libLink.messages.assignInstanceCommand.send(control, {
instance_id: props.id,
slave_id: slaveId,
}).then(() => {
setVisible(false);
if (props.onFinish) {
props.onFinish();
}
}).catch(
notifyErrorHandler("Error assigning instance")
).finally(
() => setApplying(false)
);
}
function handleCancel() {
setVisible(false);
}
return <>
<Button {...props.buttonProps} onClick={open}>
{props.buttonContent || "Assign"}
</Button>
<Modal
title="Assign Instance"
okText="Assign"
visible={visible}
confirmLoading={applying}
onOk={handleAssign}
onCancel={handleCancel}
destroyOnClose
>
<Paragraph style={{ maxWidth: "30em" }}>
Select a Slave to assign this instance to. Assignment
creates the necessary files on the slave to start up
the instance. Note that reassigning an instance from
one slave to another will not move the server save over.
</Paragraph>
<Form form={form} initialValues={{ slave: props.slaveId }}>
<Form.Item name="slave" label="Slave">
<Select loading={loading}>
{slaves.map((slave) => <Select.Option
key={slave["id"]}
value={slave["id"]}
disabled={!slave["connected"]}
>
{slave["name"]}
{!slave["connected"] && " (offline)"}
</Select.Option>)}
</Select>
</Form.Item>
</Form>
</Modal>
</>;
}
function StartStopInstanceButton(props) {
let control = useContext(ControlContext);
let [switching, setSwitching] = useState(false);
function onClick() {
setSwitching(true);
let action;
if (props.instance["status"] === "stopped") {
action = libLink.messages.startInstance.send(
control, { instance_id: props.instance["id"], save: null }
).catch(
notifyErrorHandler("Error starting instance")
);
} else if (["starting", "running"].includes(props.instance["status"])) {
action = libLink.messages.stopInstance.send(
control, { instance_id: props.instance["id"] }
).catch(
notifyErrorHandler("Error stopping instance")
);
} else {
setSwitching(false);
return;
}
action.finally(() => {
setSwitching(false);
if (props.onFinish) {
props.onFinish();
}
});
}
return <Button
{...(props.buttonProps || {})}
loading={switching}
type="primary"
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
onClick={onClick}
>
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
</Button>;
}
function InstanceConfigTree(props) {
let control = useContext(ControlContext);
let [config, setConfig] = useState(null);
let [form] = Form.useForm();
let [changedFields, setChangedFields] = useState(new Set());
let [errorFields, setErrorFields] = useState(new Map());
let [applying, setApplying] = useState(false);
async function updateConfig() {
let result = await libLink.messages.getInstanceConfig.send(control, {
instance_id: props.id,
});
let newConfig = new libConfig.InstanceConfig();
await newConfig.load(result.serialized_config);
setConfig(newConfig);
}
useEffect(() => { updateConfig(); }, [props.id]);
function renderInput(group, def) {
if (def.type === "boolean") {
return <Checkbox/>;
}
if (def.type === "string") {
return <Input/>;
}
if (def.type === "number") {
return <InputNumber/>;
}
return `Unknown type ${def.type}`;
}
if (config === null) {
return <Spin/>;
}
let initialValues = {};
let treeData = [];
let propsMap = new Map();
for (let [name, GroupClass] of libConfig.InstanceConfig.groups) {
let group = config.group(name);
let treeNode = {
key: group.name,
title: group.name,
children: [],
};
for (let def of GroupClass.definitions.values()) {
let value = group.get(def.name);
let fieldName = `${group.name}.${def.name}`;
let childNode = {
key: fieldName,
children: [],
};
if (def.type === "object") {
childNode.title = <Form.Item label={def.title || def.name} tooltip={def.description} />;
for (let prop of Object.keys(value)) {
let propPath = `${group.name}.${def.name}.${prop}`;
childNode.children.push({
key: propPath,
title: <Form.Item
name={propPath}
label={`"${prop}"`}
rules={[{ validator: (_, fieldValue) => {
try {
JSON.parse(fieldValue);
} catch (err) {
return Promise.reject(new Error("Must be valid json"));
}
return Promise.resolve();
}}]}
>
<Input className="json-input" />
</Form.Item>,
});
initialValues[propPath] = JSON.stringify(value[prop]);
propsMap.set(propPath, [fieldName, prop]);
}
} else {
childNode.title = <Form.Item
name={fieldName}
label={def.title || def.name}
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
valuePropName={def.type === "boolean" ? "checked" : "value"}
>
{renderInput(group, def)}
</Form.Item>;
initialValues[fieldName] = value;
}
treeNode.children.push(childNode);
}
treeData.push(treeNode);
}
function onValuesChange(changedValues) {
let newChangedFields = new Set(changedFields);
let changed = false;
for (let [key, value] of Object.entries(changedValues)) {
if (initialValues[key] === value) {
changed = newChangedFields.delete(key) || changed;
} else {
changed = !newChangedFields.has(key) || changed;
newChangedFields.add(key);
}
}
if (changed) {
setChangedFields(newChangedFields);
}
}
return <>
<Title level={5} style={{ marginTop: 16 }}>
Config
<Space style={{ float: "right" }}>
<Button
type="primary"
size="small"
loading={applying}
onClick={async () => {
setApplying(true);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
let [fieldName, prop] = propsMap.get(field);
try {
value = JSON.parse(value);
} catch (err) {
continue;
}
request = libLink.messages.setInstanceConfigProp.send(control, {
instance_id: props.id,
field: fieldName,
prop,
value,
});
} else {
request = libLink.messages.setInstanceConfigField.send(control, {
instance_id: props.id,
field,
value: String(value),
});
}
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
setApplying(false);
if (props.onApply) {
props.onApply();
}
}}
disabled={changedFields.size === 0}
>
Apply
</Button>
<Button
size="small"
onClick={() => {
form.resetFields();
setChangedFields(new Set());
setErrorFields(new Map());
}}
>
Revert
</Button>
</Space>
</Title>
<Card size="small" className="config-container">
<Form
size="small"
form={form}
initialValues={initialValues}
onValuesChange={onValuesChange}
>
<Tree
treeData={treeData}
selectable={false}
defaultExpandAll={true}
filterTreeNode={(node) => changedFields.has(node.key)}
/>
</Form>
</Card>
</>;
}
export default function InstanceViewPage(props) {
let params = useParams();
let instanceId = Number(params.id);
@@ -33,10 +33,10 @@ export default function InstancesPage() {
title: "Name",
}],
insert: async args => {
let instanceConfig = new libConfig.InstanceConfig();
let instanceConfig = new libConfig.InstanceConfig("control");
await instanceConfig.init();
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let serialized_config = instanceConfig.serialize("master");
let response = await libLink.messages.createInstance.send(control, { serialized_config });
},
}}
@@ -0,0 +1,44 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import libLink from "@clusterio/lib/link";
import libConfig from "@clusterio/lib/config";
import BaseConfigTree from "./BaseConfigTree";
import ControlContext from "./ControlContext";
export default function MasterConfigTree(props) {
let control = useContext(ControlContext);
async function retrieveConfig() {
let result = await libLink.messages.getMasterConfig.send(control);
return result.serialized_config;
}
async function setField(field, value) {
await libLink.messages.setMasterConfigField.send(control, {
field,
value: String(value),
});
}
async function setProp(field, prop, value) {
let data = { field, prop };
if (value) {
try {
data.value = JSON.parse(value);
} catch (err) {
return;
}
}
await libLink.messages.setMasterConfigProp.send(control, data);
}
return <BaseConfigTree
ConfigClass={libConfig.MasterConfig}
retrieveConfig={retrieveConfig}
setField={setField}
setProp={setProp}
/>;
}
@@ -0,0 +1,12 @@
import React, { useContext } from "react";
import MasterConfigTree from "./MasterConfigTree";
import PageLayout from "./PageLayout";
export default function SlavesPage() {
return <PageLayout nav={[{ name: "Master" }]}>
<h2>Master</h2>
<MasterConfigTree />
</PageLayout>;
};
@@ -3,9 +3,13 @@ import { Switch, Route, useHistory } from "react-router-dom";
import { Layout, Menu } from "antd";
import webUiPackage from "../../package.json";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
const { Header, Sider } = Layout;
import MasterPage from "./MasterPage";
import SlavesPage from "./SlavesPage";
import InstancesPage from "./InstancesPage";
import InstanceViewPage from "./InstanceViewPage";
@@ -21,6 +25,7 @@ const pages = [
];
const sidebar = [
{ name: "Master", path: "/master", content: <MasterPage /> },
{ name: "Slaves", path: "/slaves", content: <SlavesPage /> },
{ name: "Instances", path: "/instances", content: <InstancesPage />},
{ name: "Users", path: "/users", content: <UsersPage /> },
@@ -74,7 +79,9 @@ export default function SiteLayout(props) {
</Route>)}
{pages.map(page => <Route exact path={page.path} key={page.path}>
<SetSidebar path={page.sidebarPath} />
{page.content}
<ErrorBoundary Component={ErrorPage}>
{page.content}
</ErrorBoundary>
</Route>)}
<Route>
<SetSidebar path={null} />
@@ -0,0 +1,55 @@
import React, { useContext, useState } from "react";
import { Button } from "antd";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import { notifyErrorHandler } from "../util/notify";
export default function StartStopInstanceButton(props) {
let control = useContext(ControlContext);
let [switching, setSwitching] = useState(false);
function onClick() {
setSwitching(true);
let action;
if (props.instance["status"] === "stopped") {
action = libLink.messages.startInstance.send(
control, { instance_id: props.instance["id"], save: null }
).catch(
notifyErrorHandler("Error starting instance")
);
} else if (["starting", "running"].includes(props.instance["status"])) {
action = libLink.messages.stopInstance.send(
control, { instance_id: props.instance["id"] }
).catch(
notifyErrorHandler("Error stopping instance")
);
} else {
setSwitching(false);
return;
}
action.finally(() => {
setSwitching(false);
if (props.onFinish) {
props.onFinish();
}
});
}
return <Button
{...(props.buttonProps || {})}
loading={switching}
type="primary"
disabled={!["starting", "running", "stopped"].includes(props.instance["status"])}
onClick={onClick}
>
{props.instance["status"] === "stopped" ? "Start" : "Stop"}
</Button>;
}
+67 -69
View File
@@ -16,12 +16,16 @@ const { Panel } = Collapse;
* Editable - Boolean - Allow editing
* AddRecord - Boolean - Allow adding new records
* CustomDataFilterFunction - function(tableData) - Filter tabledata and return results
* RecordIdField - String - By default we assume the column "Id" is a unique identifier for a record. If that isn't the case, supply the column name here
* RecordIdField - String -
* By default we assume the column "Id" is a unique identifier for a record. If
* that isn't the case, supply the column name here
*/
class DataTable extends Component {
constructor(props) {
super(props);
if (!this.props.DataFunction) throw new Error("DataFunction not defined");
if (!this.props.DataFunction) {
throw new Error("DataFunction not defined");
}
this.state = {
pagination: {
pageSize: 100,
@@ -40,32 +44,21 @@ class DataTable extends Component {
}
async getData(pagination, filters, sorter) {
/*
pagination = {
pageSize: 10, // items per page
current: 1, // page number
total: 123, // Total amount of items
}
filters = {
ReportId: 123
}
sorter = {}
*/
// pagination = {
// pageSize: 10, // items per page
// current: 1, // page number
// total: 123, // Total amount of items
// }
// filters = {
// ReportId: 123
// }
// sorter = {}
pagination = pagination || this.state.pagination;
filters = filters || this.state.filters;
sorter = sorter || this.state.sorter;
// eslint-disable-next-line new-cap
let data = await this.props.DataFunction();
// await fetchApi(this.state.dataUrl, "post", {
// database: this.props.Database,
// table: this.props.Table,
// dataSource: this.props.DataSource,
// pagination: this.props.RecordCount? {...pagination, pageSize: this.props.RecordCount, current: 1}:pagination,
// filters,
// sorter,
// recordIdField: this.props.RecordIdField || "Id", // Sort by this
// })
data = data || {};
this.setState({
// eslint-disable-next-line new-cap
@@ -110,20 +103,16 @@ class DataTable extends Component {
async deleteRecord(record) {
delete record.key;
// Delete "null" values as they seem to cause deletes to fail silently when the value is null and field type is float
// Delete "null" values as they seem to cause deletes to fail silently when
// the value is null and field type is float
for (let key in record) {
if (record[key] === null) delete record[key];
if (record[key] === null) { delete record[key]; }
}
var status = {};
// await fetchApi(`/api/${this.props.Server}/tableData/record`, "delete", {
// database: this.props.Database,
// table: this.props.Table,
// dataSource: this.props.DataSource,
// record,
// }, undefined, true)
let status = {};
// TODO: Delete data here
if (status.ok) notify("success", status.msg, "success");
if (!status.ok) notify("error", status.msg, "error");
if (status.ok) { notify("success", status.msg, "success"); }
if (!status.ok) { notify("error", status.msg, "error"); }
// Force refresh of data
this.getData();
@@ -133,60 +122,66 @@ class DataTable extends Component {
let tableProps = {
...this.props.TableProps || {},
onRow: (record, rowIndex) => {
/*
To allow for extending functionality, we take a TableProps argument from props and pass it through to the Table component.
Since we are also using onRow internaly here, we handle the externally provided events ourselves by passing along the event
object. This way the end user isn't affected by our event handlers.
*/
// To allow for extending functionality, we take a TableProps argument
// from props and pass it through to the Table component. Since we
// are also using onRow internaly here, we handle the externally
// provided events ourselves by passing along the event object. This
// way the end user isn't affected by our event handlers.
let externalOnRow = {
...(this.props.TableProps && this.props.TableProps.onRow && this.props.TableProps.onRow(record, rowIndex)),
...(
this.props.TableProps
&& this.props.TableProps.onRow
&& this.props.TableProps.onRow(record, rowIndex)
),
};
return {
onClick: event => {
if (event.target.type !== "button") {
if (this.props.Editable) this.showEditModal(record, rowIndex);
if (externalOnRow.onClick) externalOnRow.onClick(event);
if (this.props.Editable) { this.showEditModal(record, rowIndex); }
if (externalOnRow.onClick) { externalOnRow.onClick(event); }
}
},
onDoubleClick: event => {
if (externalOnRow.onDoubleClick) externalOnRow.onDoubleClick(event);
if (externalOnRow.onDoubleClick) { externalOnRow.onDoubleClick(event); }
},
onContextMenu: event => {
if (externalOnRow.onContextMenu) externalOnRow.onContextMenu(event);
if (externalOnRow.onContextMenu) { externalOnRow.onContextMenu(event); }
},
onMouseEnter: event => {
if (externalOnRow.onMouseEnter) externalOnRow.onMouseEnter(event);
if (externalOnRow.onMouseEnter) { externalOnRow.onMouseEnter(event); }
},
onMouseLeave: event => {
if (externalOnRow.onMouseLeave) externalOnRow.onMouseLeave(event);
if (externalOnRow.onMouseLeave) { externalOnRow.onMouseLeave(event); }
},
};
},
};
let columns = this.props.columns || this.inferColumns(this.state.tableData);
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) columns.push({
// Add delete button on the right
dataIndex: "notInUse",
title: "",
render: (text, record) => <Popover
content={
<Button
type="primary"
danger
onClick={() => {
this.deleteRecord(record);
}}
>
Slett permanent
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) {
columns.push({
// Add delete button on the right
dataIndex: "notInUse",
title: "",
render: (text, record) => <Popover
content={
<Button
type="primary"
danger
onClick={() => {
this.deleteRecord(record);
}}
>
Slett permanent
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>,
});
</Popover>,
});
}
return <>
<Row>
@@ -203,7 +198,9 @@ class DataTable extends Component {
{...tableProps}
columns={columns.map(x => ({
...x, // Add a 4th parameter to the stock render function with all of our table data
render: x.render ? (...originalParams) => x.render(...originalParams, this.state.tableData) : undefined,
render: x.render
? (...originalParams) => x.render(...originalParams, this.state.tableData)
: undefined,
}))}
dataSource={this.state.tableData}
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
@@ -262,7 +259,8 @@ class DataTable extends Component {
}
inferColumns(data) {
// Look at the data and find columns and how to display them. Can be overridden by props.Columns if more customization is needed
// Look at the data and find columns and how to display them. Can be
// overridden by props.Columns if more customization is needed
let columns = [];
data.forEach(row => {
Object.keys(row).filter(x => x !== "key").forEach(key => {
+8
View File
@@ -82,6 +82,14 @@ body {
background-size: 64px;
}
.error-traceback code {
display: block;
margin: 0;
background: #000;
border-color: #252525;
white-space: pre-wrap;
}
.rcon-result code {
display: block;
background: #000;
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import libLink from "@clusterio/lib/link";
export function useInstance(id) {
let control = useContext(ControlContext);
let [instance, setInstance] = useState({ loading: true });
function updateInstance() {
if (!Number.isInteger(id)) {
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(() => {
updateInstance();
}, [id]);
return [instance, updateInstance];
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useContext, useState } from "react";
import ControlContext from "../components/ControlContext";
import libLink from "@clusterio/lib/link";
export function useSlave(id) {
let control = useContext(ControlContext);
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);
if (!match) {
setSlave({ missing: true });
} else {
setSlave({ ...match, present: true });
}
});
}
useEffect(() => {
updateSlave();
}, [id]);
return [slave, updateSlave];
}
+1 -1
View File
@@ -25,7 +25,7 @@ module.exports = (env = {}) => ({
// Make sure ant-design icons use the ES variant
new webpack.NormalModuleReplacementPlugin(
/@ant-design\/icons\/[A-Z]/,
function(resource) {
resource => {
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
}
),
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-global_chat",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"description": "Clusterio plugin forwarding between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -17,7 +17,7 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.3",
"@clusterio/web_ui": "^2.0.0-alpha.4",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
+1 -1
View File
@@ -10,7 +10,7 @@ module.exports = (env = {}) => merge(common(env), {
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
+32 -48
View File
@@ -3,41 +3,49 @@ const fs = require("fs-extra");
const path = require("path");
const libPlugin = require("@clusterio/lib/plugin");
const RateLimiter = require("@clusterio/lib/RateLimiter");
async function loadTechnologies(masterConfig, logger) {
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
logger.verbose(`Loading ${filePath}`);
try {
return new Map(JSON.parse(await fs.readFile(filePath)));
} catch (err) {
if (err.code === "ENOENT") {
logger.verbose("Creating new technologies database");
return new Map();
}
throw err;
}
}
async function saveTechnologies(masterConfig, technologies, logger) {
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
logger.verbose(`writing ${filePath}`);
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
}
class MasterPlugin extends libPlugin.BaseMasterPlugin {
async init() {
this.technologies = await loadTechnologies(this.master.config, this.logger);
this.progressRateLimiter = new RateLimiter({
maxRate: 1,
action: () => this.broadcastProgress(),
});
this.lastProgressBroadcast = Date.now();
this.progressBroadcastId = null;
this.progressToBroadcast = new Set();
}
async onShutdown() {
if (this.progressBroadcastId) {
clearTimeout(this.progressBroadcastId);
}
this.progressRateLimiter.cancel();
await saveTechnologies(this.master.config, this.technologies, this.logger);
}
registerProgress() {
// Rate limit progress broadcasts to one per second
if (Date.now() < this.lastProgressBroadcast + 1000) {
if (!this.progressBroadcastId) {
this.progressBroadcastId = setTimeout(() => {
this.broadcastProgress();
this.progressBroadcastId = null;
}, 2000);
}
} else {
if (this.progressBroadcastId) {
clearTimeout(this.progressBroadcastId);
}
this.broadcastProgress();
}
}
broadcastProgress() {
let techs = [];
for (let name of this.progressToBroadcast) {
@@ -81,7 +89,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
if (newProgress < 1) {
tech.progress = newProgress;
this.progressToBroadcast.add(name);
this.registerProgress();
this.progressRateLimiter.activate();
} else {
tech.researched = true;
@@ -106,9 +114,8 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
let match = /-(\d+)$/.exec(name);
if (!match) {
return 1;
} else {
return Number.parseInt(match[1], 10);
}
return Number.parseInt(match[1], 10);
}
for (let instanceTech of message.data.technologies) {
@@ -148,7 +155,7 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
}
}
}
this.registerProgress();
this.progressRateLimiter.activate();
let technologies = [];
for (let [name, tech] of this.technologies) {
@@ -159,29 +166,6 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
}
}
async function loadTechnologies(masterConfig, logger) {
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
logger.verbose(`Loading ${filePath}`);
try {
return new Map(JSON.parse(await fs.readFile(filePath)));
} catch (err) {
if (err.code === "ENOENT") {
logger.verbose("Creating new technologies database");
return new Map();
} else {
throw err;
}
}
}
async function saveTechnologies(masterConfig, technologies, logger) {
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
logger.verbose(`writing ${filePath}`);
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
}
module.exports = {
MasterPlugin,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-research_sync",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"description": "Clusterio plugin for synchronizing research between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -20,7 +20,7 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.3",
"@clusterio/web_ui": "^2.0.0-alpha.4",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
+1 -1
View File
@@ -10,7 +10,7 @@ module.exports = (env = {}) => merge(common(env), {
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
+16 -1
View File
@@ -7,7 +7,7 @@ Clusterio.
Installation
============
------------
Run the following commands in the folder Clusterio is installed to:
@@ -16,3 +16,18 @@ Run the following commands in the folder Clusterio is installed to:
Substitute clusteriomaster with clusterioslave or clusterioctl if this a
dedicate slave or ctl installation respectively.
Instance Configuration
----------------------
### statistics_exporter.command_timeout
Timeout in seconds to wait for the statitics gathering command to the
Factorio server return the result. If the timeout is exceeded the
previous collected results are returned instead. Normally the command
returns immediatly with the data, but if the RCON interface is
overloaded then the command may take too long to timely answer the
metrics collection request.
Defaults to `1`.
+14
View File
@@ -1,8 +1,22 @@
"use strict";
const libConfig = require("@clusterio/lib/config");
class InstanceConfigGroup extends libConfig.PluginConfigGroup {}
InstanceConfigGroup.defaultAccess = ["master", "slave", "control"];
InstanceConfigGroup.groupName = "statistics_exporter";
InstanceConfigGroup.define({
name: "command_timeout",
title: "Command Timeout",
description: "Timeout in seconds of the metrics command before returning results from the previous invocation.",
type: "number",
initial_value: 1,
});
InstanceConfigGroup.finalize();
module.exports = {
name: "statistics_exporter",
title: "Prometheus Statistics Export",
description: "Exports statistics to Prometheus",
instanceEntrypoint: "instance",
InstanceConfigGroup,
};
+13 -8
View File
@@ -5,19 +5,23 @@ const libHelpers = require("@clusterio/lib/helpers");
const instancePlayerCount = new Gauge(
"clusterio_statistics_exporter_instance_player_count", "Amount of players connected to this cluster",
"clusterio_statistics_exporter_instance_player_count",
"Amount of players connected to this cluster",
{ labels: ["instance_id"] }
);
const instanceGameTicksTotal = new Gauge(
"clusterio_statistics_exporter_instance_game_ticks_total", "Game tick an instance has progressed to",
"clusterio_statistics_exporter_instance_game_ticks_total",
"Game tick an instance has progressed to",
{ labels: ["instance_id"] }
);
const instanceForceFlowStatistics = new Gauge(
"clusterio_statistics_exporter_instance_force_flow_statistics", "Items/fluids/enemies/buildings produced/built/killed by a force",
"clusterio_statistics_exporter_instance_force_flow_statistics",
"Items/fluids/enemies/buildings produced/built/killed by a force",
{ labels: ["instance_id", "force", "statistic", "direction", "name"] },
);
const instanceGameFlowStatistics = new Gauge(
"clusterio_statistics_exporter_instance_game_flow_statistics", "Pollution produced/consumed in the game",
"clusterio_statistics_exporter_instance_game_flow_statistics",
"Pollution produced/consumed in the game",
{ labels: ["instance_id", "statistic", "direction", "name"] },
);
@@ -49,7 +53,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
throw new Error(`Error parsing statistics JSON: ${err.message}, content "${string}"`);
}
let instanceId = this.instance.config.get("instance.id");
let instanceId = this.instance.id;
instanceGameTicksTotal.labels(String(instanceId)).set(stats.game_tick);
instancePlayerCount.labels(String(instanceId)).set(stats.player_count);
@@ -73,11 +77,12 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
}
async onMetrics() {
// Wait up to 5 seconds for the metrics to be collected. It may
// Wait configured timeout for the metrics to be collected. It may
// take a long time for the command to go through if the command
// stream is overloaded. Should this take more than 5 seconds the
// stream is overloaded. Should the timeout be exceeded the
// previous values for the metrics will end up being sent to master.
await libHelpers.timeout(this.gatherMetrics(), 5e3);
let timeout = this.instance.config.get("statistics_exporter.command_timeout") * 1000;
await libHelpers.timeout(this.gatherMetrics(), timeout);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-statistics_exporter",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"description": "Clusterio plugin for gathering statistics from Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
@@ -17,7 +17,7 @@
"@clusterio/lib": "^2.0.0-alpha.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.3",
"@clusterio/web_ui": "^2.0.0-alpha.4",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
+3 -1
View File
@@ -9,8 +9,10 @@ const info = require("../info");
describe("statistics_exporter plugin", function() {
describe("class InstancePlugin", function() {
let instancePlugin;
let mockInstance = new mock.MockInstance();
mockInstance.mockConfigEntries.set("statistics_exporter.command_timeout", 1);
before(async function() {
instancePlugin = new instance.InstancePlugin(info, new mock.MockInstance());
instancePlugin = new instance.InstancePlugin(info, mockInstance);
await instancePlugin.init();
});
@@ -10,7 +10,7 @@ module.exports = (env = {}) => merge(common(env), {
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
+2
View File
@@ -3,6 +3,7 @@ const libLink = require("@clusterio/lib/link");
const libConfig = require("@clusterio/lib/config");
class MasterConfigGroup extends libConfig.PluginConfigGroup {}
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = "subspace_storage";
MasterConfigGroup.define({
name: "autosave_interval",
@@ -29,6 +30,7 @@ MasterConfigGroup.define({
MasterConfigGroup.finalize();
class InstanceConfigGroup extends libConfig.PluginConfigGroup {}
InstanceConfigGroup.defaultAccess = ["master", "slave", "control"];
InstanceConfigGroup.groupName = "subspace_storage";
InstanceConfigGroup.define({
name: "log_item_transfers",
+2 -2
View File
@@ -39,7 +39,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
async provideItems(items) {
this.info.messages.place.send(this.instance, {
items,
instance_id: this.instance.config.get("instance.id"),
instance_id: this.instance.id,
});
if (this.instance.config.get("subspace_storage.log_item_transfers")) {
@@ -52,7 +52,7 @@ class InstancePlugin extends libPlugin.BaseInstancePlugin {
async requestItems(items) {
// Request the items all at once
let response = await this.info.messages.remove.send(this.instance, {
instance_id: this.instance.config.get("instance.id"),
instance_id: this.instance.id,
items,
});
+27 -29
View File
@@ -27,6 +27,33 @@ const masterInventoryGauge = new Gauge(
);
async function loadDatabase(config, logger) {
let itemsPath = path.resolve(config.get("master.database_directory"), "items.json");
logger.verbose(`Loading ${itemsPath}`);
try {
let content = await fs.readFile(itemsPath);
return new libDatabase.ItemDatabase(JSON.parse(content));
} catch (err) {
if (err.code === "ENOENT") {
logger.verbose("Creating new item database");
return new libDatabase.ItemDatabase();
}
throw err;
}
}
async function saveDatabase(masterConfig, items, logger) {
if (items && items.size < 50000) {
let file = path.resolve(masterConfig.get("master.database_directory"), "items.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(items.serialize());
await fs.outputFile(file, content);
} else if (items) {
logger.error(`Item database too large, not saving (${items.size})`);
}
}
class MasterPlugin extends libPlugin.BaseMasterPlugin {
async init() {
@@ -169,35 +196,6 @@ class MasterPlugin extends libPlugin.BaseMasterPlugin {
}
}
async function loadDatabase(config, logger) {
let itemsPath = path.resolve(config.get("master.database_directory"), "items.json");
logger.verbose(`Loading ${itemsPath}`);
try {
let content = await fs.readFile(itemsPath);
return new libDatabase.ItemDatabase(JSON.parse(content));
} catch (err) {
if (err.code === "ENOENT") {
logger.verbose("Creating new item database");
return new libDatabase.ItemDatabase();
} else {
throw err;
}
}
}
async function saveDatabase(masterConfig, items, logger) {
if (items && items.size < 50000) {
let file = path.resolve(masterConfig.get("master.database_directory"), "items.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(items.serialize());
await fs.outputFile(file, content);
} else if (items) {
logger.error(`Item database too large, not saving (${items.size})`);
}
}
module.exports = {
MasterPlugin,
};
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clusterio/plugin-subspace_storage",
"version": "2.0.0-alpha.3",
"version": "2.0.0-alpha.4",
"description": "Clusterio plugin for sharing storage between Factorio servers",
"author": "Hornwitser <github@hornwitser.no>",
"homepage": "https://github.com/clusterio/clusterio#readme",
@@ -20,7 +20,7 @@
"fs-extra": "^8.1.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.3",
"@clusterio/web_ui": "^2.0.0-alpha.4",
"antd": "^4.6.5",
"babel-loader": "^8.1.0",
"file-loader": "^6.2.0",
+3 -3
View File
@@ -12,12 +12,12 @@ function addApiRoutes(app, items, endpointHitCounter) {
* @alias api/inventory
* @returns {object[]} JSON [{name:"iron-plate", count:100},{name:"copper-plate",count:5}]
*/
app.get("/api/inventory", function(req, res) {
app.get("/api/inventory", (req, res) => {
endpointHitCounter.labels(req.route.path).inc();
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
// Check it and send it
var inventory = [];
let inventory = [];
for (let [name, count] of items._items) {
inventory.push({ name, count });
}
@@ -33,7 +33,7 @@ function addApiRoutes(app, items, endpointHitCounter) {
* @alias api/inventoryAsObject
* @returns {object} JSON {"iron-plate":100, "copper-plate":5}
*/
app.get("/api/inventoryAsObject", function(req, res) {
app.get("/api/inventoryAsObject", (req, res) => {
endpointHitCounter.labels(req.route.path).inc();
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
+1 -1
View File
@@ -10,7 +10,7 @@ module.exports = (env = {}) => merge(common(env), {
entry: "./web/index.jsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
+30
View File
@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFJTCCAw2gAwIBAgIUIGyavTA8+spUJ/Tb3gHmTnwXOI0wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIxMDEwMzIwNTU1MFoXDTMxMDEw
MTIwNTU1MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF
AAOCAg8AMIICCgKCAgEApHfLDSqc2+kdAW8LF8Z1z68VriWEJ+wNkS7rCm8AICe/
vWYfywWDv4Kw0vzPgoGe7wmURYSS7r5i7MhFzqH4bf0B6wCIFWw2VwFqhgB94hWA
ellyIYC5ntV8RBT1RYbqSsBEJR5HKA/hFdiAf5gSIiZNnjtNT5JJLROlufD30IUQ
7teHyrbCF7/JYeVFrdisRBZg6XgMvHX6sXR4BaLkLTl6YBEe+vWkkOd9lN4pnTzG
PXSxdg4sG4QbCWsANv4bJnWuPC4rd3a/AKqonXumtBErFqluv3ylM6VY8R5E6DrQ
Wk7wjLphUXPsi8gkMAO4SY4/+CI26jFbk/5HI2baQ90jm8AOoUG6erEu5OU3a7dR
9uxIqebd9+S9FCxU9dstYiMbRcmG699Wi/mF/taWna1CdOqw7l8WRXp9q0CTqaVe
a9HdhYiSsimhOttNBD02seyKUQtUw8bw3TUeZofglqcjiP06DGH959z25NjMkiC6
b745QZ3LfzbPpSP3Ax9heHlti0Pl5LnCOIIdyk3DPDz1/xshDTXS2d18k+wpW0O0
rqOydCwzMTJjsHYYpOcqC93qfHuX/NEqNLbnOfM8SEZWqaM6ueW7tAoQc+BaHA8i
AWtgtMhFuaDrHifeIIm8FLXdyXQChL8WhpeyZa3GeSnUdCVzl+2gjSQZpaGO/fEC
AwEAAaNvMG0wHQYDVR0OBBYEFDi1Q63Txpc4NAbFf1HXlWgm+vlJMB8GA1UdIwQY
MBaAFDi1Q63Txpc4NAbFf1HXlWgm+vlJMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0R
BBMwEYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4ICAQAuOwG6NFMh
VI7ipUVsXSOBX+qTd5FnOCCTyV5UixgcDAnEZ87poH0ehI7IOia4xnhUXQsJTTaq
ho/S2hXeRuyq5Sn0RP3Zqa5IzULJWr9hQ5ajc4VuYxzhsh72gzWTJRKZRZBIL5jt
VrkS6qFG4QY4i8IAzfwDiJLhl21iEv23idbBE+yUwJNFnnuMxyllOo7fMFwB3Wij
maEaAamLd2Ocv38bstimCqtKVwg5sWMdaeELzyPtY0HnCNr1ILTW4+wMjgKwUE5z
rnwgF2d03eV2xaNY8asl5mMzJbKpz5CfD6r8KAZ2ZQM8QN+demmexZU8tMp0X3Hz
nVlBu48Ecuv5XTU1F8S2a0UoRoadIw2AtydFW6V31XtAivwrhIsz8xVkE9XiJ8c0
QpMw7bPPeQieFiSAUnRThd/5qdzeep7Vu/CtTht85rT7aqTPdPHiWUdL6x7M1Mi8
vatHGI0cvhAMXFNKAuvCFknEhUCwAKwIHfoWGsa08Kn4LhACLOd67tA45+Rdz1m5
w6Ea1zO8bfhUr/3qxod+nr9kAuN5pN3JELzIwIYGuEsp7OGWr0Wc2QRKMlDcTTIU
Xmd29Vcl5F3OwbBUmoJIqUKsENKJ0jG0DuW1svBnpRcs5wVQzPCDV/fPm3pe0c2c
KFYDk2Qa1jY9BZVyVEFJdwfVlh5iZwu0Iw==
-----END CERTIFICATE-----
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -nodes -out cert.pem -days 3650 -subj /CN=localhost -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
+52
View File
@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCkd8sNKpzb6R0B
bwsXxnXPrxWuJYQn7A2RLusKbwAgJ7+9Zh/LBYO/grDS/M+CgZ7vCZRFhJLuvmLs
yEXOofht/QHrAIgVbDZXAWqGAH3iFYB6WXIhgLme1XxEFPVFhupKwEQlHkcoD+EV
2IB/mBIiJk2eO01PkkktE6W58PfQhRDu14fKtsIXv8lh5UWt2KxEFmDpeAy8dfqx
dHgFouQtOXpgER769aSQ532U3imdPMY9dLF2DiwbhBsJawA2/hsmda48Lit3dr8A
qqide6a0ESsWqW6/fKUzpVjxHkToOtBaTvCMumFRc+yLyCQwA7hJjj/4IjbqMVuT
/kcjZtpD3SObwA6hQbp6sS7k5Tdrt1H27Eip5t335L0ULFT12y1iIxtFyYbr31aL
+YX+1padrUJ06rDuXxZFen2rQJOppV5r0d2FiJKyKaE6200EPTax7IpRC1TDxvDd
NR5mh+CWpyOI/ToMYf3n3Pbk2MySILpvvjlBnct/Ns+lI/cDH2F4eW2LQ+XkucI4
gh3KTcM8PPX/GyENNdLZ3XyT7ClbQ7Suo7J0LDMxMmOwdhik5yoL3ep8e5f80So0
tuc58zxIRlapozq55bu0ChBz4FocDyIBa2C0yEW5oOseJ94gibwUtd3JdAKEvxaG
l7JlrcZ5KdR0JXOX7aCNJBmloY798QIDAQABAoICAAvT1ZVf0CH8dKqKush97DEA
TVXVuthIIH2lWx/H9cFaOm67bmPiRKQ8GM1DgwTGn5BfJvuM2igzJtgSWvrOKRh3
znCA/EhWHN5K6scyN9tPYorp7cw8/pxOi/41Z6IVXrn7c6sbr1BRPAWfKTaD0CIG
kSRk7V1gjsSgGhXyH1AukZYrh8drNzAQCcJxg35KC7Flzphbmsbe0SV707nMCg66
iHNqJ8kyayBElYJkK6f0G+mFf9GWmVNtP6iAumY6BefRJLONOnD/6CJ7I18X6lge
t/OMQbuhkhvRs0C7xYbR2/Pyo1fre14pMQSoYvnU+qbHhRAqHbrfy88rxR/loKKN
N8iWx/q5onPXVzSfiGfzpfLUDwCIypZO8oOyg6ATzMPlMdxTHQ3Gc6nxkLpoGw3/
gAvRHGG1YVxtu9nzYKY8xglIlvmVJw1J5i10PdyhVhD1dla25TGtPxVG+fuTvfwX
vkXbLBAcLfklhMdNRknG372woB3jQ0mLSiYaanTXOYH/4pYI6hoZpyH2p+pt8MX2
8J+3xYd1MBDrsSl7czGFfgddSmT2XkhD+GtrAu3bJWbt/hjK2jAo+D+PnNP+UIhH
hC5pNIs1OQmPt7dH1ncUKqNONnkkAxC/YPlqXu4hu8YSzVtse/DeG07wksNoY+BW
p3bx8INf6OdQ6BrbSQgBAoIBAQDUgVNxN9DLE6kD+PQZv7eChbtmE8WmJjci/Yul
bCrUj9sxIgFmFSNefn+bewT6q99CLL8JGRZ0RO6RtM0W+n1xR+drzdrKrhdhuzVU
7nFVGkVMN23/J+Cc+xI0UhYdbHgpmSbj9Cn3Sd9/6HwPYsrS8xlQoctxDYTsCXvE
b1siXeTZKcii1Sej/c5M+tPoYsXH8Ig+FGTdnuHjR76YV+C4DjfKgdztt9GeEy2Y
FLU0T6mMX0msL4NLl3whSOpgvbgCuOB/vVsoMnKJJslMzJtEY1ouoTnolV91ArtX
5++xW8bGt4ROcgyW6REmdB7HG/W7MlMjyPRcLtnTW1hoZ7+BAoIBAQDGIXN1wQ9G
oADj2hdSAuTtKHA2UHPvSeq3KnM7m3d2Pz93ENAYaTUAvRXOY9duOWPy7mvkTFn3
lCI3QjskZ9GUDX9QKDWSPcVCk8Pi2wXjBYpdHc8Lvuq+ljgtwlgJB/sy1Wr7kMX+
0tAsEM8eY3mQ8RPQg9MzW/+xSdxtrS0HshZ01TpRiWC0JTrogCrSDkj0T3a7vTuB
yDI4H82G1yEZ4PjsGwNLjyo5urxdznDA9jDQRA3Ep3uTyD/l0yERg+ii+TTiXXaL
VUkRKne0bck3uMLkqAy+wtVXGwV5vZB2gCUqHYM9m3oOND73zgDEs7U2erdIM1cC
5iVI2Ct4fnZxAoIBABHPqSPDLt/mjPMFSchitkndVaCvpZBQgayQHcVGtKxORmvu
0rkwql9AMQfiHr+36iWMxJJJpQUnxes2gT4czqjRAweZIG5+ev05CLZOqfcdFZPW
zYRmXkAqFxOFkc7ziXrLGGRg0aelbHM3FDbh6+gUchm9BeU64do6zUHgp33Q6t58
zxE+1my7WDBBkcNXjAY/FdxGL1IYAAzhtYrlSrOClZCGo/LUxl/+cYLesXVp++cF
EZK6BMIwNzo+904WeLbZsg9i2qIa4Fi4hITIXXc08JkZ8FWbN34CNZufmAqAi7+a
IB/HFWEMteBIOVAs+sKu7CMCs+CKGL3LWTSfBYECggEBAKMtM6Fcok8hQfpmSOST
W6Vys5AYDJOcBgJapNMHxIiPqJ5QK4KfSzbTg90aX3f5LjcKY7N/qmz8BH4MPxdJ
pdLQlvUWWKCb3RVrC16P0jZZ/s2X2kirMTxMDfKp58VwXFNOb7DGWDr5ttdD9MDr
XligJBiMKWpYL6r80N1HThnTcyk/36r9gLZ9HxoTTLD5cN49f+U5AYRus+WDU8rk
7O8aWQhjawBU5YrsH20Gs9hIKoWsNsaqxV0+BFos20RIplVq90IjhZlVYTsRBcuE
y77nnSOiv/RwFDKMH6NXG15tRa585fTcUtbV8rI2aG2YL/uxb7e4CMZrc3OeDZdM
WBECggEAVnFjQae4ZTT0JAH+Vf6ZAZJNOKtA41PD4kA91oyDOzm0UujgE7V0ovKY
d0aLSl9zm5WfNms/BFP2u/7rg3NWhI/2bRXgbbDkvav6wod2Vwyb+96n98yvVQ6e
jJ2Qjgr93LMfjgjC+Rucc5PuV8HSMthcvF84YobSi3EuXn6j4Ws2/vpvu7DSbVxK
RiQvQ7tl0KelROSmaOI0WrDRpcJ78WBkZEIhaEEim5oKI8IcurwPaOm6OayrNsB9
fmnHOuCFnz6CzF7fHR5sy5g/QV1gYcSQDXTvoiNrsZxMEyzRy3Jm1aClLv/WNEPL
lEgQcwoqtWDAeenVEKdo2GiojTqk9g==
-----END PRIVATE KEY-----
+65 -4
View File
@@ -4,6 +4,7 @@ const fs = require("fs-extra");
const path = require("path");
const libLink = require("@clusterio/lib/link");
const libUsers = require("@clusterio/lib/users");
const { slowTest, get, exec, execCtl, sendRcon, getControl, instancesDir } = require("./index");
@@ -34,6 +35,42 @@ describe("Integration of Clusterio", function() {
});
describe("clusterioctl", function() {
describe("master config list", function() {
it("runs", async function() {
await execCtl("master config list");
});
it("should not leak auth_secret", async function() {
let result = await libLink.messages.getMasterConfig.send(getControl());
let done = false;
for (let group of result.serialized_config.groups) {
if (group.name === "master") {
assert.equal(Object.prototype.hasOwnProperty.call(group.fields, "auth_secret"), false);
done = true;
break;
}
}
assert(done, "master group not found");
});
});
describe("master config set", function() {
it("sets given config option", async function() {
await execCtl('master config set master.name "Test Cluster"');
let result = await libLink.messages.getMasterConfig.send(getControl());
let done = false;
for (let group of result.serialized_config.groups) {
if (group.name === "master") {
assert.equal(group.fields.name, "Test Cluster");
done = true;
break;
}
}
assert(done, "master group not found");
});
it("should not allow setting auth_secret", async function() {
await assert.rejects(execCtl("master config set master.auth_secret root"));
});
});
describe("slave list", function() {
it("runs", async function() {
await execCtl("slave list");
@@ -115,7 +152,7 @@ describe("Integration of Clusterio", function() {
],
[
"allow_commands", true,
"allow-commands", "Yes",
"allow-commands", "Allow Lua commands: Yes.",
],
[
"autosave_interval", 17,
@@ -173,7 +210,7 @@ describe("Integration of Clusterio", function() {
],
];
for (let [prop, value, ,] of testConfigs) {
for (let [prop, value] of testConfigs) {
value = `"'${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}'"`;
let args = `test factorio.settings ${prop} ${value}`;
await execCtl(`instance config set-prop ${args}`);
@@ -282,16 +319,40 @@ describe("Integration of Clusterio", function() {
describe("role edit", function() {
it("should modify the given role", async function() {
let args = "--name new --description \"A new role\" --permissions";
let args = "--name new --description \"A new role\" --set-perms";
await execCtl(`role edit temp ${args}`);
let result = await libLink.messages.listRoles.send(getControl());
let newRole = result.list.find(role => role.name === "new");
assert.deepEqual(newRole, { id: 5, name: "new", description: "A new role", permissions: [] });
});
it("should add permissions with --add-perms", async function() {
let args = "--name new --add-perms core.slave.list core.instance.list";
await execCtl(`role edit new ${args}`);
let result = await libLink.messages.listRoles.send(getControl());
let newRole = result.list.find(role => role.name === "new");
assert.deepEqual(newRole.permissions, ["core.slave.list", "core.instance.list"]);
});
it("should remove permissions with --remove-perms", async function() {
let args = "--name new --remove-perms core.slave.list";
await execCtl(`role edit new ${args}`);
let result = await libLink.messages.listRoles.send(getControl());
let newRole = result.list.find(role => role.name === "new");
assert.deepEqual(newRole.permissions, ["core.instance.list"]);
});
it("should grant default permissions with --grant-default", async function() {
await execCtl("role edit new --grant-default");
let result = await libLink.messages.listRoles.send(getControl());
let newRole = result.list.find(role => role.name === "new");
let defaultPermissions = [...libUsers.permissions.values()]
.filter(p => p.grantByDefault)
.map(p => p.name)
;
assert.deepEqual(new Set(newRole.permissions), new Set(defaultPermissions));
});
});
describe("role delete", function() {
it("should modify the given role", async function() {
it("should delete the given role", async function() {
await execCtl("role delete new");
let result = await libLink.messages.listRoles.send(getControl());
let newRole = result.list.find(role => role.name === "new");
+9 -6
View File
@@ -113,7 +113,7 @@ function spawn(name, cmd, waitFor) {
}
before(async function() {
this.timeout(20000);
this.timeout(40000);
// Some integration tests may cause log events
logger.add(new ConsoleTransport({
@@ -134,9 +134,10 @@ before(async function() {
await exec("node ../../packages/master config set master.auth_secret TestSecretDoNotUse");
await exec("node ../../packages/master config set master.http_port 8880");
await exec("node ../../packages/master config set master.https_port 4443");
await exec("node ../../packages/master config set master.tls_bits 1024");
await exec("node ../../packages/master config set master.heartbeat_interval 0.25");
await exec("node ../../packages/master config set master.connector_shutdown_timeout 2");
await exec("node ../../packages/master config set master.tls_certificate ../../test/file/tls/cert.pem");
await exec("node ../../packages/master config set master.tls_private_key ../../test/file/tls/key.pem");
await exec("node ../../packages/ctl plugin add ../../plugins/global_chat");
await exec("node ../../packages/ctl plugin add ../../plugins/research_sync");
@@ -145,16 +146,18 @@ before(async function() {
await exec("node ../../packages/master bootstrap create-admin test");
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/);
let createArgs = "--id 4 --name slave --generate-token";
await execCtl(`slave create-config ${createArgs}`);
await execCtl("slave create-config --id 4 --name slave --generate-token");
await exec(`node ../../packages/slave config set slave.factorio_directory ${path.join("..", "..", "factorio")}`);
await exec("node ../../packages/slave config set slave.tls_ca ../../test/file/tls/cert.pem");
slaveProcess = await spawn("slave:", "node ../../packages/slave run", /SOCKET \| registering slave/);
let controlConnector = new TestControlConnector(url, 2);
let tlsCa = await fs.readFile("test/file/tls/cert.pem");
let controlConnector = new TestControlConnector(url, 2, tlsCa);
controlConnector.token = controlToken;
control = new TestControl(controlConnector);
await controlConnector.connect();
+10 -5
View File
@@ -1,6 +1,7 @@
"use strict";
const assert = require("assert").strict;
const events = require("events");
const fs = require("fs-extra");
const jwt = require("jsonwebtoken");
const { TestControlConnector, TestControl, get, exec, url } = require("./index");
@@ -8,10 +9,14 @@ const { TestControlConnector, TestControl, get, exec, url } = require("./index")
let token = jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse");
describe("Integration of lib/link", function() {
let tlsCa;
let control;
let controlConnector;
before(async function() {
tlsCa = await fs.readFile("test/file/tls/cert.pem");
});
it("should emitt an error if authentication failed", async function() {
controlConnector = new TestControlConnector(url, 0.2);
controlConnector = new TestControlConnector(url, 0.2, tlsCa);
controlConnector.token = "gibberish";
control = new TestControl(controlConnector);
await assert.rejects(
@@ -21,7 +26,7 @@ describe("Integration of lib/link", function() {
});
it("should connect with proper credentials", async function() {
controlConnector = new TestControlConnector(url, 0.2);
controlConnector = new TestControlConnector(url, 0.2, tlsCa);
controlConnector.token = token;
control = new TestControl(controlConnector);
await controlConnector.connect();
@@ -29,17 +34,17 @@ describe("Integration of lib/link", function() {
it("should reconnect on connection lost", async function() {
controlConnector._socket.close(1008, "Test");
await events.once(controlConnector, "connect");
await events.once(controlConnector, "resume");
});
it("should reconnect on connection terminated", async function() {
controlConnector._socket.terminate();
await events.once(controlConnector, "connect");
await events.once(controlConnector, "resume");
});
it("should reconnect on connection heartbeat timeout", async function() {
controlConnector.stopHeartbeat();
await events.once(controlConnector, "connect");
await events.once(controlConnector, "resume");
});
it("should invalidate on reconnection with bad session token", async function() {
+2 -2
View File
@@ -34,7 +34,7 @@ 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) {
logFile.write(JSON.stringify(output) + "\n");
logFile.write(`${JSON.stringify(output)}\n`);
});
// Initialize sever.
@@ -71,7 +71,7 @@ describe("Integration of lib/factorio/server", function() {
});
function log(message) {
logFile.write("=================== " + message + "\n");
logFile.write(`=================== ${message}\n`);
}
describe(".create()", function() {
+64
View File
@@ -0,0 +1,64 @@
"use strict";
const assert = require("assert").strict;
const { wait } = require("@clusterio/lib/helpers");
const RateLimiter = require("@clusterio/lib/RateLimiter");
describe("lib/RateLimiter", function() {
describe("class RateLimiter", function() {
let rateLimiter;
beforeEach(function() {
rateLimiter = new RateLimiter();
});
afterEach(function() {
rateLimiter.cancel();
});
it("should rate limit activations", function() {
assert(rateLimiter.activate(), "fist invocation was rate limited");
assert(!rateLimiter.activate(), "second invocation was not rate limited");
});
it("should invoke action immediately when not rate limited", function() {
let invoked = false;
rateLimiter.action = () => { invoked = true; };
rateLimiter.activate();
assert(invoked, "action was not invoked");
});
it("should invoke action at a later point in time if rate limited", async function() {
let invokedCount = 0;
rateLimiter.maxRate = 100;
rateLimiter.action = () => { invokedCount += 1; };
rateLimiter.activate();
rateLimiter.activate();
assert.equal(invokedCount, 1, "expected one invocation");
await wait(20);
assert.equal(invokedCount, 2, "expected two invocations");
});
it("should invoke action before timeout if maxRate changed", async function() {
let invokedCount = 0;
rateLimiter.action = () => { invokedCount += 1; };
rateLimiter.activate();
rateLimiter.activate();
assert.equal(invokedCount, 1, "expected one invocation");
await wait(20);
rateLimiter.maxRate = 100;
rateLimiter.activate();
assert.equal(invokedCount, 2, "expected two invocations");
});
it("should cancel action if canceled before timeout", async function() {
let invokedCount = 0;
rateLimiter.action = () => { invokedCount += 1; };
rateLimiter.activate();
rateLimiter.activate();
rateLimiter.cancel();
assert.equal(invokedCount, 1, "expected one invocation");
await wait(20);
assert.equal(invokedCount, 1, "expected one invocation");
});
});
});
+188 -35
View File
@@ -1,11 +1,15 @@
"use strict";
const classes = require("@clusterio/lib/config/classes");
const assert = require("assert").strict;
const events = require("events");
describe("lib/config/classes", function() {
describe("ConfigGroup", function() {
let mockConfig = new events.EventEmitter();
mockConfig.location = "local";
class TestGroup extends classes.ConfigGroup { }
TestGroup.defaultAccess = ["local", "remote"];
TestGroup.groupName = "test_group";
describe(".define()", function() {
it("should throw if groupName is not set", function() {
@@ -23,6 +27,10 @@ describe("lib/config/classes", function() {
});
it("should throw on invalid properties", function() {
for (let [def, error] of [
[
{ access: "local", name: "a", type: "string", optional: true },
"access must be an array",
],
[
{ name: "a", type: "invalid", optional: true },
"invalid is not a valid type",
@@ -59,6 +67,14 @@ describe("lib/config/classes", function() {
assert.throws(() => TestGroup.define(def), new Error(error));
}
});
it("should throw on defaultAccess not set", function() {
class NoAccessGroup extends classes.ConfigGroup { }
NoAccessGroup.groupName = "no_access";
assert.throws(
() => NoAccessGroup.define({ name: "a", type: "string", optional: true }),
new Error("access not set and no defaultAccess defined for NoAccessGroup")
);
});
it("should throw on missing properties", function() {
assert.throws(
() => TestGroup.define({ type: "string", optional: true }),
@@ -74,11 +90,13 @@ describe("lib/config/classes", function() {
TestGroup.define({ name: "func", type: "number", initial_value: () => 42 });
TestGroup.define({ name: "bool", type: "boolean", initial_value: false, optional: true });
TestGroup.define({ name: "json", type: "object", initial_value: {}, optional: true });
TestGroup.define({ name: "priv", access: ["local"], type: "string", optional: true });
assert(TestGroup._definitions.has("test"), "field test was not defined");
assert(TestGroup._definitions.has("func"), "field func was not defined");
assert(TestGroup._definitions.has("bool"), "field bool was not defined");
assert(TestGroup._definitions.has("json"), "field json was not defined");
assert(TestGroup._definitions.has("priv"), "field priv was not defined");
});
it("should throw on already defined entries", function() {
@@ -90,6 +108,7 @@ describe("lib/config/classes", function() {
it("should set the entries on the class", function() {
let field = {
access: ["local", "remote"],
type: "string",
name: "enum",
title: "Enum",
@@ -122,7 +141,7 @@ describe("lib/config/classes", function() {
});
it("should throw when creating an instance before finalize", function() {
assert.throws(
() => new TestGroup(),
() => new TestGroup(mockConfig),
new Error("Cannot instantiate incomplete ConfigGroup class TestGroup")
);
});
@@ -141,24 +160,31 @@ describe("lib/config/classes", function() {
describe("constructor", function() {
it("should create an instance", function() {
let testInstance = new TestGroup();
let testInstance = new TestGroup(mockConfig);
});
});
describe(".init()", function() {
it("should initialize all fields with defaults", async function() {
let testInstance = new TestGroup();
let testInstance = new TestGroup(mockConfig);
await testInstance.init();
assert.equal(testInstance.get("enum"), "b");
assert.equal(testInstance.get("test"), null);
assert.equal(testInstance.get("func"), 42);
assert.equal(testInstance.get("priv"), null);
});
it("should not initalize inaccessible fields", async function() {
let testInstance = new TestGroup({ location: "remote" });
await testInstance.init();
assert.equal(testInstance._fields.get("enum"), "b");
assert.equal(testInstance._fields.get("priv"), undefined);
});
});
describe("get name", function() {
it("should give the name of the group", async function() {
let testInstance = new TestGroup();
let testInstance = new TestGroup(mockConfig);
await testInstance.init();
assert.equal(testInstance.name, "test_group");
});
@@ -166,10 +192,26 @@ describe("lib/config/classes", function() {
describe(".serialize()", function() {
it("should serialize a basic group", async function() {
let testInstance = new TestGroup();
let testInstance = new TestGroup(mockConfig);
await testInstance.init();
assert.deepEqual(testInstance.serialize(), {
name: "test_group",
fields: {
enum: "b",
test: null,
func: 42,
bool: false,
json: {},
priv: null,
},
});
});
it("should leave out inaccessible fields", async function() {
let testInstance = new TestGroup(mockConfig);
await testInstance.init();
assert.deepEqual(testInstance.serialize("remote"), {
name: "test_group",
fields: {
enum: "b",
@@ -184,29 +226,36 @@ describe("lib/config/classes", function() {
describe(".load()", function() {
it("should load a basic group", async function() {
let testInstance = new TestGroup();
await testInstance.load(null, { name: "test_group", fields: {
let testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: {
enum: "c",
test: "blah",
func: 22,
bool: null,
json: { valid: true },
priv: "bar",
}});
assert.equal(testInstance.get("enum"), "c");
assert.equal(testInstance.get("test"), "blah");
assert.equal(testInstance.get("func"), 22);
assert.equal(testInstance.get("bool"), null);
assert.equal(testInstance.get("priv"), "bar");
assert.deepEqual(testInstance.get("json"), { valid: true });
});
it("should load defaults for missing serialized fields", async function() {
let testInstance = new TestGroup();
await testInstance.load(null, { name: "test_group", fields: { enum: "a" } });
let testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: { enum: "a" } });
assert.equal(testInstance.get("enum"), "a");
assert.equal(testInstance.get("test"), null);
assert.equal(testInstance.get("func"), 42);
});
it("should ignore inaccessible fields", async function() {
let testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: { priv: "bad" } }, "remote");
assert.equal(testInstance.get("priv"), null);
});
it("should preserve unknown fields when serialized back", async function() {
let testFields = {
@@ -215,13 +264,14 @@ describe("lib/config/classes", function() {
func: 50,
bool: false,
json: {},
priv: null,
alpha: null,
beta: "decay",
gamma: 99,
};
let testInstance = new TestGroup();
await testInstance.load(null, { name: "test_group", fields: testFields });
let testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: testFields });
assert.deepEqual(testInstance.serialize().fields, testFields);
});
@@ -229,7 +279,7 @@ describe("lib/config/classes", function() {
describe(".update()", function() {
it("should skip updating invalid values", async function() {
let testInstance = new TestGroup();
let testInstance = new TestGroup(mockConfig);
await testInstance.init();
testInstance.update({ name: "test_group", fields: { enum: "wrong", test: 3, func: null }}, false);
@@ -237,9 +287,19 @@ describe("lib/config/classes", function() {
assert.equal(testInstance.get("test"), null);
assert.equal(testInstance.get("func"), 42);
});
it("should skip updating inaccessible fields", async function() {
let testInstance = new TestGroup(mockConfig);
await testInstance.init();
testInstance.update(
{ name: "test_group", fields: { test: "a", priv: "bad" }}, false, "remote"
);
assert.equal(testInstance.get("test"), "a");
assert.equal(testInstance.get("priv"), null);
});
it("should throw on invalid input", function() {
let testInstance = new TestGroup();
let testInstance = new TestGroup(mockConfig);
assert.throws(
() => testInstance.update(),
new Error("Expected object, not undefined for ConfigGroup")
@@ -263,13 +323,52 @@ describe("lib/config/classes", function() {
});
});
describe(".canAccess()", function() {
let testInstance;
before(async function() {
testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: {
enum: "a", priv: "blah", func: 27,
}});
});
it("should return true for fields that are accessible", function() {
for (let field of ["enum", "test", "func", "bool", "json"]) {
assert.equal(testInstance.canAccess(field), true);
assert.equal(testInstance.canAccess(field, "local"), true);
assert.equal(testInstance.canAccess(field, "remote"), true);
}
assert.equal(testInstance.canAccess("priv"), true);
assert.equal(testInstance.canAccess("priv", "local"), true);
assert.equal(testInstance.canAccess("priv", "remote"), false);
});
});
describe(".get()", function() {
let testInstance;
before(async function() {
testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: {
enum: "a", priv: "blah", func: 27,
}});
});
it("should retreive value of field", function() {
assert.equal(testInstance.get("enum"), "a");
assert.equal(testInstance.get("priv"), "blah");
assert.equal(testInstance.get("func"), 27);
});
it("should throw if field is inaccessible", function() {
assert.throws(
() => testInstance.get("priv", "remote"),
new classes.InvalidAccess("Field 'priv' is not accessible from remote")
);
});
});
describe(".set()", function() {
let testInstance;
before(async function() {
testInstance = new TestGroup();
await testInstance.load(null, { name: "test_group", fields: {
enum: "a", test: "blah", func: 27,
}});
testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: { enum: "a", test: "blah", func: 27 }});
});
it("should throw if field does not exist", function() {
@@ -279,6 +378,13 @@ describe("lib/config/classes", function() {
);
});
it("should throw if field is inaccesible", function() {
assert.throws(
() => testInstance.set("priv", "bad", "remote"),
new classes.InvalidAccess("Field 'priv' is not accessible from remote")
);
});
it("should throw if field is not in enum", function() {
assert.throws(
() => testInstance.set("enum", "bar"),
@@ -351,8 +457,11 @@ describe("lib/config/classes", function() {
assert.deepEqual(testInstance.get("json"), { json: true });
let errMsg;
try { JSON.parse("blah"); }
catch (err) { errMsg = err.message; }
try {
JSON.parse("blah");
} catch (err) {
errMsg = err.message;
}
assert.throws(
() => testInstance.set("json", "blah"),
new classes.InvalidValue(`Error parsing value for json: ${errMsg}`)
@@ -363,8 +472,8 @@ describe("lib/config/classes", function() {
describe(".setProp()", function() {
let testInstance;
before(async function() {
testInstance = new TestGroup();
await testInstance.load(null, { name: "test_group", fields: {
testInstance = new TestGroup(mockConfig);
await testInstance.load({ name: "test_group", fields: {
enum: "a", test: "blah", func: 27,
}});
});
@@ -376,9 +485,16 @@ describe("lib/config/classes", function() {
);
});
it("should throw if field is inaccesible", function() {
assert.throws(
() => testInstance.setProp("priv", "prop", "bad", "remote"),
new classes.InvalidAccess("Field 'priv' is not accessible from remote")
);
});
it("should throw if field is not an object", function() {
assert.throws(
() => testInstance.setProp("enum", "a"),
() => testInstance.setProp("enum", "prop", "a"),
new classes.InvalidField("Cannot set property on non-object field 'enum'")
);
});
@@ -394,13 +510,21 @@ describe("lib/config/classes", function() {
testInstance.setProp("json", "test", true);
assert.deepEqual(testInstance.get("json"), { test: true });
});
it("should unset field if passed undefined", function() {
testInstance.set("json", { test: true, extra: "yes" });
testInstance.setProp("json", "extra", undefined);
assert.deepEqual(testInstance.get("json"), { test: true });
});
});
});
describe("Config", function() {
class AlphaGroup extends classes.ConfigGroup { }
AlphaGroup.defaultAccess = ["local", "remote"];
AlphaGroup.groupName = "alpha";
class BetaGroup extends classes.ConfigGroup { }
BetaGroup.defaultAccess = ["local", "remote"];
BetaGroup.groupName = "beta";
class TestConfig extends classes.Config { }
@@ -445,20 +569,26 @@ describe("lib/config/classes", function() {
describe("constructor", function() {
it("should throw if config is not finalized", function() {
assert.throws(
() => new TestConfig(),
() => new TestConfig("local"),
new Error("Cannot instantiate incomplete Config class TestConfig")
);
});
it("should construct a finalized class", function() {
TestConfig.finalize();
let config = new TestConfig();
let config = new TestConfig("local");
assert(config);
});
it("should throw if location is not passed", function() {
assert.throws(
() => new TestConfig(),
new Error("location must be a string")
);
});
});
describe(".init()", function() {
it("should initialize all fields", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.init();
assert.equal(testInstance.get("alpha.foo"), null);
assert.deepEqual(testInstance.get("beta.bar"), {});
@@ -467,7 +597,7 @@ describe("lib/config/classes", function() {
describe(".serialize()", function() {
it("should serialize a basic config", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.init();
assert.deepEqual(testInstance.serialize(), {
groups: [
@@ -486,7 +616,7 @@ describe("lib/config/classes", function() {
describe(".load()", function() {
it("should reject on incorrect input passed", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await assert.rejects(
testInstance.load(null),
new Error("Expected object, not null for config")
@@ -502,7 +632,7 @@ describe("lib/config/classes", function() {
});
it("should load defaults for missing serialized groups", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.load({ groups: [{ name: "alpha", fields: { foo: "a" }}] });
assert.equal(testInstance.get("alpha.foo"), "a");
@@ -525,7 +655,7 @@ describe("lib/config/classes", function() {
},
];
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.load({ groups: testGroups });
assert.deepEqual(testInstance.serialize().groups, testGroups);
@@ -534,7 +664,7 @@ describe("lib/config/classes", function() {
describe(".update()", function() {
it("should update a basic config", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.init();
testInstance.update({ groups: [
{
@@ -568,7 +698,7 @@ describe("lib/config/classes", function() {
describe(".group()", function() {
it("should throw if instance is not initialized", function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
assert.throws(
() => testInstance.group("test"),
new Error("TestConfig instance is uninitialized")
@@ -576,16 +706,39 @@ describe("lib/config/classes", function() {
});
});
describe(".canAccess()", function() {
it("should throw if group does not exist", async function() {
let testInstance = new TestConfig("local");
await testInstance.init();
assert.throws(() => testInstance.canAccess("invalid"), new Error("No config group named 'invalid'"));
});
it("should throw if no field is specified", async function() {
let testInstance = new TestConfig("local");
await testInstance.init();
assert.throws(() => testInstance.canAccess("alpha"), new Error("No field named ''"));
});
it("should return true for an accessible field", async function() {
let testInstance = new TestConfig("local");
await testInstance.init();
assert.equal(testInstance.canAccess("alpha.foo"), true);
});
});
describe(".get()", function() {
it("should throw if group does not exist", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.init();
assert.throws(() => testInstance.get("invalid"), new Error("No config group named 'invalid'"));
});
it("should throw if no field is specified", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.init();
assert.throws(() => testInstance.get("alpha"), new Error("No field named ''"));
@@ -594,7 +747,7 @@ describe("lib/config/classes", function() {
describe(".set()", function() {
it("should set the value", async function() {
let testInstance = new TestConfig();
let testInstance = new TestConfig("local");
await testInstance.init();
testInstance.set("alpha.foo", "new value");
@@ -612,7 +765,7 @@ describe("lib/config/classes", function() {
let testInstance;
let called;
beforeEach(async function() {
testInstance = new TestConfig();
testInstance = new TestConfig("local");
await testInstance.init();
called = false;
+27
View File
@@ -5,6 +5,7 @@ const fs = require("fs-extra");
const path = require("path");
const libFactorio = require("@clusterio/lib/factorio");
const { wait } = require("@clusterio/lib/helpers");
const { testLines } = require("./lines");
@@ -243,5 +244,31 @@ describe("lib/factorio/server", function() {
assert(!await fs.pathExists(filePath), "File was not deleted");
});
});
describe(".stop()", function() {
it("should handle server quitting on its own during stop", async function() {
if (process.platform === "win32") {
this.skip();
}
server.hangTimeout = 20;
server._server = new events.EventEmitter();
server._server.kill = () => true;
server._state = "running";
server._rconReady = true;
server._rconClient = {
async end() {
server._rconClient = null;
process.nextTick(() => {
server.emit("_quitting");
server._server.emit("exit");
});
},
};
server._watchExit();
await server.stop();
await wait(21); // Wait until after hang timeout
});
});
});
});
+2 -1
View File
@@ -23,7 +23,8 @@ describe("lib/helpers", function() {
await libHelpers.wait(100);
let duration = Number(hrtime() - startNs) / 1e6;
assert(duration > 90 && duration < 110, `duration waited (${duration}ms) 10% of expected (100ms)`);
// On windows the time waited is normally up to 15 ms late with outliers in the 20ms range.
assert(duration > 90 && duration < 130, `duration (${duration}ms) not within (90-130)`);
});
});
+28 -1
View File
@@ -17,8 +17,15 @@ describe("lib/link/messages", function() {
let testRequest = new libLink.Request({
type: "test",
links: ["source-target"],
requestRequired: ["test"],
requestProperties: {
"test": { type: "string" },
"optional": { type: "number" },
},
responseRequired: ["test"],
responseProperties: {
"test": { type: "string" },
"optional": { type: "number" },
},
});
@@ -63,7 +70,24 @@ describe("lib/link/messages", function() {
});
});
it("should implicitly send an empty object on empty return", async function() {
handlerResult = undefined;
let emptyRequest = new libLink.Request({
type: "empty",
links: ["source-target"],
});
emptyRequest.attach(testTargetLink, async(message) => {});
testTargetLink._handlers.get("empty_request")({ seq: 2 });
let result = await new Promise(resolve => {
testTargetLink.connector.send = (type, data) => resolve({ type, data });
});
assert.deepEqual(result, {
type: "empty_response",
data: {
seq: 2,
},
});
});
it("should validate response from handler", async function() {
handlerResult = { test: 12 };
testTargetLink._handlers.get("test_request")({ seq: 2 });
let result = await new Promise(resolve => {
testTargetLink.connector.send = (type, data) => resolve({ type, data });
@@ -71,6 +95,7 @@ describe("lib/link/messages", function() {
assert.deepEqual(result, {
type: "test_response",
data: {
error: "Validation failed responding to test_request",
seq: 2,
},
});
@@ -106,8 +131,10 @@ describe("lib/link/messages", function() {
let testEvent = new libLink.Event({
type: "test",
links: ["source-target"],
eventRequired: ["test"],
eventProperties: {
"test": { type: "string" },
"optional": { type: "number" },
},
});
+4 -4
View File
@@ -43,24 +43,24 @@ describe("lib/plugin", function() {
let mockLink = new libLink.Link("source", "target", new mock.MockConnector());
let mockEvent = new libLink.Event({ type: "test:test", links: ["target-source"] });
it("should accept pluginInfo without messages", function() {
libPlugin.attachPluginMessages(mockLink, {}, null);
libPlugin.attachPluginMessages(mockLink, { info: {} });
});
it("should attach handler for the given message", function() {
function mockEventEventHandler() { };
libPlugin.attachPluginMessages(
mockLink, { name: "test", messages: { mockEvent }}, { mockEventEventHandler }
mockLink, { mockEventEventHandler, info: { name: "test", messages: { mockEvent }}}
);
assert(mockLink._handlers.get("test:test_event"), "handler was not registered");
});
it("should throw if missing handler for the given message", function() {
assert.throws(
() => libPlugin.attachPluginMessages(mockLink, { name: "test", messages: { mockEvent }}, {}),
() => libPlugin.attachPluginMessages(mockLink, { info: { name: "test", messages: { mockEvent }}}),
new Error("Missing mockEventEventHandler on plugin test for test:test_event on source-target link")
);
});
it("should throw if message starts with the wrong prefix", function() {
assert.throws(
() => libPlugin.attachPluginMessages(mockLink, { name: "foo", messages: { mockEvent }}, {}),
() => libPlugin.attachPluginMessages(mockLink, { info: { name: "foo", messages: { mockEvent }}}),
new Error('Type of mockEvent message must start with "foo:"')
);
});
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more