Compare commits

...
Author SHA1 Message Date
Hornwitser 67002e4b71 v2.0.0-alpha.5 2021-03-23 21:23:22 +01:00
Hornwitser 166416339a Merge pull request #373 from Hornwitser/alpha-5
Alpha 5 preparations
2021-03-23 21:18:11 +01:00
Hornwitser ead2c6d2c1 Add release checklist
Add instructions for doing releases to npm to the contributing docs.
2021-03-23 20:37:32 +01:00
Hornwitser bfea6cb28e Don't log print permission denied stack traces
Use PermissionError to signal when permission is denied for an action
and prevent them being logged as stack traces in the master log.
2021-03-23 20:09:09 +01:00
Hornwitser 7ecac539f9 Fix path stripping for relative and Windows paths
Fix path stripping logic to take into account the possibility of the
writePath being relative, and that Windows uses \ as a path separator
for some paths but not all.
2021-03-23 20:09:09 +01:00
Hornwitser e73599a800 Merge pull request #372 from Hornwitser/storage-page
Storage page
2021-03-23 20:02:31 +01:00
Arvids Godjuks f46f40ad4a Merge pull request #363 from drouarb/master
MacOS compatibility
2021-03-23 20:59:44 +02:00
Benjamin DROUARD 058197dc99 Using factorio.app/Contents for MacOS 2021-03-21 16:43:32 +01:00
Hornwitser 8fb6784964 Load plugin infos in integration tests
Make sure the permisisons defined by plugins are included in the
integration test environment by loading the plugin infos.
2021-03-20 17:03:23 +01:00
Hornwitser c16dc74ad9 Reimplement the Storage page
Reimplement the functionality of the Storage page which shows which
items are stored in the shared subspace on the master server.
2021-03-20 03:35:05 +01:00
Hornwitser cebe83542f Add access to icons and locale from export
Add models for accessing and using the icons and locale that's exported
when an instance export is runned.
2021-03-20 03:35:05 +01:00
Hornwitser 6c19145734 Add control reference to web plugins
Add reference to the Control instance in BaseWebPlugin for web plugin
code to use in a similar manner to the instance reference in instance
plugin code.
2021-03-20 03:35:05 +01:00
Hornwitser 0a6e26bb2d Add connection events for control connections
Add hooks for plugins to be able to handle connection state changes for
control connections on the master server and in the web interface.
2021-03-20 03:35:05 +01:00
Hornwitser de629d9416 Pass link to plugin message handlers
For master plugins a message can be received from any of the clients
corrently connected to the master.  In order for plugins to be able to
handle link specific requests they need a reference to the link messages
was received from.  Add link as the third argument to plugin message
handlers.
2021-03-20 03:35:05 +01:00
Hornwitser 48786f1e59 Fix --dev-plugin breaking after bd53339
The automatic determination of the public path for plugins works
correctly when they are build for production and loaded from their
respective paths.  But when the dev middelware compiles it, it doesn't
know the correct path to host it at withouth the publicPath being set.

Fix by setting publicPath explicitly when compiling with the dev
middleware.
2021-03-20 03:35:05 +01:00
Hornwitser 3d26371fa9 Make web pages pluggable
Refactor the page handling of the web interface to allow plugins to add
their own pages.
2021-03-20 03:35:05 +01:00
Hornwitser 73c713462c Merge pull request #371 from Hornwitser/account-button
Account button
2021-03-20 03:11:16 +01:00
Hornwitser 16d2ab1646 Fix logging in not saving token
Fix regression in login logic causing the token to be saved as null
instead of the real value.
2021-03-20 02:52:34 +01:00
Hornwitser f9048d7027 Set UI color on chrome mobile 2021-03-18 23:05:59 +01:00
Hornwitser d627c5e094 Fix issues with reconnect and logging out
Fix problems encountered logging out and in while the connection has
dropped and is in the process of reconnecting.
2021-03-18 23:05:59 +01:00
Hornwitser 6458810571 Add name to account model
Add the name of the account that's connected to the control link on the
web interface via an additional property on the ready payload for the
control link.
2021-03-18 23:05:47 +01:00
Hornwitser e1549c1b0c Add account button with log out
Add a button on the right of the top nav bar to access account related
functions and add a log out button there.  To make it fit on the navbar
the text and version is hidden on narrow layouts.
2021-03-18 21:52:36 +01:00
Hornwitser 9542246414 Use a diff and author friendly markdown formatting (#370)
Simplify the the way markdown files are formatted to make them easier to
author without editors that do auto line breaking, and make the diffs
from them simpler to read.
2021-03-17 21:30:51 +01:00
Hornwitser a2d56d4a8d Fix crash on continuing an active connection (#369)
Fix ServerConnector reaching an invalid state if a continue operation is
performed on an active connection.  The continue would cause the
connection established logic to be runned before the connection lost
logic of the pervious connection is perfomed, resulting in a leaked
heartbeat interval and the connection lost logic being performed on the
new connection instead of the old.  Prevent this by closing both the new
and the old connection in this case.
2021-03-17 20:20:12 +01:00
Hornwitser 72d0994ca6 Add Player Auth plugin (#368)
Add authentication plugin that does a simple challenge response protocol
to verify that the player trying to log in is who they claim to be.  The
process goes as follows:

1. A player code is generated for a player that wants to log in and is
   displayed in-game.
2. The player code is input into the web interface and the master
   generates a verification code as a challenge back along with a token.
3. The player inputs the verification code in-game and is logged in to
   the web interface in doing so.

To do the actual login the web interface polls a verify endpoint every 2
seconds until the code has either been verified or expired.  The
challenge comes from being unable to control the verification code and
having to input it as a player logging into one of the servers in order
to login.

Some additional steps are taken in order to increase the difficulty for
an attacker to subvert the challenge:

- The login process must be started from in-game.
- Generated codes are only valid for a limited timespan which is
  configurable and 2 minutes by default.
- The verification code comes with a token that only works with one
  player code and verification code combo and must be provided to pass
  validation.
2021-03-17 18:48:22 +01:00
Hornwitser f07082b52d Merge pull request #367 from Hornwitser/player-auth-plugin-preparations
Web Plugin preparations
2021-03-17 18:16:19 +01:00
Hornwitser 941639666d Use auto publicPath deduction
Change to "auto" as the publicPath to have webpack deduce the correct
path at runtime.  This fixes an issue with plugins being unable to be
loaded due to trying to load bundles from the wrong path when the web
interface is hosted under a proxied sub-path.
2021-03-17 18:00:12 +01:00
Hornwitser bd53339532 Fix web root path confusion
Remove basename in favor of making webRoot absolute.  The distinction
between the two were supposed to be that basename was absolute and did
not have a trailing slash while webRoot was relative and had a traling
slash.  This was not how it ended up working in the code, which lead to
double forward slashes in requests sent out when webRoot was not equal
to './'.
2021-03-17 17:59:35 +01:00
Hornwitser 28da8dcc58 Merge pull request #366 from Hornwitser/plugin-improvements
Plugin improvements
2021-03-15 21:54:39 +01:00
Hornwitser ceae2b25b5 Make the login form pluggable
Allow extending the login form with additional authentication methods by
adding them to the web plugin's loginForms array.
2021-03-13 21:01:53 +01:00
Hornwitser 579f6421e4 Export web_ui as a single module
Due to the way the imports and exports works with module fedderation
each require path needs it's own shared module in the plugin.  To avoid
having to specify every possible import in the module federation plugin
config, export all useful components to plugins in the browser
entrypoint for web_ui.

Note that for some reason the module wouldn't exist for import by
plugins unless some part of the build imported it.  Which is the reason
for the master index.jsx to import bootstrap via it.
2021-03-13 21:01:53 +01:00
Hornwitser a904a8fb9d Add hit counter for HTTP requests
Add a complementary total hit counter for all HTTP requests.  The
endpointHitCounter doesn't cover all endpoints and fixing it to cover
all endpoints without making it possible for it to create an infinite
number of time seriers is not straight forward.
2021-03-13 21:01:53 +01:00
Hornwitser b70303c437 Add error boundary to App
Catch errors in the component stack that reaches the App component and
display an error message for them instead of a black screen.
2021-03-13 21:01:53 +01:00
Hornwitser a8e1971483 Fix data loss on bad name passed --dev-plugin
If data from the database is not loaded but a shutdown is triggered then
the master server will overwrite the database with empty data, causing
unexpected data loss.  This occurs for example when the --dev-plugin
option throws.

Fix by setting the database entries to null before they are loaded so
that should an attempt be made to save them without having been loaded
first it will throw an error instead.
2021-03-13 21:01:53 +01:00
Hornwitser 78ce6fc382 Move /plugin/name/ to /plugins/name/
The web files for plugins were hosted at /plugin/plugin_name/ while the
view page for a plugin is hosted at /plugins/plugin_name/view.  Reduce
confusion by making both urls use the plural version.
2021-03-13 21:01:53 +01:00
Hornwitser 71804df3e0 Add --dev-plugin to compile plugins on the fly
Add hidden development option to run the webpack compiller for the given
plugin(s) similar to the --dev option for the built-in web interface.

This requires publicPath to be set in the webpack config and an updated
version of webpack-dev-middleware to function correctly.
2021-03-13 21:01:53 +01:00
Hornwitser 5ce3fb6cc6 Add loading of web plugins in interface
Add separate BaseWebPlugin class which plugins can extend and expose
through a webEntrypoint in a similar fasion to the existing master,
instance and control entrypoints.

To simplify the logic for the rest of the web interface code the plugins
context is changed to be a mapping of loaded WebPlugin instances.  This
in turn means the plugins and plugin view pages fetches the plugin list
upon load in order to show plugins that failed to load (which
incidentally also gives them an up to date list).
2021-03-13 21:01:53 +01:00
Hornwitser 6c4e094d44 Mention plugins by Hornwitser in README 2021-03-07 15:56:27 +01:00
Hornwitser f007dd640d Fix statistics exporter name and description 2021-03-07 15:32:32 +01:00
Hornwitser e1c7d35ce4 Fix plugin shutdown not called on bad startup
During startup the plugins are loaded one by one and init is called for
each of them.  But due to the already loaded plugins being discarded if
one of the plugins throw an error an unclean shutdown that might hang
on timers started by plugins occurs.  This leads to the master server
starting up, printing an error, displaying the shutdown sequence and
then hang forever.

Fix by storing the plugins loaded into masterPlugins as they are loaded
instead only after all of them have been loaded.
2021-03-07 15:18:39 +01:00
Hornwitser a17e9295e7 Use relative imports in lib
Use relative imports in lib to avoid an issue with webpack thinking
absolute imports inside @clusterio/lib are for a separate module and
giving out warnings for it being missing in package.json.
2021-03-07 15:14:38 +01:00
Hornwitser b814d8a400 Share each individual lib import webpack config
The module sharing in Webpack works on a per import basis and therefore
doesn't trigger when the value in the config is @clusterio/lib while the
module imported is @clusterio/lib/config.  This in turn caused a copy of
lib to be bundled with each plugin.  Fix by making sure each lib import
is shared and specified in the configs.
2021-03-07 15:12:23 +01:00
Hornwitser fe9cef5e1e Log stack trace on errors loading web plugins 2021-03-07 14:56:48 +01:00
Hornwitser 415f574e2d Remove obsolete "updater" code
Remove commented out reference to an updater module which was never
completed in master.js.
2021-03-07 14:55:57 +01:00
Hornwitser 63f76ce87a Rename enabled to load_plugin
Rename the config and status indicating whether to load a plugin or not
from "Enabled" to "Load Plugin".  The latter better describes what it
does, and unlike the former doesn't imply plugins can be enabled or
disabled on the fly.
2021-03-07 14:53:45 +01:00
David Hiendl 19259b2226 Add .json files to symlinkMods whitelist (#365) 2021-03-07 13:35:46 +01:00
Hornwitser 53edd7cce6 Merge pull request #364 from Hornwitser/fix-windows-2
Fix windows test failure
2021-03-07 13:34:57 +01:00
Hornwitser 2069689780 Remove --exit flag from tests
When setting up the tests there were issues with code that didn't
properly clean up after itself and left lingering connections, intervals
or timers that kept the event loop running after the tests where done.
To fix this the --exit flag was passed as a temporary measure but then
subsequently forgotten about.  The issues with keeping the event loop
alive have long since been fixed on Linux, but on Windows there was a
problem with the control connector not exiting because the master server
was killed instead of shutten down properly.

With that issue fixed there are no longer any connections or timers
keeping the event loop alive and --exit can thus be removed.
2021-03-06 18:40:07 +01:00
Hornwitser d6f09f4c1a Fix close during reconnect wait period
If the connect close method is called while the connection is waiting
for a reconnection attempt then the attempt is cancelled but the close
method then erroneously waited for a close event that would never
happen.  Fix by resetting the connector and rasing the close event in
the close method if it's in the waiting for reconnect state.
2021-03-06 18:38:30 +01:00
Hornwitser 85a984734b Fix node-forge dependency
The removal of the automatic TLS certificate generation removed the use
of node-forge in the master server, while the addition of the HttpCloser
added it as dependency of the tests.  Fix node-forge dependency being
listed in lib and master instead of as a dev dependency in package.json.
2021-03-06 18:38:30 +01:00
Benjamin DROUARD b78eafaedb MacOS compatibility 2021-03-05 17:31:14 +01:00
Hornwitser 641d8e9fc5 Merge pull request #362 from Hornwitser/plugin-list
Add plugin list
2021-03-05 16:58:15 +01:00
Hornwitser bdd70e53dc Fix errors detected by linter
I meant to fix these changes before pushing the PR, but forgot about it.
2021-03-03 14:41:55 +01:00
Hornwitser 7797ee708a Add ctl master plugin list command
Add command to list the plugins enabled on the master server and their
versions to clusterioctl.
2021-03-01 15:03:25 +01:00
Hornwitser 6c9d3d8893 Add Plugins page to web interface
Add page listing the plugins installed on the master along with a
detailed page for each plugin with fields from their package.json.
Failure to load the module and version missmatch is also reported.
This adds ./packages.json as required part of the webpack module.
2021-03-01 15:03:25 +01:00
Hornwitser 3a3a7597bf Add PluginsContext to web_ui
Add a React context to access the plugins loaded in the web interface.
The info, /api/plugins metadata and webpack container is provided for
each plugin.
2021-03-01 15:03:25 +01:00
Hornwitser d0befe5944 Add error boundnary for sidebar paths too
b538960 added an error boundnary for pages not directly linked on the
sidebar, but forgot to add it for pages on the sidebar.  This add the
missing error boundnary for those pages.
2021-03-01 15:03:25 +01:00
Hornwitser a3729504e5 Log error stack on bootstrap failure
Log the stack of the error caught during failure to load the web
interface.  This ensure the error is printed to the console instead of
"[fatal] undefined".
2021-03-01 15:03:25 +01:00
Hornwitser 4efd09de31 Expose computed tls_ca config
Provide the loaded file of the tls_ca config available to slave,
instance and ctl code.  This ensures they can easily use it when
connecting to the master server.
2021-03-01 15:03:25 +01:00
Hornwitser 6a29cd9715 Resolve webpack modules from web_ui
Remove the need for installing babel and the webpack loaders used in the
webpack.common.js file separately by resolving them from the web_ui
package.  This allows future changes to done to the modules used without
having to update them in each plugin that uses the web_ui common config.
2021-03-01 14:59:59 +01:00
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
Hornwitser b3e518d570 v2.0.0-alpha.3 2020-12-14 07:11:34 +01:00
Hornwitser 3b13dfdcb5 Merge pull request #344 from Hornwitser/alpha-3
Alpha 3 preparations
2020-12-07 21:04:24 +01:00
Hornwitser 2e5333af29 Fix banlist sync being broken
Fix the logic for determining which entries in the banlist changed being
incorrect and causing the banlist sync to be broken.  Add unit tests for
the syncUserListsEventHandler to ensure it functions as exxpected.
2020-12-07 18:59:32 +01:00
Hornwitser 450e27e001 Add metric mapping slave and instance id to name
Add exported metrics that map instance id and slave id to their
respective names.  This allows getting the instance name of a metric by
doing a left join using query of the form 'metric * on(instance_id)
group_left(instance_name) clusterio_master_instance_mapping'.
2020-12-07 17:22:17 +01:00
Hornwitser 71164faa14 Fix docs and usage of Collector callback
Fix callback typo in Collector constructor jsdocs, add notice that
the collector is passed as the first argument, and use this first
arugument in the built-in collector implementations.
2020-12-07 17:15:25 +01:00
Hornwitser a89106f4a3 Add changelog entry for logging system 2020-12-07 17:12:29 +01:00
Hornwitser d768019e1b Merge pull request #343 from Hornwitser/factorio-1.1
Add support for Factorio 1.1
2020-12-07 17:05:49 +01:00
Hornwitser 30b7fd8767 Add support for Factorio 1.1
Add variant to clusterio_lib supporting 1.1 and update travis config to
use the 1.1 compatible version of Subspace Storage.
2020-12-06 22:48:47 +01:00
Hornwitser faae6ccf97 Rework mod build system to be multi-version capable
Add a variants array which can specify overriden properties of the
info.json for building multiple versions of the mod at the same time and
fix the additonal_files to allow specifying under what path the file is
put.

Together these two changes allow setting up the build to do all
supported versions at the same time and add specialized code for each
version of Factorio supported.
2020-12-06 22:46:32 +01:00
Hornwitser 6c11cefb99 Remove resource count limiting
Remove the limiting of the resource counts sent to the subspace_storage
mod as this has been implemented on the mod side with version 1.99.6.
2020-12-06 17:09:46 +01:00
Hornwitser dd8f9dbfd5 Merge pull request #341 from Hornwitser/log-system
Implement logging system
2020-12-06 16:58:38 +01:00
Hornwitser 93752e671e Fix plugin errors show incorrect error stack
Fix master server showing the stack of the PluginError instance on
startup instead of the original error that the plugin threw.
2020-11-30 02:49:06 +01:00
Hornwitser 7b75d4f0bb Fetch log in web interface instance view
Use the queryLog request to add past entries to the log fix the console
view to scroll to the bottom and stick there upon being loaded up.
2020-11-30 02:49:06 +01:00
Hornwitser 1c8a2fef32 Add log inspection commands
Add log query and log follow commands which allows inspection of the
combined cluster log.  Past entries can be queried via the new queryLog
request.  The log is stored into a single flat file for now, though the
performance of this may good enough.
2020-11-30 02:49:06 +01:00
Hornwitser 69221a310c Add startTimer to Gauge
Add startTimer interface to Gauge similar to how the startTimer
interface for Histogram works.
2020-11-30 02:49:06 +01:00
Hornwitser 49b5b4412f Fix require paths shown in contributing doc
Change the example code in the contributing doc to use the clusterio org
when referencing the lib package.
2020-11-27 18:21:26 +01:00
Hornwitser fe86041fc4 Document server settings applied live
Add a list of the server settings that are automatically applied to a
running instance when they are changed.
2020-11-27 18:20:13 +01:00
Hornwitser 957983d8a5 Add logging system based on winston
Add winston based logger for master, slave, ctl, lib, and web_ui code.
Logging done from slaves and instances are sent to the master server and
tagged with the instance and/or slave it originated from.  This allows
for example error messages to propagate back to the source that iniated
the action.  The master server also stores the log to allow it to be
queried at a later point.  For slaves a copy of the slave's log is also
stored locally.
2020-11-27 17:51:07 +01:00
Hornwitser 8f176c969b Merge pull request #340 from Hornwitser/rcon-performance
Improvements from investigating RCON performance
2020-11-20 22:27:19 +01:00
Hornwitser 79d20260c5 Document instance.auto_start config
Add entry to docs/configuration.md for the auto_start option implemented
in 87da492.
2020-11-20 04:55:01 +01:00
Hornwitser 0079d8634a Optimize labelsToKey for single label metrics
Avoid doing an Array.join when there's only one label in labelsToKey.
2020-11-20 01:36:21 +01:00
Hornwitser a08087c5e8 Optimize escapeLabelValue for the common case
Avoid an expensive string copy for the common case of there being no
characters to escape in the label value.
2020-11-20 01:36:07 +01:00
Hornwitser cdb048741c Rework RCON statistics gathering
Add a sendRcon method to BaseInstancePlugin and the slave's Instance
class.  This servers two purposes, one is to reduce the repetition when
send RCON commands particularly from plugins, and the other is to
instrument the RCON commands sent with metrics about duration and size.
2020-11-20 01:36:07 +01:00
Hornwitser 4d0720ade8 Add documentation for lib/prometheus
Document the basic usage of lib/prometheus for instrumenting code, and
the interfaces it exposes.
2020-11-20 01:36:07 +01:00
Hornwitser e5e1851852 Add Histogram collector to prometheus client
Move common functionality between labeled collectors out of
ValueCollector and into a new LabeledCollector class, and use this as
the basis for implementing a Histogram collector.
2020-11-18 21:27:18 +01:00
Daniel 0b249b6018 Merge pull request #339 from justchen1369/master
change some occurrences of var
2020-11-18 09:40:51 +01:00
justchen1369 c65494f093 change some occurrences of var 2020-11-17 23:53:35 -05:00
Hornwitser e82f849118 Fix web instance log following lost on reconnect
Fix the console view of the instance web interface losing the output
subscription after a reconnect to the master server due to not
re-subscribing to the outputs.
2020-11-17 04:34:49 +01:00
Hornwitser 87da492ef8 Add auto_start config option to instances
Add option to automatically start the instance on slave startup.
2020-11-17 04:34:49 +01:00
Hornwitser 9d2ac2b81f Rework instance assigment process
Under the current system there's no distinction made on the slave side
between instance config files that are present in the filesystem and
instances actually assigned to the slave.  Which could lead to problems
with determing if an instance is actually assigned to the slave.

Resolve this issue by separating discovered instances with assigned
instances.  Upon startup discovered instances are put into the
discoveredInstanceInfos mapping on the Slave object and all of these are
sent to the master server.  The master server in turn sends assignment
requests for all the instances which are assigned to the slave.  These
are put into the instanceInfos mapping of the Slave object which can
then be relied up to include only assigned instances.
2020-11-17 04:34:49 +01:00
Hornwitser c78bf02ce4 Fix event forwarding to master
Fix trying to use non-existent method on Slave class for forwading
events to the master.  Also update request forward to be consistent with
event forwarding.
2020-11-17 04:22:08 +01:00
Hornwitser 2f7419cf3b Add max_concurrent_commands option
Add option to set the the maximum amount of commands transmitted in
parallel to the game.  Increasing this value allows a greater rate of
commands to be executed.
2020-11-17 04:22:07 +01:00
Hornwitser 10303f979d Add timeout to statistics_exporter metrics
When statistics_exporter handles getMetrics it may take a long time for
the result of command it sends to be returned if the instance's command
stream is overloaded, this in turn would cause the slave to miss its
window to reply on the getMetrics request and no metrics for the slave
would be available.  Ensure statistics_exporter does not use more than 5
seconds to respond by adding a timeout to the metrics gathering.
2020-11-17 04:19:05 +01:00
Hornwitser b742291131 Add timeout to slave metric request
When the slave metrics request takes more than 10 seconds the response
to the /metrics endpoint ends up being discarded by Prometheus which
defaults to a timeout of 10 seconds.  Fix by ignoring slaves that take
longer than 8 seconds to respond to the metrics request.
2020-11-17 04:19:05 +01:00
Hornwitser a06781ebc3 Merge pull request #338 from Hornwitser/assorted-improvements
Assorted improvements
2020-11-15 20:03:35 +01:00
Hornwitser f5e28f6815 Fix errors detected by linter 2020-11-12 04:35:22 +01:00
Hornwitser 770a6664d4 Add missing instance request forwarding
Add missing forwardRequestToInstance default handler for Instance
connections for handling request forwarding from one instance to
another.
2020-11-12 04:22:13 +01:00
Hornwitser f89e66e3cb Rename getMetrics request type
Remove statistics_exporter: prefix to the getMetrics request which was
left over from when this event was a part of statistics exporter.
2020-11-12 04:19:57 +01:00
Hornwitser 65a25e2510 Add more context to missing handler error
Amend the error message shown when a message handler is missing from a
plugin with the expected name of the handler and the name of the plugin
it's missing from.
2020-11-12 04:17:52 +01:00
Hornwitser 5b74432ee3 Improve web interface console performance
Calculate the coloring for instance console messages once instead of
every time the instance console is updated.
2020-11-12 04:07:08 +01:00
Hornwitser 49eb966315 Respond to unhandled requests
Give an error response back to unhandled requests, this fixes requests
hanging forever when the target does not support the given request.
2020-11-12 04:05:24 +01:00
Hornwitser 42e680f09d Fix typos in documentation
Fix typos spotted by T-A-R#8121 in the documentation.
2020-11-12 03:53:40 +01:00
Hornwitser 31c0925354 Fix error on dash in instance name
Fix stripPaths logic breaking when the instance name contains RegExp
meta characters due to a lack of escaping.
2020-11-10 22:13:07 +01:00
Hornwitser 4c9b91ab57 Lower default heartbeat interval
Use 45 seconds as the default heartbeat interval instead of 60.  Using
60 causes connections to drop when proxied through Nginx as the default
timeout there is also 60 seconds.
2020-11-09 20:19:22 +01:00
Hornwitser a92ae95ab1 Fix updating config breaking instance status
Fix broken assign instance code in slave sending of the wrong instance
status to the master server.
2020-11-09 18:36:03 +01:00
Hornwitser fc8edeba5f Reduce log spam on connection error
Print only the error message in the console when a connection error is
encountered on the link connector.  This reduces log spam on reconnect
for slaves.
2020-11-09 17:26:56 +01:00
Hornwitser db1f239a0f Add formatting of Script and Verbose levels
Detect and colour the Script and Verbose levels.  The format of the
source file for the Script level is slightly different and needs an
update to the output parsing.
2020-11-09 15:57:46 +01:00
Hornwitser c8c65d2e92 Add verbose logging option
Add option to pass the --verbose flag to the Factorio server.
2020-11-09 15:57:46 +01:00
Hornwitser 67654fdf85 Add stripping of instance paths in Factorio log
Add option defaulting to true to strip instance directory paths printed
into the Factorio log down to be relative to the instance.
2020-11-09 15:57:46 +01:00
Hornwitser 63a656cd61 Rename default Admin role to Cluster Admin
Clarify the purpose and distinquesh the Admin role a bit better from the
in-game admin status by renaming the role to Cluster Admin.
2020-11-09 15:57:46 +01:00
166 changed files with 10634 additions and 3971 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/
+22 -22
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",
@@ -124,13 +124,13 @@ module.exports = {
"no-caller": "error",
"no-catch-shadow": "error",
"no-confusing-arrow": "error",
"no-console": "off",
"no-console": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
"no-constructor-return": "error",
"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,8 +213,8 @@ module.exports = {
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "warn",
"no-void": "error",
"no-var": "error",
"no-void": ["error", { "allowAsStatement": true }],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "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",
+1
View File
@@ -0,0 +1 @@
package-lock.json -diff
+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/
+2 -2
View File
@@ -7,8 +7,8 @@ before_install:
- npm install -g codecov
script:
- npx lerna bootstrap --no-ci
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --bump-patch --factorio-version 0.18 --output-dir temp/test/sharedMods
- curl -o temp/test/sharedMods/subspace_storage_1.99.1.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.1/subspace_storage_1.99.1.zip
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.6/subspace_storage_1.99.8.zip
- npx lerna run build
- npm run-script ci-cover
- codecov -f coverage/lcov.info
+113 -166
View File
@@ -1,239 +1,186 @@
Changelog
=========
# Changelog
Version 2.0.0
-------------
## Version 2.0.0
### Major Features
- Replaced Hotpatch with save patching. Removed the Hotpatch scenario and
the depency on it for getting code into the game. Added a save patcher than
runs before starting up Factorio that patches in lua modules based on the
event_loader lib into the savegame. Regular freeplay games can now be
used with Clusterio and will be compatible without having to convert them
to Hotpatch.
- Daemonized slaves. Slaves now have the ability to run multiple Factorio
instances, starting and stopping them individually. To manage this the
local command line interface has been replaced with a remote interface on
the master server that can be accessed through the clusterioctl cli tool.
- Rewritten the communication between slaves and the master from scratch. The
new system is based on a WebSocket connection between the slaves and the
master server and provides efficient validated bi-directonal communication.
- Rewritten the plugin system from scratch. Plugins now inherit from
a base class and use the same WebSocket connection Clusterio uses to
communicate.
- New configuration system with support for initializing configs when needed,
modifying config entries using built-in commands, and updating instance
configuration remotely.
- Packaged and uploaded to npm again, greatly simplifying distribution and
installation of both Clusterio and plugins.
- Replaced Hotpatch with save patching.
Removed the Hotpatch scenario and the depency on it for getting code into the game.
Added a save patcher than runs before starting up Factorio that patches in lua modules based on the event_loader lib into the savegame.
Regular freeplay games can now be used with Clusterio and will be compatible without having to convert them to Hotpatch.
- Daemonized slaves.
Slaves now have the ability to run multiple Factorio instances, starting and stopping them individually.
To manage this the local command line interface has been replaced with a remote interface on the master server that can be accessed through the clusterioctl cli tool.
- Rewritten the communication between slaves and the master from scratch.
The new system is based on a WebSocket connection between the slaves and the master server and provides efficient validated bi-directonal communication.
- Rewritten the plugin system from scratch.
Plugins now inherit from a base class and use the same WebSocket connection Clusterio uses to communicate.
- New configuration system with support for initializing configs when needed, modifying config entries using built-in commands, and updating instance configuration remotely.
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
- Added centralized logging for the cluster.
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
### Features
- Added export of pollution statitics.
- Reconnection logic that esures no data is dropped talking to the master
server provided the session can be resumed.
- Added support for having multiple Factorio installs and selecting which
version to use on a per instance basis.
- Added config to specify which path the master interface is accessed under,
allowing it to be proxied behind web-server.
- Reconnection logic that esures no data is dropped talking to the master server provided the session can be resumed.
- Added support for having multiple Factorio installs and selecting which version to use on a per instance basis.
- Added config to specify which path the master interface is accessed under, allowing it to be proxied behind web-server.
- Added WebSocket usage statistics.
- Added commands sent statistic.
- Added Factorio server CPU and memory usage statistic.
- Added Factorio autosave size statistic.
- Added support for extracting locale and item icons from Factorio and mods.
- Added integrated user management for controlling access and storing per
player data.
- Added synchronization of in-game adminlist, banlist and whitelist to
Clusterio core. Previously this was handled by the playerManager plugin.
- Added integrated user management for controlling access and storing per player data.
- Added synchronization of in-game adminlist, banlist and whitelist to Clusterio core.
Previously this was handled by the playerManager plugin.
- Added stripping of long paths in the Factorio server log.
- Added option to configure maximum number of commands to send in parallel.
- 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.
- Added an overview of the installed plugins to the web interface.
- Added clusterioctl command to list plugins on the master server.
- Added Player Auth plugin for logging in to the web interface by proving the ability to log in to a server as a given user.
### 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.
- Fixed rcon password being generated with Math.random().
- Added plugins directory to the views path. This makes it possible for
plugins to render their own ejs views or pages in their own folders by
using paths of the format "pluginName/path/to/page-or-view".
- Replaced the per instance copy of the shared Factorio mods with
symlinks. On Windows hard links are used instead due to the
privileges requirements of symlinks.
- Changed the per instance scenario folder to be linked instead of
copied on instance creation.
- Instance id is no longer derived from the rcon password, instead it's
generated upon instance creation and stored in the instance config.
- The game port, RCON port and RCON password instance config entries are are
now null by default, indicating that a random one will be generated every
time the instance is started.
- Removed the broken client download command as this would download
pre-releases.
- Moved the item database and HTTP definitions for /api/place, /api/remove,
/api/inventory, /api/inventoryAsObject and the web interface view for the
storage page into the clusterioMod plugin. If you disable this plugin
then these things will not be available.
- Added plugins directory to the views path.
This makes it possible for plugins to render their own ejs views or pages in their own folders by using paths of the format "pluginName/path/to/page-or-view".
- Replaced the per instance copy of the shared Factorio mods with symlinks.
On Windows hard links are used instead due to the privileges requirements of symlinks.
- Changed the per instance scenario folder to be linked instead of copied on instance creation.
- Instance id is no longer derived from the rcon password, instead it's generated upon instance creation and stored in the instance config.
- The game port, RCON port and RCON password instance config entries are are now null by default, indicating that a random one will be generated every time the instance is started.
- Removed the broken client download command as this would download pre-releases.
- Moved the item database and HTTP definitions for /api/place, /api/remove, /api/inventory, /api/inventoryAsObject and the web interface view for the storage page into the clusterioMod plugin.
If you disable this plugin then these things will not be available.
- Removed undocumented --port and --rcon-port arguments to client.
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable
handling in client.
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable handling in client.
- Removed redundant call to /api/slaves in globalChat plugin.
- Removed broken serverManager plugin.
- Removed factorio_version from config. The version installed is auto
detected and used instead.
- Removed factorio_version from config.
The version installed is auto detected and used instead.
- Removed the playerManager specific command CLI tools/delete_player.js.
- Creating an instance, assigning it to a slave, creating a save for an
instance and starting an instance is now four separate commands.
- Creating an instance, assigning it to a slave, creating a save for an instance and starting an instance is now four separate commands.
- Removed oddball limits to slaves.json size.
- Moved slave specific and instance specific configuration into their own
configuration files.
- Moved slave specific and instance specific configuration into their own configuration files.
- Removed unused binary option from plugin config.
- Removed info and shout command from globalChat plugin
- Removed mirrorAllChat and enableCrossServerShout configuration options for
globalChat plugin.
- Removed UPSdisplay plugin. UPS statistics is exported by the statistics
exporter plugin.
- Removed info and shout command from globalChat plugin - Removed mirrorAllChat and enableCrossServerShout configuration options for globalChat plugin.
- Removed UPSdisplay plugin.
UPS statistics is exported by the statistics exporter plugin.
- Master server now defaults to hosting on https on port 8443.
- Renamed client to slave.
### Breaking Changes
- Removed lib/authenticate. Breaks playerManager.
- Removed lib/authenticate.
Breaks playerManager.
- The masterIP and masterPort config entries has been merged into masterURL.
Breaks discordChat, playerManager, serverSelect, trainTeleports and
clusterioModel.
Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel.
- Instance names can no longer be invalid Windows file names.
- Removed config management from the command line and the server manager.
- Moved ejs templates into views folder and changed their extension to
.ejs. Breaks playerManager.
- Moved ejs templates into views folder and changed their extension to .ejs.
Breaks playerManager.
- Mods are no longer copied from the per instance instanceMods directory.
If you need per instance mods you can now place them directly in the mods
directory inside the instance folder.
If you need per instance mods you can now place them directly in the mods directory inside the instance folder.
- Hotpatch scenarios and code loading is no longer compatible with Clusterio.
Breaks playerManager, serverSelect, and tranTeleports.
- Removed getLua and getCommand from lib/clusterTools. If you need to run
more than the most trivial of code in commands use the save patcher and
add in a remote interface.
- Removed mod uploading and distributing from the HTTP interface. Breaks
the old unmaintained and no longer needed factorioClusterioClient.
- Removed the remoteCommands plugin and the old runCommand interface. Breaks
playerManager and external tools sending commands.
- Removed getLua and getCommand from lib/clusterTools.
If you need to run more than the most trivial of code in commands use the save patcher and add in a remote interface.
- Removed mod uploading and distributing from the HTTP interface.
Breaks the old unmaintained and no longer needed factorioClusterioClient.
- Removed the remoteCommands plugin and the old runCommand interface.
Breaks playerManager and external tools sending commands.
- Removed broken serverManager plugin.
- Removed fields info, time, rconPort, rconPassword, serverPort, unique,
mods, instanceName, playerCount, mac, and meta from the slaves in the
slave database.
- Removed getInstanceName and lib/clusterTools. Breaks playerManager,
and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints. Breaks web
interface, trainTeleports, and discordChat.
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints. Breaks
researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake. Breaks
playerManager, trainTeleports, serverSelect, and discordChat
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the slaves in the slave database.
- Removed getInstanceName and lib/clusterTools.
Breaks playerManager, and discordChat.
- Removed the /api/rconPasswords, and /api/slaves endpoints.
Breaks web interface, trainTeleports, and discordChat.
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints.
Breaks researchSync, and UPSdisplay.
- Removed the hello event from the socket connection handshake.
Breaks playerManager, trainTeleports, serverSelect, and discordChat
- Changed the format of database/slaves.json.
- Removed the output file subscription system. Breaks inventoryImports,
playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins. The onOutput
hook provides parsed output instead.
- Removed the output file subscription system.
Breaks inventoryImports, playerManager, trainTeleports, serverSelect and researchSync.
- Removed the factorioOutput hook from instance plugins.
The onOutput hook provides parsed output instead.
- Removed the onLoadFinish hook from master plugins.
- Moved plugins from the sharedPlugins directory to plugins directory.
- Implemented a new plugin system that replaces the old. Breaks all plugins.
- Implemented a new plugin system that replaces the old.
Breaks all plugins.
- Removed express metric http_request_duration_milliseconds metric.
- Removed socket.io metrics socket_io_connected, socket_io_connect_total,
socket_io_disconnect_total, socket_io_events_received_total,
socket_io_events_sent_total, socket_io_recieve_bytes, and
socket_io_transmit_bytes,
- Removed clusterio_connected_instaces_gauge and added
clusterio_master_connected_clients_count in its place.
- Renamed clusterio_player_count_gauge to
clusterio_statistics_exporter_instance_player_count
- Removed clusterio_UPS_gauge and added
clusterio_statistics_exporter_instance_game_ticks_total in its place.
- Removed socket.io metrics socket_io_connected, socket_io_connect_total, socket_io_disconnect_total, socket_io_events_received_total, socket_io_events_sent_total, socket_io_recieve_bytes, and socket_io_transmit_bytes,
- Removed clusterio_connected_instaces_gauge and added clusterio_master_connected_clients_count in its place.
- Renamed clusterio_player_count_gauge to clusterio_statistics_exporter_instance_player_count
- Removed clusterio_UPS_gauge and added clusterio_statistics_exporter_instance_game_ticks_total in its place.
- Renamed clusterio_endpoint_hit_gauge to clusterio_http_enpoint_hits_total
- Renamed clusterio_statistics_gauge to
clusterio_statistics_exporter_instance_force_flows
- Renamed clusterio_statistics_gauge to clusterio_statistics_exporter_instance_force_flows
- Renamed clusterio_nn_dole_gauge to clusterio_subspace_storage_nn_dole_gauge.
- Renamed clusterio_dole_factor_gauge to
clusterio_subspace_storage_dole_factor_gauge.
- Renamed clusterio_dole_factor_gauge to clusterio_subspace_storage_dole_factor_gauge.
- Renamed clusterio_import_gauge to clusterio_subspace_storage_import_total.
- Renamed clusterio_export_gauge to clusterio_subspace_storage_export_total.
- Renamed clusterio_master_inventory_gauge to
clusterio_subspace_storage_master_inventory.
- Removed clusterioMod plugin specific config options logItemTransfers,
disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and
disableImportsOfEverythingExceptElectricity from the master config.
- Removed msBetweenCommands config option. The RCON is instead limited to 5
concurrent commands.
- Removed allowRemoteCommandExecution config option. Remote commands are
always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line
arguments from the master server.
- Implemented a new config system that replaces the old. Breaks all plugins.
- Renamed clusterio_master_inventory_gauge to clusterio_subspace_storage_master_inventory.
- Removed clusterioMod plugin specific config options logItemTransfers, disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and disableImportsOfEverythingExceptElectricity from the master config.
- Removed msBetweenCommands config option.
The RCON is instead limited to 5 concurrent commands.
- Removed allowRemoteCommandExecution config option.
Remote commands are always allowed with the move to master managing slaves/instances.
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line arguments from the master server.
- Implemented a new config system that replaces the old.
Breaks all plugins.
- 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
-------------
## Version 1.2.4
- Removed broken remote combinator signaling.
- Fixed research sync endlessly updating already researched technologies
- Removed obsolete item/fluid statistics from clusterioMod
- Removed per mod upload logging when config.uploadModsToMaster disabled
- Fixed bcrypt failing to install on windows due to the new version not
having Windows binaries.
- Fixed bcrypt failing to install on windows due to the new version not having Windows binaries.
Version 1.2.3
-------------
## Version 1.2.3
- Disabled uploading mods to the master server by default as this is mostly
just a waste of bandwidth with the mod portal being integrated into the
game now.
- Fixed researchSync breaking with heavily modded games due to the tech tree
exceeding the default 100kb limit on JSON payloads.
- Fixed the command inventoryImport sends referencing player instead of
character, and trying to count the non-existant quick bar slots.
- Fixed inventoryImport never receiving the script output after the default
mode for reading script output changed to tail mode.
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting
out of sync when plugins update their scenario mod code. To fix existing
games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua
over the existing mod-tools.lua in the save's hotpatch folder.
- Disabled uploading mods to the master server by default as this is mostly just a waste of bandwidth with the mod portal being integrated into the game now.
- Fixed researchSync breaking with heavily modded games due to the tech tree exceeding the default 100kb limit on JSON payloads.
- Fixed the command inventoryImport sends referencing player instead of character, and trying to count the non-existant quick bar slots.
- Fixed inventoryImport never receiving the script output after the default mode for reading script output changed to tail mode.
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting out of sync when plugins update their scenario mod code.
To fix existing games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua over the existing mod-tools.lua in the save's hotpatch folder.
- Updated the Windows install instructions.
Version 1.2.2
-------------
## Version 1.2.2
- Fixed possible crash with modded technologies named the same as a built-in
Object prototype property in researchSync.
- Fixed progress of a current infinite tech carrying over to the next one
when researching it and another node completes it in researchSync.
- Fixed progress of a previous infinite tech from another node being applied
to the current one in researchSync.
- Fixed crash in researchSync when modded technologies are present only on some
nodes.
- Fixed install failing due to bcrypt version less than 3 not being supported
on node v10.
- Reordered install instructions to avoid problem with npm creating files owned
by root in the home directory.
- Swapped curl out with wget in the install instructions as the latter comes
pre-installed on Ubuntu.
- Fixed possible crash with modded technologies named the same as a built-in Object prototype property in researchSync.
- Fixed progress of a current infinite tech carrying over to the next one when researching it and another node completes it in researchSync.
- Fixed progress of a previous infinite tech from another node being applied to the current one in researchSync.
- Fixed crash in researchSync when modded technologies are present only on some nodes.
- Fixed install failing due to bcrypt version less than 3 not being supported on node v10.
- Reordered install instructions to avoid problem with npm creating files owned by root in the home directory.
- Swapped curl out with wget in the install instructions as the latter comes pre-installed on Ubuntu.
### Breaking Changes
- Node.js versions below 10 are no longer supported.
Version 1.2.1
-------------
## Version 1.2.1
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking
([#229][#229].)
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C
on Linux ([#217][#217].)
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking ([#229][#229]).
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C on Linux ([#217][#217]).
- Fixed package.json incorrectly reporting the license as ISC.
[#217]: https://github.com/clusterio/factorioClusterio/issues/217
+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
+116 -169
View File
@@ -10,21 +10,20 @@ Discord for development/support/play: https://discord.gg/5XuDkje
## Important notice
This is the development branch for factorioClusterio 2.0 which is currently undergoing heavy
restructuring and refactoring. Expect plugins and existing installations to frequently break when
using this branch. If you don't want to be an alpha tester for 2.0 please use the stable
[1.2.x branch][1.2.x] or
[latest stable release](https://github.com/clusterio/factorioClusterio/releases/latest).
This is the development branch for factorioClusterio 2.0 which is currently undergoing heavy restructuring and refactoring.
Expect plugins and existing installations to frequently break when using this branch.
If you don't want to be an alpha tester for 2.0 please use the stable [1.2.x branch][1.2.x] or [latest stable release](https://github.com/clusterio/factorioClusterio/releases/latest).
Installation instructions below are for the unstable master branch. Go to the page for
the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
Installation instructions below are for the unstable master branch.
Go to the page for the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
[1.2.x]: https://github.com/clusterio/factorioClusterio/tree/1.2.x
### Ways to support me/the project:
* Contribute with code/documentation. See [Contributing](docs/contributing.md) for how to make pull
requests. Always nice to move the project forward.
* Contribute with code/documentation.
See [Contributing](docs/contributing.md) for how to make pull requests.
Always nice to move the project forward.
* Support me monetarily on [patreon](https://www.patreon.com/danielv123) or paypal: danielv@live.no
@@ -35,6 +34,7 @@ the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
* [Plugins](#plugins)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [MacOS setup](#macos-setup)
* [Installing Plugins](#installing-plugins)
* [Configure Master Server](#configure-master-server)
* [Hosting Behind Proxy](#hosting-behind-proxy)
@@ -48,64 +48,54 @@ the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
## Introduction
Clusterio is a clustered Factorio server manager that provides the
tooling for implementing cross server interactions in Factorio. It
was previously best known for implementing cross server transfer and
cloud storage of items via teleporter chests. But this functionality
has been pulled out of Clusterio into its own plugin for Clusterio named
[Subspace Storage](https://github.com/clusterio/factorioClusterioMod).
Clusterio is a clustered Factorio server manager that provides the tooling for implementing cross server interactions in Factorio.
It was previously best known for implementing cross server transfer and cloud storage of items via teleporter chests.
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/factorioClusterioMod).
By itself Clusterio doesn't change the gameplay in any way, you could
even use Clusterio to manage completely vanilla Factorio servers.
Plugins do the work of modding in the visible changes into the game, see
the [Plugins section](#plugins) for ready made plugins you can install
into a Clusterio cluster.
By itself Clusterio doesn't change the gameplay in any way, you could even use Clusterio to manage completely vanilla Factorio servers.
Plugins do the work of modding in the visible changes into the game, see the [Plugins section](#plugins) for ready made plugins you can install into a Clusterio cluster.
## Features
- Clustered Factorio server management allowing you manage the running
of Factorio servers across a fleet of physical servers from both a web
interface and a command line interface.
- Clustered Factorio server management allowing you manage the running of Factorio servers across a fleet of physical servers from both a web interface and a command line interface.
- User list management for synchronizing in-game admins, whitelisted
users, and bans to all the servers in the cluster.
- User list management for synchronizing in-game admins, whitelisted users, and bans to all the servers in the cluster.
- Integrated support for exporting statistics for the whole cluster to
Prometheus via a single metrics endpoint.
- Integrated support for exporting statistics for the whole cluster to Prometheus via a single metrics endpoint.
- Extensive plugin support for adding your own cross server features to
Factorio using Clusterio's communication backbone.
- Extensive plugin support for adding your own cross server features to Factorio using Clusterio's communication backbone.
## Plugins
The heart of Clusterio is its plugin syste. Plugins add functionality
to Factorio servers, Clusterio itself or both. These are the plugins
supported and maintained by the Clusterio developers:
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:
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat
between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize
research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect
in-game statistics from all the servers and makes it available to the
Prometheus endpoint on the master server.
- [Subspace Storage](https://github.com/clusterio/factorioClusterioMod):
Provide shared storage that can transport items between servers via
teleport chests.
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the master server.
- [Subspace Storage](https://github.com/clusterio/factorioClusterioMod): Provide shared storage that can transport items between servers via teleport chests.
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
Want to make your own plugin? Check out the documentation on [Writing
Plugins](/docs/writing-plugins.md) for where to start.
There's also plugins developed and maintained by the community:
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge): Bridges chat between instances and Discord.
By Hornwitser.
- [Server Select](https://github.com/Hornwitser/server_select): In-game GUI for connecting to other server in the cluster.
Originally by Godmave, ported to 2.0 by Hornwitser.
Want to make your own plugin?
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
## Ubuntu setup
**Warning**: These instructions are for the unstable master version and is not
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
**Warning**: These instructions are for the unstable master version and is not recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
Clusterio runs on Node.js v12 and up, and v10.16.0+. Node.js itself is not
supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
Clusterio runs on Node.js v12 and up, and v10.16.0+.
Node.js itself is not supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
Master and all slaves:
@@ -118,12 +108,13 @@ Master and all slaves:
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.gz
downloads and installs nodejs, git and clusterio. To specify a version, change "latest" in the link to a version number like 0.14.21.
downloads and installs nodejs, git and clusterio.
To specify a version, change "latest" in the link to a version number like 0.14.21.
**Ubuntu with Docker**
The Docker support for Clusterio is curently broken. If you need it
open an issue about it.
The Docker support for Clusterio is curently broken.
If you need it open an issue about it.
<!--
Clusterio has *very* limited support for using docker.
@@ -134,18 +125,18 @@ Clusterio has *very* limited support for using docker.
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/factorioClusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
The -v flag is used to specify the instance directory. Your instances (save files etc) will be stored there.
The -v flag is used to specify the instance directory.
Your instances (save files etc) will be stored there.
-->
## Windows setup
**Warning**: These instructions are for the unstable master version and is not
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
**Warning**: These instructions are for the unstable master version and is not recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
**Requirements**
download and install nodeJS 12 from http://nodejs.org. Clusterio runs on Node.js v12 and
up, and v10.16.0+.
download and install nodeJS 12 from http://nodejs.org.
Clusterio runs on Node.js v12 and up, and v10.16.0+.
**Master**
@@ -160,31 +151,48 @@ up, and v10.16.0+.
- Via the stand alone version on from their website
1. Create a new folder named "factorio" in the the factorioClusterio
folder.
1. Create a new folder named "factorio" in the the factorioClusterio folder.
2. Download the MS Windows (64-bit zip package) from
https://www.factorio.com/download .
2. Download the MS Windows (64-bit zip package) from https://www.factorio.com/download .
3. Open the zip file and drag the folder called "Factorio_x.y.z" into
the factorio folder created in step 1.
3. Open the zip file and drag the folder called "Factorio_x.y.z" into the factorio folder created in step 1.
- Via steam installation
1. Create a new folder named "factorio" in the the factorioClusterio
folder.
1. Create a new folder named "factorio" in the the factorioClusterio folder.
2. Locate the game files by right clicking the game in steam,
selecting properties, then Local Files, then Browse local files.
2. Locate the game files by right clicking the game in steam, selecting properties, then Local Files, then Browse local files.
3. Go to the parent folder of the folder that Steam opened and copy
the Factorio folder into the factorio folder created in step 1.
3. Go to the parent folder of the folder that Steam opened and copy the Factorio folder into the factorio folder created in step 1.
## MacOS Setup
**Requirements**
download and install nodeJS 12 from http://nodejs.org or brew (`brew install node`). Clusterio runs on Node.js v12 and
up, and v10.16.0+.
1. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
```bash
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
```
2. Obtain Factorio:
Install Factorio and copy the factorio.app/Contents in your clusterio install folder as factorio.
If you installed factorio on your mac you can run
```bash
cp -r /Applications/factorio.app/Contents/ factorio
```
## Installing Plugins
Installing plugins to make them work with Clusterio consists of two
steps. First install the package via npm, for example
Installing plugins to make them work with Clusterio consists of two steps.
First install the package via npm, for example
npm install @clusterio/plugin-subspace_storage
@@ -192,128 +200,69 @@ Then tell clusterio that this plugin exists by adding it as a plugin.
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
This adds it to the default `plugin-list.json` file which in the shared
folder setup is loaded by master, slave and ctl. If you have slaves or
ctl installed on separate computers (or directories) then you need to
repeat the plugin install process for all of them. The clusteriomaster,
clusterioslave and clusterioctl commands has the plugin sub-command
so you do not need to install clusteriomaster to add plugins.
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by master, slave and ctl.
If you have slaves or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriomaster, clusterioslave and clusterioctl commands has the plugin sub-command so you do not need to install clusteriomaster to add plugins.
For development purposes the `plugin add` command supports adding
plugins by the absolute path to them, or a relative path that must start
with either . or .. (which will then be resolved to an absolute path).
For development purposes the `plugin add` command supports adding plugins by the absolute path to them, or a relative path that must start with either . or .. (which will then be resolved to an absolute path).
## 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.
Change the url to reflect the IP, protocol, and port the master server 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
@clusterio/master](/packages/master/README.md) for more
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.
You can list the config of the master server with the `npx clusteriomaster config list` command.
See the [readme for @clusterio/master](/packages/master/README.md) for more information.
### Setting up an admin account
Before you can manage the cluster you need to bootstrap an admin account
for it. Replace `<username>` with your Factorio username (do not make
up a new username here).
Before you can manage the cluster you need to bootstrap an admin account for it.
Replace `<username>` with your Factorio username (do not make up a new username here).
npx clusteriomaster bootstrap create-admin <username>
npx clusteriomaster bootstrap create-ctl-config <username>
The first command creates a user account with the given name and
promotes it to a cluster admin. The second one sets up a
`config-control.json` config for clusterioctl to connect to the master
server under the given user account.
The first command creates a user account with the given name and promotes it to a cluster admin.
The second one sets up a `config-control.json` config for clusterioctl to connect to the master server under the given user account.
## Running Clusterio
After following the installation and master configuration instructions
you can use the following commands to run Clusterio.
After following the installation and master configuration instructions you can use the following commands to run Clusterio.
### Master Server
It's necessary to run the master server in order for anything to work.
Once you've completed the setup run the following command to start it
up:
Once you've completed the setup run the following command to start it up:
npx clusteriomaster run
### Slaves
Slaves connect to the master server and are managed remotely from the
master server. In order for slaves to connect to the master server they
need a valid authentication token, you can create a slave config with
a valid token with the following command.
Slaves connect to the master server and are managed remotely from the master server.
In order for slaves to connect to the master server they need a valid authentication token, you can create a slave config with a valid token with the following command.
npx clusterioctl slave create-config --name Local --generate-token
This will write a new `config-slave.json` file in the current directory
(you can change the location with the `--output` option) with the name,
token and url to connect to the master server with. If you are making
the config for a remote slave you will need to have set the
`master.external_address` option to the URL the master server can be
reached on.
This will write a new `config-slave.json` file in the current directory (you can change the location with the `--output` option) with the name, token and url to connect to the master server with.
If you are making the config for a remote slave you will need to have set the `master.external_address` option to the URL the master server can be reached on.
You can list the config of a slave on the slave itself with the `npx
clusterioslave config list` command. See the [readme for
@clusterio/slave](/packages/slave/README.md) for more information.
You can list the config of a slave on the slave itself with the `npx clusterioslave config list` command.
See the [readme for @clusterio/slave](/packages/slave/README.md) for more information.
Once the config is set up run the slave with
@@ -322,13 +271,9 @@ Once the config is set up run the slave with
### Instances
Instances are created, managed and started from the master server. For
now the only interface available is the `clusterioctl` command line tool
included in Clusterio. You can run this tool from any slave, or the
master server without having to set up a config, if you want to manage
the cluster from somewhere else you will need to set the
`control.master_url` and `control.master_token` options with the `node
clusterioctl control-config set` command:
Instances are created, managed and started from the master server.
For now the only interface available is the `clusterioctl` command line tool included in Clusterio.
You can run this tool from any slave, or the master server without having to set up a config, if you want to manage the cluster from somewhere else you will need to set the `control.master_url` and `control.master_token` options with the `node clusterioctl control-config set` command:
The basic operations to start a new instance is the following
@@ -337,21 +282,19 @@ The basic operations to start a new instance is the following
npx clusterioctl instance start "My instance"
The first line creates the instance configuration on the master server.
The second assigns the instance to a slave which creates the instance
directory and files needed to run the instance on the given slave. The
third line starts the instance, which creates a new save if there are no
save games present.
The second assigns the instance to a slave which creates the instance directory and files needed to run the instance on the given slave.
The third line starts the instance, which creates a new save if there are no save games present.
There are many more commands available with clusterioctl. See the
[Managing a Cluster](/docs/managing-a-cluster.md) document or
`npx clusterioctl --help` for a full list of them.
There are many more commands available with clusterioctl.
See the [Managing a Cluster](/docs/managing-a-cluster.md) document or `npx clusterioctl --help` for a full list of them.
## Common problems
### EACCESS [...] LISTEN 443
Some systems don't let non root processes listen to ports below 1000. Either run with `sudo` or change config.json to use higher port numbers.
Some systems don't let non root processes listen to ports below 1000.
Either run with `sudo` or change config.json to use higher port numbers.
According to [this link](https://askubuntu.com/questions/839520/open-port-443-for-a-node-web-app) if you manually installed node.js following the above instructions, you may need to run the following command to fix this issue:
@@ -359,7 +302,11 @@ According to [this link](https://askubuntu.com/questions/839520/open-port-443-fo
### Portforwarding doesn't work on the master server when running under WSL
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip. This is also true when you correctly port-forwarded the correct ports. Even when routing this server through nginx in WSL, the issue persists. Then, on a hunch, I tried to run nginx from windows itself and found that this DID work. It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip.
This is also true when you correctly port-forwarded the correct ports.
Even when routing this server through nginx in WSL, the issue persists.
Then, on a hunch, I tried to run nginx from windows itself and found that this DID work.
It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
TLDR: the tested fix is:
+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
+21 -44
View File
@@ -1,59 +1,36 @@
Communication Layer
===================
# Communication Layer
<sub>This document describes the communication layer in Clusterio and
unless you're developing Clusterio it's probably not going to be very
useful for you.</sub>
<sub>This document describes the communication layer in Clusterio and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Communication is facilitated by the lib/link library and is based on
three core abstractions: links, connectors and messages. Links
represents one side of a two way communication pipe, connectors deal
with connecting and establishing communication, and messages are the
content sent over the link.
Communication is facilitated by the lib/link library and is based on three core abstractions: links, connectors and messages.
Links represents one side of a two way communication pipe, connectors deal with connecting and establishing communication, and messages are the content sent over the link.
Link
----
## Link
The Link class represents one side of a two way communication channel
where the actual communication is done by a connector associated with
the link. The link also knows what the endpoint types it's connected to
are via the source and target attributes.
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
The link also knows what the endpoint types it's connected to are via the source and target attributes.
The endpoint types currently implemented are master, slave, instance,
and control. Although slave and instances run in the same Node.js
program they use virtual links to communicate between them in order to
simplify the communication architecture.
The endpoint types currently implemented are master, slave, instance, and control.
Although slave and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
Upon creation the link will register with the connector in order to
receive and process messages from it. The messages received are
validated using the validator registered for the type of the message. A
message for which there is no validator for is considered an error.
Upon creation the link will register with the connector in order to receive and process messages from it.
The messages received are validated using the validator registered for the type of the message.
A message for which there is no validator for is considered an error.
Connector
---------
## Connector
The connector used with a link is responsible for establishing and/or
maintain the connection that messages are sent over. Reconnect logic is
expected to be implemented in the connector, though this has not yet
been done.
The connector used with a link is responsible for establishing and/or maintain the connection that messages are sent over.
Reconnect logic is expected to be implemented in the connector, though this has not yet been done.
There's also the virtual connector which lets two links in the same
program be connected to each other without having to establish a
network connection.
There's also the virtual connector which lets two links in the same program be connected to each other without having to establish a network connection.
Message
-------
## Message
There's two types of messages currently implemented apart from the
Link builtin close message and that's Request and Event. The builtin
messages are defined in
[packages/lib/link/messages.js](/packages/lib/link/messages.js) and are
attached to links via the attachAllMessages function, which is usally
called in the Link subclass constructor.
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The messages define which links they are valid on and only attaches
handlers for those links. There's also a forwarding mechanism for when
a request or event is to be forwarded another hop.
The messages define which links they are valid on and only attaches handlers for those links.
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
+38 -62
View File
@@ -1,45 +1,34 @@
Configuration System
====================
# Configuration System
<sub>This document describes the implementation and inner working of the
configuration system in Clusterio, and unless you're developing
Clusterio it's probably not going to be very useful for you.</sub>
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
Clusterio uses a group based configuration system to manage settings for
the master server, instances, slaves, and the clusterioctl utility.
Plugins get their own config group for the master server and instances
which they can add their own fields to. The fields are defined early in
the startup, and the groups and their fields for disabled plugins will
still be created.
Clusterio uses a group based configuration system to manage settings for the master server, instances, slaves, and the clusterioctl utility.
Plugins get their own config group for the master server and instances which they can add their own fields to.
The fields are defined early in the startup, and the groups and their fields for disabled plugins will still be created.
The built-in config groups and their fields are defined in
[packages/lib/config/definitions.js](/packages/lib/config/definitions.js)
while plugins can add their own config groups to the `MasterConfigGroup`
and `InstanceConfigGroup` properties of the `info.js` export using
subclasses of `PluginConfigGroup`. The plugin group has the enabled
field pre-defined on it, and it's used to enable/disable plugins.
The built-in config groups and their fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config groups to the `MasterConfigGroup` and `InstanceConfigGroup` properties of the `info.js` export using subclasses of `PluginConfigGroup`.
The plugin group has the enabled field pre-defined on it, and it's used to enable/disable plugins.
Defing Fields
-------------
## Defing Fields
Fields are defined using the `define` class method on group classes
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:
Fields are defined using the `define` class method on group classes 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
made to set the field to a value that is not of the right type it
will be reject unless it's a string that can be converted to the
right type.
The type of config value this field will support.
The supported values are boolean, string, number, and object, and if an attept is made to set the field to a value that is not of the right type it will be reject unless it's a string that can be converted to the right type.
**name**:
Name of the configuration field, this is the primary way the field
is accessed, both in code and on the command line. As such it
should follow the lower\_case\_underscore style and not contain any
spaces or dots.
Name of the configuration field, this is the primary way the field is accessed, both in code and on the command line.
As such it should follow the lower\_case\_underscore style and not contain any spaces or dots.
**title** (optional):
Text used to identify the config field in user interfaces.
@@ -48,27 +37,24 @@ following properties:
Text used to describe the config field in user interfaces.
**enum** (optional):
Array of acceptable values this field can have. Attempts to set a
value not in this array will be rejected, which makes it useful for
making enumareted list of choices.
Array of acceptable values this field can have.
Attempts to set a value not in this array will be rejected, which makes it useful for making enumareted list of choices.
**optional** (optional):
True if this field can be set to null. Defaults to false.
True if this field can be set to null.
Defaults to false.
**initial_value** (optional if optional is true):
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
If the value for a field stored in on disk somehow ends up being invalid
the field will take on the initial\_value instead when the config is
loaded.
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
Accessing Fields
----------------
## Accessing Fields
On the Config instance there's two ways to access fields: through the
set and get methods using dot notation. E.g.:
On the Config instance there's two ways to access fields: through the set and get methods using dot notation.
E.g.:
let value = configInstance.get("group.field");
@@ -77,26 +63,16 @@ E.g.:
let value = configInstance.group("group").get("field")
The set method takes an extra argument, which is the value to set the
field to. If the value is not valid this will throw an InvalidValue
exception, if the group or field doesn't exist it will throw an
InvalidField exception.
The set method takes an extra argument, which is the value to set the field to.
If the value is not valid this will throw an InvalidValue exception, if the group or field doesn't exist it will throw an InvalidField exception.
Config Lifecycle
----------------
## Config Lifecycle
Values for all config fields are created when the config is initialized,
as well as for any fields with missing values when a config is loaded.
Once a field has a value it is never automatically removed, even if the
field or group it was part of no longer exists. This ensures that
plugin configuration is not lost should the server be started up without
the plugins that defined the configuration installed.
Values for all config fields are created when the config is initialized, as well as for any fields with missing values when a config is loaded.
Once a field has a value it is never automatically removed, even if the field or group it was part of no longer exists.
This ensures that plugin configuration is not lost should the server be started up without the plugins that defined the configuration installed.
For instance configs, they are kept on the master server and serialized
and sent over to the relevant slaves on startup. This makes it possible
to edit instance configs when the slave the instance is on is offline.
A copy is also stored in the instance directory on the slave, and this
config will be deliverd to the master server on slave startup, which
enables instances to be copied between clusters as long as their IDs are
unique.
For instance configs, they are kept on the master server and serialized and sent over to the relevant slaves on startup.
This makes it possible to edit instance configs when the slave the instance is on is offline.
A copy is also stored in the instance directory on the slave, and this config will be deliverd to the master server on slave startup, which enables instances to be copied between clusters as long as their IDs are unique.
+211 -136
View File
@@ -1,155 +1,159 @@
Configuration
=============
# Configuration
Master 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
master server. This is also used by plugins to store its data.
Directory used to store cluster data to.
Needs to be writeable by the master server.
This is also used by plugins to store its data.
Defaults to "database".
### master.http_port
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.
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
Port to host HTTPS server on. Uses master.tls_certificate and
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.
Port to host HTTPS server on.
Uses master.tls_certificate and 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
External address the master server is accessible on. Currently only
used for `clusteriomaster bootstrap create-ctl-config` in order to give
the right url to connect to. This should be a full URL ending with a /.
External address the master server is accessible on.
Currently only used for `clusteriomaster bootstrap create-ctl-config` in order to give the right url to connect to.
This should be a full URL ending with a /.
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
slaves, users, and sessions. This should contain 256 bytes of random
data, and if changed will cause all tokens become invalid. Keep this
secret if leaked an attacker could easily compromise the cluster.
Base64 encoded authentication secret used to verify tokens issued to slaves, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
Defaults to null which means generate a secure secret on startup.
### master.heartbeat_interval
Interval in seconds heartbeats are sent out at for WebSocket
connections. If a connection hasn't received a heartbeat in 2 times the
heartbeat interval it will be considered stale and closed. A lower
value means less time between connections going stale and them being
closed.
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Defaults to 60
Defaults to 45.
### master.connector_shutdown_timeout
Timeout in seconds to use while shutting down the master server for link
sessions that currently don't have an active connection and haven't been
closed down properly. This happens when the network between a slave and
the master server goes down unexpectedly, or the slave goes down without
closing the connection properly either do to crashing or the machine
it's hosted on going down. If the timeout expires the session is
terminated and data loss may occur, if the slave went down this is
unavoidable.
Timeout in seconds to use while shutting down the master server for link sessions that currently don't have an active connection and haven't been closed down properly.
This happens when the network between a slave and the master server goes down unexpectedly, or the slave goes down without closing the connection properly either do to crashing or the machine it's hosted on going down.
If the timeout expires the session is terminated and data loss may occur, if the slave went down this is 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.
Role to automatically grant to new users.
If null no role is granted.
Defaults to 1 which correspond to the default Player role.
### <plugin_name>.enabled
### <plugin_name>.load_plugin
Whether to enable the given plugin on the master server. Plugins
disabled on the master server will not be loaded on instances.
Whether to load the plugin on the master server.
Plugins not loaded on the master server will not be loaded on instances.
Defaults to true.
Slave Configuration
-------------------
## Slave Configuration
### slave.name
Name of the slave. Shows up in slave lists and is used to reference
this slave in the clusterioctl command line interface.
Name of the slave.
Shows up in slave lists and is used to reference this slave in the clusterioctl command line interface.
Defaults to "New Slave".
### slave.id
Imutable numeric id of the slave which uniquely identifies this slave in
the cluster.
Immutable numeric id of the slave which uniquely identifies this slave in the cluster.
Defaults to a random 31 bit number.
### slave.factorio_directory
Directory to look for the Factorio server(s) in. This can either point
to a Factorio server directory, or to a directory containing multiple
versions of the Factorio server.
Directory to look for the Factorio server(s) in.
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
Defaults to "factorio".
### slave.instances_directory
Directory to store instances in. One sub-directory will be created in
it for each instance assigned to this slave and these sub-directories
will contain logs, saves, and various auto generated configuration files
for the instances.
Directory to store instances in.
One sub-directory will be created in it for each instance assigned to this slave and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
Defaults to "instances".
@@ -158,166 +162,237 @@ Defaults to "instances".
URL to connect to the master server to.
Defaults to "https://localhost:8443/".
Defaults to "http://localhost:8080/".
### slave.master_token
Access token used for authenticating with the master server. You can
generate an access token with `clusterioctl slave generate-token
--id <slave-id>`, or use the `clusterioctl slave create-config` to
create a new slave config with the correct url and token in it.
Access token used for authenticating with the master server.
You can generate an access token with `clusterioctl slave generate-token --id <slave-id>`, or use the `clusterioctl slave create-config` to 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.
This is used by plugins like Server Select to give the correct address
to switch between instances in-game.
This is used by plugins like Server Select to give the correct address to switch between instances in-game.
Defaults to "localhost".
### slave.reconnect_delay
Delay in seconds to wait after connection to the master server is
dropped before attempting to reconnect to it. The actual delay on each
reconnect will be a random number between 0 and this configured value to
avoid all slaves trying to reconnect at the same time.
Delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all slaves trying to reconnect at the same time.
Defaults to 5.
Instance Configuration
======================
## Instance Configuration
### instance.name
Name of the instance, shown in instance lists and used to reference to this
instance in the clusterioctl command line interface.
Name of the instance, shown in instance lists and used to reference to this instance in the clusterioctl command line interface.
Defaults to "New Instance"
Defaults to "New Instance".
### instance.id
Imutable numeric id of the instance which uniquely identifies this
instance in the cluster.
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
Defaluts to a random 31 bit integer.
### instance.assigned_slave
Slave this instance is assigned to. To change this you need to use the
separate assign instance interface. After being assigned to a slave the
directory for the instance is created on the slave.
Slave this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a slave the directory for the instance is created on the slave.
Defaults to null meaning not assigned.
### instance.auto_start
If enabled start this instance when the slave it is assigned to is started up.
Does not affect start or stop of the instance while the slave is running.
Defaults to false.
### factorio.version
Version of Factorio to use for this instance. Can be a version like
"1.0.0" or special string "latest" meaning the latest version of
Factorio found on the slave.
Version of Factorio to use for this instance.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the slave.
Defaults to "latest"
Defaults to "latest".
### factorio.game_port
UDP port to run the Factorio server on. If null a random port in the
dynamyc range assigned each time the instance starts.
UDP port to run the Factorio server on.
If null a random port in the dynamyc range assigned each time the instance starts.
Defaults to null
Defaults to null.
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on. If null
a random port in the dynamic range is assigned each time the instance
starts.
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
Defaults to null
Defaults to null.
### factorio.rcon_password
Password to use for the RCON interafec. If null a random secure
password is generated and used each time the instance starts.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
Defaults to null
Defaults to null.
### factorio.enable_save_patching
Whether to use save patching or not. When enabled lua code is patched
into the game save before starting it. Most plugins require the use of
save patching to function. Turning it off makes it possible to use
Clusterio to run and manage regular vanilla games and scenarios not
compatible with Clusterio.
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
Defaults to true
Defaults to true.
### factorio.enable_whitelist
Turn on the whitelist on the server.
Defaults to false
Defaults to false.
### factorio.settings
Object with the settings to put into `server-settings.json` for the
Factorio server. The settings in `data/server-settings.example.json` of
the Factorio installation is used as the base and properties specified
here overrides the properties there.
Object with the settings to put into `server-settings.json` for the Factorio server.
The settings in `data/server-settings.example.json` of the Factorio installation is used as the base and properties specified here overrides the properties there.
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
### factorio.verbose_logging
Pass the `--verbose` flag to Factorio when starting the server.
This prints more messages to the log.
Defaults to false.
### factorio.strip_paths
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
This improves the signal to noise ratio of the log.
Sample output when disabled:
0.487 Loading map /srv/clusterio/instances/test/saves/world.zip: 2615888 bytes.
0.614 Checksum for script /srv/clusterio/instances/test/temp/currently-playing/control.lua: 2390553941
1.277 Script @/srv/clusterio/instances/test/temp/currently-playing/modules/example/test.lua:7: Example log line.
Sample output when enabled:
0.487 Loading map saves/world.zip: 2615888 bytes.
0.614 Checksum for script temp/currently-playing/control.lua: 2390553941
1.277 Script @modules/example/test.lua:7: Example log line.
Defaults to true.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in
cluster. If enabled Clusterio will generate a new
`server-adminlist.json` on instance startup based on the users in the
cluster with admin status set to true, and keep the admins in sync with
the users that have admin status while the instance is running.
Synchronize in-game admin list with admin status of the users in cluster.
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
Defaults to true
Defaults to true.
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users
in the cluster. If enabled Clusterio will generate a new
`server-whitelist.json` on instance startup based on the users in the
cluster with whitelisted status set to true, and keep the whitelisted
users in sync with the users that have whitelisted status while the
instance in running.
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
Defaults to true
Defaults to true.
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the
cluster. If enabled Clusterio will generate a new `server-banlist.json`
on instance startup based on users in the cluster with banned status set
to true, and keep the banned users in sync with the users that have the
banned status while the instance is running.
Synchronize in-game banlist with the banned status of users in the cluster.
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
Defaults to true
Defaults to true.
### <plugin_name>.enabled
### factorio.max_concurrent_commands
Whether to enable the given plugin on the instance. Note that plugins
need to be enabled on the master server in order for them to be loaded
on instances.
Maximum number of commands being transmitted into the game in parallel.
Since the rate at which long commands are streamed into the game is by default limeted to 100 bytes/tick and scales down to 25 bytes/tick when there's 20 players connected, long commands can hold up the command interface and cause large latencies in getting data pushed into the game.
(The rates are controlled by the `segment_size` options in the server settings.)
To counteract this Clusterio sends up to this configured value number of commands in parallel over the RCON interface.
This causes the game updates sent to clients to have this many command streams in parallel contained in them, drastically increasing the maxiumum size they can become.
Since commands are only processed so ofter the maximum rate of commands that can be sustained is roughly 30 times this configured value per second, though note that large commands will lower this value.
Defaults to true
The game updates sent by the server is split into roughly 500 byte packets, and if any one of those parts are lost the entire update is resent.
This means a high number of concurrent commands can amplify resends due to packet loss and degrade the player experience up to the point of becomming completely unplayable.
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
Defaults to 5.
### <plugin_name>.load_plugin
Whether to load the given plugin on the instance.
Note that plugins need to be loaded 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.
+135 -127
View File
@@ -1,13 +1,11 @@
Contributing to Clusterio
=========================
# Contributing to Clusterio
You are probably reading this because you want to contribute code to
Clusterio. Great! We need people like you. Though we have some
conventions and workflows that we kindly ask you to follow.
You are probably reading this because you want to contribute code to Clusterio.
Great! We need people like you.
Though we have some conventions and workflows that we kindly ask you to follow.
Contents
--------
## Contents
- [Workflow](#workflow)
- [Starting a Feature Branch](#starting-a-feature-branch)
@@ -23,208 +21,164 @@ Contents
- [Naming Style](#naming-style)
- [Indenting](#indenting)
- [Line Length](#line-length)
- [Markdown](#markdown)
- [Pushing releases to npm](#pushing-releases-to-npm)
Workflow
--------
## Workflow
While we don't require you to follow this workflow, it makes things much
simpler for both you and us if you choose to do so. Especially when it
comes to making the commit history reflect what happened and not be a
giant ball of mess. GitHub's git workflow is less than ideal for
dealing with small feature-branches so you might see things being done
here a little diffrent than the usual.
While we don't require you to follow this workflow, it makes things much simpler for both you and us if you choose to do so.
Especially when it comes to making the commit history reflect what happened and not be a giant ball of mess.
GitHub's git workflow is less than ideal for dealing with small feature-branches so you might see things being done here a little diffrent than the usual.
It is recommended that you use the command line git version for doing
the operations described here. GUI wrappers tend to have different
ideas of what is what and you might find it difficult to make it do the
things described here. For doing the actual commits though, your
faviorit git GUI should work just fine.
It is recommended that you use the command line git version for doing the operations described here.
GUI wrappers tend to have different ideas of what is what and you might find it difficult to make it do the things described here.
For doing the actual commits though, your faviorit git GUI should work just fine.
If you pressed the fork button on GitHub and used `git clone` _on your
own fork_ you can skip to the next paragraph. Otherwise you will have
to click the fork button and clone your fork. If you already cloned the
main GitHub repository you can change your clone to point at your own
fork using the `git remote` command, but it's probably easier to delete
your local clone and clone it again.
If you pressed the fork button on GitHub and used `git clone` _on your own fork_ you can skip to the next paragraph.
Otherwise you will have to click the fork button and clone your fork.
If you already cloned the main GitHub repository you can change your clone to point at your own fork using the `git remote` command, but it's probably easier to delete your local clone and clone it again.
To keep your local repository up to to date with the main repository add
the main repository as a remote.
To keep your local repository up to to date with the main repository add the main repository as a remote.
git remote add upstream https://github.com/clusterio/factorioClusterio
After this running `git fetch upstream` will get the updated branches
from the main repository. Do not run `git pull upstream` it will mess
thing up!
After this running `git fetch upstream` will get the updated branches from the main repository.
Do not run `git pull upstream` it will mess thing up!
### Setting up the Development Environment
The project is organized into several packages that are all managed
through Lerna. In order to get the development environment up and
running you will need to run the following commands:
The project is organized into several packages that are all managed through Lerna.
In order to get the development environment up and running you will need to run the following commands:
npm install
npx lerna bootstrap
This installs dependencies needed by tests and links the packages up so
they work from the git work tree.
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
### Starting a Feature Branch
Development is done exclusively in feature branches. This means the
first thing to do before starting any development at all is to make a
new branch starting from the master or 1.2.x branch of the main
repository. To do this check out that branch from the main repository
(substitute master for 1.2.x if you're working on that branch.)
Development is done exclusively in feature branches.
This means the first thing to do before starting any development at all is to make a new branch starting from the master or 1.2.x branch of the main repository.
To do this check out that branch from the main repository (substitute master for 1.2.x if you're working on that branch).
git fetch upstream
git checkout upstream/master
This will give a warning about detached HEAD which means you did it
correctly. Now you can make a feature branch based on this with.
This will give a warning about detached HEAD which means you did it correctly.
Now you can make a feature branch based on this with.
git checkout -b my-feature-branch
The name of the branch is not important, as long as it's not a name
you've used before. But it's nice to have its name be somewhat
descriptive of what it implements.
The name of the branch is not important, as long as it's not a name you've used before.
But it's nice to have its name be somewhat descriptive of what it implements.
### Checking your code for errors
There's automated tests and linting setup for the project. Please do
add tests for any code you add and run both:
There's automated tests and linting setup for the project.
Please do add tests for any code you add and run both:
npm run test
npm run lint
To check that your changes pass the integration tests as well as the
ESLint rules set up for the project.
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
### Submitting a Pull Request
Once you've commited changes to your feature branch that you want to
have included into the main repository push it to you fork with
Once you've commited changes to your feature branch that you want to have included into the main repository push it to you fork with
git push origin my-feature-branch
and make a pull request through the GitHub web interface. You can also
pass the `--set-upstream` (or `-u`) flag to make `git push` do the same
thing for this branch. After your feature branch have been merged you
should consider it final. If there are more changes you want to have
merged or mistakes you need to fix you must start a new feature branch
and submit a new pull request
and make a pull request through the GitHub web interface.
You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the same thing for this branch.
After your feature branch have been merged you should consider it final.
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
### Editing your Feature Branch
Until your feature branch is merged you can edit it as you see fit by
using the rebase tool. If your feature branch has been merged however
it's final and you will have to start a new feature branch and submit
that in a new pull request. The base usage of rebase assuming you are
checked out on your feature branch is the following command (substitute
master with 1.2.x if your feature branch is based on that)
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
If your feature branch has been merged however it's final and you will have to start a new feature branch and submit that in a new pull request.
The base usage of rebase assuming you are checked out on your feature branch is the following command (substitute master with 1.2.x if your feature branch is based on that)
git rebase -i upstream/master
This will open up an editor with the commits of your feature branch and
let you reorder, edit, and remove commits as you see fit.
This will open up an editor with the commits of your feature branch and let you reorder, edit, and remove commits as you see fit.
If the main repository has been updated and you have fetched down those
updates with `git fetch upstream` this will also move your feature
branch so that it is based on the latest commit from the main
repository.
If the main repository has been updated and you have fetched down those updates with `git fetch upstream` this will also move your feature branch so that it is based on the latest commit from the main repository.
If you have already pushed your feature branch to your fork a simple
`git push origin my-feature-branch` will no longer work as the histories
are different, instead you will have to run
If you have already pushed your feature branch to your fork a simple `git push origin my-feature-branch` will no longer work as the histories are different, instead you will have to run
git push origin +my-feature-branch
The + sign signifies a force push and should be used with care. If you
have a pull request that has not yet been merged submitted for this
feature branch this will update the pull request too.
The + sign signifies a force push and should be used with care.
If you have a pull request that has not yet been merged submitted for this feature branch this will update the pull request too.
### Resolving Merge Conflicts
The best way to resolve merge conflicts in feature braches is to rebase
it on top of the updated upstream. Run the following commands
(substitute master with 1.2.x if your feature branch is based on that)
The best way to resolve merge conflicts in feature braches is to rebase it on top of the updated upstream.
Run the following commands (substitute master with 1.2.x if your feature branch is based on that)
git fetch upstream
git rebase upstream/master
This will stop on the commit(s) that caused the merge conflict and let
you resolve them manually before continuing. Once you're done force
push your updated feature branch with
This will stop on the commit(s) that caused the merge conflict and let you resolve them manually before continuing.
Once you're done force push your updated feature branch with
git push origin +my-feature-branch
### Updating Your GitHub Fork
No. This is pointless busywork that accomplishes nothing. Fetch the
main repository directly as described above and base your feature
branches on the refs from it. GitHub has no interface or functionality
for keeping the branches in your fork up to date with the branches in
the main repository and there is no point in doing this for our feature
branch based workflow.
No.
This is pointless busywork that accomplishes nothing.
Fetch the main repository directly as described above and base your feature branches on the refs from it.
GitHub has no interface or functionality for keeping the branches in your fork up to date with the branches in the main repository and there is no point in doing this for our feature branch based workflow.
Changelog
---------
## Changelog
There's a [Changelog](../CHANGELOG.md) in the root of the project. If
you make changes that are visible to users of this project you should
add an entry to the changelog describing what has changed. The format
of this log should be self explanatory, please follow it carefully.
There's a [Changelog](../CHANGELOG.md) in the root of the project.
If you make changes that are visible to users of this project you should add an entry to the changelog describing what has changed.
The format of this log should be self explanatory, please follow it carefully.
Supported Node.js and Factorio Versions
---------------------------------------
## Supported Node.js and Factorio Versions
Clusterio run on Node.js v12 and up, and v10 from v10.16.0, make sure
to check that the Node builtin API's and npm packages you use are
supported on these versions. The Travis CI tests are runned on whatever
version of Node.js v10 is the default, which at the time of writing is
v10.18.0.
Clusterio run on Node.js v12 and up, and v10 from v10.16.0, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
The Travis CI tests are runned on whatever version of Node.js v10 is the default, which at the time of writing is v10.18.0.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up,
including the latest experimental release. It's recommended that you
use the lua API reference for 0.17.69, as there's no information on what
version Factorio API's were introduced in. The Travis CI tests are
runned against latest experimental release.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
The Travis CI tests are runned against latest experimental release.
Code Style
----------
## Code Style
The style of the code is a bit of a mixed bag at the moment. But there
are at least a few things that have been agreed upon.
The style of the code is a bit of a mixed bag at the moment.
But there are at least a few things that have been agreed upon.
### Naming style
JavaScript variables in general uses camelCase. The exception is for
classes/constructors which use PascalCase and variables destructured
from over the wire objects.
JavaScript variables in general uses camelCase.
The exception is for classes/constructors which use PascalCase and variables destructured from over the wire objects.
Fields in configs and messages sent over links uses
lowercase_underscore.
Fields in configs and messages sent over links uses lowercase_underscore.
JavaScript files are named using lowercase_underscore for files that
export multiple items. Files containing and exporting a single class
should be named the same as the class in PascalCase.
JavaScript files are named using lowercase_underscore for files that export multiple items.
Files containing and exporting a single class should be named the same as the class in PascalCase.
Imported modules from the lib package are prefixed with lib to make them
easier to distinguish, as the names tend to be very generic. E.g.:
Imported modules from the lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
const libLink = require("lib/link");
const libLuaTools = require("lib/lua_tools");
const libLink = require("@clusterio/lib/link");
const libLuaTools = require("@clusterio/lib/lua_tools");
For lua code lowercase_underscore is used for everything.
@@ -232,9 +186,7 @@ For lua code lowercase_underscore is used for everything.
### Strings
JavaScript and Lua code should use double quoted strings (`"string"`)
for all strings with the only exception that strings that contain double
quotes may use single quotes (i.e., `'String with "quotes"'`).
JavaScript and Lua code should use double quoted strings (`"string"`) for all strings with the only exception that strings that contain double quotes may use single quotes (i.e., `'String with "quotes"'`).
JavaScript template literals may also be used where appropriate.
@@ -248,8 +200,8 @@ Lua code should be indented with 4 spaces.
### Line Length
Lines are limited to 120 characters where tabs count as 4 characters.
If the argument list for a function or expressino for compound statement
gets very long break them out on their own line(s). For example:
If the argument list for a function or expressino for compound statement gets very long break them out on their own line(s).
For example:
function foo(
really, long, argument, list,
@@ -262,3 +214,59 @@ gets very long break them out on their own line(s). For example:
// code
}
})
### Markdown
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
For example:
```md
# A markdown File
Sentence of text which end in a period.
Each sentence is on it's own line.
And there's two spaces before the next heading.
## Subheading
### Sub-subheading
Headings use the `# heading` format.
```
## Pushing releases to npm
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
```sh
git switch master
git pull --ff-only upstream master
```
Check and make sure there are no untracked files with `git status`, otherwise they will be auto commited!
2. Run a lerna version to create and tag a new version.
This will push a commit to the master branch.
```sh
npx lerna version prerelease --git-remote upstream --no-granular-pathspec
```
3. Run a full build in every package to update the dist/ files.
```sh
npx lerna run build
```
4. Publish the packages with lerna.
```sh
npx lerna publish from-package
```
+105 -195
View File
@@ -1,13 +1,9 @@
Developing for Clusterio
========================
# Developing for Clusterio
This document describes the various different types of content that can
be created for Factorio and/or Clusterio and how this is made compatible
with Clusterio.
This document describes the various different types of content that can be created for Factorio and/or Clusterio and how this is made compatible with Clusterio.
Contents
--------
## Contents
- [Factorio Mods](#factorio-mods)
- [Factorio Scenarios](#factorio-scenarios)
@@ -18,35 +14,21 @@ Contents
- [Clusterio Plugins](#clusterio-plugins)
Factorio Mods
-------------
## Factorio Mods
Factorio mods that do not interact with Clusterio should not have any
considerations that need to be taken into account for them to work with
Clusterio.
Factorio mods that do not interact with Clusterio should not have any considerations that need to be taken into account for them to work with Clusterio.
Factorio Scenarios
------------------
## Factorio Scenarios
Clusterio patches saves and because of this scenarios have a few
limitations and must explicitly mark themselves as compatible with
Clusterio to work. The main limitation is that the
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
lib must be used, and control.lua cannot contain any code other than
calls to event_handler loading the relevant libs of the scenario.
This is because the control.lua file will be overwritten by the save
patcher. Scenarios also cannot use the event registering functions
exposed by `script` such as `script.on_event` or `script.on_nth_tick` as
using these will overwrite the handlers registered by the
event_handler lib.
Clusterio patches saves and because of this scenarios have a few limitations and must explicitly mark themselves as compatible with Clusterio to work.
The main limitation is that the [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) lib must be used, and control.lua cannot contain any code other than calls to event_handler loading the relevant libs of the scenario.
This is because the control.lua file will be overwritten by the save patcher.
Scenarios also cannot use the event registering functions exposed by `script` such as `script.on_event` or `script.on_nth_tick` as using these will overwrite the handlers registered by the event_handler lib.
A brief description of the usage of the event_handler library is
provided in the [event_handler interface](#event_handler-interface)
section.
A brief description of the usage of the event_handler library is provided in the [event_handler interface](#event_handler-interface) section.
Once a scenario is made using the event_handler it should have a
control.lua file that looks something like this:
Once a scenario is made using the event_handler it should have a control.lua file that looks something like this:
```lua
-- control.lua
@@ -55,8 +37,7 @@ event_handler.add_lib(require("module_foo"))
event_handler.add_lib(require("module_bar"))
```
To make Clusterio recognize and correctly handle this scenario it
needs to include a clusterio.json file with following content:
To make Clusterio recognize and correctly handle this scenario it needs to include a clusterio.json file with following content:
```json
{
@@ -70,60 +51,43 @@ needs to include a clusterio.json file with following content:
}
```
This tells Clusterio that it should load both module_foo and module_bar
by passing the result of requiring them to the `add_lib` function when
writing a new control.lua file to the save.
This tells Clusterio that it should load both module_foo and module_bar by passing the result of requiring them to the `add_lib` function when writing a new control.lua file to the save.
### event_handler interface
<sub>**Note:** At the time of writing the event_handler lib has been
a part of Factorio since at least 0.17.4, and was moved from base to
core in 0.17.63 but is still not documented anywhere. A brief
description is provided here for this reason.</sub>
<sub>**Note:** At the time of writing the event_handler lib has been a part of Factorio since at least 0.17.4, and was moved from base to core in 0.17.63 but is still not documented anywhere.
A brief description is provided here for this reason.</sub>
The event_handler lib provides a simple interface for registering
multiple callbacks to the same events without having to be concerned
with callback chaining or overwriting callbacks defined elsewhere. It
works by taking over the task of registering the actual callbacks with
Factorio, providing its own interface for the rest of the code to use.
The event_handler lib provides a simple interface for registering multiple callbacks to the same events without having to be concerned with callback chaining or overwriting callbacks defined elsewhere.
It works by taking over the task of registering the actual callbacks with Factorio, providing its own interface for the rest of the code to use.
The sole function of interest exported by event_handler is `add_lib`
which accepts a table with event handler callbacks definitions that it
will register. The following entries are recognized by `add_lib`, all
of which are optional:
The sole function of interest exported by event_handler is `add_lib` which accepts a table with event handler callbacks definitions that it will register.
The following entries are recognized by `add_lib`, all of which are optional:
- `on_init`:
Callback called when the callback of `script.on_init` is invoked.
- `on_load`:
Callback called when the callback of `script.on_load` is invoked.
- `on_configuration_changed`:
Callback called when the callback of
`script.on_configuration_changed` is invoked.
Callback called when the callback of `script.on_configuration_changed` is invoked.
- `events`:
Table mapping event ids as defined in `defines.events` to callbacks.
For example the table `{ [defines.events.on_player_died] = foo }`
will call bar with the usual `event` table as argument every time a
player dies.
For example the table `{ [defines.events.on_player_died] = foo }` will call bar with the usual `event` table as argument every time a player dies.
- `on_nth_tick`:
Table mapping the nth tick number to a callback. For example the
table `{ [30] = bar }` will call bar every 30th tick.
Table mapping the nth tick number to a callback.
For example the table `{ [30] = bar }` will call bar every 30th tick.
- `add_remote_interface`:
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
has no special meaning and receives no arguments but should be used
for registering remote interfaces.
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
It has no special meaning and receives no arguments but should be used for registering remote interfaces.
- `add_commands`:
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
has no special meaning and receives no arguments but should be used
for registering commands.
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
It has no special meaning and receives no arguments but should be used for registering commands.
<sub>1: Before 0.17.69 these callbacks were called after
on_init/on_load.</sub>
<sub>1: Before 0.17.69 these callbacks were called after on_init/on_load.</sub>
The usual way to use `add_lib` is to define the table of events to
register in a separate file and return it, then load it in control.lua
via `require` before passing it to `add_lib`. For example a module
could be defined as
The usual way to use `add_lib` is to define the table of events to register in a separate file and return it, then load it in control.lua via `require` before passing it to `add_lib`.
For example a module could be defined as
```lua
-- example.lua
@@ -148,41 +112,27 @@ local event_handler = require("event_handler")
event_handler.add_lib(require("example"))
```
Because the event_handler lib registers events itself you may not use
`script.on_load` or `script.on_init` at all in your code and any usage
of `script.on_configuration_changed`, `script.on_nth_tick` and/or
`script.on_event` will cause the corresponding events registered with
event_handler to break and should therefore not be used.
Because the event_handler lib registers events itself you may not use `script.on_load` or `script.on_init` at all in your code and any usage of `script.on_configuration_changed`, `script.on_nth_tick` and/or `script.on_event` will cause the corresponding events registered with event_handler to break and should therefore not be used.
There's also the `add_libraries` function exported by event_handler,
which accepts a table and calls `add_lib` for each value in the table.
There's also the `add_libraries` function exported by event_handler, which accepts a table and calls `add_lib` for each value in the table.
Communicating with Clusterio
----------------------------
## 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
-----------------
## Clusterio Modules
Modules are primarily used by plugins to inject code into Factorio games
with the save patcher, though it's also possible to make stand alone
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.
Modules are primarily used by plugins to inject code into Factorio games with the save patcher, though it's also possible to make stand alone 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.
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
either case a `module.json` file is required and has the following
structure:
Stand alone modules are placed into the modules folder of Clusterio, plugin modules are located in the module folder of the plugin.
In either case a `module.json` file is required and has the following structure:
```json
{
@@ -200,68 +150,44 @@ structure:
The following entries are supported in the module.json file:
- `name`:
Name of the module, must match the folder the module is located in
for stand alone modules or the name of the plugin the module is
located in.
Name of the module, must match the folder the module is located in for stand alone modules or the name of the plugin the module is located in.
- `version`:
Version of the module. Must be compatible with [Semantic Versioning
2.0.0](https://semver.org/). In plugin modules this defaults to the
version of the plugin otherwise it's required.
Version of the module.
Must be compatible with [Semantic Versioning 2.0.0](https://semver.org/).
In plugin modules this defaults to the version of the plugin otherwise it's required.
- `dependencies`:
Optional mapping of modules this module depends on and their
version. In the example `>=0.4.2` means that the foo module must be
at least version 0.4.2. See the [Ranges
syntax](https://www.npmjs.com/package/semver#ranges) of the
node-semver package for a full description of what operators are
supported. The events for dependencies is invoked before the events
of the dependent, and starting an instance will fail if the
dependencies cannot be satisfied. If not specified it will default
to depending on `clusterio`. Make sure to add `clusterio` to your
dependencies if you add other dependencies and depend on the Lua API.
Optional mapping of modules this module depends on and their version.
In the example `>=0.4.2` means that the foo module must be at least version 0.4.2.
See the [Ranges syntax](https://www.npmjs.com/package/semver#ranges) of the node-semver package for a full description of what operators are supported.
The events for dependencies is invoked before the events of the dependent, and starting an instance will fail if the dependencies cannot be satisfied.
If not specified it will default to depending on `clusterio`.
Make sure to add `clusterio` to your dependencies if you add other dependencies and depend on the Lua API.
- `require`:
Lua files to require in `control.lua`. This should only really be
needed if you want to make a global function available for use in
Lua commands. The paths are relative to the module's own folder and
should be specified using forward slashes as directory sepparators
if it's located in a sub directory in the module.
Lua files to require in `control.lua`.
This should only really be needed if you want to make a global function available for use in Lua commands.
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
- `load`:
Lua files to load with the `event_handler` lib, the result of
requiring the file will be passed to the `add_lib` function of the
`event_handler` lib in `control.lua`. See the section on the
[event_handler interface](#event_handler-interface) for a detailed
description on how the `event_handler` lib works. The paths are
relative to the module's own folder and should be specified using
forward slashes as directory sepparators if it's located in a sub
directory in the module.
Lua files to load with the `event_handler` lib, the result of requiring the file will be passed to the `add_lib` function of the `event_handler` lib in `control.lua`.
See the section on the [event_handler interface](#event_handler-interface) for a detailed description on how the `event_handler` lib works.
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
It's recommended to use the new style of defining modules in Lua, as
well as avoid the use of global variables as much as possible. This
means always declearing your top level variables and functions local and
exporting only the things you need in other files. You can require
other files in your module by prefixing your require paths with
`modules/your_module_name/`. It's also possible to require files from
other modules this way too.
It's recommended to use the new style of defining modules in Lua, as well as avoid the use of global variables as much as possible.
This means always declearing your top level variables and functions local and exporting only the things you need in other files.
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
It's also possible to require files from other modules this way too.
The global variables and data stored in the global table is shared by
all Clusterio modules as well as the scenario, so it's important that
you use unique names. It's recommended to prefix the global variables
and data in the global table that your module has with the name of your
module.
The global variables and data stored in the global table is shared by all Clusterio modules as well as the scenario, so it's important that you use unique names.
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
Because modules can be patched into an existing game you cannot rely on
the `on_init` callback to be called in Clusterio Modules. Nor can you
rely on the `on_configuration_changed` callback, as this is not called
when level code changes. The Clusterio Lua API provides the custom
`on_server_startup` event that can be used as a substitute, see the next
section.
Because modules can be patched into an existing game you cannot rely on the `on_init` callback to be called in Clusterio Modules.
Nor can you rely on the `on_configuration_changed` callback, as this is not called when level code changes.
The Clusterio Lua API provides the custom `on_server_startup` event that can be used as a substitute, see the next section.
Clusterio Lua API
-----------------
## Clusterio Lua API
Clusterio provides a save patched module as well as a regular Factorio
mod for interfacing with Clusterio. This includes tools sending data to
plugins, script events to listen for, and an item serialization module.
Clusterio provides a save patched module as well as a regular Factorio mod for interfacing with Clusterio.
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
### clusterio_api library
@@ -272,30 +198,27 @@ From a module
From a mod
`local clusterio_api = require("__clusterio_lib__/api")`
Provides the main interface to Clusterio from within the game. When
using the mod version you must call `init` before it'll function
properly.
Provides the main interface to Clusterio from within the game.
When using the mod version you must call `init` before it'll function properly.
#### init
<sub>Mod version only</sub>
Initialize the API. This should be called from both the `on_init` and
`on_load` events in your mod, and before this is called certain features
will not be available, most notably the events table.
Initialize the API.
This should be called from both the `on_init` and `on_load` events in your mod, and before this is called certain features will not be available, most notably the events table.
#### clusterio_api.events
Table of events raised by clusterio. If the game is started outside of
Clusterio then none of these events will be raised.
Table of events raised by clusterio.
If the game is started outside of Clusterio then none of these events will be raised.
In the mod version this table does not exist before the `init` function
is called.
In the mod version this table does not exist before the `init` function is called.
##### on_instance_updated
Raised after the name and id of an instance has been updated. This may
occur even if the id and name didn't change.
Raised after the name and id of an instance has been updated.
This may occur even if the id and name didn't change.
Event data:
- `instance_id`: the id of the instance.
@@ -303,33 +226,26 @@ Event data:
##### on_server_startup
Raised when Clusterio is starting up the server. It can be used to
initialize clusterio related features in a mod, and as a stand-in for
the `on_init` and `on_configuration_changed` event for modules. It is
invoked on the first tick the server runs after the save has been
patched, before most other events.
Raised when Clusterio is starting up the server.
It can be used to initialize clusterio related features in a mod, and as a stand-in for the `on_init` and `on_configuration_changed` event for modules.
It is invoked on the first tick the server runs after the save has been patched, before most other events.
Use this event to initialize and/or migrate the data structures
you need. Keep in mind that both mods and Clusterio modules can switch
from any version to any version so it should be able to handle both
forwards and backwards migrations gracefully.
Use this event to initialize and/or migrate the data structures you need.
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
#### clusterio_api.send_json(channel, data)
Send JSON data to Clusterio over the given channel. The `data` argument
should be table that can be serialized with `game.table_to_json`.
Clusterio plugins can listen to channels and will receive an event with
the data sent here. See the [Communicating with Factorio
section](writing-plugins.md#communicating-with-factorio) in the Writing
Plugins document for more information.
Send JSON data to Clusterio over the given channel.
The `data` argument should be table that can be serialized with `game.table_to_json`.
Clusterio plugins can listen to channels and will receive an event with the data sent here.
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
If the game is started outside of Clusterio the data will be sent, but
since there's no code following stdout to pick it up it will be lost.
If the game is started outside of Clusterio the data will be sent, but since there's no code following stdout to pick it up it will be lost.
**Note**: Payloads greater than 4 MB will cause stuttering in the game.
**Note**: This is not a binary safe way of sending data. Strings
embedded into the tables sent must be valid UTF-8 text.
**Note**: This is not a binary safe way of sending data.
Strings embedded into the tables sent must be valid UTF-8 text.
Parameters:
- `channel`: string identifying which channel to send it on.
@@ -344,19 +260,17 @@ From a module
From a mod
`local serialize = require("__clusterio_lib__/serialize")`
Serializes inventories and item stacks into a table that in turn can be
converted into JSON with `game.table_to_json`. Properly handles the
extra data for most items.
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `game.table_to_json`.
Properly handles the extra data for most items.
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
Returns a table containing a serialized representation of the equipment
grid passed.
Returns a table containing a serialized representation of the equipment grid passed.
#### serialize.deserialize_equipment_grid(LuaEquipmentGrid, serialized_grid)
Deserialize a previously serialized equipment grid into the target
LuaEquipmentGrid. Overwrites all content in the target.
Deserialize a previously serialized equipment grid into the target LuaEquipmentGrid.
Overwrites all content in the target.
#### serialize.serialize_item_stack(LuaItemStack, destination_table)
@@ -365,23 +279,19 @@ The destination_table should be a new empty table.
#### serialize.deserialize_item_stack(LuaItemStack, serialized_stack)
Deserialize a previously serialized item stack into the target
LuaItemStack. Overwrites the content of the item stack.
Deserialize a previously serialized item stack into the target LuaItemStack.
Overwrites the content of the item stack.
#### serialize.serialize_inventory(LuaInventory)
Returns a table containing a serialized representation of the inventory
passed.
Returns a table containing a serialized representation of the inventory passed.
#### serialize.deserialize_inventory(LuaInventory, serialized_inventory)
Deserialize a previously serialized inventory into the target
LuaInventory. Overwrites slots that have content in the serialized
inventory.
Deserialize a previously serialized inventory into the target LuaInventory.
Overwrites slots that have content in the serialized inventory.
Clusterio Plugins
-----------------
## Clusterio Plugins
See the [Writing Plugins](writing-plugins.md) document for details on
the plugin interface and how to write plugins for Clusterio.
See the [Writing Plugins](writing-plugins.md) document for details on the plugin interface and how to write plugins for Clusterio.
+10 -24
View File
@@ -1,30 +1,16 @@
Save Patching
-------------
## Save Patching
In order to efficiently get data in and out of running Factorio servers
and to provide custom interfaces Clusterio uses save
patching<sup>[1]</sup> to add code to the game. The save patching is
based on the built in
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
library to Factorio and requires scenarios to be coded specifically for
it. Mods on the other hand work without modifications.
In order to efficiently get data in and out of running Factorio servers and to provide custom interfaces Clusterio uses save patching<sup>[1]</sup> to add code to the game.
The save patching is based on the built in [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) library to Factorio and requires scenarios to be coded specifically for it.
Mods on the other hand work without modifications.
This can be disabled with the `factorio.enable_save_patching` config on
a per instance basis, however disabling it means that modules are not
loaded into the game and most plugins will not function.
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
<sub>1: Before version 2.0 of Clusterio this was done at runtime
with a patcher called Hotpatch, but due to the difficulty in supporting
run time patching and the lack of documentation it was replace it with
save patching instead.</sub>
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
User Accounts
-------------
## User Accounts
The master server creates and stores accounts for all Factorio users
that log into instances under its control. These accounts are used to
store per player data and authorize management of the cluster. By
default users accounts are assigned a Player role that gives a limited
read access to the cluster resources, and admins of the cluster can be
assigned an Admin role to get access to manage it.
The master server creates and stores accounts for all Factorio users that log into instances under its control.
These accounts are used to store per player data and authorize management of the cluster.
By default users accounts are assigned a Player role that gives a limited read access to the cluster resources, and admins of the cluster can be assigned a Cluster Admin role to get access to manage it.
+31 -62
View File
@@ -1,39 +1,27 @@
Managing a Cluster
==================
# Managing a Cluster
Clusterio clusters are managed through the master server by using the
`clusterioctl` command line interface which is invoked by running
`npx clusterioctl <command>` in the clusterio directory. This document
uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should
be executed. Mandatory parameters are shown in `<angles bracket>` and
optional pameters are in `[square brackets]`.
Clusterio clusters are managed through the master server by using the `clusterioctl` command line interface which is invoked by running `npx clusterioctl <command>` in the clusterio directory.
This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should be executed.
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
Before `clusterioctl` can be used it needs to be configured for the
cluster it will connect to. The easiest way to do this is to run
`npx clusteriomaster bootstrap create-ctl-config <username>` on the
master server, which creates the necessary `config-control.json` for
managing the cluster as the given user.
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
The easiest way to do this is to run `npx clusteriomaster bootstrap create-ctl-config <username>` on the master server, which creates the necessary `config-control.json` for managing the cluster as the given user.
Slaves
------
## Slaves
To be written.
Instances
---------
## Instances
To be written.
Users
-----
## Users
Clusterio automatically creates user accounts for all players that join
an instance when save patching is enabled. These accounts are used to
store per player data shared between instances, like if the player
should be whitelisted, admin or banned.
Clusterio automatically creates user accounts for all players that join an instance when save patching is enabled.
These accounts are used to store per player data shared between instances, like if the player should be whitelisted, admin or banned.
### List users
@@ -46,40 +34,32 @@ Lists all user accounts in the cluster along with some data for each.
ctl> user create <name>
Creates a new empty user account for the given Factorio player. Note
that case matters here. This is usually not required as user accounts
are created automatically for players that join instances with save
patching enabled.
Creates a new empty user account for the given Factorio player.
Note that case matters here.
This is usually not required as user accounts are created automatically for players that join instances with save patching enabled.
### Promote User to Server Admin
ctl> user set-admin <name> [--revoke] [--create]
Promotes the user given to in-game admin on instances with the
`sync_adminlist` option enabled. If the `--revoke` switch is used the
user is removed from the adminlist.
Promotes the user given to in-game admin on instances with the `sync_adminlist` option enabled.
If the `--revoke` switch is used the user is removed from the adminlist.
Since admin status is a part of the account data the account must exist
for this to succeed, passing `--create` will create the account if it
does not exist.
Since admin status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
**Note:** Being a server admin does not grant any access to manage the
cluster. See [set-roles](#set-cluster-roles) for adding roles which
grant access to managing the clusetr.
**Note:** Being a server admin does not grant any access to manage the cluster.
See [set-roles](#set-cluster-roles) for adding roles which grant access to managing the clusetr.
### Whitelist User
ctl> user set-whitelisted <name> [--remove] [--create]
Add the user given to the whitelist on instances with the
`sync_whitelist` option enabled. If the `--remove` switch is used the
user is removed from the whitelist.
Add the user given to the whitelist on instances with the `sync_whitelist` option enabled.
If the `--remove` switch is used the user is removed from the whitelist.
Since whitelisted status is a part of the account data the account must
exist for this to succeed, passing `--create` will create the account if
it does not exist.
Since whitelisted status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
### Ban User
@@ -87,16 +67,12 @@ it does not exist.
ctl> user set-banned <name> [--reason <message>] [--pardon] [--create]
Ban user in-game from instances with the `sync_banlist` option enabled.
Reason is a message that will be shown to the user when attemption to
log in. If the `--pardon` switch is used it removes the ban.
Reason is a message that will be shown to the user when attemption to log in.
If the `--pardon` switch is used it removes the ban.
Since ban status is a part of the account data the account must exist
for this to succeed, passing `--create` will create the account if it
does not exist.
Since ban status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
Note: This bans the user from logging in to Factorio servers in the
cluster, it does not revoke access to any cluster management they might
have, see next section on setting roles for revoking tha.
Note: This bans the user from logging in to Factorio servers in the cluster, it does not revoke access to any cluster management they might have, see next section on setting roles for revoking tha.
### Set Cluster Roles
@@ -104,13 +80,9 @@ have, see next section on setting roles for revoking tha.
ctl> user set-roles <name> [roles...]
Replaces the roles the user has in the cluster with a new list of roles.
Calling with an empty roles argument will remove all roles from the
user.
Calling with an empty roles argument will remove all roles from the user.
By default there's a roled named Admin which grants access to
everything and a role named Player which grants a limited read access to
the cluster, see [the section on roles](#roles) for more information
about setting up roles and permissions.
By default there's a roled named Cluster Admin which grants access to everything and a role named Player which grants a limited read access to the cluster, see [the section on roles](#roles) for more information about setting up roles and permissions.
### Delete user
@@ -119,14 +91,11 @@ about setting up roles and permissions.
Deletes everything stored on the master server for this user.
Note: If the player is banned from the cluster this will effectively
unban them, as the ban status is stored with the user account.
Note: If the player is banned from the cluster this will effectively unban them, as the ban status is stored with the user account.
Note: If the player joins the cluster again a new account will be made
for them automatically.
Note: If the player joins the cluster again a new account will be made for them automatically.
Roles
-----
## Roles
To be written.
+72
View File
@@ -0,0 +1,72 @@
# 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
+61 -123
View File
@@ -1,13 +1,8 @@
WebSocket protocol
==================
# WebSocket protocol
<sub>This document describes the protocol used between the master and
slaves, and unless you're developing Clusterio it's probably not going
to be very useful for you.</sub>
The protocol is built on WebSocket and uses JSON encoded text messages
to communicate to and from the master server. These messages contain
a JSON encoded object with the following three properties:
<sub>This document describes the protocol used between the master and slaves, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
The protocol is built on WebSocket and uses JSON encoded text messages to communicate to and from the master server.
These messages contain a JSON encoded object with the following three properties:
- seq - integer|null - The sequence number of the message
- type - string - The type of message
@@ -16,98 +11,66 @@ a JSON encoded object with the following three properties:
For the handshake and heartbeats the seq property is null.
Handshake
---------
## Handshake
Upon connecting to the WebSocket endpoint the server will respond with a
`hello` message containing the master server version as the data. The
client is then expected to send either `register_*` or a `resume`
message to set up the connection with the master. The exact flow is
described below.
Upon connecting to the WebSocket endpoint the server will respond with a `hello` message containing the master server version as the data.
The client is then expected to send either `register_*` or a `resume` message to set up the connection with the master.
The exact flow is described below.
### Client connection flow
For a client making a new connecting to the master server the first
course of action after obtaining a token is to connect with WebSocket to
the /api/socket endpoint and wait for the server hello. After the
hello, the client sends a register\_\* message corresponding to the type
of the client. If successful the server will reply with a ready message
and the connection has been established. See the flowchart below.
For a client making a new connecting to the master server the first course of action after obtaining a token is to connect with WebSocket to the /api/socket endpoint and wait for the server hello.
After the hello, the client sends a register\_\* message corresponding to the type of the client.
If successful the server will reply with a ready message and the connection has been established.
See the flowchart below.
![Client connection flowchart](assets/connection_flow.svg)
If for any reason the connection is closed or lost during the connection
attempt the process is started over after a random delay, with the
exception of the connection being closed with a 4003 code which means
authentication failed and the client should stop trying to connect. The
wait for hello and wait for ready states should also have a timeout,
after which the connection is closed if the hello/reply wasn't received
If for any reason the connection is closed or lost during the connection attempt the process is started over after a random delay, with the exception of the connection being closed with a 4003 code which means authentication failed and the client should stop trying to connect.
The wait for hello and wait for ready states should also have a timeout, after which the connection is closed if the hello/reply wasn't received
in time.
### Client reconnection flow
If the connection is lost after the client has received the ready
message from the server it should attempt a reconnect instead. This is
very similar to a new connection, but instead of a register\_\* messsage
the client sends a resume message and the server replies with a continue
message if resuming the session was successful. This continue message
has sequence number of the last message the master received and the
client is expected to imediately send all messages from its send buffer
that occured after the sequence number given.
If the connection is lost after the client has received the ready message from the server it should attempt a reconnect instead.
This is very similar to a new connection, but instead of a register\_\* messsage the client sends a resume message and the server replies with a continue message if resuming the session was successful.
This continue message has sequence number of the last message the master received and the client is expected to imediately send all messages from its send buffer that occured after the sequence number given.
The server will respond with an invalidate message if resuming the
session is not possible. In this case the client should continue from
the new session state of the connection flow (see the previous section).
The server will respond with an invalidate message if resuming the session is not possible.
In this case the client should continue from the new session state of the connection flow (see the previous section).
![Client reconnection flowchart](assets/reconnection_flow.svg)
Like the connection flow the process starts over after a delay if the
connection is closed or lost. The same exception for connection closed
with 4003 applies as with the connection flow. Additionally after an
invalidate message has been received and the connection flow fails, it
should start over from the connection flow.
Like the connection flow the process starts over after a delay if the connection is closed or lost.
The same exception for connection closed with 4003 applies as with the connection flow.
Additionally after an invalidate message has been received and the connection flow fails, it should start over from the connection flow.
### Server connection flow
From the master server side new connections and reconnection attempts
start out the same, a new connection has been made and the server sends
the hello messoge to the client. If a vaild register\_\* message is
received a new connection is set up for the client and the ready message
is sent in reply. If a valid resume message is received and the session
that's attempted to be resumed is active it'll signal that the session
is resumed with a continue message and the client upon receiving the
continue message should send the messages from its send buffer from
after the last\_seq given in the continue message.
From the master server side new connections and reconnection attempts start out the same, a new connection has been made and the server sends the hello messoge to the client.
If a vaild register\_\* message is received a new connection is set up for the client and the ready message is sent in reply.
If a valid resume message is received and the session that's attempted to be resumed is active it'll signal that the session is resumed with a continue message and the client upon receiving the continue message should send the messages from its send buffer from after the last\_seq given in the continue message.
If the session token used to resume is not valid it'll send an invalidate
message and expect the client to continue with a registr\_\* message. If an
invalid or unexpected message is received during the handshake the connection
is closed. See the flowchart below.
If the session token used to resume is not valid it'll send an invalidate message and expect the client to continue with a registr\_\* message.
If an invalid or unexpected message is received during the handshake the connection is closed.
See the flowchart below.
![Client connection flowchart](assets/server_flow.svg)
For authentication failure the connection is closed with code 4003,
which the connection client should interpret as connecting will never
succeed and stop trying to reconnect.
For authentication failure the connection is closed with code 4003, which the connection client should interpret as connecting will never succeed and stop trying to reconnect.
### Connection tracking and heartbeats
Once connected as indicated by the ready and continue messages from the
master server the client must start sending heartbeats at the interval
given in the ready/continue message, and storing messages sent over the
link in a sending side buffer. The heartbeats sent must have the seq of
the last received message on the link and acts as an ack for received
messages. The master server also sends heartbeats in the same manner
and the client should remove messages up to and including the sequence
number received in the heartbeat from its send buffer.
Once connected as indicated by the ready and continue messages from the master server the client must start sending heartbeats at the interval given in the ready/continue message, and storing messages sent over the link in a sending side buffer.
The heartbeats sent must have the seq of the last received message on the link and acts as an ack for received messages.
The master server also sends heartbeats in the same manner and the client should remove messages up to and including the sequence number received in the heartbeat from its send buffer.
Handshake messages
------------------
## Handshake messages
### `hello`
@@ -115,8 +78,7 @@ Sent by the master server when the WebSocket connection has been opened.
- version - string - The version of the master, e.g. "2.0.0".
- plugins - Object&lt;string, string&gt; -
Object mapping plugin names to plugin version for plugins that are
loaded on the master server.
Object mapping plugin names to plugin version for plugins that are loaded on the master server.
### `register_slave`
@@ -128,8 +90,7 @@ Slave handshake for establishing a new connection session.
- name - string - Name of the slave.
- id - integer - ID of the slave.
- plugins - Object&lt;string, string&gt; -
Object mapping plugin names to plugin version for plugins that are
available on the slave.
Object mapping plugin names to plugin version for plugins that are available on the slave.
### `register_control`
@@ -141,17 +102,15 @@ Control handshake for enstablishing a new connection session.
### `resume`
Handshake for resuming an existing connection session where the
connection had dropped unexpectedly.
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
- session_token - string - Session token to resume session with.
- last_seq - integer|null - Last message seq received by the client.
### `invalidate`
Sent by the master server in reply to a resume for a session that cannot
be resumed. The client should start a new session with a register\_\*
message in response.
Sent by the master server in reply to a resume for a session that cannot be resumed.
The client should start a new session with a register\_\* message in response.
### `continue`
@@ -162,22 +121,19 @@ Sent by the master server to signal resuming the session was successful.
### `ready`
Sent by the master server to signal the connection has been established
with a new session.
Sent by the master server to signal the connection has been established with a new session.
- session_token - string - Token to resume the session with.
- heartbeat_interval - number - Rate client should send heartbeats at.
### `heartbeat`
Sent by both the master server and clients at the interval given in the
continue/ready messages.
Sent by both the master server and clients at the interval given in the continue/ready messages.
- last_seq - integer - Last message seq received over the link.
Example handshake
-----------------
## Example handshake
Server hello after opening the WebSocket.
@@ -221,32 +177,23 @@ Master server reply signaling the session is established and ready.
```
Events
------
## Events
Events are messages send in one direction over the link, that the
receiver is expected to act upon, but not make any replies (for that
there's [Requests](#requests). By convention message types ending in
`*_event` are events. The data payload of an event consists solely of
event specific properties.
Events are messages send in one direction over the link, that the receiver is expected to act upon, but not make any replies (for that there's [Requests](#requests).
By convention message types ending in `*_event` are events.
The data payload of an event consists solely of event specific properties.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for
the recognized events and their contents.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized events and their contents.
Requests
--------
## Requests
Requests are messages which expects a response in return. They function
a lot like HTTP requests except that both parties of a connection can
inniate a request that is expected to be responded to. By convention
message types ending in `*_request` are requests, and is expected to be
replied to with a corresponding `*_response` message. If an error
occured while processing the request an error response containing an
error property in the data payload is sent instead.
Requests are messages which expects a response in return.
They function a lot like HTTP requests except that both parties of a connection can inniate a request that is expected to be responded to.
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for
the recognized requests and their contents.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized requests and their contents.
### `*_response`
@@ -259,29 +206,20 @@ the recognized requests and their contents.
- error - string - Human readable text message describing the error.
Graceful Disconnect
-------------------
## Graceful Disconnect
To ensure no data is lost when closing the link it's important to
send a prepare disconnect request and wait for it to complete before
closing the WebSocket. Either party of the link can send this request
and the party sending the request should be the one that initates the
close of the WebSocket.
To ensure no data is lost when closing the link it's important to send a prepare disconnect request and wait for it to complete before closing the WebSocket.
Either party of the link can send this request and the party sending the request should be the one that initates the close of the WebSocket.
### `prepare_disconnect_request`
Request graceful disconnect of the connection. The receiving party should
stop tasks that send events and requests over the link and wait for
pending requests it has over the link to complete, but it should not
stop responding to requests sent to it. Only after this process is done
should the response be sent.
Request graceful disconnect of the connection.
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
Only after this process is done should the response be sent.
### `prepare_disconnect_response`
Signals the preparation for disconnect is complete and that the
connection can be closed. The party that initiatied the prepare
disconnect request may still send other requests/events over the link to
finish its part of the disconnection proceedure.
Signals the preparation for disconnect is complete and that the connection can be closed.
The party that initiatied the prepare disconnect request may still send other requests/events over the link to finish its part of the disconnection proceedure.
If the error response variant is received it should be logged if
possible and treated as a success.
If the error response variant is received it should be logged if possible and treated as a success.
+244 -325
View File
@@ -1,16 +1,14 @@
Writing Plugins
===============
# Writing Plugins
Plugins for Clusterio are classes written in JavaScript that run under
Node.js. The plugin classes have pre-defined hooks that are called
during various stages and operations of Clusterio.
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
Contents
--------
## Contents
- [Plugin Structure](#plugin-structure)
- [Defining the plugin class](#defining-the-plugin-class)
- [Logging Messages](#logging-messages)
- [Plugin Configuration](#plugin-configuration)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
@@ -22,8 +20,7 @@ Contents
- [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl)
Plugin Structure
----------------
## Plugin Structure
The basic file structure of a plugin is the following.
@@ -39,21 +36,17 @@ The basic file structure of a plugin is the following.
+- module.json
+- plugin.lua
Clusterio plugins are Node.js packages that can be published on npm for
ease of installation and distribution. The usual guides for creating
such packages apply. At minimum the `package.json` file must contain a
version entry.
Clusterio plugins are Node.js packages that can be published on npm for ease of installation and distribution.
The usual guides for creating such packages apply.
At minimum the `package.json` file must contain a version entry.
A possible workflow for developing plugins is to place the plugin in a
sub-directory of where clusterio has been installed, and rely on Node.js
searching up the folder heirarchy for it to find `@clusterio/lib`. To
add it to `plugin-list.json` so that it gets loaded use the `plugin add
<path>` sub-command to either clusteriomaster, clusterioslave or
clusterioctl. Note that it's important that the path starts with ./ or
../ (use .\ or ..\ on Windows).
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriomaster, clusterioslave or clusterioctl.
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
For a plugin the most important file is the `info.js` file. Without it
the plugin will not recognized by Clusterio. Here's an example of it:
For a plugin the most important file is the `info.js` file.
Without it the plugin will not recognized by Clusterio.
Here's an example of it:
const libLink = require("@clusterio/lib/link"); // For messages
@@ -71,70 +64,58 @@ the plugin will not recognized by Clusterio. Here's an example of it:
The following properties are recognized:
**name**:
Internal name of the plugin. Must be the same as the plugin's
directory name
Internal name of the plugin.
Must be the same as the plugin's directory name
**title**:
Name of the plugin as shown to users. Currently not used.
Name of the plugin as shown to users.
Currently not used.
**description**:
Brief description of what the plugin does. Currently not used.
Brief description of what the plugin does.
Currently not used.
**instanceEntrypoint**:
Path to a Node.js module relative to the plugin directory which
contains the InstancePlugin class definition for this plugin. This
is an optional paramater. A plugin may have code only for instances
but it must still be loaded on the master in order for it to be
possible to load it on an instance.
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
This is an optional paramater.
A plugin may have code only for instances but it must still be loaded on the master in order for it to be possible to load it on an instance.
**InstanceConfigGroup**:
Subclass of `PluginConfigGroup` for defining the per instance
configuration fields for this plugin. See [Plugin
Configuration](#plugin-configuration)
Subclass of `PluginConfigGroup` for defining the per instance configuration fields for this plugin.
See [Plugin Configuration](#plugin-configuration)
**masterEntrypoint**:
Path to a Node.js module relative to the plugin directory which
contains the MasterPlugin class definiton for this plugin. This is
an optional parameter. A plugin can be made that only runs on the
master server.
Path to a Node.js module relative to the plugin directory which contains the MasterPlugin class definiton for this plugin.
This is an optional parameter.
A plugin can be made that only runs on the master server.
**MasterConfigGroup**:
Subclass of `PluginConfigGroup` for defining the master server
configuration fields for this plugin. See [Plugin
Configuration](#plugin-configuration)
Subclass of `PluginConfigGroup` for defining the master server configuration fields for this plugin.
See [Plugin Configuration](#plugin-configuration)
**controlEntrypoint**:
Path to a Node.js module relative to the plugin directory which
contains the ControlPlugin class definition for this plugin. This
is an optional paramater. A plugin can be made that only runs on
the clusterioctl side.
Path to a Node.js module relative to the plugin directory which contains the ControlPlugin class definition for this plugin.
This is an optional paramater.
A plugin can be made that only runs on the clusterioctl side.
**messages**:
Object with link messages definitions for this plugin. See guide
for [defining link messages](#defining-link-messages) below.
Object with link messages definitions for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
The optional module folder contains a Clusterio module that will be
patched into the save when the plugin is enabled. See the section on
[Clusterio Modules](developing-for-clusterio.md) in the Developing for
Clusterio document. The only restriction imposed on modules embedded
into plugins is that they must be named the same as the plugin.
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
While there is no standard for how to organize a plugin it's recommended
to put the MasterPlugin class definition into master.js and the
InstancePlugin class definition into instance.js. You can put them into
whatever file you want (even the same one for both).
While there is no standard for how to organize a plugin it's recommended to put the MasterPlugin class definition into master.js and the InstancePlugin class definition into instance.js.
You can put them into whatever file you want (even the same one for both).
For both instanceEntrypoint and masterEntrypoint the path should not end
with .js and it should use forward slashes for directory separators if
any.
For both instanceEntrypoint and masterEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
Defining the plugin class
-------------------------
## Defining the plugin class
The plugin class should derive from its respective base class defined in
`lib/plugin`. For example, to define a MasterPlugin class the following
code can be used:
The plugin class should derive from its respective base class defined in `lib/plugin`.
For example, to define a MasterPlugin class the following code can be used:
const libPlugin = require("@clusterio/lib/plugin");
@@ -151,13 +132,11 @@ code can be used:
MasterPlugin,
}
For the instance plugin it's exactly the same except "Master" is
replaced with "Instance", and for the clusterioctl plugin "Control" is
used. The available hooks that you can override are documented in the
base class [in lib/plugin.js](/packages/lib/plugin.js).
For the instance plugin it's exactly the same except "Master" is replaced with "Instance", and for the clusterioctl plugin "Control" is used.
The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js).
It's best to avoid defining a constructor, but if you insist on defining
one, forward all arguments to the base class. E.g.:
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
E.g.:
constructor(...args) {
super(...args);
@@ -165,24 +144,61 @@ one, forward all arguments to the base class. E.g.:
// Code here
}
The arguments passed may change, and attempting to modify them will
result in unpredicatable behaviour. The async init method is always called
immediatly after the constructor, so there's little reason to do this.
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
The async init method is always called immediatly after the constructor, so there's little reason to do this.
Defining Configuration
----------------------
## Logging Messages
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
`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:
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
For instances a copy of the log is also stored on the slave the instance is on.
To use it, pass a string to one of the log levels functions, for example:
async init() {
this.logger.info("Initializing frobbing");
}
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
The available levels are fatal, error, warn, audit, info, and verbose.
These levels have roughly the following meanings:
- fatal: Unrecoverable error that prevents continuing execution of the Node.js process.
Note that Plugins shouldn't have fatal errors.
- error: An unexpected error condition occured, usually an exception thrown when it wasn't expected.
Should be investigated and fixed.
- warn: An potential error was detected, but operation continued gracefully.
These may indicate that something is running sub-optimally, or is not working as intedend.
- audit: Record of actions performed by users of the cluster.
This is usually emitted in response to management requests by a control link.
- info: A general log message.
May indicae a status or operation completed.
This should not be emitted by periodic tasks that run often.
- verbose: A verbose log message which might be useful when investigating issues, does not show by default.
Note that logging messages is not a cheap operation even when the messages do not show up and spammy logging should be avoided for the verbose level.
If the logs are still valuable in certain cases consider putting them behind a config option.
For guarding unexpected errors the best option is to log a short description along with the`stack` property of the Error:
try {
// Operation that should not throw but may end up throwing
} catch (err) {
this.logger.error(`Operation failed:\n${err.stack}`);
)
For plugin hooks expections thrown are automatically catched and logged, but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately.
## 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 `defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export.
For example in info.js:
const libConfig = require("@clusterio/lib/config");
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
MasterConfigGroup.groupName = "foo_frobber";
MasterConfigGroup.define({
name: "level",
@@ -197,26 +213,22 @@ the `info.js` export. For example in info.js:
MasterConfigGroup: MasterConfigGroup,
};
Code inside the `MasterPlugin` class will then be able to access the
level config field through the `Config` object at `this.master.config`,
for example in the MasterPluginClass:
Code inside the `MasterPlugin` class will then be able to access the level config field through the `Config` object at `this.master.config`, for example in the MasterPluginClass:
async init() {
let level = this.master.config.get("foo_frobber.level");
console.log("I got a frobnication level of", level);
this.logger.info(`I got a frobnication level of ${level}`);
}
The same applies for instance configs, replace "master" with "instance"
where appropriate. See [Configuration System](configuration-system.md)
for more details on how this system works.
The same applies for instance configs, replace "master" with "instance" where appropriate.
See [Configuration System](config-system.md) for more details on how this system works.
### Handling Invalid Configuration
If the plugin requires a certain feature to be enabled to function it
should throw an error during init if this is not the case. The most
common such feature is the save patching, which can be disabled to run
vanilla or scenarios not compatible with Clusterio. For example:
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
For example:
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
@@ -225,16 +237,14 @@ vanilla or scenarios not compatible with Clusterio. For example:
}
Communicating with Factorio
---------------------------
## Communicating with Factorio
For pushing data into Factorio there's RCON, which lets you send
arbitrary Lua commands to invoke whatever code you want in the game.
This is done by calling the `sendRcon` method on the instance's server object.
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
This is done by calling the `sendRcon` method on the plugin object.
For example:
async onStart() {
let response = await this.instance.server.sendRcon(
let response = await this.sendRcon(
"/sc rcon.print('data')"
);
@@ -242,24 +252,19 @@ For example:
}
Because data into Factorio is streamed at a rate of 3-6 kB/s by default,
it is recommended to avoid sending large commands as much as possible,
and to strip down the data on the ones you send to only what's strictly
necessary. You can have lua code injected into the game via the module
system and call that from RCON to avoid having to send code through the
commands.
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
You can have lua code injected into the game via the module system and call that from RCON to avoid having to send code through the commands.
For getting data out from Factorio there's both RCON and the `send_json`
API of the Clusterio module. Returning data via RCON is prefered if the
action is initiated from the Node.js side. The `send_json` API allows
sending JSON payloads on channels that plugins can listen to. From a
plugin you listen for an event named `ipc-channel_name` in order to get
data sent by `send_json`. For example in the plugin code:
For getting data out from Factorio there's both RCON and the `send_json` API of the Clusterio module.
Returning data via RCON is prefered if the action is initiated from the Node.js side.
The `send_json` API allows sending JSON payloads on channels that plugins can listen to.
From a plugin you listen for an event named `ipc-channel_name` in order to get data sent by `send_json`.
For example in the plugin code:
async init() {
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => console.log(
"Error handling foo:", foo
this.handleFoo(content).catch(err => this.logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
@@ -275,145 +280,118 @@ And then in the module for the plugin:
-- inside some event handler
clusterio_api.send_json("my_plugin_foo", { data = 123 })
It's recommended to either use the plugin name as the channel name or to
prefix the channel name with the name of the plugin if you need multiple
channels. It's also important to catch any errors that might occur as
they will otherwise be propogated to the instance code and kill the
server.
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
Data out from Factorio does not have the same limits as data into
Factorio, RCON responses can be in 100kB range without causing issues,
and payloads to the `send_json` API can be in the 4MB range provided the
server has a fast enough storage system.
Data out from Factorio does not have the same limits as data into Factorio, RCON responses can be in 100kB range without causing issues, and payloads to the `send_json` API can be in the 4MB range provided the server has a fast enough storage system.
**Note:** both `send_json` and RCON can operate out of order. For
`send_json` it's possible that payloads greater than 4kB are received
after payloads that were sent at a later point in time. For RCON,
commands longer than 50 characters may end up being executed after
shorter commands sent after it.
**Note:** both `send_json` and RCON can operate out of order.
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
Defining Link Messages
----------------------
## Defining Link Messages
You will most likely have to communicate with the master or other
instances in your plugin for it to do anything useful. For this there's
a WebSocket communication channel established between the slaves and the
master server that plugins can define their own messages to send over
it. This channel is bi-directional and all messages sent over it are
validated with a JSON schema (see [this guide][guide] for an
introduction to writing JSON schema).
You will most likely have to communicate with the master or other instances in your plugin for it to do anything useful.
For this there's a WebSocket communication channel established between the slaves and the master server that plugins can define their own messages to send over it.
This channel is bi-directional and all messages sent over it are validated with a JSON schema (see [this guide][guide] for an introduction to writing JSON schema).
[guide]: https://json-schema.org/learn/getting-started-step-by-step.html
There are currently two kinds of messages that can be defined: events
and requests. Events are simple one-way notifications that invoke a
handler on the target it's sent to. Requests are pairs of request and
response messages where the request is sent to the target and the
response is the reply back from the target. The requests are similar to
HTTP requests, except both parties of a link may initiate one.
There are currently two kinds of messages that can be defined: events and requests.
Events are simple one-way notifications that invoke a handler on the target it's sent to.
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
The requests are similar to HTTP requests, except both parties of a link may initiate one.
### Defining Events
Events are defined as properties of the messages object exported by
`info.js` that map to instances of the `Event` class from `lib/link`.
The name of the property correspond to the handler invoked on the plugin
class. The Event constructor takes an object of properties that define
the event, for example the following could be defined in `info.js`:
Events are defined as properties of the messages object exported by `info.js` that map to instances of the `Event` class from `lib/link`.
The name of the property correspond to the handler invoked on the plugin class.
The Event constructor takes an object of properties that define the event, for example the following could be defined in `info.js`:
messages: {
startFrobnication: new libLink.Event({
type: "foo_frobber:start_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
eventRequired: ["frobnication_type"],
eventProperties: {
"frobnication_type": { type: "string" },
"urgent": { type: "boolean" },
},
}),
},
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.
This specifies an event that can be sent from the master to a slave, and from a slave to an instance.
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
It will also be forwarded by slaves to a specific instance.
The following properties are recognized by the Event constructor:
#### type
The message type sent over the wire. This must start with the name of
the plugin followed by colon and and be unique for the plugin. The type
of the message sent over the socket will have the suffix `_event`
appended to it.
The message type sent over the wire.
This must start with the name of the plugin followed by colon and and be unique for the plugin.
The type of the message sent over the socket will have the suffix `_event` appended to it.
#### links
An array of strings describing which links this event can be sent over.
Direction matters, `"master-slave"` means the event can be sent from the
master to the slave, but can't be sent back the other way, unless
`"slave-master"` is also present in the links array.
Direction matters, `"master-slave"` means the event can be sent from the master to the slave, but can't be sent back the other way, unless `"slave-master"` is also present in the links array.
The available endpoints are `master`, `slave`, `instance`, and
`control`. Master talks with slave and control, and slave talks to
instance. The full chain must be specified as the individual links in
order for a message to travers multiple hops, (i.e., for a message to go
from master to instance it must have both `"master-slave"` and
`"slave-instance"` in the links array). See `forwardTo` and
`broadcastTo` for ways to forward an event to the next link in a chain.
The available endpoints are `master`, `slave`, `instance`, and `control`.
Master talks with slave and control, and slave talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
#### forwardTo
Target to forward an event to. Can either be `"master"`, to indicate a
slave should forward it to the master server, or `"instance"`, to
indicate it should be forwarded to the instances specified by the
`instance_id` event property. This works by using a default handler for
the event at the links that forward it.
Target to forward an event to.
Can either be `"master"`, to indicate a slave should forward it to the master server, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
This works by using a default handler for the event at the links that forward it.
#### broadcastTo
Target to broadcast this message towards. A value of "instance" means
the event will be broadcast to all instances downstream of the target
it's sent to, but not back from where it came from. Currently, only
"instance" is supported. This means that sending an event to a slave
that slave except for the instance it came from. from an instance will
cause it to be broadcast to all instances of
Target to broadcast this message towards.
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
Currently, only "instance" is supported.
This means that sending the event to a slave from an instance will cause it to be broadcast to all instances of that slave except for the instance it came from.
#### 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
are not allowed. See [this guide][guide] for an introduction to writing
JSON schemas.
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 by default required and additional properties are not allowed.
See [this guide][guide] for an introduction to writing JSON schemas.
The forwardTo and broadcastTo can be combined such that specifying
`"master"` as the forwardTo value and `"instance"` as the broadcastTo
value will cause the event to be broadcast to all instances in the
cluster. For this to work, you will need to specify `instance-slave`,
`slave-master`, `master-slave`, and `slave-instance` as the links.
The forwardTo and broadcastTo can be combined such that specifying `"master"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
For this to work, you will need to specify `instance-slave`, `slave-master`, `master-slave`, and `slave-instance` as the links.
Keep in mind when forwarding events that if the target an event is being
forwarded to is not online, the event will be dropped. Use a request if
you need a confirmation that the message was received.
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
Use a request if you need a confirmation that the message was received.
### Definining Requests
Requests are defined as properties of the messages object exported by
`info.js` that map to instances of the `Request` class from `lib/link`.
The name of the property corresponds to the handler invoked on the plugin
class. The Request constructor takes an object of properties that define
the event. For example, the following could be defined in `info.js`:
Requests are defined as properties of the messages object exported by `info.js` that map to instances of the `Request` class from `lib/link`.
The name of the property corresponds to the handler invoked on the plugin class.
The Request constructor takes an object of properties that define the event.
For example, the following could be defined in `info.js`:
messages: {
reportFrobnication: new libLink.Request({
type: "foo_frobber:report_frobnication",
links: ["master-slave", "slave-instance"],
forwardTo: "instance",
requestRequired: ["verbosity"],
requestProperties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
},
responseProperties: {
"report": {
@@ -424,138 +402,103 @@ 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
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`.
This specifies a request that can be sent from the master to a slave, and from a slave to an instance.
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
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`.
The following properties are recognized by the Request constructor:
#### type
The message type sent over the wire. This must start with the name of
the plugin followed by colon and and be unique for the plugin. The type
of the message sent over the socket will have the suffix `_request`
appended to it for the request and `_response` appended to it for the
response.
The message type sent over the wire.
This must start with the name of the plugin followed by colon and and be unique for the plugin.
The type of the message sent over the socket will have the suffix `_request` appended to it for the request and `_response` appended to it for the response.
#### links
An array of strings describing which links this request can be sent
over. Direction matters; `"master-slave"` means the request can be sent
from the master to the slave and the slave can reply to the master, but
the slave can't send a request to the master unless `"slave-master"` is
also present in the links array.
An array of strings describing which links this request can be sent over.
Direction matters; `"master-slave"` means the request can be sent from the master to the slave and the slave can reply to the master, but the slave can't send a request to the master unless `"slave-master"` is also present in the links array.
The available endpoints are `master`, `slave`, `instance`, and
`control`. Master talks with slave and control, and slave talks to
instance. The full chain must be specified as the individual links in
order for a message to travers multiple hops, (i.e., for a message to go
from master to instance it must have both `"master-slave"` and
`"slave-instance"` in the links array). See `forwardTo` for ways to
forward a request to the next link in a chain.
The available endpoints are `master`, `slave`, `instance`, and `control`.
Master talks with slave and control, and slave talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
See `forwardTo` for ways to forward a request to the next link in a chain.
#### forwardTo
Target to forward the request to. Can either be `"master"` to indicate
a slave should forward it to the master server when receiving it from an
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.
Target to forward the request to.
Can either be `"master"` to indicate a slave should forward it to the master server when receiving it from an 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
are not allowed. See [this guide][guide] for an introduction to writing
JSON schemas
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 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
target.
Same as the requestProperties only for the response sent back by the target.
Sending Link Messages
---------------------
## Sending Link Messages
Link messages are sent by calling the `.send()` method on the
Event/Request instance with the link you want to send it over and the
data to send. For `InstancePlugin` code the link to the slave is the
`instance` itself, which is accessible through the `.instance` property
of the `InstancePlugin`. The `.info` property of the plugin class exposes
the data exported from the plugin's `info.js` module. In other words:
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
For `InstancePlugin` code the link to the slave is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
The `.info` property of the plugin class exposes the data exported from the plugin's `info.js` module.
In other words:
// In an InstancePlugin class
async frobnicate() {
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
}
For the Request class the send method is async and returns the response
data received from the target it was sent to, or throws an error if the
request failed.
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
### Handling connection events
There are a few connection related events that plugins neeed to repsond
to in order to avoid data loss and connection problems. The most
important is the prepare disconnect for the link between master and
slave. This is signaled to `MasterPlugin` classes via the
`onPrepareSlaveDisconnect` hook and to `InstancePlugin` classes via the
`onPrepareMasterDisconnect` hook.
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
The most important is the prepare disconnect for the link between master and slave.
This is signaled to `MasterPlugin` classes via the `onPrepareSlaveDisconnect` hook and to `InstancePlugin` classes via the `onPrepareMasterDisconnect` hook.
After the prepare disconnect the connection will be closed, which will
result in pending requests and events being dropped. Plugins must
respond to the prepare disconnect by stopping any processess it does
that send events or requests over the link in question. This can be
accomplished either through listening for the prepare disconnect hook,
or by checking the `connected` or `closing` property of the connector
for the master/slave connection. For example the sending of an event
from an `InstancePlugin` class can be stopped while the connection is
closing by using the following code:
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` or `closing` property of the connector for the master/slave connection.
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is closing by using the following code:
if (!this.slave.connector.closing) {
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
}
If the event or request needs to be sent to the master it can be put
into a queue stored on the plugin instance and sent out when the
connection is established again. The re-establishement of the
connection is notified to plugins via the `connect` event to the
`onMasterConnectionEvent` and `onSlaveConnectionEvent` hooks.
If the event or request needs to be sent to the master it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `onMasterConnectionEvent` and `onSlaveConnectionEvent` hooks.
The second connection event which is of lesser importance to respond to
is the `drop` connection event served through `onMasterConnectionEvent`
for `InstancePlugin` classes and through `onSlaveConnectionEvent` for
`MasterPlugin` classes. This is raised when the connection between the
master and slave in question is lost, most likely due to networking
issues. When in the dropped state the slave will keep trying to
reconnect to the master server in order to re-establish it, and if
successful no events or requests will be lost. However while in the
dropped state any requests and events sent gets queued up in memory
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.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onMasterConnectionEvent` for `InstancePlugin` classes and through `onSlaveConnectionEvent` for `MasterPlugin` classes.
This is raised when the connection between the master and slave in question is lost, most likely due to networking issues.
When in the dropped state the slave will keep trying to reconnect to the master server in order to re-establish it, and if successful no events or requests will be lost.
However while in the dropped state any requests and events sent gets queued up in memory 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 `resume` is raised.
Collecting Statistics
---------------------
## Collecting Statistics
Clusterio comes with its own Prometheus client implementation, one part
due to Not Invented Here and another part due to collectors in
prom-client being difficult to get to work nicely with collecting data
from plugins optionally loaded at runtime on different computers.
Clusterio comes with its own Prometheus client implementation, one part due to Not Invented Here and another part due to collectors in prom-client being difficult to get to work nicely with collecting data from plugins optionally loaded at runtime on different computers.
In its simplest form collecting data from plugins consists of defining
the metric and updating it somewhere in the plugin code. For example:
In its simplest form collecting data from plugins consists of defining the metric and updating it somewhere in the plugin code.
For example:
const { Counter } = require("@clusterio/lib/prometheus");
@@ -566,13 +509,10 @@ the metric and updating it somewhere in the plugin code. For example:
// Somewhere in the master plugin code
fooMetric.inc();
This works for master plugins, and the metric will be automatically
available through the /metric HTTP endpoint. It's recommended that
plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the
naming scheme.
This works for master plugins, and the metric will be automatically available through the /metric HTTP endpoint.
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and
set it accordingly, for example:
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
const { Counter } = require("@clusterio/lib/prometheus");
@@ -582,28 +522,19 @@ 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.
This means that it's important that you place the definition of the
metric at module level so that it's not created more than once over the
lifetime of a slave. Since the metrics remember their values and would
continue to be exported after an instance is shutdown, there's code at
instance shutdown that removes all the values where the `instance_id`
label matches the id of the instance shut down.
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the master server on slaves.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a slave.
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
For statistics you need to update on collection there's an `onMetrics`
hook on both master and instance plugins that is run before the
metrics in the default registry are collected.
For statistics you need to update on collection there's an `onMetrics` hook on both master and instance plugins that is run before the metrics in the default registry are collected.
Adding Custom Commands to clusterioctl
--------------------------------------
## Adding Custom Commands to clusterioctl
The control entrypoint for plugins allows you to extend clustectl with
your own commands. The creation of custom commands typically starts
with defining a command tree for the plugin:
The control entrypoint for plugins allows you to extend clustectl with your own commands.
The creation of custom commands typically starts with defining a command tree for the plugin:
const { Command, CommandTree } = require("@clusterio/lib/command");
const fooFrobberCommands = new CommandTree({
@@ -628,25 +559,13 @@ Then commands are added to the the plugin's command tree:
},
}));
For a command the `definition` is the arguments to pass to
[yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler)
(see also
[yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt)
and
[yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt)
for setting up positional and optional arguments to commands). The
`handler` is an async function that's invoked when the command is
executed and it's passed the parsed command line arguments and a
reference to the `Control` class of clusterioctl. It's possible to optain
a reference to the plugin class with `control.plugins.get(info.name)`.
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
Note that messages sent from clusterioctl needs to have
`"control-master"` as a part of the links array for it to be accepted by
the master server, see [Defining Link Messages](#defining-link-messages)
for how to define the messages that can be sent to the master.
Note that messages sent from clusterioctl needs to have `"control-master"` as a part of the links array for it to be accepted by the master server, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the master.
To have the command tree become part of clusterioctl it needs to be added
to the rootCommand tree in `addCommands` callback of the Control plugin:
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Control plugin:
const libPlugin = require("@clusterio/lib/plugin");
+1 -1
View File
@@ -3,5 +3,5 @@
"packages/*",
"plugins/*"
],
"version": "2.0.0-alpha.2"
"version": "2.0.0-alpha.5"
}
+4 -3
View File
@@ -2,7 +2,7 @@
"name": "root",
"private": true,
"scripts": {
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/*/test",
"test": "mocha --check-leaks -R spec --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/test",
"fast-test": "FAST_TEST=y npm test",
"cover": "nyc npm test",
"fast-cover": "FAST_TEST=y nyc npm test",
@@ -21,19 +21,20 @@
"node": ">=10.16 <11 || >= 12"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-react": "^7.12.1",
"@clusterio/ctl": "file:./packages/ctl",
"@clusterio/lib": "file:./packages/lib",
"@clusterio/master": "file:packages/master",
"@clusterio/slave": "file:./packages/slave",
"body-parser": "^1.18.2",
"eslint": "^7.2.0",
"express": "^4.16.2",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.3",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"lerna": "^3.22.1",
"mocha": "^6.2.0",
"node-forge": "^0.10.0",
"nyc": "^14.1.1",
"phin": "^3.4.1",
"webpack": "^5.0.0"
+27 -44
View File
@@ -1,70 +1,57 @@
Clusterio Command Line Interface
================================
# Clusterio Command Line Interface
Provides a command line interface for managing a Clusterio cluster.
Note that this document only describes the options and commands for
managing clusterioctl itself, see [Managing a
cluster](/docs/managing-a-cluster.md) for the available commands used
for managing a cluster.
Note that this document only describes the options and commands for managing clusterioctl itself, see [Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
Usage
-----
## Usage
npx clusterioctl <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to ctl. Defaults to `plugin-list.json`
and will be created if it does not exist. See the `plugin` command
for managing this list.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to ctl.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for
ctl. Defaults to `config-control.json` and will be created if it
does not exist. See the `config` command for inspecting and
modifying the configuration.
* `--config <file>` JSON file to use for storing configuration for ctl.
Defaults to `config-control.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by ctl. The available plugins
will be automatically loaded and the commands they provide will be added
to the ctl utility.
Configure plugins available to be loaded by ctl.
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
Remove a plugin by its name.
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusterioctl plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -74,18 +61,17 @@ Lists the plugins set up to be available by name followed by path.
### `config-control`
Manage the ctl configuration. This allows setting the url and token to
the master server to manage.
Manage the ctl configuration.
This allows setting the url and token to the master server to manage.
#### `config-control set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
Set a config entry to the given value.
If value is not provided the entry is set to null.
If the config entry is of type object the value must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration.
See docs/configuration.md in the main repositiory for the available configuration.
#### `config-control show <config-entry>`
@@ -100,9 +86,6 @@ Lists up all configuration entries with their currently configured values.
## See Also
[Managing a cluster](/docs/managing-a-cluster.md) for the available
commands used for managing a cluster.
[Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/factorioClusterio) for instructions on how to set up a cluster.
+373 -130
View File
@@ -9,9 +9,11 @@ const fs = require("fs-extra");
const yargs = require("yargs");
const version = require("./package").version;
const asTable = require("as-table").configure({ delimiter: " | " });
const chalk = require("chalk");
const events = require("events");
const path = require("path");
const winston = require("winston");
const setBlocking = require("set-blocking");
const phin = require("phin");
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
@@ -20,52 +22,114 @@ const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
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");
/**
* Format a parsed Factorio output message with colors
*
* Formats a parsed Factorio output from lib/factorio into a readable
* colorized output using terminal escape codes that can be printed.
*
* @param {Object} output - Factorio server output.
* @returns {string} terminal colorized message.
* @private
*/
function formatOutputColored(output) {
let time = "";
if (output.format === "seconds") {
time = chalk.yellow(output.time.padStart(8)) + " ";
} else if (output.format === "date") {
time = chalk.yellow(output.time) + " ";
}
let info = "";
if (output.type === "log") {
let level = output.level;
if (level === "Info") {
level = chalk.bold.blueBright(level);
} else if (output.level === "Warning") {
level = chalk.bold.yellowBright(level);
} else if (output.level === "Error") {
level = chalk.bold.redBright(level);
}
info = level + " " + chalk.gray(output.file) + ": ";
} else if (output.type === "action") {
info = "[" + chalk.yellow(output.action) + "] ";
}
return time + info + output.message;
function print(...content) {
// eslint-disable-next-line no-console
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 masterPluginCommands = new libCommand.CommandTree({
name: "plugin", alias: ["p"], description: "master plugin inspection",
});
masterPluginCommands.add(new libCommand.Command({
definition: ["list", "List plugins on master"],
handler: async function(args, control) {
let url = new URL(control.config.get("control.master_url"));
url.pathname += "api/plugins";
let response = await phin({
url,
parse: "json",
core: { ca: control.tlsCa },
});
print(asTable(response.body));
},
}));
masterCommands.add(masterPluginCommands);
const slaveCommands = new libCommand.CommandTree({ name: "slave", description: "Slave management" });
slaveCommands.add(new libCommand.Command({
definition: [["list", "l"], "List slaves connected to the master"],
handler: async function(args, control) {
let response = await libLink.messages.listSlaves.send(control);
console.log(asTable(response.list));
print(asTable(response.list));
},
}));
@@ -75,7 +139,7 @@ slaveCommands.add(new libCommand.Command({
}],
handler: async function(args, control) {
let response = await libLink.messages.generateSlaveToken.send(control, { slave_id: args.id });
console.log(response.token);
print(response.token);
},
}));
@@ -97,9 +161,9 @@ slaveCommands.add(new libCommand.Command({
let content = JSON.stringify(response.serialized_config, null, 4);
if (args.output === "-") {
console.log(content);
print(content);
} else {
console.log(`Writing ${args.output}`);
logger.info(`Writing ${args.output}`);
try {
await fs.outputFile(args.output, content, { flag: "wx" });
} catch (err) {
@@ -120,7 +184,7 @@ instanceCommands.add(new libCommand.Command({
definition: [["list", "l"], "List instances known to the master"],
handler: async function(args, control) {
let response = await libLink.messages.listInstances.send(control);
console.log(asTable(response.list));
print(asTable(response.list));
},
}));
@@ -133,13 +197,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 });
},
}));
@@ -157,20 +221,28 @@ instanceConfigCommands.add(new libCommand.Command({
for (let group of response.serialized_config.groups) {
for (let [name, value] of Object.entries(group.fields)) {
console.log(`${group.name}.${name} ${JSON.stringify(value)}`);
print(`${group.name}.${name} ${JSON.stringify(value)}`);
}
}
},
}));
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,
@@ -180,32 +252,61 @@ 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);
instanceCommands.add(new libCommand.Command({
definition: ["assign <instance> <slave>", "Assign instance to a slave", (yargs) => {
definition: ["assign <instance> [slave]", "Assign instance to a slave", (yargs) => {
yargs.positional("instance", { describe: "Instance to assign", type: "string" });
yargs.positional("slave", { describe: "Slave to assign to", type: "string" });
yargs.positional("slave", { describe: "Slave to assign to or unassign if none", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let slaveId = await libCommand.resolveSlave(control, args.slave);
let slaveId = args.slave ? await libCommand.resolveSlave(control, args.slave) : null;
await libLink.messages.assignInstanceCommand.send(control, {
instance_id: instanceId,
slave_id: slaveId,
@@ -219,7 +320,7 @@ instanceCommands.add(new libCommand.Command({
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.createSave.send(control, {
instance_id: instanceId,
});
@@ -232,7 +333,7 @@ instanceCommands.add(new libCommand.Command({
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.exportData.send(control, {
instance_id: instanceId,
});
@@ -249,11 +350,12 @@ instanceCommands.add(new libCommand.Command({
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.startInstance.send(control, {
instance_id: instanceId,
save: args.save || null,
});
control.keepOpen = args.keepOpen;
},
}));
@@ -267,11 +369,12 @@ instanceCommands.add(new libCommand.Command({
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.loadScenario.send(control, {
instance_id: instanceId,
scenario: args.scenario || null,
});
control.keepOpen = args.keepOpen;
},
}));
@@ -281,7 +384,7 @@ instanceCommands.add(new libCommand.Command({
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
await control.setLogSubscriptions({ instance_ids: [instanceId] });
let response = await libLink.messages.stopInstance.send(control, {
instance_id: instanceId,
});
@@ -320,7 +423,7 @@ permissionCommands.add(new libCommand.Command({
definition: [["list", "l"], "List permissions in the cluster"],
handler: async function(args, control) {
let response = await libLink.messages.listPermissions.send(control);
console.log(asTable(response.list));
print(asTable(response.list));
},
}));
@@ -330,7 +433,7 @@ roleCommands.add(new libCommand.Command({
definition: [["list", "l"], "List roles in the cluster"],
handler: async function(args, control) {
let response = await libLink.messages.listRoles.send(control);
console.log(asTable(response.list));
print(asTable(response.list));
},
}));
@@ -348,7 +451,7 @@ roleCommands.add(new libCommand.Command({
description: args.description,
permissions: args.permissions,
});
console.log(`Created role ID ${response.id}`);
logger.info(`Created role ID ${response.id}`);
},
}));
@@ -358,7 +461,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" },
});
}],
@@ -371,8 +478,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);
@@ -398,7 +515,7 @@ userCommands.add(new libCommand.Command({
definition: [["list", "l"], "List user in the cluster"],
handler: async function(args, control) {
let response = await libLink.messages.listUsers.send(control);
console.log(asTable(response.list));
print(asTable(response.list));
},
}));
@@ -500,12 +617,75 @@ userCommands.add(new libCommand.Command({
},
}));
const logCommands = new libCommand.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new libCommand.Command({
definition: ["follow", "follow cluster log", (yargs) => {
yargs.options({
"all": { describe: "Follow the whole cluster log", nargs: 0, type: "boolean", default: false },
"master": { describe: "Follow log of the master server", nargs: 0, type: "boolean", default: false },
"slave": { describe: "Follow log of given slave", nargs: 1, type: "string", default: null },
"instance": { describe: "Follow log of given instance", nargs: 1, type: "string", default: null },
});
}],
handler: async function(args, control) {
if (!args.all && !args.master && !args.slave && !args.instance) {
logger.error("At least one of --all, --master, --slave and --instance must be passed");
process.exitCode = 1;
return;
}
let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : [];
let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : [];
await control.setLogSubscriptions({ all: args.all, master: args.master, slave_ids, instance_ids });
control.keepOpen = true;
},
}));
logCommands.add(new libCommand.Command({
definition: ["query", "Query cluster log", (yargs) => {
yargs.options({
"all": { describe: "Query the whole cluster log", nargs: 0, type: "boolean", default: false },
"master": { describe: "Query log of the master server", nargs: 0, type: "boolean", default: false },
"slave": { describe: "Query log of given slave", nargs: 1, type: "string", default: null },
"instance": { describe: "Query log of given instance", nargs: 1, type: "string", default: null },
"max-level": { describe: "Maximum log level to return", nargs: 1, type: "string", default: null },
});
}],
handler: async function(args, control) {
if (!args.all && !args.master && !args.slave && !args.instance) {
logger.error("At least one of --all, --master, --slave and --instance must be passed");
process.exitCode = 1;
return;
}
let instance_ids = args.instance ? [await libCommand.resolveInstance(control, args.instance)] : [];
let slave_ids = args.slave ? [await libCommand.resolveSlave(control, args.slave)] : [];
let result = await libLink.messages.queryLog.send(control, {
all: args.all,
master: args.master,
slave_ids,
instance_ids,
max_level: args.maxLevel,
});
let stdoutLogger = winston.createLogger({
level: "verbose",
levels,
format: new libLoggingUtils.TerminalFormat({ showTimestamp: true }),
transports: [
new ConsoleTransport({ errorLevels: [], warnLevels: [] }),
],
});
for (let info of result.log) {
stdoutLogger.log(info);
}
},
}));
const debugCommands = new libCommand.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new libCommand.Command({
definition: ["dump-ws", "Dump WebSocket messages sent and received by master", (yargs) => { }],
handler: async function(args, control) {
await libLink.messages.debugDumpWs.send(control);
return new Promise(() => {});
control.keepOpen = true;
},
}));
@@ -515,13 +695,13 @@ 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;
}
register() {
console.log("SOCKET | registering control");
logger.verbose("SOCKET | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterioctl",
@@ -538,27 +718,54 @@ class ControlConnector extends libLink.WebSocketClientConnector {
*/
class Control extends libLink.Link {
constructor(connector, controlPlugins) {
constructor(connector, controlConfig, tlsCa, controlPlugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* Control config used for connecting to the master.
* @type {module:lib/config.ControlConfig}
*/
this.config = controlConfig;
/**
* Certificate authority used to validate TLS connections to the master.
* @type {?string}
*/
this.tlsCa = tlsCa;
/**
* Mapping of plugin names to their instance for loaded plugins.
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
*/
this.plugins = controlPlugins;
for (let controlPlugin of controlPlugins.values()) {
libPlugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
libPlugin.attachPluginMessages(this, controlPlugin);
}
/**
* Keep the control connection alive after the command completes.
* @type {boolean}
*/
this.keepOpen = false;
}
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutputColored(output));
async setLogSubscriptions({
all = false,
master = false,
slave_ids = [],
instance_ids = [],
max_level = null,
}) {
await libLink.messages.setLogSubscriptions.send(this, {
all, master, slave_ids, instance_ids, max_level,
});
}
async logMessageEventHandler(message) {
logger.log(message.data.info);
}
async debugWsMessageEventHandler(message) {
console.log("WS", message.data.direction, message.data.content);
print("WS", message.data.direction, message.data.content);
}
async shutdown() {
@@ -576,11 +783,60 @@ 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
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
nargs: 1,
describe: "Log level to print to stderr",
default: "server",
choices: Object.keys(levels),
type: "string",
})
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
@@ -601,9 +857,16 @@ async function startControl() {
;
// Parse the args first to get the configured plugin list.
let args = yargs.argv;
let args = yargs.parse();
console.log(`Loading available plugins from ${args.pluginList}`);
// Log stream for the ctl session.
logger.add(new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new libLoggingUtils.TerminalFormat(),
}));
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
@@ -619,54 +882,27 @@ 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(debugCommands);
console.log("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);
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()
;
console.log(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig();
logger.verbose(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig("control");
try {
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
} catch (err) {
if (err.code === "ENOENT") {
console.log("Config not found, initializing new config");
logger.verbose("Config not found, initializing new config");
await controlConfig.init();
} else {
@@ -694,20 +930,27 @@ async function startControl() {
// The remaining commands require connecting to the master server.
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
console.error("Missing URL and/or token to connect with. See README.md for setting up access.");
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
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);
let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins);
try {
await controlConnector.connect();
} catch(err) {
} catch (err) {
if (err instanceof libErrors.AuthenticationFailed) {
throw new libErrors.StartupError(err.message);
}
@@ -715,25 +958,26 @@ async function startControl() {
}
process.on("SIGINT", () => {
console.log("Caught interrupt signal, closing connection");
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
console.error(err);
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line no-process-exit
process.exit(1);
});
});
let keepOpen = Boolean(args.keepOpen);
try {
await targetCommand.run(args, control);
} catch (err) {
keepOpen = false;
control.keepOpen = false;
if (err instanceof libErrors.CommandError) {
console.error(`Error running command: ${err.message}`);
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof libErrors.RequestError) {
console.error(`Error sending request: ${err.message}`);
logger.error(`Error sending request: ${err.message}`);
process.exitCode = 1;
} else {
@@ -741,7 +985,7 @@ async function startControl() {
}
} finally {
if (!keepOpen) {
if (!control.keepOpen) {
await control.shutdown();
}
}
@@ -749,13 +993,11 @@ async function startControl() {
module.exports = {
Control,
// for testing only
_formatOutputColored: formatOutputColored,
};
if (module === require.main) {
// eslint-disable-next-line no-console
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
@@ -765,21 +1007,22 @@ I version of clusterio. Expect things to break. I
);
startControl().catch(err => {
if (!(err instanceof libErrors.StartupError)) {
console.error(`
logger.fatal(`
+----------------------------------------------------------------+
| Unexpected error occured while starting control, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+`
+----------------------------------------------------------------+
${err.stack}`
);
} else {
console.error(`
logger.error(`
+---------------------------------+
| Unable to to start clusterioctl |
+---------------------------------+`
+---------------------------------+
${err.stack}`
);
}
console.error(err);
process.exitCode = 1;
});
}
+4 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.5",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,10 +18,12 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.2",
"@clusterio/lib": "^2.0.0-alpha.5",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
"phin": "^3.5.1",
"winston": "^3.3.3",
"yargs": "^14.2.0"
},
"publishConfig": {
+3 -6
View File
@@ -1,8 +1,5 @@
Common Library for Clusterio
============================
# Common Library for Clusterio
This package contains functionality shared between master, slave, ctl
and/or plugins of Clusterio.
This package contains functionality shared between master, slave, ctl and/or plugins of Clusterio.
See [the Clusterio repository](https://github.com/clusterio/factorioClusterio)
for more inforation on Clusterio.
See [the Clusterio repository](https://github.com/clusterio/factorioClusterio) for more information on Clusterio.
+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;
+82 -72
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-console */
"use strict";
const events = require("events");
const fs = require("fs-extra");
@@ -7,34 +8,64 @@ 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", 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", nargs: 1, type: "string", default: "dist" },
"bump-patch": { describe: "Increment patch number of build", type: "boolean", default: false },
"factorio-version": { describe: "Override factorio_version", type: "string" },
})
.strict()
.argv
;
async function buildMod(args, info) {
if (args.build) {
await fs.ensureDir(args.outputDir);
let modName = `${info.name}_${info.version}`;
await build(args);
if (args.pack) {
let zip = new JSZip();
let walker = klaw(args.sourceDir)
.on("data", item => {
if (item.stats.isFile()) {
// On Windows the path created uses backslashes as the directory sepparator
// but the zip file needs to use forward slashes. We can't use the posix
// version of relative here as it doesn't work with Windows style paths.
let basePath = path.relative(args.sourceDir, item.path).replace(/\\/g, "/");
zip.file(path.posix.join(modName, basePath), fs.createReadStream(item.path));
}
})
;
await events.once(walker, "end");
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
let filePath = path.join(args.sourceDir, ...pathParts);
if (!await fs.pathExists(filePath)) {
throw new Error(`Additional file ${filePath} does not exist`);
}
zip.file(path.posix.join(modName, fileName), fs.createReadStream(filePath));
}
delete info.additional_files;
zip.file(path.posix.join(modName, "info.json"), JSON.stringify(info, null, 4));
let modPath = path.join(args.outputDir, `${modName}.zip`);
console.log(`Writing ${modPath}`);
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
await events.once(writeStream, "finish");
} else {
let modDir = path.join(args.outputDir, modName);
if (await fs.exists(modDir)) {
console.log(`Removing existing build ${modDir}`);
await fs.remove(modDir);
}
console.log(`Building ${modDir}`);
await fs.copy(args.sourceDir, modDir);
for (let [fileName, pathParts] of Object.entries(info.additional_files) || []) {
let filePath = path.join(...pathParts);
await fs.copy(filePath, path.join(modDir, fileName));
}
delete info.additional_files;
await fs.writeFile(path.join(modDir, "info.json"), JSON.stringify(info, null, 4));
}
}
}
async function build(args) {
let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json")));
if (args.bumpPatch) {
let [major, minor, patch] = info.version.split(".");
patch = String(Number.parseInt(patch, 10) + 1);
info.version = [major, minor, patch].join(".");
}
if (args.factorioVersion) {
info.factorio_version = args.factorioVersion;
}
@@ -58,60 +89,39 @@ async function build(args) {
}
}
if (args.build) {
await fs.ensureDir(args.outputDir);
let modName = `${info.name}_${info.version}`;
if (args.pack) {
let zip = new JSZip();
let walker = klaw(args.sourceDir)
.on("data", item => {
if (item.stats.isFile()) {
// On Windows the path created uses backslashes as the directory sepparator
// but the zip file needs to use forward slashes. We can't use the posix
// version of relative here as it doesn't work with Windows style paths.
let basePath = path.relative(args.sourceDir, item.path).replace(/\\/g, "/");
zip.file(path.posix.join(modName, basePath), fs.createReadStream(item.path));
}
})
;
await events.once(walker, "end");
for (let pathParts of info.additional_files || []) {
let filePath = path.join(args.sourceDir, ...pathParts);
if (!await fs.pathExists(filePath)) {
throw new Error(`Additional file ${filePath} does not exist`);
}
zip.file(path.posix.join(modName, path.basename(filePath)), fs.createReadStream(filePath));
}
delete info.additional_files;
zip.file(path.posix.join(modName, "info.json"), JSON.stringify(info, null, 4));
let modPath = path.join(args.outputDir, `${modName}.zip`);
console.log(`Writing ${modPath}`);
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
await events.once(writeStream, "finish");
} else {
let modDir = path.join(args.outputDir, modName);
if (await fs.exists(modDir)) {
console.log(`Removing existing build ${modDir}`);
await fs.remove(modDir);
}
console.log(`Building ${modDir}`);
await fs.copy(args.sourceDir, modDir);
for (let pathParts of info.additional_files || []) {
let filePath = path.join(...pathParts);
await fs.copy(filePath, path.join(modDir, path.basename(filePath)));
}
delete info.additional_files;
await fs.writeFile(path.join(modDir, "info.json"), JSON.stringify(info, null, 4));
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); });
}
+2 -2
View File
@@ -7,8 +7,8 @@
* @module lib/command
*/
"use strict";
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libLink = require("./link");
const libErrors = require("./errors");
/**
* Represents a command that can be runned by clusterioctl
+171 -45
View File
@@ -4,9 +4,20 @@
const events = require("events");
const isDeepStrictEqual = require("../is_deep_strict_equal");
const { basicType } = require("@clusterio/lib/helpers");
const { basicType } = require("../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`);
}
@@ -688,8 +813,8 @@ class PluginConfigGroup extends ConfigGroup {
if (!this._initialized) {
super._initSubclass();
this.define({
name: "enabled",
description: "Enable plugin",
name: "load_plugin",
title: "Load Plugin",
type: "boolean",
initial_value: true,
});
@@ -699,6 +824,7 @@ class PluginConfigGroup extends ConfigGroup {
module.exports = {
InvalidAccess,
InvalidField,
InvalidValue,
+69 -16
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:
@@ -78,7 +85,7 @@ MasterGroup.define({
title: "Heartbeat Interval",
description: "Interval heartbeats are sent out on WebSocket connections",
type: "number",
initial_value: 60,
initial_value: 45,
});
MasterGroup.define({
name: "connector_shutdown_timeout",
@@ -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",
@@ -196,6 +218,12 @@ InstanceGroup.define({
type: "number",
optional: true,
});
InstanceGroup.define({
name: "auto_start",
description: "Automatically start this instance when the slave hosting it is started up",
type: "boolean",
initial_value: false,
});
InstanceGroup.finalize();
/**
@@ -204,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",
@@ -245,10 +274,19 @@ 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",
description: "Enable verbose logging on the Factorio server",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "strip_paths",
description: "Strip down instance paths in the log",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_adminlist",
@@ -268,6 +306,12 @@ FactorioGroup.define({
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "max_concurrent_commands",
description: "Maximum number of RCON commands trasmitted in parallel",
type: "number",
initial_value: 5,
});
FactorioGroup.finalize();
/**
@@ -286,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",
@@ -299,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",
@@ -345,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);
@@ -357,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
+3 -5
View File
@@ -8,7 +8,7 @@
"use strict";
const fs = require("fs-extra");
const { basicType } = require("@clusterio/lib/helpers");
const { basicType } = require("./helpers");
/**
@@ -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;
}
}
+11
View File
@@ -16,6 +16,16 @@ class CommandError extends Error { }
*/
class RequestError extends Error { }
/**
* Thrown when a permission check fails.
*
* Is a subclass of RequestError to prevent logging stack traces when
* requests fail due to permission denied.
*
* @static
*/
class PermissionError extends RequestError { }
/**
* Signal for messages that fail validation
* @static
@@ -66,6 +76,7 @@ class PluginError extends Error {
module.exports = {
CommandError,
RequestError,
PermissionError,
InvalidMessage,
SessionLost,
AuthenticationFailed,
+105 -92
View File
@@ -6,7 +6,7 @@ const ini = require("ini");
const Jimp = require("jimp");
const JSZip = require("jszip");
const libBuildMod = require("@clusterio/lib/build_mod");
const libBuildMod = require("../build_mod");
/**
* Generate the export mod needed for exportData
@@ -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;
console.log(`Warning: ${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);
}
}
}
}
@@ -324,9 +337,9 @@ async function exportData(server) {
server.on("ipc-item_export", data => items.push(data));
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("output", data => {
if (data.format === "seconds" && data.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(data.message);
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
if (match) {
modOrder.push(match[1]);
}
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable global-require */
/**
* Library for interfacing with Factorio Servers and saves.
* @module lib/factorio
+4 -4
View File
@@ -7,7 +7,7 @@ const JSZip = require("jszip");
const path = require("path");
const semver = require("semver");
const libHash = require("@clusterio/lib/hash");
const libHash = require("../hash");
const knownScenarios = {
@@ -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}")`);
}
}
}
+137 -37
View File
@@ -9,7 +9,9 @@ const crypto = require("crypto");
const ini = require("ini");
const rconClient = require("rcon-client");
const libErrors = require("@clusterio/lib/errors");
const libErrors = require("../errors");
const { logger } = require("../logging");
const { escapeRegExp } = require("../helpers");
/**
@@ -286,7 +288,6 @@ class LineSplitter {
function parseOutput(line, source) {
let output = {
source,
received: Date.now(),
};
// There are three broad categories of output from Factorio, the first kind
@@ -302,13 +303,15 @@ function parseOutput(line, source) {
// A seconds output has two different kinds of formats: The first is a
// log level and source location and has a format of
// "Level File.cpp:123: message"
const secLogRegex = /^(\w+) (\w+\.cpp:\d+): (.*)$/;
// "Script @/path/to/file.lua:123: message"
// "Script =(command):123: message"
const secLogRegex = /^(\w+) ((\w+\.cpp:\d+)|([@=].*?:\d+)): (.*)$/;
let secLogMatch = secLogRegex.exec(secContent);
if (secLogMatch) {
output.type = "log";
output.level = secLogMatch[1];
output.file = secLogMatch[2];
output.message = secLogMatch[3];
output.message = secLogMatch[5];
// The other possibility is that the content is a generic message
} else {
@@ -365,7 +368,7 @@ const outputHeuristics = [
// Message indicating the RCON interface has started
{
filter: { type: "log", message: /^Starting RCON interface/ },
action: function(output) {
action: function() {
this._startRcon().catch((err) => { this.emit("error", err); });
},
},
@@ -373,7 +376,7 @@ const outputHeuristics = [
// Message indicating the server is done starting up
{
filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
action: function(output) {
action: function() {
this._notifyGameReady().catch((err) => { this.emit("error", err); });
},
},
@@ -384,8 +387,8 @@ const outputHeuristics = [
type: "log",
message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/,
},
action: function(output) {
let name = /^Saving to (_autosave\d+) /.exec(output.message)[1];
action: function(parsed) {
let name = /^Saving to (_autosave\d+) /.exec(parsed.message)[1];
this.emit("_autosave", name);
},
},
@@ -396,7 +399,7 @@ const outputHeuristics = [
type: "log",
message: /^Saving game as /,
},
action: function(output) {
action: function() {
this.emit("_saving");
},
},
@@ -412,7 +415,7 @@ const outputHeuristics = [
"Can't save to default location: Default location not known",
],
},
action: function(output) {
action: function() {
this.emit("_saved");
},
},
@@ -423,13 +426,13 @@ const outputHeuristics = [
type: "generic",
message: /^Quitting: /,
},
action: function(output) {
action: function(parsed) {
this.emit("_quitting");
// On windows the Factorio server will wait for input on the console input buffer
// when an error occurs. Since we can't send input there we terminate the server
// process when this happens.
if (process.platform === "win32" && output.message === "Quitting: multiplayer error.") {
if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") {
this._server.kill();
}
},
@@ -445,8 +448,8 @@ const outputHeuristics = [
"MultiplayerManager failed: Host address is already in use.",
],
},
action: function(output) {
this._unexpected.push(`Factorio failed to bind to game port: ${output.message}`);
action: function(parsed) {
this._unexpected.push(`Factorio failed to bind to game port: ${parsed.message}`);
},
},
{
@@ -458,8 +461,8 @@ const outputHeuristics = [
"Can't bind socket: Permission denied",
],
},
action: function(output) {
this._unexpected.push(`Factorio failed to bind to RCON port: ${output.message}`);
action: function(parsed) {
this._unexpected.push(`Factorio failed to bind to RCON port: ${parsed.message}`);
},
},
];
@@ -491,12 +494,21 @@ class FactorioServer extends events.EventEmitter {
* @param {string} factorioDir - Directory of the Factorio install(s).
* @param {string} writeDir - Directory to write runtime data to.
* @param {object} options - Optional parameters.
* @param {Logger} options.logger - Logger to use for reporting errors.
* @param {string} options.version -
* Version of Factorio to use. Can also be the string "latest" to
* use the latest version found in `factorioDir`.
* @param {number} options.gamePort - UDP port to host game on.
* @param {number} options.rconPort - TCP port to use for RCON.
* @param {string} options.rconPassword - Password use for RCON.
* @param {boolean} options.enableWhitelist - Turn on whitelisting.
* @param {boolean} options.verboseLogging - Enable verbose logging.
* @param {boolean} options.stripPaths - Strip paths in the console.
* @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();
@@ -508,6 +520,7 @@ class FactorioServer extends events.EventEmitter {
this._version = null;
this._dataDir = null;
this._logger = options.logger || logger;
this._targetVersion = options.version || "latest";
/** UDP port used for hosting the Factorio game server on */
this.gamePort = options.gamePort || randomDynamicPort();
@@ -517,12 +530,35 @@ class FactorioServer extends events.EventEmitter {
this.rconPassword = options.rconPassword;
/** Enable player whitelist */
this.enableWhitelist = options.enableWhitelist;
/** Enable verbose logging */
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;
this._rconReady = false;
this._gameReady = false;
if (options.stripPaths) {
let chars = new Set(path.resolve(this.writePath("temp")));
chars.delete(":"); // Having a colon could lead to matching the line number
chars = [...chars].join("");
let tempPath = `${path.resolve(this.writePath("temp", "currently-playing"))}${path.sep}`;
let writePath = `${path.resolve(this.writePath())}${path.sep}`;
this._stripRegExp = new RegExp(
// The script printer formats paths using / on both windows and linux.
// But the save path is printed with \ on windows and / on linux.
`(${escapeRegExp(tempPath.replace(/\\/g, "/"))})|` +
`(${escapeRegExp(writePath)})|` +
`(\\.\\.\\.[${escapeRegExp(chars)}/]*?currently-playing/)`,
"g"
);
}
// Array of possible causes for an unexpected shutdown
this._unexpected = [];
@@ -531,12 +567,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;
@@ -594,7 +630,7 @@ class FactorioServer extends events.EventEmitter {
}
if (!this.emit(`ipc-${channel}`, content)) {
console.warn(`Warning: Unhandled ipc-${channel}`, content);
this._logger.warn(`Warning: Unhandled ipc-${channel}`, { content });
}
}
@@ -606,31 +642,37 @@ class FactorioServer extends events.EventEmitter {
this.emit(source, line);
let output = parseOutput(line.toString("utf-8"), source);
line = line.toString("utf-8");
if (this._stripRegExp) {
line = line.replace(this._stripRegExp, "");
}
let parsed = parseOutput(line, source);
heuristicLoop: for (let heuristic of outputHeuristics) {
for (let [name, expected] of Object.entries(heuristic.filter)) {
if (!Object.hasOwnProperty.call(output, name)) {
if (!Object.hasOwnProperty.call(parsed, name)) {
continue heuristicLoop;
}
if (expected instanceof RegExp) {
if (!expected.test(output[name])) {
if (!expected.test(parsed[name])) {
continue heuristicLoop;
}
} else if (expected instanceof Array) {
if (!expected.includes(output[name])) {
if (!expected.includes(parsed[name])) {
continue heuristicLoop;
}
} else if (expected !== output[name]) {
} else if (expected !== parsed[name]) {
continue heuristicLoop;
}
}
// If we get here the filter matched the output
heuristic.action.call(this, output);
heuristic.action.call(this, parsed);
}
this.emit("output", output);
this.emit("output", parsed, line);
}
async _startRcon() {
@@ -647,7 +689,7 @@ class FactorioServer extends events.EventEmitter {
port: this.rconPort,
password: this.rconPassword,
timeout: 200000, // 200s, should allow for commands up to 1250kB in length
maxPending: 5,
maxPending: this.maxConcurrentCommands,
};
this._rconClient = new rconClient.Rcon(config);
@@ -655,12 +697,32 @@ 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) {
this._rconClient.emitter.setMaxListeners(this.maxConcurrentCommands + 5);
}
await this._rconClient.connect();
}
get maxConcurrentCommands() {
return this._maxConcurrentCommands;
}
set maxConcurrentCommands(value) {
this._maxConcurrentCommands = value;
this.setMaxListeners(value + 5);
if (this._rconClient) {
if (this._rconClient.sendQueue) {
this._rconClient.sendQueue.maxConcurrent = value;
}
// XXX: Workaround to suppress bogus event listener warning
if (this._rconClient.emitter instanceof events.EventEmitter) {
this._rconClient.emitter.setMaxListeners(value + 5);
}
}
}
/**
* Initialize class instance
*
@@ -725,12 +787,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;
@@ -770,10 +836,11 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath("..", "bin", "x64", "factorio"),
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
@@ -808,7 +875,7 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath("..", "bin", "x64", "factorio"),
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server", this.writePath("saves", save),
@@ -816,6 +883,7 @@ class FactorioServer extends events.EventEmitter {
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
@@ -842,7 +910,7 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath("..", "bin", "x64", "factorio"),
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
@@ -850,6 +918,7 @@ class FactorioServer extends events.EventEmitter {
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
@@ -880,6 +949,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 !== "") {
@@ -898,17 +970,25 @@ class FactorioServer extends events.EventEmitter {
this._check(["running"]);
let hanged = true;
function setAlive(output) { hanged = false; }
let stopped = false;
function setAlive() { hanged = false; }
let timeoutId = setTimeout(() => {
if (hanged) {
console.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.
@@ -940,6 +1020,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
@@ -1020,6 +1106,20 @@ class FactorioServer extends events.EventEmitter {
return path.join(this._dataDir, ...parts);
}
/**
* Get Factorio binary path
*
* Get the path to the factorio binary depending on the platform (MacOS support)
*
* @returns {string} Path to factorio binary
*/
binaryPath() {
if (process.platform === "darwin") {
return this.dataPath("..", "MacOS", "factorio");
}
return this.dataPath("..", "bin", "x64", "factorio");
}
/**
* Return path in write directory
*
+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);
});
+76
View File
@@ -19,6 +19,82 @@ function basicType(value) {
return typeof value;
}
/**
* Asynchronously wait for the given duration
*
* @param {number} duration - Time to wait for in milliseconds.
*/
async function wait(duration) {
await new Promise(resolve => { setTimeout(resolve, duration); });
}
/**
* Resolve a promise with a timeout.
*
* @param {Promise} promise - Promise to wait for.
* @param {number} time - Maximum time im milliseconds to wait for.
* @param {*=} timeoutResult - Value to return if the operation timed out.
*/
async function timeout(promise, time, timeoutResult) {
let timer;
try {
return await Promise.race([
promise,
new Promise(resolve => {
timer = setTimeout(() => resolve(timeoutResult), time);
}),
]);
} finally {
clearTimeout(timer);
}
}
/**
* 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") {
+49 -25
View File
@@ -3,8 +3,9 @@
const events = require("events");
const WebSocket = require("isomorphic-ws");
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
/**
@@ -45,7 +46,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
_doHeartbeat() {
this._check(["connected", "closing"]);
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
console.log("SOCKET | closing after heartbeat timed out");
logger.verbose("SOCKET | closing after heartbeat timed out");
this._socket.close(1008, "Heartbeat timeout");
clearInterval(this._heartbeatId);
this._heartbeatId = null;
@@ -61,9 +62,8 @@ class WebSocketBaseConnector extends events.EventEmitter {
_processHeartbeat(message) {
if (!libSchema.heartbeat(message)) {
console.log("SOCKET | closing after received invalid heartbeat:", message);
logger.warn("SOCKET | closing after received invalid heartbeat");
this._socket.close(1002, "Invalid heartbeat");
console.log(libSchema.heartbeat.errors);
return;
}
@@ -75,6 +75,9 @@ class WebSocketBaseConnector extends events.EventEmitter {
* Start sending heartbeats
*/
startHeartbeat() {
if (this._heartbeatId) {
throw new Error("heartbeat is already running");
}
this._lastHeartbeat = Date.now();
this._heartbeatId = setInterval(() => {
this._doHeartbeat();
@@ -144,10 +147,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
@@ -174,7 +178,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
setTimeout(timeout) {
this._timeout = timeout;
if (this._reconnectId && this.timeout < this._reconnectDelay / 2) {
if (this._reconnectId && this._timeout < this._reconnectDelay / 2) {
clearTimeout(this._reconnectId);
this.reconnect();
}
@@ -210,6 +214,13 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
if (this._reconnectId) {
clearTimeout(this._reconnectId);
this._reconnectId = null;
this._lastReceivedSeq = null;
this._sessionToken = null;
this._sendBuffer.length = 0;
this._state = "new";
this._connected = false;
this.emit("close");
return;
}
this.stopHeartbeat();
@@ -236,18 +247,16 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
url.pathname += "api/socket";
// Open WebSocket to master
console.log(`SOCKET | connecting to ${url}`);
logger.verbose(`SOCKET | connecting to ${url}`);
// eslint-disable-next-line no-process-env
if (process.env.APP_ENV === "browser") {
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();
@@ -266,18 +275,21 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
if (this._startedReconnect + this._timeout * 1000 < Date.now()) {
console.log("SOCKET | Timed out trying to reconnect");
logger.error("SOCKET | Timed out trying to reconnect");
this._heartbeatInterval = null;
this._lastReceivedSeq = null;
this._sessionToken = null;
this._sendBuffer.length = 0;
this._state = "new";
this._connected = false;
this.emit("close");
return;
}
let delay = Math.random() * this._reconnectDelay;
console.log(`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`);
logger.verbose(
`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`
);
this._reconnectId = setTimeout(() => {
this._reconnectId = null;
this._doConnect();
@@ -286,7 +298,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
_attachSocketHandlers() {
this._socket.onclose = event => {
console.log(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
logger.verbose(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
// Authentication failed
if (event.code === 4003) {
this.emit("error", new libErrors.AuthenticationFailed(event.reason));
@@ -315,11 +327,23 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._socket.onerror = event => {
// It's assumed that close is always called by ws
console.error("SOCKET | Error:", event);
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 = () => {
console.log("SOCKET | Open");
logger.verbose("SOCKET | Open");
};
// Handle messages
@@ -348,17 +372,17 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
_processHandshake(message) {
if (!libSchema.serverHandshake(message)) {
console.log("SOCKET | closing after received invalid handshake:", message);
logger.verbose("SOCKET | closing after received invalid handshake");
this._socket.close(1002, "Invalid handshake");
return;
}
let { seq, type, data } = message;
if (type === "hello") {
console.log(`SOCKET | received hello from master version ${data.version}`);
logger.verbose(`SOCKET | received hello from master version ${data.version}`);
this.emit("hello", data);
if (this._sessionToken) {
console.log("SOCKET | Attempting resume");
logger.verbose("SOCKET | Attempting resume");
this.sendHandshake("resume", {
session_token: this._sessionToken,
last_seq: this._lastReceivedSeq,
@@ -368,7 +392,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
} else if (type === "ready") {
console.log("SOCKET | received ready from master");
logger.verbose("SOCKET | received ready from master");
this._state = "connected";
this._sessionToken = data.session_token;
this._heartbeatInterval = data.heartbeat_interval;
@@ -378,10 +402,10 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
this._startedReconnect = null;
this._connected = true;
this.emit("connect");
this.emit("connect", data);
} else if (type === "continue") {
console.log("SOCKET | resuming existing session");
logger.verbose("SOCKET | resuming existing session");
this._state = "connected";
this._heartbeatInterval = data.heartbeat_interval;
this.startHeartbeat();
@@ -391,7 +415,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
+18 -6
View File
@@ -1,8 +1,9 @@
// Implementation of Link class
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
const messages = require("./messages");
// Some definitions for the terminology used here:
@@ -35,9 +36,21 @@ class Link {
this.processMessage(payload);
} catch (err) {
if (err instanceof libErrors.InvalidMessage) {
console.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
logger.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
if (err.errors) {
console.error(err.errors);
logger.error(JSON.stringify(err.errors, null, 4));
}
// Send back an error response if this was a request.
if (
typeof payload.type === "string"
&& payload.type.endsWith("_request")
&& Number.isInteger(payload.seq)
) {
this.connector.send(
`${payload.type.slice(0, -8)}_response`,
{ seq: payload.seq, error: err.message }
);
}
} else {
this.connector.emit("error", err);
@@ -111,9 +124,8 @@ class Link {
if (handler) {
handler(message);
return true;
} else {
return false;
}
return false;
}
/**
+210 -91
View File
@@ -1,7 +1,27 @@
// Message definitions for links
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const libSchema = require("../schema");
const libErrors = require("../errors");
const { logger } = require("../logging");
class MissingLinkHandlerError extends Error {
constructor(type, source, target) {
super();
this.code = "MISSING_LINK_HANDLER";
this.type = type;
this.source = source;
this.target = target;
this.handler = null;
this.plugin = null;
}
get message() {
let handler = this.handler ? `${this.handler}` : "handler";
let plugin = this.plugin ? ` on plugin ${this.plugin}` : "";
return `Missing ${handler}${plugin} for ${this.type} on ${this.source}-${this.target} link`;
}
}
/**
* Represents a message that can be sent over the link
@@ -50,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;
@@ -68,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`);
@@ -78,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,
@@ -94,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: {
@@ -108,7 +145,7 @@ class Request extends Message {
anyOf: [
{
additionalProperties: false,
required: ["seq", ...Object.keys(responseProperties)],
required: ["seq", ...responseRequired],
properties: {
"seq": { type: "integer" },
...responseProperties,
@@ -168,15 +205,18 @@ class Request extends Message {
}
if (!handler) {
throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`);
throw new MissingLinkHandlerError(this.requestType, link.source, link.target);
}
// Check permission if this is a handler for a control connection on the master
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);
};
}
@@ -187,16 +227,17 @@ class Request extends Message {
response = {};
}
if (!this._responseValidator({ seq: 0, type: this.responseType, response })) {
console.error(this._responseValidator.errors);
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)) {
console.error(`Unexpected error while responding to ${this.requestType}`, err);
logger.error(`Unexpected error while responding to ${this.requestType}:\n${err.stack}`);
}
link.connector.send(this.responseType, { seq: message.seq, error: err.message });
});
@@ -217,7 +258,7 @@ class Request extends Message {
async send(link, data = {}) {
// XXX validate link target/source?
if (!this._requestValidator({ seq: 0, type: this.requestType, data })) {
console.error(this._requestValidator.errors);
logger.error(JSON.stringify(this._requestValidator.errors, null, 4));
throw new Error(`Validation failed sending ${this.requestType}`);
}
let seq = link.connector.send(this.requestType, data);
@@ -259,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"],
@@ -330,18 +402,6 @@ messages.createSlaveConfig = new Request({
},
});
messages.setInstanceOutputSubscriptions = new Request({
type: "set_instance_output_subscriptions",
links: ["control-master"],
permission: "core.instance.follow_log",
requestProperties: {
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.createInstance = new Request({
type: "create_instance",
links: ["control-master"],
@@ -378,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" },
@@ -392,19 +453,10 @@ messages.assignInstanceCommand = new Request({
permission: "core.instance.assign",
requestProperties: {
"instance_id": { type: "integer" },
"slave_id": { type: "integer" },
"slave_id": { type: ["integer", "null"] },
},
});
messages.assignInstance = new Request({
type: "assign_instance",
links: ["master-slave"],
requestProperties: {
"serialized_config": { type: "object" },
},
forwardTo: "instance",
});
messages.startInstance = new Request({
type: "start_instance",
links: ["control-master", "master-slave", "slave-instance"],
@@ -636,8 +688,90 @@ messages.deleteUser = new Request({
},
});
messages.setLogSubscriptions = new Request({
type: "set_log_subscriptions",
links: ["control-master"],
permission: "core.log.follow",
requestProperties: {
"all": { type: "boolean" },
"master": { type: "boolean" },
"slave_ids": {
type: "array",
items: { type: "integer" },
},
"instance_ids": {
type: "array",
items: { type: "integer" },
},
"max_level": { type: ["string", "null"] },
},
});
messages.queryLog = new Request({
type: "query_log",
links: ["control-master"],
permission: "core.log.query",
requestProperties: {
"all": { type: "boolean" },
"master": { type: "boolean" },
"slave_ids": {
type: "array",
items: { type: "integer" },
},
"instance_ids": {
type: "array",
items: { type: "integer" },
},
"max_level": { type: ["string", "null"] },
},
responseProperties: {
"log": {
type: "array",
items: { type: "object" },
},
},
});
// Internal requests
messages.updateInstances = new Request({
type: "update_instances",
links: ["slave-master"],
requestProperties: {
"instances": {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["serialized_config", "status"],
properties: {
"serialized_config": { type: "object" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
},
},
},
});
messages.assignInstance = new Request({
type: "assign_instance",
links: ["master-slave"],
requestProperties: {
"serialized_config": { type: "object" },
},
forwardTo: "instance",
});
messages.unassignInstance = new Request({
type: "unassigne_instance",
links: ["master-slave"],
forwardTo: "instance",
});
messages.getMetrics = new Request({
type: "statistics_exporter:get_metrics",
type: "get_metrics",
links: ["master-slave", "slave-instance"],
responseProperties: {
"results": { type: "array" },
@@ -675,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;
@@ -687,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,
@@ -701,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,
},
},
@@ -766,13 +908,13 @@ class Event extends Message {
}
if (!handler) {
throw new Error(`Missing handler for ${this.eventType} on ${link.source}-${link.target} link`);
throw new MissingLinkHandlerError(this.eventType, link.source, link.target);
}
link.setHandler(this.eventType, message => {
// XXX Should event handlers even be allowed to be async?
handler.call(link, message, this).catch(err => {
console.error(`Unexpected error while handling ${this.eventType}`, err);
logger.error(`Unexpected error while handling ${this.eventType}:\n${err.stack}`);
});
}, this._eventValidator);
}
@@ -788,7 +930,7 @@ class Event extends Message {
*/
send(link, data = {}) {
if (!this._eventValidator({ seq: 0, type: this.eventType, data })) {
console.error(this._eventValidator.errors);
logger.error(JSON.stringify(this._eventValidator.errors, null, 4));
throw new Error(`Validation failed sending ${this.eventType}`);
}
@@ -805,6 +947,21 @@ messages.debugWsMessage = new Event({
},
});
messages.logMessage = new Event({
type: "log_message",
links: ["slave-master", "master-control"],
eventProperties: {
"info": {
type: "object",
required: ["level", "message"],
properties: {
"level": { type: "string" },
"message": { type: "string" },
},
},
},
});
messages.instanceInitialized = new Event({
type: "instance_initialized",
links: ["instance-slave"],
@@ -827,52 +984,6 @@ messages.instanceStatusChanged = new Event({
},
});
messages.updateInstances = new Event({
type: "update_instances",
links: ["slave-master"],
eventProperties: {
"instances": {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["serialized_config", "status"],
properties: {
"serialized_config": { type: "object" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
},
},
},
});
messages.instanceOutput = new Event({
type: "instance_output",
links: ["instance-slave", "slave-master", "master-control"],
forwardTo: "master",
eventProperties: {
"instance_id": { type: "integer" },
"output": {
type: "object",
additionalProperties: false,
required: ["source", "received", "format", "type", "message"],
properties: {
"source": { type: "string" },
"received": { type: "number" },
"format": { type: "string" },
"time": { type: "string" },
"level": { type: "string" },
"file": { type: "string" },
"type": { type: "string" },
"action": { type: "string" },
"message": { type: "string" },
},
},
},
});
messages.masterConnectionEvent = new Event({
type: "master_connection_event",
links: ["slave-instance"],
@@ -959,7 +1070,15 @@ messages.playerEvent = new Event({
*/
function attachAllMessages(link) {
for (let [name, message] of Object.entries(messages)) {
message.attach(link, link[name + message.handlerSuffix]);
let handler = `${name}${message.handlerSuffix}`;
try {
message.attach(link, link[handler]);
} catch (err) {
if (err.code === "MISSING_LINK_HANDLER") {
err.handler = handler;
}
throw err;
}
}
}
+94
View File
@@ -0,0 +1,94 @@
"use strict";
const winston = require("winston");
const Transport = require("winston-transport");
const { LEVEL, MESSAGE } = require("triple-beam");
const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
audit: 3,
info: 4,
server: 5,
verbose: 6,
});
const colors = {
fatal: "inverse red",
error: "bold brightRed",
warn: "bold brightYellow",
audit: "bold brightGreen",
info: "bold brightBlue",
server: "bold",
verbose: "bold grey",
};
winston.addColors(colors);
// The Console Transport is replicated here because it's overly complicated
// in winston, writes to stdout/err instead of console and doesn't work in
// the browser.
class ConsoleTransport extends Transport {
constructor(options = {}) {
super(options);
this.errorLevels = new Set(options.errorLevels || ["fatal", "error"]);
this.warnLevels = new Set(options.warnLevels || ["warn"]);
}
log(info, callback) {
/* eslint-disable no-console */
if (this.errorLevels.has(info[LEVEL])) {
console.error(info[MESSAGE]);
} else if (this.warnLevels.has(info[LEVEL])) {
console.warn(info[MESSAGE]);
} else {
console.log(info[MESSAGE]);
}
/* eslint-enable no-console */
callback();
}
}
/**
* Formats winston log messages for the web console
*/
class WebConsoleFormat {
constructor(options = {}) {
this.options = options;
}
transform(info, options) {
let src = " ";
if (info.slave_id) {
let slaveName = info.slave_id;
src += `s:${slaveName} - `;
}
if (info.instance_id) {
let instanceName = info.instance_id;
src += `i:${instanceName}`;
}
if (info.plugin) {
src += `${info.plugin}: `;
}
if (info.stack) {
info[MESSAGE] = `[${info.level}]${src}${info.stack}`;
} else {
info[MESSAGE] = `[${info.level}]${src}${info.message}`;
}
return info;
}
}
const logger = winston.createLogger({
level: "verbose",
levels,
format: winston.format.timestamp(),
});
module.exports = {
ConsoleTransport,
WebConsoleFormat,
colors,
levels,
logger,
};
+131
View File
@@ -0,0 +1,131 @@
"use strict";
const winston = require("winston");
const Transport = require("winston-transport");
const { LEVEL, MESSAGE } = require("triple-beam");
const chalk = require("chalk");
const libLink = require("./link");
const libErrors = require("./errors");
/**
* Format a parsed Factorio message with colors
*
* Formats a parsed Factorio output from lib/factorio into a readable
* colorized output using terminal escape codes that can be printed.
*
* @param {Object} parsed - Parsed Factorio server output.
* @returns {string} terminal colorized message.
* @private
*/
function formatServerOutput(parsed) {
let time = "";
if (parsed.format === "seconds") {
time = `${chalk.yellow(parsed.time.padStart(8))} `;
} else if (parsed.format === "date") {
time = `${chalk.yellow(parsed.time)} `;
}
let info = "";
if (parsed.type === "log") {
let level = parsed.level;
if (level === "Script") {
level = chalk.bold.greenBright(level);
} else if (level === "Verbose") {
level = chalk.bold.gray(level);
} else if (level === "Info") {
level = chalk.bold.blueBright(level);
} else if (parsed.level === "Warning") {
level = chalk.bold.yellowBright(level);
} else if (parsed.level === "Error") {
level = chalk.bold.redBright(level);
}
info = `${level} ${chalk.gray(parsed.file)}: `;
} else if (parsed.type === "action") {
info = `[${chalk.yellow(parsed.action)}] `;
}
return time + info + parsed.message;
}
// These are defined here to avoid circular dependencies and pulling them
// into the web interface code.
/**
* Formats winston log messages for a character terminal.
*/
class TerminalFormat {
constructor(options = {}) {
this.options = options;
this.colorize = winston.format.colorize(options);
}
transform(info, options) {
info = this.colorize.transform(info, this.colorize.options);
let ts = "";
if (options.showTimestamp && info.timestamp) {
ts = `${info.timestamp.replace("T", " ")} `;
}
let src = " ";
if (info.instance_name) {
src += `i:${info.instance_name} `;
} else if (info.slave_name) {
src += `s:${info.slave_name} `;
}
if (info.plugin) {
src += `${info.plugin}: `;
}
if (info[LEVEL] === "server" && info.parsed) {
info.message = formatServerOutput(info.parsed);
}
if (info.stack) {
info[MESSAGE] = `${ts}[${info.level}]${src}${info.stack}`;
} else {
info[MESSAGE] = `${ts}[${info.level}]${src}${info.message}`;
}
return info;
}
}
/**
* Sends logs over a lib/link connection.
*/
class LinkTransport extends Transport {
constructor(options) {
super(options);
this.link = options.link;
this.filter = options.filter || null;
}
log(info, callback) {
if (this.filter && !this.filter(info)) {
return callback();
}
try {
libLink.messages.logMessage.send(this.link, { info });
} catch (err) {
// Ignore session lost errors.
if (!(err instanceof libErrors.SessionLost)) {
throw err;
}
}
return callback();
}
}
module.exports = {
TerminalFormat,
LinkTransport,
// for testing only
_formatServerOutput: formatServerOutput,
};
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.5",
"repository": "https://github.com/clusterio/factorioClusterio",
"license": "MIT",
"keywords": [
@@ -19,9 +19,12 @@
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"klaw": "^3.0.0",
"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",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
+249 -37
View File
@@ -51,6 +51,15 @@ class BaseInstancePlugin {
* @type {module:slave/slave~Slave}
*/
this.slave = slave;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = instance.logger.child({ plugin: this.info.name });
}
/**
@@ -117,34 +126,34 @@ class BaseInstancePlugin {
* stderr. The output is a parsed form of the line and contains the
* following properties:
*
* @param {Object} output - parsed server output.
* @param {string} output.source -
* @param {Object} parsed - parsed server output.
* @param {string} parsed.source -
* Where the message came from, one of "stdout" and "stderr".
* @param {string} output.format -
* @param {string} parsed.format -
* Timestamp format, one of "date", "seconds" and "none".
* @param {string=} output.time -
* @param {string=} parsed.time -
* Timestamp of the message. Not present if format is "none".
* @param {string} output.type -
* @param {string} parsed.type -
* Type of message, one of "log", "action" and "generic".
* @param {string=} output.level -
* @param {string=} parsed.level -
* Log level for "log" type. i.e "Info" normally.
* @param {string=} output.file - File reported for "log" type.
* @param {string=} output.action -
* @param {string=} parsed.file - File reported for "log" type.
* @param {string=} parsed.action -
* Kind of action for "action" type. i.e "CHAT" for chat.
* @param {string} output.message - Main content of the line.
* @param {string} parsed.message - Main content of the line.
* @param {string} line - raw line of server output.
*/
async onOutput(output) { }
async onOutput(parsed, line) { }
/**
* 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
*
@@ -156,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
@@ -163,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) { }
@@ -192,6 +206,10 @@ class BaseInstancePlugin {
* @param {string} event.name - Name of the player that joined/left.
*/
async onPlayerEvent(event) { }
async sendRcon(message, expectEmpty) {
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
}
}
/**
@@ -206,10 +224,19 @@ class BaseInstancePlugin {
* @static
*/
class BaseMasterPlugin {
constructor(info, master, metrics) {
constructor(info, master, metrics, logger) {
this.info = info;
this.master = master;
this.metrics = metrics;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
}
/**
@@ -270,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
*
@@ -288,16 +314,58 @@ 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) { }
/**
* Called when an avent on a control connection happens
*
* 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 control has been established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the master and a
* control. Plugins should respond to this event by throtteling
* messages it is sending to the given control connection to an absolute
* minimum.
*
* Messages sent over a dropped control connection will get queued up in
* 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 control has been closed.
*
* @param {module:master/master~ControlConnection} connection -
* The connection the event occured on.
* @param {string} event - one of connect, drop, resume, and close.
*/
onControlConnectionEvent(connection, event) { }
/**
* Called when a slave is preparing to disconnect from the master
*
@@ -358,11 +426,20 @@ class BaseMasterPlugin {
* @static
*/
class BaseControlPlugin {
constructor(info) {
constructor(info, logger) {
/**
* The plugin's own info module
*/
this.info = info;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
}
/**
@@ -383,32 +460,166 @@ class BaseControlPlugin {
async addCommands(rootCommand) { }
}
/**
* Plugin supplied login form
* @typedef {Object} module:lib/plugin~LoginForm
* @property {string} name -
* Internal name of the login form, this should start with the
* plugin name followed by a dot.
* @property {string} title -
* Name displayed above this form in the login window.
* @property {React.Component} Component -
* React component that's rendered for this login form. This is
* supplied the setToken function via its props which should be
* called when an authentication token is aquired via this
* form.
*/
/**
* Plugin supplied pages
* @typedef {Object} module:Lib/plugin~Page
* @property {string} path - URL path to this page.
* @property {string=} sidebarPath -
* If present and this path matches one of the pages in the sidebar it
* will cause that sidebar entry to be highlighted as active.
* @property {string=} sidebarName -
* If present creates an entry in the sidebar for this page with the
* given text.
* @property {ReactNode} content -
* A react node which is rendered when this page is navigated to.
* Should render a PageLayout.
*/
/**
* Base class for web interface plugins
*
* @static
*/
class BaseWebPlugin {
constructor(container, packageData, info, logger) {
/**
* Webpack container for this plugin
*/
this.container = container;
/**
* Contents of the plugin's package.json file
* @type {Object}
*/
this.package = packageData;
/**
* The plugin's own info module
*/
this.info = info;
/**
* Control link to the master server, not available until the
* connect event in onMasterConnectionEvent is signaled.
* @type {?module:lib/link.Link}
*/
this.control = null;
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
this.logger = logger.child({ plugin: this.info.name });
/**
* List of login forms provided by this plugin
*
* @type {Array<module:lib/plugin~LoginForm>}
*/
this.loginForms = [];
/**
* List of pages provided by this plugin
*
* @type {Array<module:lib/plugin~Page>}
*/
this.pages = [];
}
/**
* Called immediately after the class is instantiated.
*/
async init() { }
/**
* Called when an event on the master connection happens
*
* 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.
*
* ##### drop
*
* Invoked when a connection loss is detected between the control link
* and the master. Plugins should respond to this event by throtteling
* messages it is sending to the master to an absolute minimum.
*
* Messages sent over a dropped master connection will get queued up in
* memory in the browser 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
* typically means the master server has shut down. Plugins should not
* 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, resume and close
*/
onMasterConnectionEvent(event) { }
}
/**
* 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;
if (plugin === null || !plugin[handler]) {
messageFormat.attach(link);
try {
if (plugin === null || !plugin[handler]) {
messageFormat.attach(link);
} else {
messageFormat.attach(link, async function(message, format) {
return await plugin[handler](message, format, this);
});
} else {
messageFormat.attach(link, async (message, format) => await plugin[handler](message, format, link));
}
} catch (err) {
if (err.code === "MISSING_LINK_HANDLER") {
err.plugin = plugin.info.name;
err.handler = handler;
}
throw err;
}
}
}
@@ -431,7 +642,7 @@ async function invokeHook(plugins, hook, ...args) {
results.push(result);
}
} catch (err) {
console.error(`Ignoring error from plugin ${name} in ${hook}:`, err);
plugin.logger.error(`Ignoring error from plugin ${name} in ${hook}:\n${err.stack}`);
}
}
return results;
@@ -442,6 +653,7 @@ module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
BaseControlPlugin,
BaseWebPlugin,
attachPluginMessages,
invokeHook,
+47 -1
View File
@@ -1,10 +1,11 @@
/* eslint-disable global-require */
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
*/
"use strict";
const libErrors = require("@clusterio/lib/errors");
const libErrors = require("./errors");
const path = require("path");
@@ -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,
};
+1166 -53
View File
File diff suppressed because it is too large. Load diff
+9 -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",
@@ -101,6 +102,13 @@ const serverHandshake = ajv.compile({
properties: {
"session_token": { type: "string" },
"heartbeat_interval": { type: "number" },
"account": {
additionalProperties: false,
required: ["name"],
properties: {
"name": { type: "string" },
},
},
},
},
},
+27 -13
View File
@@ -7,8 +7,15 @@ const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
const { logger } = require("./logging");
const libHelpers = require("./helpers");
function print(...content) {
// eslint-disable-next-line no-console
console.log(...content);
}
/**
* Yargs plugin command
*
@@ -48,37 +55,37 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
let pluginInfo;
try {
// eslint-disable-next-line global-require
pluginInfo = require(path.posix.join(pluginPath, "info"));
} catch (err) {
console.error(`Unable to import plugin info from ${args.path}`);
console.error(err);
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
process.exitCode = 1;
return;
}
if (pluginList.has(pluginInfo.name)) {
console.error(`Plugin with the same ${pluginInfo.name} already exists`);
logger.error(`Plugin with the same ${pluginInfo.name} already exists`);
process.exitCode = 1;
return;
}
pluginList.set(pluginInfo.name, pluginPath);
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
console.log(`Added ${pluginInfo.name}`);
print(`Added ${pluginInfo.name}`);
} else if (command === "remove") {
if (!pluginList.delete(args.name)) {
console.error(`Plugin with name ${args.name} does not exist`);
logger.error(`Plugin with name ${args.name} does not exist`);
process.exitCode = 1;
return;
}
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
console.log(`Removed ${args.name}`);
print(`Removed ${args.name}`);
} else if (command === "list") {
for (let [pluginName, pluginPath] of pluginList) {
console.log(`${pluginName} - ${pluginPath}`);
print(`${pluginName} - ${pluginPath}`);
}
}
}
@@ -94,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")
@@ -119,23 +130,26 @@ async function handleConfigCommand(args, instance, configPath) {
for (let GroupClass of instance.constructor.groups.values()) {
for (let def of GroupClass.definitions.values()) {
let value = instance.get(def.fullName);
console.log(`${def.fullName} ${JSON.stringify(value)}`);
print(`${def.fullName} ${JSON.stringify(value)}`);
}
}
} else if (command === "show") {
try {
console.log(instance.get(args.field));
print(instance.get(args.field));
} catch (err) {
if (err instanceof libConfig.InvalidField) {
console.error(err.message);
logger.error(err.message);
} else {
throw err;
}
}
} 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;
}
@@ -144,7 +158,7 @@ async function handleConfigCommand(args, instance, configPath) {
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
} catch (err) {
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
console.error(err.message);
logger.error(err.message);
} else {
throw err;
}
+27 -8
View File
@@ -9,6 +9,9 @@
"use strict";
const jwt = require("jsonwebtoken");
const libErrors = require("./errors");
/**
* Represents a permission that can be granted
* @static
@@ -102,7 +105,7 @@ class Role {
function ensureDefaultAdminRole(roles) {
let admin = roles.get(0);
if (!admin) {
admin = new Role({ id: 0, name: "Admin", description: "Cluster wide administrator." });
admin = new Role({ id: 0, name: "Cluster Admin", description: "Cluster wide administrator." });
roles.set(0, admin);
}
admin.permissions.add("core.admin");
@@ -254,7 +257,8 @@ class User {
* "Permission denied" error is thrown.
*
* @param {string} permission - The permission to check for.
* @throws {Error} if the user does noh have the given permission.
* @throws {Error} If the given permission does not exist.
* @throws {module:lib/errors.PermissionError} if the user does noh have the given permission.
*/
checkPermission(permission) {
if (!permissions.has(permission)) {
@@ -267,7 +271,7 @@ class User {
}
}
throw new Error("Permission denied");
throw new libErrors.PermissionError("Permission denied");
}
notifyJoin(instance_id) {
@@ -302,6 +306,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",
@@ -345,11 +359,6 @@ definePermission({
title: "Assign instance",
description: "Assign or reassign instances to a slave.",
});
definePermission({
name: "core.instance.follow_log",
title: "Follow instance log",
description: "Listen for the stdout log of instances.",
});
definePermission({
name: "core.instance.create_save",
title: "Create new instance save",
@@ -450,6 +459,16 @@ definePermission({
title: "Delete user",
description: "Delete users and all data stored for them.",
});
definePermission({
name: "core.log.follow",
title: "Follow cluster log",
description: "Receive new entries in the cluster log. Required to see instance console.",
});
definePermission({
name: "core.log.query",
title: "Query cluster log",
description: "Query past entries in the cluster log. Required to see past entries in instance console.",
});
definePermission({
name: "core.debug.dump_ws",
+31 -52
View File
@@ -1,68 +1,55 @@
Clusterio Master Server
=======================
# Clusterio Master Server
Communication hub for Clusterio clusters. The master server forwards
data between Clusterio slaves connected to it and allows the cluster to
be remotely managed through WebSocket connections to it either by using
the included web interface or the Clusterio ctl command line utility.
Communication hub for Clusterio clusters.
The master server forwards data between Clusterio slaves connected to it and allows the cluster to be remotely managed through WebSocket connections to it either by using the included web interface or the Clusterio ctl command line utility.
Usage
-----
# Usage
npx clusteriomaster <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to the master server. Defaults to
`plugin-list.json` and will be created if it does not exist. See the
`plugin` command for managing this list.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the master server.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the
master server. Defaults to `config-master.json` and will be created
if it does not exist. See the `config` command for inspecting and
modifying the configuration.
* `--config <file>` JSON file to use for storing configuration for the master server.
Defaults to `config-master.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the master server. The
available plugins will be loaded unless they have been disabled in the
configuration, see the config command for disabling plugins.
Configure plugins available to be loaded by the master server.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
Remove a plugin by its name.
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusteriomaster plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -72,20 +59,17 @@ Lists the plugins set up to be available by name followed by path.
### `config`
Manage the master server configuration offline. This should only be
used when the master server is stopped, otherwise the config read might
be out of date and config changes will be overwritten when the master
server shuts down.
Manage the master server configuration offline.
This should only be used when the master server is stopped, otherwise the config read might be out of date and config changes will be overwritten when the master server shuts down.
#### `config set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
Set a config entry to the given value.
If value is not provided the entry is set to null.
If the config entry is of type object the value must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration entries.
See docs/configuration.md in the main repositiory for the available configuration entries.
#### `config show <config-entry>`
@@ -105,22 +89,19 @@ Commands for setting up initial admin acount and access to the cluster.
#### `bootstrap create-admin <name>`
Creates a new user account with the given name if it does not already
exist in the cluster and grant it full cluster admin permission. You
should be using your own Factorio multiplayer username here.
Creates a new user account with the given name if it does not already exist in the cluster and grant it full cluster admin permission.
You should be using your own Factorio multiplayer username here.
#### `bootstrap generate-user-token <name>`
Generate an access token for logging in and possibly managing the
cluster from the given user account. This can be used both in the web
interface and as the `master_token` to connect with clusterioctl.
Generate an access token for logging in and possibly managing the cluster from the given user account.
This can be used both in the web interface and as the `master_token` to connect with clusterioctl.
#### `bootstrap create-ctl-config <name>`
Creates a clusterioctl config for the given user with url and token set
up for connecting to the cluster.
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
### `run`
@@ -130,6 +111,4 @@ Runs the master server.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/factorioClusterio) for instructions on how to set up a cluster.
+669 -312
View File
File diff suppressed because it is too large. Load diff
+5 -10
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.5",
"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.2",
"@clusterio/lib": "^2.0.0-alpha.5",
"body-parser": "^1.18.2",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
@@ -29,24 +29,19 @@
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"node-factorio-api": "^0.3.8",
"node-forge": "^0.10.0",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.2",
"babel-loader": "^8.1.0",
"css-loader": "^4.3.0",
"@clusterio/web_ui": "^2.0.0-alpha.5",
"fast-deep-equal": "^3.1.3",
"file-loader": "^6.2.0",
"null-loader": "^4.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"style-loader": "^1.2.1",
"webpack-cli": "^3.3.12",
"webpack-dev-middleware": "^3.7.2",
"webpack-dev-middleware": "^4.1.0",
"webpack-merge": "^5.2.0"
},
"publishConfig": {
+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;
-45
View File
@@ -1,45 +0,0 @@
"use strict";
const forge = require("node-forge");
const fs = require("fs-extra");
const util = require("util");
const hrtime = process.hrtime.bigint;
module.exports = async function({
bits = 2048,
sslCertPath,
sslPrivKeyPath,
doLogging = false,
} = {}){
function log(t) {
if(doLogging) console.log("src/generate_ssl_cert.js | "+t);
}
log("Generating SSL certificate with "+bits+" bits at "+sslCertPath);
let startNs = hrtime();
let keypair = await util.promisify(forge.pki.rsa.generateKeyPair)({ bits: bits, e: 0x10001 });
log("Generated 2048bit 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));
log("Generated certificate in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
};
+5 -5
View File
@@ -2,15 +2,15 @@
// 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",
"/plugins",
"/plugins/:name/view",
];
+2 -1
View File
@@ -3,9 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2a2a2a" />
<title>Clusterio</title>
<script>
window.webRoot = "__WEB_ROOT__";
window.webRoot = new URL("__WEB_ROOT__", document.location).pathname;
</script>
</head>
<body>
+3 -1
View File
@@ -1 +1,3 @@
import("@clusterio/web_ui/src/bootstrap");
import("@clusterio/web_ui").then(webUi => {
return webUi.bootstrap();
}).catch(console.error);
+11 -4
View File
@@ -9,19 +9,26 @@ module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.jsx",
devServer: {
contentBase: "./static",
contentBase: "./dist/web",
},
output: {
publicPath: "auto",
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "master",
shared: {
"@clusterio/lib": { singleton: true },
"@clusterio/lib/config": { singleton: true },
"@clusterio/lib/errors": { singleton: true },
"@clusterio/lib/helpers": { singleton: true },
"@clusterio/lib/link": { singleton: true },
"@clusterio/lib/logging": { singleton: true },
"@clusterio/lib/plugin": { singleton: true },
"@clusterio/lib/schema": { singleton: true },
"@clusterio/lib/users": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"ajv": {},
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
+27 -45
View File
@@ -1,70 +1,56 @@
Clusterio Slave
===============
# Clusterio Slave
Node hosting Factorio servers in a Clusterio cluster. Clusterio slaves
connect to the master server and waits for commands from the master
server to start up and stop instances. A cluster can have any number of
slaves in it located on different computers, and each slave can host any
number of instances each of which is a Factorio server that talks with
the rest of the cluster.
Node hosting Factorio servers in a Clusterio cluster.
Clusterio slaves connect to the master server and waits for commands from the master server to start up and stop instances.
A cluster can have any number of slaves in it located on different computers, and each slave can host any number of instances each of which is a Factorio server that talks with the rest of the cluster.
Usage
-----
## Usage
npx clusterioslave <command>
Common options:
* `--plugin-list <file>` JSON file to use for storing the list of
plugins that are available to the slave. Defaults to
`plugin-list.json` and will be created if it does not exist. See the
`plugin` command for managing this list.
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to the slave.
Defaults to `plugin-list.json` and will be created if it does not exist.
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the
slave. Defaults to `config-slave.json` and will be created if it
does not exist. See the `config` command for inspecting and
modifying the configuration.
* `--config <file>` JSON file to use for storing configuration for the slave.
Defaults to `config-slave.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
### `plugin <command>`
Configure plugins available to be loaded by the slave. The available
plugins will be loaded unless they have been disabled in the
configuration, see the config command for disabling plugins.
Configure plugins available to be loaded by the slave.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the
plugin directory. A relative path must start with ./ or ../ (or .\ and
..\ on Windows) otherwise it will be assumed to be a require path for an
installed package in node_modules.
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
For example, installing the Subspace Storage plugin:
npm install @clusterio/plugin-subspace_storage
npx clusterioslave plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
Remove a plugin by its name. This should be done before uninstalling
the plugin, otherwise there will be an error when Clusterio tries to
load the info from the plugin. Removing and unistalling a plugin is
usually not neccessary as the functions provided by the plugin can be
disabled in the config.
Remove a plugin by its name.
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
For example, uninstalling the Subspace Storage plugin:
npx clusterioslave plugin remove subspace_storage
npm uninstall @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between master, slave and ctl you
usually only need to do this once per machine.
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
#### `plugin list`
@@ -74,19 +60,17 @@ Lists the plugins set up to be available by name followed by path.
### `config`
Manage the slave configuration offline. This should only be used when
the slave is stopped, otherwise the config read might be out of date and
config changes will be overwritten when the slave shuts down.
Manage the slave configuration offline.
This should only be used when the slave is stopped, otherwise the config read might be out of date and config changes will be overwritten when the slave shuts down.
#### `config set <config-entry> [value]`
Set a config entry to the given value. If value is not provided the
entry is set to null. If the config entry is of type object the value
must be a valid JSON serialization of an object.
Set a config entry to the given value.
If value is not provided the entry is set to null.
If the config entry is of type object the value must be a valid JSON serialization of an object.
See docs/configuration.md in the main repositiory for the available
configuration.
See docs/configuration.md in the main repositiory for the available configuration.
#### `config show <config-entry>`
@@ -106,6 +90,4 @@ Runs the slave.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
[The Clusterio repository](https://github.com/clusterio/factorioClusterio) for instructions on how to set up a cluster.
@@ -1,4 +1,8 @@
---------------------------------------------------------------------------------------------------
Version: 0.1.2
Features:
- Added support for Factorio 1.1.
---------------------------------------------------------------------------------------------------
Version: 0.1.1
Features:
- Added support for Factorio 0.18.x
+17 -5
View File
@@ -1,12 +1,24 @@
{
"name": "clusterio_lib",
"version": "0.1.1",
"variants": [
{
"version": "0.1.0",
"factorio_version": "0.17"
},
{
"version": "0.1.1",
"factorio_version": "0.18"
},
{
"version": "0.1.2",
"factorio_version": "1.1"
}
],
"title": "Clusterio Library (Alpha)",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"factorio_version": "0.17",
"additional_files": [
["..", "..", "modules", "clusterio", "serialize.lua"]
]
"additional_files": {
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
}
}
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio slave server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.5",
"main": "slave.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -18,11 +18,12 @@
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.2",
"@clusterio/lib": "^2.0.0-alpha.5",
"fs-extra": "^8.1.0",
"phin": "^3.4.1",
"pidusage": "^2.0.18",
"set-blocking": "^2.0.0",
"winston": "^3.3.3",
"yargs": "^14.2.0"
},
"publishConfig": {
+398 -227
View File
File diff suppressed because it is too large. Load diff
+15 -3
View File
@@ -3,7 +3,8 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.2",
"version": "2.0.0-alpha.5",
"browser": "src/index.jsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
@@ -15,14 +16,25 @@
},
"dependencies": {
"@ant-design/icons": "^4.2.2",
"@clusterio/lib": "^2.0.0-alpha.2",
"@babel/core": "^7.13.8",
"@babel/preset-react": "^7.12.13",
"@clusterio/lib": "^2.0.0-alpha.5",
"antd": "^4.6.5",
"babel-loader": "^8.2.2",
"buffer": "^6.0.2",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^5.1.0",
"events": "^3.2.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.1",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0"
"react-router-dom": "^5.2.0",
"style-loader": "^2.0.0",
"util": "^0.12.3"
},
"publishConfig": {
"access": "public"
-6
View File
@@ -1,6 +0,0 @@
// Calculate basedir of application based on current location and
// the provided web_root from master server.
const location = new URL(window.webRoot, document.location);
const basename = location.pathname !== "/" ? location.pathname : "";
export default basename;
+41 -18
View File
@@ -4,6 +4,8 @@ import React from "react";
import ReactDOM from "react-dom";
import libConfig from "@clusterio/lib/config";
import libPlugin from "@clusterio/lib/plugin";
import { ConsoleTransport, WebConsoleFormat, logger } from "@clusterio/lib/logging";
import App from "./components/App";
import { Control, ControlConnector } from "./util/websocket";
@@ -24,38 +26,60 @@ async function loadScript(url) {
return result;
}
async function loadPluginInfos() {
async function loadPlugins() {
let response = await fetch(`${webRoot}api/plugins`);
let pluginList;
if (response.ok) {
pluginList = await response.json();
} else {
console.log("Failed to get plugin data, running without plugins");
logger.error("Failed to get plugin data, running without plugins");
pluginList = [];
}
let pluginInfos = [];
let plugins = new Map();
await __webpack_init_sharing__("default");
for (let plugin of pluginList) {
for (let meta of pluginList) {
try {
await loadScript(`${webRoot}plugin/${plugin.name}/remoteEntry.js`);
let container = window[`plugin_${plugin.name}`];
await loadScript(`${webRoot}plugins/${meta.name}/remoteEntry.js`);
let container = window[`plugin_${meta.name}`];
if (!container) {
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
}
await container.init(__webpack_share_scopes__.default);
let info = (await container.get("./info"))();
info.container = container;
pluginInfos.push(info);
let pluginInfo = (await container.get("./info"))();
let pluginPackage = (await container.get("./package.json"))();
let WebPluginClass = libPlugin.BaseWebPlugin;
if (meta.enabled && pluginInfo.webEntrypoint) {
let webModule = (await container.get(pluginInfo.webEntrypoint))();
if (!webModule.WebPlugin) {
throw new Error("Plugin webEntrypoint does not export WebPlugin class");
}
WebPluginClass = webModule.WebPlugin;
}
let plugin = new WebPluginClass(container, pluginPackage, pluginInfo, logger);
await plugin.init();
plugins.set(pluginInfo.name, plugin);
} catch (err) {
console.log(`Failed to load plugin info for ${plugin.name}`);
console.log(err);
logger.error(`Failed to load plugin ${meta.name}`);
if (err.stack) {
logger.error(err.stack);
}
}
}
return pluginInfos;
return plugins;
}
async function load() {
let pluginInfos = await loadPluginInfos();
export default async function bootstrap() {
logger.add(new ConsoleTransport({
level: "verbose",
format: new WebConsoleFormat(),
}));
let plugins = await loadPlugins();
let pluginInfos = [...plugins.values()].map(p => p.info);
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
@@ -63,9 +87,8 @@ async function load() {
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10);
let control = new Control(controlConnector, []);
controlConnector.setTimeout(15);
let control = new Control(controlConnector, plugins);
ReactDOM.render(<App control={control}/>, document.getElementById("root"));
ReactDOM.render(<App control={control} plugins={plugins}/>, document.getElementById("root"));
}
load().catch(console.log);
+41 -18
View File
@@ -2,13 +2,27 @@ import events from "events";
import React, { useEffect, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import basename from "../basename";
import { logger } from "@clusterio/lib/logging";
import ErrorBoundary from "./ErrorBoundary";
import SiteLayout from "./SiteLayout";
import ControlContext from "./ControlContext";
import PluginsContext from "./PluginsContext";
import LoginForm from "./LoginForm";
import { Card, Spin } from "antd";
import { Card, Spin, Typography } from "antd";
const { Paragraph } = Typography;
function ErrorCard(props) {
return <div className="login-container">
<Card>
<h1>An unexpected error occured</h1>
<Paragraph code className="error-traceback">{props.error.stack}</Paragraph>
</Card>
</div>;
}
export default function App(props) {
let [connected, setConnected] = useState(false);
@@ -17,6 +31,7 @@ export default function App(props) {
function clearToken() {
setToken(null);
connector.token = null;
localStorage.removeItem("master_token");
}
@@ -31,7 +46,7 @@ export default function App(props) {
}
function onError(err) {
console.error("Unexpected error in connector", err);
logger.error(`Unexpected error in connector:\n${err.stack}`);
clearToken();
setConnected(false);
}
@@ -40,17 +55,24 @@ export default function App(props) {
connector.on("close", onClose);
connector.on("error", onError);
if (token && !connected) {
connector.token = token;
connector.connect();
}
return () => {
connector.off("error", onError);
connector.off("close", onClose);
connector.off("connect", onConnect);
};
});
}, [token, props.control]);
useEffect(() => {
if (token && !connected) {
if (props.control.loggingOut) {
clearToken();
props.control.loggingOut = false;
} else {
connector.token = token;
connector.connect();
}
}
}, [token, connected]);
let page;
if (connected) {
@@ -66,18 +88,19 @@ export default function App(props) {
} else {
page = <div className="login-container">
<Card>
<h1>Clusterio</h1>
<LoginForm setToken={setToken} />
</Card>
<LoginForm setToken={setToken} />
</div>;
}
return (
<ControlContext.Provider value={props.control}>
<BrowserRouter basename={basename}>
{page}
</BrowserRouter>
</ControlContext.Provider>
<ErrorBoundary Component={ErrorCard}>
<ControlContext.Provider value={props.control}>
<PluginsContext.Provider value={props.plugins}>
<BrowserRouter basename={webRoot}>
{page}
</BrowserRouter>
</PluginsContext.Provider>
</ControlContext.Provider>
</ErrorBoundary>
);
}
@@ -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,506 +1,24 @@
import React, { useEffect, useContext, 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 formatOutputColored(output, key) {
let time = "";
if (output.format === "seconds") {
time = <span className="factorio-time">{output.time.padStart(8)} </span>;
} else if (output.format === "date") {
time = <span className="factorio-time">{output.time} </span>;
}
let info = "";
if (output.type === "log") {
let level = output.level;
if (level === "Info") {
level = <span className="factorio-info">{level}</span>;
} else if (output.level === "Warning") {
level = <span className="factorio-warning">{level}</span>;
} else if (output.level === "Error") {
level = <span className="factorio-error">{level}</span>;
}
info = <>{level} <span className="factorio-filename">{output.file}: </span></>;
} else if (output.type === "action") {
info = <>[<span className="factorio-action">{output.action}</span>] </>;
}
return <span key={key}>{time}{info}{output.message}<br/></span>;
}
function InstanceConsole(props) {
let control = useContext(ControlContext);
let [lines, setLines] = useState([{ message: "<previous messages are not shown>" }]);
useEffect(() => {
function outputHandler(output) {
setLines(currentLines => currentLines.concat([output]));
}
control.onInstanceOutput(props.id, outputHandler);
return () => {
control.offInstanceOutput(props.id, outputHandler);
};
}, [props.id]);
return <>
<Paragraph code className="instance-console">
{lines.map(formatOutputColored)}
<div className="scroll-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,19 +33,16 @@ export default function InstancesPage() {
title: "Name",
}],
insert: async args => {
let instanceConfig = new libConfig.InstanceConfig();
let instanceConfig = new libConfig.InstanceConfig("control");
await instanceConfig.init();
console.log("Base config", instanceConfig);
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 });
console.log("Created instance", response);
},
}}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/instances/${record.key}/view`);
},
}),
+43 -9
View File
@@ -1,27 +1,61 @@
import React from "react";
import { Form, Input, Button } from "antd";
import React, { Fragment, useContext } from "react";
import { Button, Card, Divider, Form, Image, Input, List, Space, Spin, Typography } from "antd";
import LockOutlined from "@ant-design/icons/LockOutlined";
export default function LoginForm(props) {
return (
import logo from "../images/logo.png";
import PluginsContext from "./PluginsContext";
const { Paragraph, Text } = Typography;
function TokenAuth(props) {
return <>
<Paragraph>If you have generated an authentication token from the master server, use that here.</Paragraph>
<Form
name="login"
onFinish={(values) => { props.setToken(values.token); }}
layout="inline"
>
<Form.Item
name="token"
rules={[
{ required: true, message: "Please input authentication token" },
]}
rules={[{ required: true }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
style={{ width: "14em" }}
prefix=<LockOutlined className="site-form-item-icon" />
type="password"
placeholder="Authentication Token"
/>
</Form.Item>
<Button type="primary" htmlType="submit">Log in</Button>
</Form>
);
</>;
}
export default function LoginForm(props) {
let plugins = useContext(PluginsContext);
let loginForms = [];
for (let plugin of plugins.values()) {
loginForms.push(...plugin.loginForms);
}
loginForms.push({
name: "core.token",
title: "Token",
Component: TokenAuth,
});
return <Card style={{ maxWidth: "30em" }}>
<Space style={{ width: "100%" }} direction="vertical" align="center">
<h1>Clusterio</h1>
<Image width={128} height={123} src={logo} />
Log in to the cluster using one of the methods below.
</Space>
{loginForms.map(form => <Fragment key={form.name}>
<Divider orientation="center">{form.title}</Divider>
<form.Component setToken={props.setToken} />
</Fragment>)}
</Card>;
}
@@ -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>;
};
@@ -0,0 +1,96 @@
import React, { useEffect, useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Alert, Descriptions, Spin } from "antd";
import PluginsContext from "./PluginsContext";
import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
export default function PluginViewPage() {
let params = useParams();
let plugins = useContext(PluginsContext);
let [pluginList, setPluginList] = useState(null);
let pluginName = params.name;
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
let plugin = plugins.get(pluginName);
let pluginTitle = plugin ? plugin.info.title : pluginName;
let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }];
if (!pluginList) {
return <PageLayout nav={nav}>
<Descriptions borderd size="small" title={pluginTitle} />
<Spin size="large" />
</PageLayout>;
}
let pluginMeta = pluginList.find(p => p.name === pluginName);
if (!pluginMeta) {
return <PageLayout nav={nav}>
<h2>Plugin not found</h2>
<p>Plugin with name {pluginName} was not found on the master server.</p>
</PageLayout>;
}
if (!plugin) {
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
</Descriptions>
<Alert
style={{
marginTop: "1em",
}}
message="Error loading web module"
description={
"The web interface was unable to load the webpack module for this plugin. This is "+
"usually due to an incorrect or missing webpack build for the plugin. Due to the "+
"module not being loaded, the configs and web controls defined by this plugin will "+
"not be available in the web interface."
}
type="error"
showIcon
/>
</PageLayout>;
}
return <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded" span={2}>{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions.Item label="Description" span={3}>{plugin.info.description}</Descriptions.Item>
{plugin.package.homepage ? <Descriptions.Item label="Homepage" span={3}>
<a href={plugin.package.homepage}>{plugin.package.homepage}</a>
</Descriptions.Item> : null}
{plugin.package.author ? <Descriptions.Item label="Author" span={3}>
{plugin.package.author}
</Descriptions.Item> : null}
<Descriptions.Item label="License" span={3}>{plugin.package.license}</Descriptions.Item>
</Descriptions>
{pluginMeta.version !== plugin.package.version ? <Alert
style={{
marginTop: "1em",
}}
message="Version missmatch detected"
description={
`The version of the web interface module for this plugin (${plugin.package.version}) `+
`does not match the version running on the master server (${pluginMeta.version}), `+
"spurious errors may occur. This usually happens when the plugin is updated but the "+
"master server has not been restarted yet, but may also be due to an outdated build."
}
type="warning"
showIcon
/> : null}
</PageLayout>;
}
@@ -0,0 +1,3 @@
import React from "react";
export default React.createContext();
@@ -0,0 +1,81 @@
import React, { useContext, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { Table } from "antd";
import notify from "../util/notify";
import PluginsContext from "./PluginsContext";
import PageLayout from "./PageLayout";
export default function PluginsPage() {
let plugins = useContext(PluginsContext);
let history = useHistory();
let [pluginList, setPluginList] = useState([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
} else {
notify("Failed to load plugin list");
}
})();
}, []);
let tableContents = [];
for (let meta of pluginList) {
if (plugins.has(meta.name)) {
let plugin = plugins.get(meta.name);
tableContents.push({
meta,
info: plugin.info,
package: plugin.package,
});
} else {
tableContents.push({
meta,
});
}
}
return <PageLayout nav={[{ name: "Plugins" }]}>
<h2>Plugins</h2>
<Table
columns={[
{
title: "Name",
key: "name",
render: plugin => (plugin.info ? plugin.info.title : plugin.meta.name),
},
{
title: "Version",
key: "version",
render: plugin => {
if (!plugin.package) {
return "Error loading module";
}
if (plugin.package.version !== plugin.meta.version) {
return "Version missmatched";
}
return plugin.package.version;
},
},
{
title: "Loaded",
dataIndex: ["meta", "loaded"],
render: loaded => (loaded ? "Yes" : null),
responsive: ["sm"],
},
]}
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
pagination={false}
onRow={plugin => ({
onClick: event => {
history.push(`/plugins/${plugin.meta.name}/view`);
},
})}
/>
</PageLayout>;
}
@@ -78,7 +78,6 @@ export default function RoleViewPage() {
initialValues={initialValues}
onValuesChange={() => { setEdited(true); }}
onFinish={values => {
console.log(values);
let newPermissions = [];
for (let [name, value] of Object.entries(values.permissions)) {
if (value) { newPermissions.push(name); }
@@ -41,7 +41,6 @@ export default function UsersPage() {
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/roles/${record.key}/view`);
},
}),
+43 -37
View File
@@ -1,52 +1,58 @@
import React, { useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { Switch, Route, useHistory } from "react-router-dom";
import { Layout, Menu } from "antd";
import { Dropdown, Layout, Menu } from "antd";
import UserOutlined from "@ant-design/icons/UserOutlined";
import webUiPackage from "../../package.json";
import { useAccount } from "../model/account";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import PluginsContext from "./PluginsContext";
import { pages } from "../pages";
const { Header, Sider } = Layout;
import SlavesPage from "./SlavesPage";
import InstancesPage from "./InstancesPage";
import InstanceViewPage from "./InstanceViewPage";
import UsersPage from "./UsersPage";
import UserViewPage from "./UserViewPage";
import RolesPage from "./RolesPage";
import RoleViewPage from "./RoleViewPage";
const pages = [
{ path: "/instances/:id/view", sidebarPath: "/instances", content: <InstanceViewPage /> },
{ path: "/users/:name/view", sidebarPath: "/users", content: <UserViewPage /> },
{ path: "/roles/:id/view", sidebarPath: "/roles", content: <RoleViewPage /> },
];
const sidebar = [
{ name: "Slaves", path: "/slaves", content: <SlavesPage /> },
{ name: "Instances", path: "/instances", content: <InstancesPage />},
{ name: "Users", path: "/users", content: <UsersPage /> },
{ name: "Roles", path: "/roles", content: <RolesPage /> },
];
export default function SiteLayout(props) {
let history = useHistory();
let [sidebarPath, setSidebarPath] = useState(null);
let [currentSidebarPath, setCurrentSidebarPath] = useState(null);
let account = useAccount();
let plugins = useContext(PluginsContext);
function SetSidebar(setSidebarProps) {
useEffect(() => {
setSidebarPath(setSidebarProps.path);
setCurrentSidebarPath(setSidebarProps.path);
});
return null;
}
let accountMenu = <Menu>
<Menu.ItemGroup title={account.name}/>
<Menu.Divider/>
<Menu.Item danger onClick={() => { account.logOut(); }}>Log out</Menu.Item>
</Menu>;
let combinedPages = [...pages];
for (let plugin of plugins.values()) {
combinedPages.push(...plugin.pages);
}
return <Layout style={{ minHeight: "100vh" }}>
<Header className="header">
<div className="site-logo" />
<span className="site-name" style={{ top: -10 }}>Clusterio</span>
<span style={{ position: "absolute", top: 10, left: 120, color: "#f00" }}>{ webUiPackage.version }</span>
<span className="site-name">Clusterio</span>
<span className="site-version">{ webUiPackage.version }</span>
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={["1"]}>
<Menu.Item key="1">Dashboard</Menu.Item>
</Menu>
<Dropdown
className="account-dropdown-header"
placement="bottomRight"
trigger="click"
overlay={accountMenu}
>
<UserOutlined/>
</Dropdown>
</Header>
<Layout className="site-layout">
<Sider
@@ -59,22 +65,22 @@ export default function SiteLayout(props) {
>
<Menu
mode="inline"
selectedKeys={[sidebarPath]}
selectedKeys={[currentSidebarPath]}
style={{ height: "100%", borderRight: 0 }}
onClick={({ key }) => history.push(key)}
>
{sidebar.map(({name, path}) => <Menu.Item key={path}>{name}</Menu.Item>)}
{combinedPages.map(({sidebarName, path}) => (
sidebarName ? <Menu.Item key={path}>{sidebarName}</Menu.Item> : null)
)}
</Menu>
</Sider>
<Layout className="site-layout-content-container">
<Switch>
{sidebar.map(({path, content}) => <Route exact path={path} key={path}>
<SetSidebar path={path} />
{content}
</Route>)}
{pages.map(page => <Route exact path={page.path} key={page.path}>
<SetSidebar path={page.sidebarPath} />
{page.content}
{combinedPages.map(({path, sidebarPath, content}) => <Route exact path={path} key={path}>
<SetSidebar path={sidebarPath ? sidebarPath : path} />
<ErrorBoundary Component={ErrorPage}>
{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>;
}
@@ -59,7 +59,6 @@ export default function UsersPage() {
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
console.log(record, rowIndex);
history.push(`/users/${record.key}/view`);
},
}),
+82 -90
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,34 +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;
// console.log(pagination, filters, 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
// })
console.log(data);
data = data || {};
this.setState({
// eslint-disable-next-line new-cap
@@ -96,8 +87,6 @@ class DataTable extends Component {
}
async onEditFinish(values) {
console.log("Finished editing", values);
if (this.state.inserting) { // Create new record
await this.props.AddRecord.insert(values);
} else { // Edit existing record, capable of doing sparse updates
@@ -114,22 +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
console.log("Deleted record", status);
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();
@@ -139,61 +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") {
console.log("onRow", record, rowIndex);
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>
@@ -210,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}
@@ -242,20 +232,21 @@ class DataTable extends Component {
onFinish={this.onEditFinish.bind(this)}
initialValues={this.state.record}
>
{
(
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields) // Use externally supplied form input fields
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse") // Generate input fields from external columns
|| this.inferColumns(this.state.tableData)).map(col => { // Generate input fields from autogenerated columns (only works with existing data)
console.log(this.state.record);
return <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
{col.renderEdit && col.renderEdit(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || <Input
placeholder={this.state.record[col.dataIndex]}
/>}
</Form.Item>;
})
}
{(
// Use externally supplied form input fields
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields)
// Generate input fields from external columns
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse")
// Generate input fields from autogenerated columns (only works with existing data)
|| this.inferColumns(this.state.tableData)
).map(col => <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
{col.renderEdit && col.renderEdit(
this.state.record[col.dataIndex],
this.state.record,
undefined /* Non spec compliant, is supposed to be index of record */,
this.state.tableData
) || <Input placeholder={this.state.record[col.dataIndex]} />}
</Form.Item>)}
<Form.Item>
<Button type="primary" htmlType="submit">
{this.state.inserting ? "Legg til" : "Lagre"}
@@ -268,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 => {
File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+113
View File
@@ -64,15 +64,24 @@ body {
.header {
padding-left: 250px;
padding-right: 64px;
}
.site-name {
position: absolute;
top: -10px;
left: 112px;
font-size: 18pt;
font-weight: 400;
}
.site-version {
position: absolute;
top: 10px;
left: 120px;
color: #f00;
}
.site-logo {
position: absolute;
left: 36px;
@@ -82,6 +91,58 @@ body {
background-size: 64px;
}
@media not all and (min-width: 576px) {
.header {
padding-left: 88px;
}
.site-logo {
left: 12px;
}
.site-name {
display: none;
}
.site-version {
display: none;
}
}
.account-dropdown-header {
position: absolute;
right: 0;
top: 0;
width: 64px;
height: 64px;
font-size: 18px;
line-height: 72px;
text-align: center;
}
.account-dropdown-header::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: transparent;
transition: all 0.3s;
content: "";
}
.account-dropdown-header:hover::after {
background: rgba(255, 255, 255, 0.1);
}
.error-traceback code {
display: block;
margin: 0;
background: #000;
border-color: #252525;
white-space: pre-wrap;
}
.rcon-result code {
display: block;
background: #000;
@@ -108,6 +169,11 @@ body {
border-color: #252525;
height: 300px;
overflow-y: scroll;
white-space: pre-wrap;
/* Make sure there's at least one pixel of scroll space, this */
/* is necessary for the view to stick on the scroll anchor. */
padding-top: 300px;
}
.instance-console .factorio-time {
@@ -118,6 +184,16 @@ body {
color: #808080;
}
.instance-console .factorio-script {
font-weight: bold;
color: #49c33c;
}
.instance-console .factorio-verbose {
font-weight: bold;
color: #888888;
}
.instance-console .factorio-info {
font-weight: bold;
color: #4646ea;
@@ -137,6 +213,37 @@ body {
color: #afaf1f;
}
.log-verbose {
font-weight: bold;
color: #888888;
}
.log-info {
font-weight: bold;
color: #4646ea;
}
.log-audit {
font-weight: bold;
color: #49c33c;
}
.log-warn {
font-weight: bold;
color: #afaf1f;
}
.log-error {
font-weight: bold;
color: #ad3939;
}
.log-fatal {
font-weight: bold;
background-color: #ad3939;
color: #000;
}
.config-container {
background-color: #111111;
}
@@ -152,3 +259,9 @@ body {
.json-input {
font-family: monospace;
}
.factorio-icon.item-unknown-item {
background: url("./images/unknown-item.png");
height: 32px;
width: 32px;
}
+21
View File
@@ -0,0 +1,21 @@
export { default as bootstrap } from "./bootstrap.jsx";
export { default as notify, notifyErrorHandler } from "./util/notify.jsx";
export * from "./model/instance.jsx";
export * from "./model/slave.jsx";
export * from "./model/locale.jsx";
export * from "./model/item_metadata.jsx";
export { default as ControlContext } from "./components/ControlContext";
export { default as PluginsContext } from "./components/PluginsContext";
export { default as BaseConfigTree } from "./components/BaseConfigTree";
export { default as InstanceConfigTree } from "./components/InstanceConfigTree";
export { default as MasterConfigTree } from "./components/MasterConfigTree";
export { default as AssignInstanceModal } from "./components/AssignInstanceModal";
export { default as InstanceConsole } from "./components/InstanceConsole";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as PageLayout } from "./components/PageLayout";
+38
View File
@@ -0,0 +1,38 @@
import { useContext, useEffect, useState } from "react";
import { notifyErrorHandler } from "../util/notify";
import ControlContext from "../components/ControlContext";
/**
* @typedef {Object} Account
* @property {?string} name - Name of the currently logged in account.
* @property {function()} logout - Logs out of the web interface.
*/
export function useAccount() {
let control = useContext(ControlContext);
let [name, setName] = useState(control.accountName);
useEffect(() => {
function onConnect(data) {
setName(data.account.name);
}
function onClose() {
setName(null);
}
control.connector.on("connect", onConnect);
control.connector.on("close", onClose);
return () => {
control.connector.off("connect", onConnect);
control.connector.off("close", onClose);
};
}, [control]);
return {
name,
logOut() {
control.loggingOut = true;
control.shutdown(notifyErrorHandler("Error logging out"));
},
};
}
+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];
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more