Compare commits

..
Author SHA1 Message Date
Hornwitser eee4c69575 v2.0.0-alpha.1 2020-10-28 15:02:13 +01:00
Hornwitser adbbdef1a8 Merge pull request #327 from Hornwitser/add-package-readmes
Add package readmes and update project
readme file.
2020-10-28 13:32:04 +01:00
Hornwitser 36e5606e25 Add section on configuring the master server
Add detailed instructions for setting the https port the master server
runs on, setting the external_address option and how to set up a reverse
proxy to the master server.
2020-10-28 01:48:59 +01:00
Hornwitser 5fb7a33199 Link slave and ctl docs in README
Link to the slave package README and the managing-a-cluster document in
the sections for slave and clusterctl.
2020-10-28 01:39:43 +01:00
Hornwitser db0a3fb85e Drop support for Node.js v11
Drop claims of supporting the EOL v11 branch of Node.js.
2020-10-28 00:58:01 +01:00
Hornwitser 1215bbf3ab Rewrite introduction to Clusterio
Rewrite the woefully out of date description of Clusterio in the
readme to give a better sense of what Clusterio is and the available
plugins.
2020-10-28 00:58:01 +01:00
Hornwitser 64ac5a181a Fix typo in managing-a-cluster.md filename. 2020-10-28 00:58:01 +01:00
Hornwitser 4834c289ff Add README.md files to packages
Add README with basic descriptions of each package to all of the
packages in this repository.
2020-10-28 00:58:01 +01:00
Hornwitser 5fffc282fe Merge pull request #332 from Hornwitser/web-plugins
Implement plugin loading in web interface
2020-10-27 21:57:42 +01:00
Hornwitser 6dbf29153e Implement plugin loading in web interface
Add loading of plugin info to the web interface via webpack Module
Federation.
2020-10-27 18:24:45 +01:00
Hornwitser 542d8a798f Merge pull request #326 from Hornwitser/publish-fixes
Publish fixes
2020-10-26 15:50:53 +01:00
Hornwitser 7d8b55adb6 Use less strict matching for lib dependency
Stop requiring exact version match for the clusterio/lib dependency in
master, slave and ctl to make doing alpha releases simpler.
2020-10-26 15:24:29 +01:00
Hornwitser 1eca2ec6e6 Add missing ajv dependency to clusterio/lib
The lib/schema module uses ajv for compiling JSON Schema validators but
for some reason ajv was not part of the dependency list in its
package.json.
2020-10-26 15:17:01 +01:00
Hornwitser 9ee3fe6c37 Move gitignore for web build out of package
Mave the ignore entry for packages/master/static out of
packages/master/.gitignore to avoid npm using it and causing the web
build to be excluded from the package.
2020-10-24 16:46:02 +02:00
Hornwitser d96fb75703 Mark packages public
Add the required publishConfig.access: public for publishing public
packages in an ord on npm.
2020-10-24 14:43:42 +02:00
Hornwitser 20c7b613f2 Use lerna's pre-publish version format
Use -alpha.0 instead of -alpha0 as the pre-publish version as this is
the format lerna uses with the --canary option to publish.
2020-10-24 14:31:49 +02:00
Hornwitser 59436e7f10 Add notice in changelog about uploading to npm 2020-10-24 14:18:17 +02:00
Hornwitser a614ef8405 Merge pull request #325 from Hornwitser/lint-2
Lint fixes 2
2020-10-24 14:00:41 +02:00
Hornwitser 6d005505ce Fix brace style in lib/prometheus 2020-10-23 21:22:45 +02:00
Hornwitser ceade431ef Convert dole code to follow project style 2020-10-23 21:22:45 +02:00
Hornwitser 8586dfb95f Fix capitalization of constructors
Enforce new-cap rule and fix capitalization and mistakes related to
constructors.
2020-10-23 21:22:45 +02:00
Hornwitser 00a1f6aea7 Remove variable shadowing
Refactor to remove all no-shadow warnings.
2020-10-23 21:22:45 +02:00
Hornwitser e5e82ba2fb Remove inventoryImports plugin
Remove defunct and coding horror inventoryImports.  The functionality
provided in it should be added as an optional extra to subspace_storage.
2020-10-23 21:22:45 +02:00
Hornwitser 24397b75cc Prefix clusterio/lib imports with lib
Use lib* prefix for the modules impored from the lib package to help
distinguish them more from ordinary variables.  Fix up the naming
convention for files to be consistently lowercase_underscore for
JavaScript files.
2020-10-23 21:22:43 +02:00
Hornwitser fbbaf3dd3a Alter no-mixed-operators rule
Change the rule for mixed operators to allow basic arithmetic and
boolean logic.
2020-10-23 19:05:12 +02:00
Hornwitser a84f2d5eb6 Use strict equality checking
Remove usage of == and != in favour of === and !==.
2020-10-23 19:05:12 +02:00
Hornwitser ee81fbb7f6 Merge pull request #323 from Hornwitser/plugin-restructure
Convert plugins to node packages
2020-10-23 19:04:17 +02:00
Hornwitser 65f0845398 Fix plugin modules not loading
Fix the path to plugin modules using the old plugins dir instead of
resolving to the actual path of the plugin, which in turn caused no
plugin modules to be loaded.
2020-10-23 18:13:13 +02:00
Hornwitser 7968d89345 Use peerDependency for plugins
Avoid any potential for accidentally installing multiple copies of
clusterio/lib by specifiying it as a peer dependency instead of a
regular dependency.
2020-10-23 17:01:43 +02:00
Hornwitser 0ae520b5cc Fix plugin add command incorrectly resolving path
Fix the plugin add command not storing the resolved path for relative
paths passed to it into plugin-list.json and incorrectly resolving any
path containing ./ or ../ instead of only paths starting with these.
2020-10-23 16:59:20 +02:00
Hornwitser 1b0b245484 Fix plugin version not being loaded
Load the version of the plugin from its package.json file and document
this requirement in the writing plugins document.
2020-10-23 16:57:49 +02:00
Hornwitser c5a14db8ea Fix path for export test
Fix the path tested for the export-data command test incorrectly using
the project root instead of temp/test.
2020-10-22 16:38:46 +02:00
Hornwitser 7bba6cbde6 Add basic error check in build_mod.js
Check if the additional file to add exists before trying to pack it.
Note that there may be other ways reading the files could break, but
this should suffice for most cases of getting it wrong.
2020-10-21 21:38:24 +02:00
Hornwitser b2408d54ec Convert plugins to node packages
Setup package.json for the plugins and add them back to tests now that
they are compatible again.
2020-10-21 21:37:26 +02:00
Hornwitser f06d3509e6 Move lua code to slave package and fix paths
Move the export and clusterio mod code to the slave package and fix the
path used for them.  Refactor tests to use temp/tests/ as the cwd when
running integration tests in order to ensure paths to lua code are
correctly handled.
2020-10-21 21:26:44 +02:00
Hornwitser 18e609f3fe Allow plugins to be installed as npm packages
Add a separate plugin-list.json file for storing which plugins are
available and the path to pass to require in order to load it.  This
allows plugins to be published and installed as npm packages.  And also
replaces the old method of installing plugins by putting them in the
plugins folder.
2020-10-21 19:35:24 +02:00
Hornwitser 2a7bc75929 Move generation of auth secret to master startup
Move the code generating the master authentication secret from the
inital value function of the master.auth_secret config entry to the
master startup.  Having the authentication secret generated in the
config library poses issues with using the config library in browser.
2020-10-21 17:58:30 +02:00
Hornwitser 34a0ce4dc3 Merge pull request #321 from Hornwitser/instance-status
Rework instance status
2020-09-29 11:34:55 +02:00
Hornwitser 003b035abd Send instance status in listInstances request
Send the status of the instance in the repsonse to the listInstances
request and use this status in the integration tests to check if the
instance was started/stopped.
2020-09-29 01:28:00 +02:00
Hornwitser b82de23876 Extend instance statuses
Add the statuses unknown and unassigned for when the status has not yet
been set by a slave and replace the initialized status with starting,
creating_save and exporting_data.
2020-09-29 01:01:19 +02:00
Hornwitser 363bb54902 Merge pull request #320 from Hornwitser/web-interface
Import new web interface
2020-09-28 18:09:41 +02:00
Hornwitser dfaf74aac5 Supress webpack asset size warning
Set threshold for webpack's asset size warning to 2 MB due to Ant Design
being large.
2020-09-28 05:57:10 +02:00
Hornwitser 86b8775df0 Fix modals stuck after web interface import
Fix the modals for adding new entries to instances, users, and roles not
being closable due to a missing bind of the close method.
2020-09-28 05:52:31 +02:00
Hornwitser bccc89fa0b Finish linting web interface
Fix remaining linter errors detected in web interface code.
2020-09-28 05:52:31 +02:00
Hornwitser 03b7d22ba6 Use consistent semi-colon placment
Add semicolons to web interface code to be consistent with project
standard.
2020-09-28 05:52:29 +02:00
Hornwitser 8dce1dd0fd Fix web interface indents
Use project standard of tabs for indents.
2020-09-28 05:51:15 +02:00
Hornwitser 94200433cc Serve interface on all routes for the interface
Add all routes the web interface is served on to the express app and
serve the index.html file on.  Due to the index html not knowing which
path it's hosted under, a path to the root of the web interface is
replaced using string substitution from the master server.
2020-09-28 05:51:11 +02:00
Hornwitser 392a7655c4 Use property for calculating handler name
Replace usage of .constructor.name with a separate property to avoid
issue with JavaScript terser mangling the class name during minification
for the web build.
2020-09-28 04:23:45 +02:00
Hornwitser f1f9b4d020 Close webpack middleware on shutdown
Call the .close method on the webpack-dev-middleware in order to
properly shut it down.
2020-09-28 04:23:45 +02:00
Hornwitser 701ff87bcd Implement webpack build for web interface
Set up webpack for the master server's web interface and fix the issues
from the imported interface source files.  The .jsx files in
package/master/web are bundled together into package/master/static and
served by the master server via express.
2020-09-28 04:23:45 +02:00
Danielv123 2382f4bbdc Import web interface from clusterio/clusterioWeb
Import the source files in use for the web interface, excluding the
copied and modified lib/link files.
2020-09-28 04:19:00 +02:00
Hornwitser f318f8826b Adapt config and link library to web
Make the config and link libraries compatible with packing for web with
webpack.  This would fail due to ws not being web compatible and config
using a node function not available in the web shim for util.
2020-09-28 04:19:00 +02:00
Hornwitser fbf2843f5b Remove old web interface 2020-09-22 22:06:03 +02:00
Daniel bca949f7be Merge pull request #318 from Hornwitser/restructure-project
Restructure project
2020-09-21 11:57:43 -07:00
Hornwitser 5e410a6ac3 Use full path when referencing files in documentation
Include the packages/ component in paths referenced in the documentation
to make it clear where the files are located in the repository.

[skip travis]
2020-09-21 19:57:52 +02:00
Hornwitser 0cca9ed22c Mark user creation tests as slow
Mark the "should not create the user if not instructed to" and "should
create the user if instructed to" tests as slow as these take more than
2 seconds to execute on travis.
2020-09-21 19:27:24 +02:00
Hornwitser 6621e37e23 Do not use ci in travis environment
Disable ci mode for lerna in travis environment as this requires
package-lock.json files to be commited to the repository and we do not
for the time being do this.
2020-09-21 18:51:21 +02:00
Hornwitser bd8f685a18 Fix JSDoc module references
Fix module names, which are incorrectly deduced by JSDoc, and references
to master, slave and ctl modules that are now in separate packages.
2020-09-21 18:00:07 +02:00
Hornwitser 3485c9ac68 Rename clusterctl to clusterioctl
Rename the clusterctl command line utility to clusterioctl in order to
be consistent with the naming of the commands for master and slave.
2020-09-21 18:00:07 +02:00
Hornwitser bf5a4a8a9c Split up into multiple packages with Lerna
Split up codebase into the packages @clsterio/lib, @clusterio/master,
@clusterio/slave, and @clusterio/ctl using lerna to manage them.  This
will allow deployment to use only the parts needed reducing install
size, and better separate the code into shared library, master, slave
and control.
2020-09-21 18:00:07 +02:00
Hornwitser 198d5c344b Remove unused dependencies from dead code
Remove dependencies used by inventoryImports, external plugins and the
removed modManager code.
2020-09-16 15:09:23 +02:00
Hornwitser efa3eff0d5 Delete old mod manager code 2020-09-16 15:09:23 +02:00
Hornwitser 6fde461da5 Merge pull request #313 from Hornwitser/list-management
List management
2020-09-16 15:08:21 +02:00
dependabot[bot] 14ddb76a0b Bump node-forge from 0.7.6 to 0.10.0 (#315)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 0.7.6 to 0.10.0.
- [Release notes](https://github.com/digitalbazaar/forge/releases)
- [Changelog](https://github.com/digitalbazaar/forge/blob/master/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/0.7.6...0.10.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-09-16 14:59:35 +02:00
Hornwitser 3753c303dd Add changelog entry for list synchronization
[skip travis]
2020-09-01 20:33:31 +02:00
Hornwitser 08ec542907 Move integration tests for lists sync
Move the integration tests for the admin, user, and ban list sync to be
done while an instance is running in order to hit more lines in the
coverage with them.
2020-09-01 20:16:17 +02:00
Hornwitser a8b8232d73 Merge pull request #314 from RMuskovets/readme-typo-factorio-version
Fix a typo in README.md
2020-09-01 19:17:56 +02:00
RMuskovets 4f86be1b7f Fix a typo in README.md:
Line 174, "Factorio_0.x.y" -> "Factorio.x.y.z"
Since 1.0.0 release Factorio versions no longer start from 0
2020-09-01 19:56:08 +03:00
Hornwitser 84450b83b4 Implement adminlist, whitelist and banlist sync
Add integration of the server adminlist, whitelist and banlist into
clusterio.  Membership is stored as booleans in the user acconuts and
changes are synchronized live to slaves which pass it on to instances
which in turn will update the running server's lists with RCON commands.
2020-09-01 18:17:40 +02:00
Hornwitser f08774bd4a Add execCtl shortcut for clusterctl tests
Add execCtl which is a shortcut to execute node clusterctl with the test
config set.
2020-09-01 18:17:40 +02:00
Hornwitser 5e5a47ce02 Leave out roles array in serialized user
Leave out the roles property containing the array of role ids if the
user does not have any roles.
2020-09-01 17:59:19 +02:00
Hornwitser 9761fbb71c Fix test names for clusterctl
Rename the test cases named after the old clusterctl commands for the
integrations to the new clusterctl commands.
2020-09-01 17:59:19 +02:00
Hornwitser 3c55188a87 Merge pull request #311 from Hornwitser/fix-logger-warning
Fix logger.warning is not a function

Fixes #303
2020-08-27 11:41:43 +02:00
Hornwitser a40c78d85d Fix logger.warning is not a function
Replace logger.warning with logger.warn as I incorrectly assumed the
warning method of console was called console.warning instead of
console.warn.
2020-08-27 11:31:36 +02:00
Arvids Godjuks 0b790357f9 Merge pull request #310 from Hornwitser/remove-bcrypt
Remove bcrypt dependency
2020-08-27 11:28:11 +02:00
Hornwitser 2dcc5c9d47 Remove bcrypt dependency
Remove the unused dependencies to bcrypt and bcrypt-promise.  These were
used by the playerManager plugin to implement password based
authentication, but this plugin is likely not going to be ported to 2.0.
2020-08-27 11:17:35 +02:00
dependabot[bot] 6d8d926268 Bump express-fileupload from 1.1.5 to 1.2.0 (#308)
Bumps [express-fileupload](https://github.com/richardgirges/express-fileupload) from 1.1.5 to 1.2.0.
- [Release notes](https://github.com/richardgirges/express-fileupload/releases)
- [Commits](https://github.com/richardgirges/express-fileupload/compare/v1.1.5...1.2.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-08-27 11:07:35 +02:00
Hornwitser c59c30b840 Merge pull request #304 from Hornwitser/clusterctl-restructure
Restructre clusterctl cli interface
2020-08-26 13:23:33 +02:00
Hornwitser fd307e58c2 Add global-chat shout command
Add command to broadcast a message to all instances from clusterctl.
2020-08-18 18:52:11 +02:00
Hornwitser da1cbc16f7 Restructre clusterctl cli interface
Turn the flat list of commands into a hierarchy of commands, converting
most required parameters into positional parameters and add plugin
loading support for clusterctl.
2020-08-18 18:52:11 +02:00
ToxicFrazzles 9b870fde64 Fix syntax error in plugins/subspace_storage/static/storage.js (#302)
Fix _lastData being used as a global causing the script to fail to load
after strict mode was added.
2020-07-08 12:51:16 +02:00
Hornwitser 0802c5b29d Merge pull request #301 from Hornwitser/custom-scenario
Add support for running custom scenarios
2020-07-08 12:46:49 +02:00
Hornwitser 781e40428f Add enable_save_patching to mock instance config
Handle the factorio.enable_save_patching config option in the mock
instance as these are queried by instances after 9458155.
2020-07-07 14:24:47 +02:00
Hornwitser 999386c088 Add --keep-open option to clusterctl
Add option to keep the console log open after starting an instance
instead of exiting as soon as it has finished starting up.
2020-07-06 13:16:57 +02:00
Hornwitser 8c6130adba Add load scenario command
Add alternative method of starting an instance by loading a scenario.
Since there's no save when starting a scenario it can only be used if
the save patching is disabled.
2020-07-06 13:10:26 +02:00
Hornwitser 9458155a1b Add option to disable save patching
Add factorio.enable_save_patching config entry to allow disabling the
save patching done on startup.  This is for running vannila games or
scenarios that are not compatible with Clusterio.
2020-07-04 16:45:42 +02:00
Hornwitser 0969e07d79 Merge pull request #300 from Hornwitser/lint
Lint codebase
2020-06-17 22:12:27 +02:00
Hornwitser 9c9b3ac625 Document running tests and linting
Add section to contributing docs detailing how to run tests and linting
on the project.
2020-06-17 21:08:45 +02:00
Hornwitser 7814656241 Add ESLint check to travis integration
Invoke the lint package.json script during travis integration check.
2020-06-17 20:59:37 +02:00
Hornwitser e9f5cae4f0 Fix misc problems detected by ESLint
Reformat and clean up issues detected by ESLint.
2020-06-17 20:59:37 +02:00
Hornwitser 8cb21c5fa8 Add dangling commas
Ensure multiline object and array litterals are consistenly typed out
with dangling commas for the last element.
2020-06-17 20:59:37 +02:00
Hornwitser 3e4f0b6b5e Fix indents
Fix incorrectly indented code as detected by ESLint.
2020-06-17 20:59:33 +02:00
Hornwitser 703fda43ad Fix JSDoc issues
Fix missing documentation for parameters and return types, incorect
config and errors in type definitions.
2020-06-17 20:53:18 +02:00
Hornwitser 3dba92a03d Use strict mode
Add strict mode directives to all js files.
2020-06-17 20:53:18 +02:00
Hornwitser b254e4189d Use more consistent spacing in objects literals 2020-06-17 20:53:18 +02:00
Hornwitser 29f066a191 Fix missing semicolons
Ensure all statements end with a semicolon.
2020-06-17 20:53:18 +02:00
Hornwitser 34aaeee354 Use double quotes
Convert existing mix of double quotes and single quotes to only use
double quotes unless the string contains double quotes.
2020-06-17 20:53:18 +02:00
Hornwitser efd759bec5 Add code linting using ESLint
Add ESLint as a dev dependency and configure it to match the preferred
style for the project.  The config options set to warn are tentative and
may not be sensible as they haven't been fully investigated yet.
2020-06-17 20:53:18 +02:00
Hornwitser 922c6af632 Merge pull request #299 from Hornwitser/user-management
User management and authentication rework
2020-06-17 20:47:52 +02:00
Hornwitser cf6f2cb70a Add user and permission system
Add users, roles and permissions and rework the authentication to use
separate user and slave tokens for control and slave connections
respectively.

Add commands for listing permissions, roles and users, and creating,
editing and deleting roles and users.
2020-06-15 16:47:15 +02:00
Hornwitser 2acf57d23d Fix erroneous delete of local variable
The delete operator only works on object properties, and causes the code
coverage to fail for the file in question if used on a local variable.
2020-06-15 00:13:25 +02:00
Hornwitser 07cff0ab9a Fix error during slave authentication failure
Treat sending messages over a link that is not connected as a
SessionLost error and handle AuthenticationFailed error in the
startup logic of slave.
2020-06-15 00:13:25 +02:00
Hornwitser 3dfed4d2ad Remove master.factorio_directory config
Remove old config entry for the old locale API that was removed with the
introduction of the new export system in 260f976.
2020-06-15 00:13:25 +02:00
Hornwitser c3179f0423 Move ControlConfig to lib/config/definitions.js
Move the definition of the control config to lib/config so that the
master code can use it.
2020-06-15 00:13:25 +02:00
Hornwitser 37bff10382 Merge pull request #298 from Hornwitser/config-event
Config events
2020-06-11 22:07:23 +02:00
Hornwitser 931ad4816f Update config of running Factorio instances
Monitor the factorio.settings config for changes and apply them to
running instances via the /config set command.
2020-05-31 22:28:51 +02:00
Hornwitser 5b043524b4 Implement setProp in configs
Add setProp method for selectively changing one property of an object
field in a config group.
2020-05-31 16:08:21 +02:00
Hornwitser 8c9daff0bb Add name shortcut for ConfigGroup instances
Add .name shortcut to get the groupName class property of config group
instances.
2020-05-31 14:16:06 +02:00
Hornwitser 67ca8dcc4d Implement config notification event
Add fieldChanged event to config objects that is triggered when the
value of a field in a group belonging to it is changed.
2020-05-30 14:27:59 +02:00
Hornwitser fc3686b865 Invoke plugin hooks via common function
Use a common function to iterate through and invoke the given hook in
every plugin, and ignore errors thrown by plugins from these hooks.
2020-05-30 14:27:59 +02:00
Hornwitser 01ea301a8c Merge pull request #297 from Hornwitser/automate-export
Automate export process
2020-05-26 18:13:17 +02:00
Hornwitser 36cf14074e Automatically upload export to master server
Upload the created export data zip file to a new /api/upload-export
endpoint on the master server which unpacks it into the appropriate
directory on the master.
2020-05-26 15:31:42 +02:00
Hornwitser f3d4cfdcf5 Add reference to Slave in Instance
Add a reference to the Slave that's running the given instance in order
for it to be able to access the slave config.
2020-05-26 14:04:25 +02:00
Hornwitser f8597f500c Remove license note for angels icons
Remove notice about license for the now removed modded angels icons.
2020-05-26 13:17:58 +02:00
Hornwitser 2b20c943bb Merge pull request #296 from Hornwitser/item-export
Implement icon and locale export

Closes #290
2020-05-26 13:16:11 +02:00
Hornwitser 1bddea8cec Remove Factorio icons
Remove no longer used Factorio icons.
2020-05-26 01:12:05 +02:00
Hornwitser e2255ddb7c Remove inclusion of 3625 Angels item icons
Remove obsolete inclusion of the 3625 Angels item icons added by
09f476e.
2020-05-26 01:12:05 +02:00
Hornwitser 453226f246 Implement locale trenslation of resource names
Translate the names of the resources using the exported Factorio locale.
2020-05-26 01:12:05 +02:00
Hornwitser 260f976fa3 Implement export of item icons and locale
Add mod and command for exporting the Factorio locale including mods,
and item icons assemled into a sprite sheet including mods.
2020-05-26 01:11:49 +02:00
Hornwitser d1b851d890 Move clusterio_lib to lua/clusterio_lib
Move the library mod to lua/clusterio_lib and restructure build_mod.js
to handle additonal files as a property to info.js instead of being hard
coded.
2020-05-25 20:45:09 +02:00
Hornwitser 2e24d6c8ae Merge pull request #295 from Hornwitser/fix-windows
Fix Windows stop logic
2020-05-25 17:58:54 +02:00
Hornwitser 203f2d2a14 Fix Windows stop logic
Handle the Factorio server not exiting when encountering an error on
Windows and fix the hang detection for .stop().
2020-05-21 18:06:20 +02:00
Hornwitser 87d14252fc Merge pull request #294 from Hornwitser/plugin-track
Integrate plugins closer with connections
2020-05-12 14:23:04 +02:00
Hornwitser 3211fbe985 Notify master about instance status update
Send updates to the master server when the running status of instances
change and add onInstanceStatusChanged hook to master plugins.
2020-05-11 18:10:11 +02:00
Hornwitser a8e29fdc56 Separate instance from config in master
Separate the instance object and the config that belongs to the instance
object in the instances db held in memory on the master.
2020-05-11 18:07:41 +02:00
Hornwitser a194508f52 Filter plugin messages forwarded to links
Drop events forwarded to a slave or instance that does not have the
plugin the event is defined in.  Reject requests for the same reason.
2020-05-11 18:04:03 +02:00
Hornwitser ca2839594e Timeout quikly when disconnecting existing slave
If a slave that is already connected attempts connecting the existing
connection for that slave is disconnected.  The most common scenario for
this is that the existing connection is stale and the slave is
attempting to connect agan.  However if the connection is stale then the
disconnection will take 15 minutes to time out and the slave will appear
unresponsive during this time.  Fix this problem by setting the timeout
to 15 seconds before disconnecting the existing connection.
2020-05-09 16:36:33 +02:00
Hornwitser 1b427af5f0 Fix client send buffer not being sent on ready
Fix WebSocketClientConnector not sending the send buffer queued up
between starting the connection and the master server sending the ready
signal for it.
2020-05-09 16:34:46 +02:00
Hornwitser 04ee3d3f96 Fix WebSocketClientConnector lifecycle events
Fix drop being repeatedly emitted while reconnecting and close not being
emitted on connection timeout.
2020-05-09 16:28:07 +02:00
Hornwitser c87538ca58 Handle connection being closed in slave
Listen for the close event and connect again if the connection was
closed from proper disconnect or initiate a shutdown if the connection
was unexpectedly closed.  Unexpected closure happens from authentication
failure and if a connection fails to reconnect before the timeout.
2020-05-09 16:24:02 +02:00
Hornwitser c35b6de70e Notify on connection events
Implement notification to plugins for disconnect request, connect, drop,
and close.  This allows plugins to properly handle slaves and the master
server shutting down without sending messages that will get lost.
2020-05-08 23:49:28 +02:00
Hornwitser b8b8c44083 Fix onExit not being invoked if plugin load fails
Fix instance not properly exiting if an error occurs during the loading
of a plugin.
2020-05-08 18:15:17 +02:00
Hornwitser 1beffb1bfd Fix crash on hartbeat on closing connection
Fix heartbeat incorrectly asserting it cannot be sent on a connection in
the closing state.
2020-05-08 18:09:48 +02:00
Hornwitser b151dd8469 Fix documentation
Fix missing module declarations, broken lings, broken formating and
incorrect scopes in the documentation.  For modules split over several
files it appears the only viable option is to use the memberof directive
on each documented member and only declare the index file as a module.
2020-05-08 17:28:06 +02:00
Hornwitser 7a9b6592e6 Handle disconnect in link integration test
Handle the prepareDisconnect request properly in the TestControl in
test/integration/link.js.
2020-05-07 13:23:11 +02:00
Hornwitser 5c1214ad38 Rename connected and dropped events
Rename the connector events connected and dropped to connect and drop
respectively to be consistent with common Node.js names for events like
close.
2020-05-07 12:36:43 +02:00
Hornwitser 1629eaf225 Track plugins loaded by master and slaves
Send a mapping of the plugins the master server has loaded to slaves
vice versa.  Limit the plugins that may be loaded by instances to only
those that the master server has loaded.
2020-05-06 20:14:40 +02:00
Hornwitser 4060c75142 Merge pull request #293 from Hornwitser/alpha-bugfixes-2
Alpha bugfixes 2
2020-05-06 20:13:22 +02:00
Hornwitser f3c13eab21 Simplify level logic
Simplify tech level logic according to TheAiBot's suggestion.
2020-05-06 19:49:33 +02:00
Hornwitser e9fab3cc10 Fix level > max_level crash in research_sync
Fix crash when technologiy level stored on the master is greater than
the max_level for the technology in Factorio.  This can happen with mods
that for example reduce the max_level from inifity to a finite level.
2020-04-24 15:27:30 +02:00
Hornwitser 446333f4aa Fix stylesheet loaded from wrong location
Fix stylesheet path not taking into account the web_root config.
2020-04-24 15:26:52 +02:00
Hornwitser f8817ecf0c Merge pull request #292 from Hornwitser/alpha-bugfixes
Alpha bugfixes
2020-04-19 23:32:10 +02:00
Hornwitser c601c53044 Fix typo in clusterctl.js
Fix SessionLost being refered to as a global instead of from the errors
module in clusterctl.js.
2020-04-19 18:24:43 +02:00
Hornwitser 3603b16901 Fix missing space in researched notification
Add space between the technology name and the level in the notification
for researched technologies.
2020-04-19 18:23:37 +02:00
Hornwitser f4ca8366e2 Handle contributions to the next level of a technology
Fix research_sync sending out contribution events for the previous level
of a researched technology and sending finished events for the wrong
level when synchronizing technologies.  Both of these issues are related
to failing to accunt for the unlocked level being one less than the
level stored when the researched property is false.
2020-04-19 18:20:28 +02:00
Hornwitser b6585e6364 Fix reconnect giving up too early
Fix the reconnect logic giving immediately if a previous reconnection
had been made long prior to the connection being dropped again.
2020-04-18 23:37:50 +02:00
Hornwitser d2fb33f8b9 Fix log spam by subspace_storage plugin
Fix log spam when the dole distribution method is used on
subspace_storage plugin.  This was caused by a bogus if condition being
translated into a sensible but wrong one.
2020-04-18 23:37:50 +02:00
MyNameIsTrez 568cf81204 Edit docs/writing-plugins.md (#287)
Fix grammar, punctuation and spelling mistakes in
docs/writing-plugins.md.
2020-04-18 23:34:26 +02:00
Hornwitser 15ad64e356 Fix web icons ignoring web_root (#291)
Fix the URLs for the item icons in the web interface ignoring the
web_root config option.

[travis skip]
2020-04-17 17:46:17 +02:00
MyNameIsTrez ef6441ca46 Edit docs/developing-for-clusterio.md (#285)
Fix grammar, punctuation and spelling mistakes in
docs/developing-for-clusterio.md.
2020-04-17 15:46:35 +02:00
Hornwitser 534408856c Merge pull request #284 from Hornwitser/small-things-2
Small things 2
2020-04-11 02:04:59 +02:00
Hornwitser 430aa6d207 Disable heap and memory size tests
Disable the tests for the memory and heap size metrics as the travis
environment is apparently using 3x as much memory as locally.
2020-04-09 20:44:06 +02:00
Hornwitser 00d1d304d2 Fix integration test certificate path
Fix integration tests using the default certificate paths instead of
having them located in the dedicated test database directory.  Also
increment key size to the minimum allowed of 1024.
2020-04-09 20:35:51 +02:00
Hornwitser 38c392ff6d Fix kill logic of integration test
Test if the process variable exists before trying to call kill on it, as
in certain failure situations it is not set.
2020-04-09 20:08:04 +02:00
Hornwitser 4f463a8180 Reduce number of bits in generated test cert
Add config entry for the number of bits to use in the auto generate TLS
certificate for the master server and reduce it to 512 for the
integration test.  This reduces the time needed to generate the
certificate in the integration test.
2020-04-09 20:02:29 +02:00
Hornwitser 2c855a5846 Add debug-dump-ws command
Add debug command to dump the WebSocket messages being sent and received
by the master server to aid in finding chatty events.
2020-04-09 19:50:01 +02:00
Hornwitser 61d92a6910 Rename master plugin onExit hook to onShutdown
Rename the onExit hook for master plugins to onShutdown to better
reflect when it is invoked.
2020-04-09 19:50:01 +02:00
Hornwitser 9507608e59 Rework disconnect logic for connectors
Rework the way disconnection at shutdown is done to properly close
the connections when the master server is shut down.
2020-04-09 19:50:01 +02:00
Hornwitser 47ea8f5477 Rename connection shutdown to prepare disconnect
Rename the shutdownConnection request to prepareDisconnect to better
reflect its actual purpose.
2020-04-09 16:17:38 +02:00
Hornwitser e7d1039942 Link to writings plugins document in docs
Replace notice about in development plugin interface with a link to the
writing plugins document in the developing for clusterio document.
2020-04-09 16:17:38 +02:00
Hornwitser fc04fd9ebc Remove master branch warning
Remove warning of master branch being broken now that it's no longer
completely broken.
2020-04-09 16:17:38 +02:00
Hornwitser 3dc4bdaab6 Remove debug log
Remove debug log statement annoing events have been broadcast.
2020-04-09 16:17:38 +02:00
Hornwitser 59111cc844 Disable replay in patched save
Ensure that the replay is disabled after the game is patched by calling
game.disable_replay().  It's not possible to replay a patched save, and
it also stores every command sent into save, causing it to quickly grow
into an unplayably large size.
2020-04-09 16:17:38 +02:00
Hornwitser d537e5cf6b Fix typo in default cpu metric
Fix proces_cpu_seconds_total being mislabled as process_cpu_seconds.
2020-04-09 16:17:38 +02:00
Hornwitser 47c221f6f3 Fix typo in test names
Fix the labels function being refered to as the label function in the
test descriptions.
2020-04-09 16:17:38 +02:00
Hornwitser 56488b2c2d Add resident memory and heap size metrics
Implement the default resident memory and heap size metrics.
2020-04-09 16:17:38 +02:00
Hornwitser e908c5069b Add autosave tracking and statistics
Detect when autosaves are made and track the size in a Prometheus gague.
2020-04-09 16:17:38 +02:00
Hornwitser 139585f8c6 Prevent metrics being called on createSave
Gate the onMetrics plugin hook behind a boolean that's set only after
the Factorio server has fully started, and reset when it stops.  This
prevents errors due to plugins trying to get metrics while the server
is creating a new save or stopped.
2020-04-09 16:17:38 +02:00
Hornwitser 6bc07ad1b3 Add Factorio CPU and Memory usage statistics
Add per instance metrics for CPU and memory usage with the pidusage
library.
2020-04-09 16:17:38 +02:00
Hornwitser 56ee1fe955 Rework save logic with regard to start instance
Ensure that notifyExit is called if errors occur during startInstance
and createSave request, and rework startInstance save logic to create
a new savegame if no save is present and allow specifying the save to
load.
2020-04-09 16:17:38 +02:00
Hornwitser 62e38199bd Rework exit notification for Instances
Pull the exit handler into its own method that's invoked if the startup
fails before the Factorio server is created.  Also move the metrics
clearing to the exit notification method to ensure it's also invoked.
2020-04-09 16:17:38 +02:00
Hornwitser 407e3d2aed Merge pull request #283 from clusterio/TheAIBot-patch-1
Fix overflow check in subspace storage
2020-04-08 15:08:32 +02:00
TheAIBot fcfad08260 fix overflow check in subspace storage
Previous and new value:
0x7ffffff -- old 134.217.727
0x7fffffff -- new 2.147.483.647
2020-04-08 14:59:53 +02:00
Hornwitser 112cad8a49 Merge pull request #282 from Hornwitser/small-things
Small things
2020-04-06 14:50:55 +02:00
Hornwitser 621c834126 Merge pull request #281 from Hornwitser/reconnection-logic
Reconnection logic
2020-04-04 18:39:36 +02:00
3880 changed files with 14124 additions and 7896 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
packages/master/static
plugins/*/static
+276
View File
@@ -0,0 +1,276 @@
"use strict";
module.exports = {
"env": {
"node": true,
"commonjs": true,
"es6": true,
},
"parserOptions": {
"ecmaVersion": 11,
},
"overrides": [
{
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
"env": {
"mocha": "true",
},
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
},
},
{
"files": ["packages/master/web/**/*.jsx"],
"env": {
"browser": true,
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
},
},
},
],
"rules": {
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": ["error", "never"],
"array-callback-return": "error",
"array-element-newline": "off",
"arrow-body-style": "error",
"arrow-parens": "off",
"arrow-spacing": ["error", { "after": true, "before": true }],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["warn", "1tbs", { "allowSingleLine": true }],
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
},
],
"comma-spacing": "warn",
"comma-style": ["error", "last"],
"complexity": "warn",
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
"curly": "warn",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "error",
"dot-location": ["error", "property"],
"dot-notation": ["warn", { "allowKeywords": true }],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": ["error", "declaration"],
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"global-require": "warn",
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"handle-callback-err": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "error",
"indent": ["error", "tab", { "SwitchCase": 1 }],
"indent-legacy": "off",
"init-declarations": "off",
"jsx-quotes": "error",
"key-spacing": "error",
"keyword-spacing": "warn",
"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-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-ternary": "off",
"new-cap": "error",
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "error",
"no-array-constructor": "error",
"no-await-in-loop": "off",
"no-bitwise": "off",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "error",
"no-confusing-arrow": "error",
"no-console": "off",
"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-empty-function": "off",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-extra-parens": "off",
"no-floating-decimal": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-invalid-this": "warn",
"no-iterator": "error",
"no-label-var": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-mixed-operators": [
"error",
{
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
},
],
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
"no-multiple-empty-lines": "error",
"no-native-reassign": "error",
"no-negated-condition": "off",
"no-negated-in-lhs": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-path-concat": "error",
"no-plusplus": ["warn", { "allowForLoopAfterthoughts": true }],
"no-process-env": "error",
"no-process-exit": "warn",
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "error",
"no-return-await": "off",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["yargs"] }],
"no-spaced-func": "error",
"no-sync": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-undefined": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": "warn",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "warn",
"no-void": "error",
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "error",
"object-curly-newline": "error",
"object-curly-spacing": "off",
"object-shorthand": "off",
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": "warn",
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "warn",
"prefer-const": "off",
"prefer-destructuring": "off",
"prefer-exponentiation-operator": "error",
"prefer-named-capture-group": "off",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-reflect": "off",
"prefer-regex-literals": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "warn",
"quote-props": "off",
"quotes": ["error", "double", { "avoidEscape": true }],
"radix": ["error", "always"],
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
"rest-spread-spacing": ["error", "never"],
"semi": "error",
"semi-spacing": "error",
"semi-style": "off",
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "error",
"space-before-blocks": "warn",
"space-before-function-paren": "off",
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["warn", "always"],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": ["error", "never"],
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", { "requireReturn": false }],
"vars-on-top": "warn",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", { "exceptRange": true }],
},
};
+6 -14
View File
@@ -28,34 +28,26 @@ dist/
# Autogenerated docs output
/docs/api
node_modules/*
node_modules
Factorio*
/factorio
/instances
/sharedMods
/database
/plugin-list.json
/config-master.json
/config-control.json
/config-slave.json
package-lock.json
secret-api-token.txt
mod_cache.json
static/pictures/spritesheet.js
static/pictures/spritesheet.png
static/external
static/export
saves
/temp/
# weird pattern to ignore sharedPlugins except not ignore some core modules
!sharedPlugins/
sharedPlugins/*
!sharedPlugins/inventoryImports/
!sharedPlugins/researchSync/
!sharedPlugins/clusterioMod/
# File used for marking plugins as disabled
sharedPlugins/*/DISABLED
# Web build
/packages/master/static
/plugins/*/static
# IDE
.idea/
+4 -2
View File
@@ -6,8 +6,10 @@ before_install:
- tar -xf factorio.tar.gz
- npm install -g codecov
script:
- node build-mod --bump-patch --factorio-version 0.18 --output-dir sharedMods
- curl -o sharedMods/subspace_storage_1.99.1.zip -L https://github.com/clusterio/factorioClusterioMod/releases/download/1.99.1/subspace_storage_1.99.1.zip
- 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
- npm run-script ci-cover
- cat temp/test/integration/log.txt
- codecov -f coverage/lcov.info
- npm run-script lint
+12 -3
View File
@@ -15,7 +15,7 @@ Version 2.0.0
- 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 clusterctl cli tool.
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.
@@ -25,6 +25,8 @@ Version 2.0.0
- 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.
### Features
@@ -37,6 +39,13 @@ Version 2.0.0
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.
### Changes
@@ -89,8 +98,7 @@ Version 2.0.0
### Breaking Changes
- lib/authenticate no longer requires config to be passed to it. 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.
@@ -163,6 +171,7 @@ Version 2.0.0
- 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.
Version 1.2.4
-5
View File
@@ -19,8 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The icons for angels modded items located in /static/pictures/ are licensed
by https://mods.factorio.com/user/Arch666Angel under CC BY-NC-ND 4.0
https://creativecommons.org/licenses/by-nc-nd/4.0/
+181 -101
View File
@@ -10,8 +10,6 @@ Discord for development/support/play: https://discord.gg/5XuDkje
## Important notice
**WARNING: The master branch is currently broken, do not use it!**
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
@@ -32,87 +30,93 @@ the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
### Table of contents
* [Introduction & methodology](#introduction)
* [Introduction](#introduction)
* [Features](#features)
* [Plugins](#plugins)
* [Ubuntu setup](#ubuntu-setup)
* [Windows setup](#windows-setup)
* [Installing Plugins](#installing-plugins)
* [Configure Master Server](#configure-master-server)
* [Hosting Behind Proxy](#hosting-behind-proxy)
* [Setting up an admin account](#setting-up-an-admin-account)
* [Running Clusterio](#running-clusterio)
* [Master Server](#master-server)
* [Slaves](#slaves)
* [Instances](#instances)
* [Optional plugins](#Plugins)
* [Common problems](#Common-problems)
## Introduction
Features:
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).
- Entities to send/recieve items
- Cross dimensional storage
- Sending of liquids
- Sending of circuit network signals
- Inventory combinator to display item levels in the cluster (and epoch time)
- Reporting of graphs and UPS on master interface (Also has extensive Prometheus reporting)
Optional extras (see [Plugins](#Plugins))
- Have your inventory synchronize across servers
- Teleport trans from the border of one world to te next
- Show in-game chat in discord
By itself Clusterio does 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.
Connection diagram:
## Features
![http://i.imgur.com/7FdVfgB.png](http://i.imgur.com/7FdVfgB.png)
- 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.
There can be any number of clients connected to each slave, and any number of slaves connected to a master but there can only be one master server in each cluster.
- User list management for synchronizing in-game admins, whitelisted
users, and bans to all the servers in the cluster.
**How does it work?**
- Integrated support for exporting statistics for the whole cluster to
Prometheus via a single metrics endpoint.
Traditional factorio mods have always been limited by the games deterministic design. This gives us a very bug free and predictable game, but doesn't allow us cool stuff such as internet communication.
Clusterio exploits one of the games logging features, game.write_file and RCON to communicate between servers. Sending an item from one server to another takes this path:
- Extensive plugin support for adding your own cross server features to
Factorio using Clusterio's communication backbone.
1. server1: Chest has stuff in it, write the contents to a file and delete them from the game world
2. client.js on server1: File has stuff in it, parse and send to the master for storage
## Plugins
3. master: server1 gave us stuff, store it in the storage and write some statistics
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:
4. server2: get-chest is empty, write a request to file
- [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.
5. client.js on server2: Request file has stuff in it, parse and send a request to master for more items of that type
Want to make your own plugin? Check out the documentation on [Writing
Plugins](/docs/writing-plugins.md) for where to start.
6. master: server2 asked for stuff, check if we have enough and how much demand there is, then send however much is appropriate back
7. client.js on server2: We were allowed to import x of item y, run command /c remote.call("clusterio", "importMany", "{'copper-plate':120}")
This process works the same for both items and liquids, independent on what mods are used. Yes, modded items are fully supported.
Clusterio can also do a few other neat things, such as giving you access to epoch time, syncing player inventories between servers, keeping track of playtime (playerManager plugin), teleporting trains between servers (trainTeleports) and exporting tons of factorio related statistics to Prometheus for graphing in grafana.
## 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.
Clusterio runs on Node.js v12 and up, v11.13.0+ and v10.16.0+. Node.js itself is not
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:
wget -qO - https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt install -y nodejs python-dev git build-essential
git clone -b master https://github.com/clusterio/factorioClusterio.git
cd factorioClusterio
sudo apt install -y nodejs
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
tar -xf factorio.tar.gz
npm install --only=production
downloads and installs nodejs, git and clusterio. To specify a version, change "latest" in the link to a version number like 0.14.21.
@@ -138,30 +142,19 @@ The -v flag is used to specify the instance directory. Your instances (save file
**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 is built up of multiple parts. Here is a quick guide:
Master = master.js
Server host (Slave) = slave.js + factorio server
Game Client = The people connecting to the server
**Requirements**
download and install nodeJS 12 from http://nodejs.org. Clusterio runs on Node.js v12 and
up, v11.13.0+ and v10.16.0+.
download and install git from https://git-scm.com/
reboot when you are done, then proceed to the next steps. *reboots matter*
up, and v10.16.0+.
**Master**
1. Open PowerShell or Command prompt in the directory you want to install to and run the following commands.
git clone -b master https://github.com/clusterio/factorioClusterio
cd factorioClusterio
npm install --only=production
mkdir clusterio
cd clusterio
npm init -y
npm install @clusterio/master @clusterio/slave @clusterio/ctl
2. Obtain Factorio by either of these two methods:
@@ -173,7 +166,7 @@ reboot when you are done, then proceed to the next steps. *reboots matter*
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_0.x.y" into
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
@@ -188,10 +181,109 @@ reboot when you are done, then proceed to the next steps. *reboots matter*
the Factorio folder into the factorio folder created in step 1.
## Installing Plugins
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
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.
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
npx clusteriomaster config set master.https_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/
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.
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.
### Setting up an admin account
Before you can manage the cluster you need to bootstrap an admin account
for it. Replace `<username>` with your Factorio username (do not make
up a new username here).
npx clusteriomaster bootstrap create-admin <username>
npx clusteriomaster bootstrap create-ctl-config <username>
The first command creates a user account with the given name and
promotes it to a cluster admin. The second one sets up a
`config-control.json` config for clusterioctl to connect to the master
server under the given user account.
## Running Clusterio
After following the installation 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
@@ -200,69 +292,61 @@ 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:
node master run
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 an authentication token from the master server. This token is
written to secret-api-token.txt on the master server when it is started
up.
need a valid authentication token, you can create a slave config with
a valid token with the following command.
To set up the configuration for a new local slave run the following.
node slave config set slave.name "Local"
node slave config set slave.master_token "<token>"
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 `--config` option) with the name
and token provided. If you are connecting to a remote master server you
will also need to set the `slave.master_url` option to that url.
(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 with the `node slave config list`
command. Use `node slave config --help` 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
node slave run
npx clusterioslave run
### Instances
Instances are created, managed and started from the master server. For
now the only interface available is the `clusterctl` command line tool
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
clusterctl control-config set` command:
clusterioctl control-config set` command:
The basic operations to start a new instance is the following
node clusterctl create-instance --name "My instance"
node clusterctl assign-instance --instance "My instance" --slave "Local"
node clusterctl create-save --instance "My instance"
node clusterctl start-instance --instance "My instance"
npx clusterioctl instance create "My instance"
npx clusterioctl instance assign "My instance" "Local"
npx clusterioctl instance start "My instance"
The first line creates the instance configuration on the master server.
The second assigns the instance to a slave which creates the instance
directory and files needed to run the instance on the given slave. The
third line creates a new savegame for the instance. You could also
upload your own save to the instance directory instead. And finally the
fourth line starts the instance.
third line starts the instance, which creates a new save if there are no
save games present.
There are many more commands available with clusterctl. See
`node clusterctl --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.
## Plugins
Here are the known Clusterio plugins in the wild:
1. [Player Manager](https://github.com/Danielv123/playerManager) - Adds player management to the Web UI and shared inventory handling (beta)
2. [DiscordChat](https://github.com/jakedraddy/ClusterioDiscordChat) - Logs in-game chat/joins/leave messages on all instances to a Discord webhook.
3. [TrainTeleports](https://github.com/Godmave/clusterioTrainTeleports) - Allows you to teleport cargotrains between servers.
## Common problems
### EACCESS [...] LISTEN 443
@@ -288,7 +372,3 @@ TLDR: the tested fix is:
- select TCP and select specific local ports and type in the ports that you want to open (comma separated) and click next > 3 times
- give the rule a name (like 'web server' or something), give it a description (optionally) and click finish
### Other fixes for other potential problems:
Sometimes the install fails. Try `node ./lib/npmPostinstall` to complete it.
-566
View File
@@ -1,566 +0,0 @@
const jwt = require("jsonwebtoken");
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 link = require("lib/link");
const errors = require("lib/errors");
const config = require("lib/config");
/**
* 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.
*/
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;
}
/**
* Represents a command that can be runned by clusterctl
*/
class Command {
constructor({ definition, handler }) {
this.name = definition[0];
this._definition = definition;
this._handler = handler;
}
register(yargs) {
yargs.command(...this._definition);
}
async run(args, control) {
await this._handler.call(this, args, control);
}
}
/**
* Resolve a string to an instance ID
*
* Resolves a string with either an instance name or an id into an integer
* with the instance ID.
*
* @param client - link to master server to query instance on.
* @param instanceName - string with name or id of instance.
* @returns {number} instance ID.
*/
async function resolveInstance(client, instanceName) {
let instanceId;
if (/^-?\d+$/.test(instanceName)) {
instanceId = parseInt(instanceName, 10);
} else {
let response = await link.messages.listInstances.send(client);
for (let instance of response.list) {
if (instance.name === instanceName) {
instanceId = instance.id;
break;
}
}
if (instanceId === undefined) {
throw new errors.CommandError(`No instance named ${instanceName}`);
}
}
return instanceId;
}
/**
* Resolve a string into a slave ID
*
* Resolves a string with either an slave name or an id into an integer with
* the slave ID.
*
* @param client - link to master server to query slave on.
* @param slaveName - string with name or id of slave.
* @returns {number} slave ID.
*/
async function resolveSlave(client, slaveName) {
let slaveId;
if (/^-?\d+$/.test(slaveName)) {
slaveId = parseInt(slaveName, 10);
} else {
let response = await link.messages.listSlaves.send(client);
for (let slave of response.list) {
if (slave.name === slaveName) {
slaveId = slave.id;
break;
}
}
if (slaveId === undefined) {
throw new errors.CommandError(`No slave named ${slaveName}`);
}
}
return slaveId;
}
let commands = [];
commands.push(new Command({
definition: ["list-slaves", "List slaves connected to the master"],
handler: async function(args, control) {
let response = await link.messages.listSlaves.send(control);
console.log(asTable(response.list));
},
}));
commands.push(new Command({
definition: ["list-instances", "List instances known to the master"],
handler: async function(args, control) {
let response = await link.messages.listInstances.send(control);
console.log(asTable(response.list));
}
}));
commands.push(new Command({
definition: ['create-instance', "Create an instance", (yargs) => {
// XXX TODO: set any specific options?
yargs.option('name', {
type: "string",
nargs: 1,
describe: "Instance name",
group: "Instance Config:",
});
yargs.option('id', {
type: "number",
nargs: 1,
describe: "Instance ID",
group: "Instance Config:",
});
yargs.option('base', {
type: "boolean",
nargs: 0,
describe: "Specify this is a base config",
group: "Instance Config:"
});
}],
handler: async function(args, control) {
let instanceConfig = new config.InstanceConfig();
await instanceConfig.init();
if (args.id) {
instanceConfig.set("instance.id", args.id);
}
if (args.name) {
instanceConfig.set("instance.name", args.name);
}
let serialized_config = instanceConfig.serialize();
let response = await link.messages.createInstance.send(control, { serialized_config });
},
}));
commands.push(new Command({
definition: ["list-instance-config", "List configuration for an instance", (yargs) => {
yargs.option({
"instance": { describe: "Instance to list config for", nargs: 1, type: "string", demandOption: true },
});
}],
handler: async function(args, control) {
let instanceId = await resolveInstance(control, args.instance);
let response = await link.messages.getInstanceConfig.send(control, { instance_id: instanceId });
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)}`);
}
}
},
}));
commands.push(new Command({
definition: ["set-instance-config", "Set field in instance config", (yargs) => {
yargs.option({
"instance": { describe: "Instance to set config on", nargs: 1, type: "string", demandOption: true },
"field": { describe: "Field to set", nargs: 1, type: "string", demandOption: true },
"value": { describe: "Value to set", nargs: 1, type: "string", demandOption: true },
});
}],
handler: async function(args, control) {
let instanceId = await resolveInstance(control, args.instance);
await link.messages.setInstanceConfigField.send(control, {
instance_id: instanceId,
field: args.field,
value: args.value,
});
},
}));
commands.push(new Command({
definition: ['assign-instance', "Assign instance to a slave", (yargs) => {
yargs.options({
'instance': { describe: "Instance to assign", nargs: 1, type: 'string', demandOption: true },
'slave': { describe: "Slave to assign to", nargs: 1, type: 'string', demandOption: true },
});
}],
handler: async function(args, control) {
let instanceId = await resolveInstance(control, args.instance);
let slaveId = await resolveSlave(control, args.slave);
await link.messages.assignInstanceCommand.send(control, {
instance_id: instanceId,
slave_id: slaveId,
});
},
}));
commands.push(new Command({
definition: ['create-save', "Create a new save on an instance", (yargs) => {
yargs.options({
'instance': { describe: "Instance to create on", nargs: 1, type: 'string', demandOption: true },
});
}],
handler: async function(args, control) {
let instanceId = await resolveInstance(control, args.instance);
await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await link.messages.createSave.send(control, {
instance_id: instanceId,
});
},
}));
commands.push(new Command({
definition: ['start-instance', "Start instance", (yargs) => {
yargs.options({
'instance': { describe: "Instance to start", nargs: 1, type: 'string', demandOption: true },
});
}],
handler: async function(args, control) {
let instanceId = await resolveInstance(control, args.instance);
await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await link.messages.startInstance.send(control, {
instance_id: instanceId,
});
},
}));
commands.push(new Command({
definition: ['stop-instance', "Stop instance", (yargs) => {
yargs.options({
'instance': { describe: "Instance to stop", nargs: 1, type: 'string', demandOption: true },
});
}],
handler: async function(args, control) {
let instanceId = await resolveInstance(control, args.instance);
await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await link.messages.stopInstance.send(control, {
instance_id: instanceId,
});
},
}));
commands.push(new Command({
definition: ['delete-instance', "Delete instance", (yargs) => {
yargs.options({
'instance': { describe: "Instance to delete", nargs: 1, type: 'string', demandOption: true },
});
}],
handler: async function(args, control) {
let response = await link.messages.deleteInstance.send(control, {
instance_id: await resolveInstance(control, args.instance),
});
},
}));
commands.push(new Command({
definition: ['send-rcon', "Send RCON command", (yargs) => {
yargs.options({
'instance': { describe: "Instance to sent to", nargs: 1, type: 'string', demandOption: true },
'command': { describe: "command to send", nargs: 1, type: 'string', demandOption: true },
});
}],
handler: async function(args, control) {
let response = await link.messages.sendRcon.send(control, {
instance_id: await resolveInstance(control, args.instance),
command: args.command
})
// Factorio includes a newline in it's response output.
process.stdout.write(response.result);
},
}));
// Convert to mapping from name to command instance
commands = new Map([...commands.map(command => [command.name, command])]);
/**
* Connector for control connection to master server
*/
class ControlConnector extends link.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
super(url, reconnectDelay);
this._token = token;
}
register() {
console.log("SOCKET | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterctl",
version: version,
});
}
}
/**
* Handles running the control
*
* Connects to the master server over WebSocket and sends commands to it.
*/
class Control extends link.Link {
// I don't like God classes, but the alternative of putting all this state
// into global variables is not much better.
constructor(connector) {
super('control', 'master', connector);
link.attachAllMessages(this);
}
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutputColored(output));
}
async shutdown() {
await link.messages.shutdownConnection.send(this);
this.connector.close(1001, "Control Quit")
await events.once(this.connector, "close");
}
}
async function findCredentials(controlConfig) {
// Try looking in the default master config location
try {
let masterConfig = new config.MasterConfig();
await masterConfig.load(JSON.parse(await fs.readFile("config-master.json")));
let token = jwt.sign({ id: "api" }, masterConfig.get("master.auth_secret"), { expiresIn: 600 });
controlConfig.set("control.master_token", token);
let url;
if (masterConfig.get("master.https_port")) {
url = `https://localhost:${masterConfig.get("master.https_port")}`;
} else {
url = `http://localhost:${masterConfig.get("master.http_port")}`;
}
controlConfig.set("control.master_url", url);
return;
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
// Try looking in the default slave config location
// In the future it's likely that tokens will be assigned more granular
// authorizations, meaning that you cannot use a slave token to control
// the cluster.
try {
let slaveConfig = new config.SlaveConfig();
await slaveConfig.load(JSON.parse(await fs.readFile("config-slave.json")));
controlConfig.set("control.master_token", slaveConfig.get("slave.master_token"));
controlConfig.set("control.master_url", slaveConfig.get("slave.master_url"));
return;
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
throw new errors.StartupError("Couldn't find credentials to connect to the master server with");
}
class ControlGroup extends config.ConfigGroup {}
ControlGroup.groupName = "control";
ControlGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
type: "string",
optional: true,
});
ControlGroup.define({
name: "master_token",
description: "Token to authenticate to master server with.",
type: "string",
optional: true,
});
ControlGroup.define({
name: "reconnect_delay",
title: "Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initial_value: 2,
});
ControlGroup.finalize();
class ControlConfig extends config.Config { }
ControlConfig.registerGroup(ControlGroup);
ControlConfig.finalize();
async function startControl() {
yargs
.scriptName("clusterctl")
.usage("$0 <command> [options]")
.option('config', {
nargs: 1,
describe: "config file to get credentails from",
default: "config-control.json",
defaultDescription: "auto",
type: 'string',
})
.command("control-config", "Manage Control config", config.configCommand)
.wrap(yargs.terminalWidth())
;
for (let command of commands.values()) {
command.register(yargs);
}
const args = yargs.demandCommand(1, "You need to specify a command to run")
.strict()
.argv
;
config.finalizeConfigs();
console.log(`Loading config from ${args.config}`);
let controlConfig = new ControlConfig();
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");
await controlConfig.init();
} else {
throw err;
}
}
let commandName = args._[0];
if (commandName === "control-config") {
await config.handleConfigCommand(args, controlConfig, args.config);
return;
}
// The remaining commands require connecting to the master server.
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
await findCredentials(controlConfig);
}
let controlConnector = new ControlConnector(
controlConfig.get("control.master_url"),
controlConfig.get("control.reconnect_delay"),
controlConfig.get("control.master_token")
);
let control = new Control(controlConnector);
try {
await controlConnector.connect();
} catch(err) {
if (err instanceof errors.AuthenticationFailed) {
throw new errors.StartupError(err.message);
}
throw err;
}
if (commands.has(commandName)) {
command = commands.get(commandName);
try {
await command.run(args, control)
} catch (err) {
if (err instanceof errors.CommandError) {
console.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof errors.RequestError) {
console.error(`Error sending request: ${err.message}`);
process.exitCode = 1;
} else {
throw err;
}
} finally {
await control.shutdown();
}
}
//XXX control.close("done");
}
module.exports = {
// for testing only
_formatOutputColored: formatOutputColored,
_resolveInstance: resolveInstance,
_Control: Control,
_commands: commands,
}
if (module === require.main) {
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startControl().catch(err => {
if (!(err instanceof errors.StartupError)) {
console.error(`
+----------------------------------------------------------------+
| Unexpected error occured while starting control, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+`
);
} else {
console.error(`
+-------------------------------+
| Unable to to start clusterctl |
+-------------------------------+`
);
}
console.error(err);
process.exitCode = 1;
});
}
+4 -3
View File
@@ -49,9 +49,10 @@ 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 [lib/link/messages.js](../lib/link/messages.js)
and are attached to links via the attachAllMessages function, which is
usally called in the Link subclass constructor.
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
+4 -4
View File
@@ -6,16 +6,16 @@ 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 clusterctl utility.
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
[/lib/config/definitions.js](lib/config/definitions.js) while plugins
can add their own config groups to the `MasterConfigGroup` and
`InstanceConfigGroup` properties of the `info.js` export using
[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.
+323
View File
@@ -0,0 +1,323 @@
Configuration
=============
Master Configuration
--------------------
### 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.
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.
Defaults to null.
### 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.
Defaults to 8443.
### 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 /.
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.
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.
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.
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.
Defaults to 60
### 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.
Defaults to 30.
### master.default_role_id
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
Whether to enable the given plugin on the master server. Plugins
disabled on the master server will not be loaded on instances.
Defaults to true.
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.
Defaults to "New Slave".
### slave.id
Imutable 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.
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.
Defaults to "instances".
### slave.master_url
URL to connect to the master server to.
Defaults to "https://localhost:8443/".
### 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.
Defaults to "enter token here".
### 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.
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.
Defaults to 5.
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.
Defaults to "New Instance"
### instance.id
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.
Defaults to null meaning not assigned.
### 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.
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.
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.
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.
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.
Defaults to true
### factorio.enable_whitelist
Turn on the whitelist on the server.
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.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
### 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.
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.
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.
Defaults to true
### <plugin_name>.enabled
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.
Defaults to true
+54 -11
View File
@@ -11,6 +11,8 @@ Contents
- [Workflow](#workflow)
- [Starting a Feature Branch](#starting-a-feature-branch)
- [Setting up the Development Environment](#setting-up-the-development-environment)
- [Checking your code for errors](#checking-your-code-for-errors)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Editing your Feature Branch](#editing-your-feature-branch)
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
@@ -18,6 +20,7 @@ Contents
- [Changelog](#changelog)
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
- [Code Style](#code-style)
- [Naming Style](#naming-style)
- [Indenting](#indenting)
- [Line Length](#line-length)
@@ -55,6 +58,19 @@ 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:
npm install
npx lerna bootstrap
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
@@ -76,6 +92,18 @@ 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:
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.
### Submitting a Pull Request
Once you've commited changes to your feature branch that you want to
@@ -159,7 +187,7 @@ of this log should be self explanatory, please follow it carefully.
Supported Node.js and Factorio Versions
---------------------------------------
Clusterio run on Node.js v12 and up, v11.13.0+ and v10.16.0+, make sure
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
@@ -179,25 +207,40 @@ 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.
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.
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");
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"'`.) For
quotes may use single quotes (i.e., `'String with "quotes"'`).
JavaScript template literals may also be used where appropriate.
There are places in the codebase that are using `'single'` quoted
strings. Should you be modifying such code use double quotes on new and
modified lines and leave the rest untouched.
### Indenting
JavaScript code should be indented with tabs. There are places in the
codebase that are mixing tabs and spaces or uses only spaces for
indents. Should you be modifiying such code that is mixing spaces and
tabs as indending you should update the indenting to use tabs _on the
code you change_ while leaving the rest of the code untouched.
JavaScript code should be indented with tabs.
Lua code should be indented with 4 spaces.
+13 -15
View File
@@ -22,7 +22,7 @@ Factorio Mods
-------------
Factorio mods that do not interact with Clusterio should not have any
conciderations that need to be taken into account for them to work with
considerations that need to be taken into account for them to work with
Clusterio.
@@ -36,12 +36,12 @@ Clusterio to work. The main limitation is that the
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 registring functions
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 breif description of the usage of the event_handler library is
A brief description of the usage of the event_handler library is
provided in the [event_handler interface](#event_handler-interface)
section.
@@ -82,10 +82,10 @@ 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 registring
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 registring the actual callbacks with
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`
@@ -111,11 +111,11 @@ of which are optional:
- `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 registring remote interfaces.
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 registring commands.
for registering commands.
<sub>1: Before 0.17.69 these callbacks were called after
on_init/on_load.</sub>
@@ -298,7 +298,7 @@ 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.
- `instance_id`: the id of the instance.
- `instance_name`: the name of the instance.
##### on_server_startup
@@ -309,14 +309,14 @@ 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 and/or migrate the data structures
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
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
@@ -376,14 +376,12 @@ passed.
#### serialize.deserialize_inventory(LuaInventory, serialized_inventory)
Deserialize a previously serialized inventory into the target
LuaInventory. Overwrites slots that has content in the serialized
LuaInventory. Overwrites slots that have content in the serialized
inventory.
Clusterio Plugins
-----------------
The plugin interface is undergoing a major rewrite, and will be
documented once that is done. See
[issue #220](https://github.com/clusterio/factorioClusterio/issues/220)
for details.
See the [Writing Plugins](writing-plugins.md) document for details on
the plugin interface and how to write plugins for Clusterio.
+15
View File
@@ -9,7 +9,22 @@ based on the built in
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.
<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
-------------
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.
+8 -8
View File
@@ -11,13 +11,13 @@
},
"source": {
"include": [
"master.js",
"slave.js",
"clusterctl.js",
"lib/",
"plugins/",
"routes/",
"routes.js"
]
"packages/master/src/",
"packages/master/master.js",
"packages/slave/slave.js",
"packages/ctl/ctl.js",
"packages/lib/",
"plugins/"
],
"excludePattern": "node_modules"
}
}
+132
View File
@@ -0,0 +1,132 @@
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]`.
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
------
To be written.
Instances
---------
To be written.
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.
### List users
ctl> user list
Lists all user accounts in the cluster along with some data for each.
### Create User
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.
### 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.
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.
### 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.
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
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.
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.
### Set Cluster Roles
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.
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.
### Delete user
ctl> user delete <name>
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 joins the cluster again a new account will be made
for them automatically.
Roles
-----
To be written.
+20 -13
View File
@@ -114,6 +114,9 @@ Handshake messages
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.
### `register_slave`
@@ -124,6 +127,9 @@ Slave handshake for establishing a new connection session.
- version - string - The protocol version of the slave, e.g. "2.0.0".
- 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.
### `register_control`
@@ -224,8 +230,8 @@ 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 [lib/link/messages.js](/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
@@ -239,8 +245,8 @@ 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 [lib/link/messages.js](/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`
@@ -253,28 +259,29 @@ requests and their contents.
- error - string - Human readable text message describing the error.
Graceful shutdown
-----------------
Graceful Disconnect
-------------------
To ensure no data is lost when closing the link it's important to
send connection shutdown request and wait for it to complete before
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.
### `connection_shutdown_request`
### `prepare_disconnect_request`
Request graceful shutdown of the connection. The receiving party should
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.
### `connection_shutdown_response`
### `prepare_disconnect_response`
Signals the shutdown is complete and that the connection can be closed.
The party that initiatied the shutdown request may still send other
requests/events over the link to finish its part of the shutdown.
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.
+288 -124
View File
@@ -17,7 +17,9 @@ Contents
- [Defining Events](#defining-events)
- [Defining Requests](#defining-requests)
- [Sending Link Messages](#sending-link-messages)
- [Handling connection events](#handling-connection-events)
- [Collecting Statistics](#collecting-statistics)
- [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl)
Plugin Structure
@@ -30,22 +32,35 @@ The basic file structure of a plugin is the following.
+- package.json
+- master.js
+- instance.js
+- control.js
+- test/
| +- plugin.js
+- module/
+- module.json
+- plugin.lua
The most important file is the `info.js` file. Without it the plugin
will not recognized by Clusterio. Here's an example of it:
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.
const link = require('lib/link'); // For messages
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:
const libLink = require('@clusterio/lib/link'); // For messages
module.exports = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advance frobnication",
version: "0.8.1",
description: "Does advanced frobnication",
instanceEntrypoint: "instance",
masterEntrypoint: "master",
messages: {
@@ -65,20 +80,15 @@ The following properties are recognized:
**description**:
Brief description of what the plugin does. Currently not used.
**version**:
Semver compatible version string of the plugin. Currently only
servers as the default for the Clusterio module embedded in the
plugin, if any.
**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 can be for instances only but
for it to be able to send any messages to other instances it will
still have to be loaded on the master server.
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` to defining the per instance
Subclass of `PluginConfigGroup` for defining the per instance
configuration fields for this plugin. See [Plugin
Configuration](#plugin-configuration)
@@ -89,10 +99,16 @@ The following properties are recognized:
master server.
**MasterConfigGroup**:
Subclass of `PluginConfigGroup` to defining the master server
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.
**messages**:
Object with link messages definitions for this plugin. See guide
for [defining link messages](#defining-link-messages) below.
@@ -106,10 +122,10 @@ 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)
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 sepparators if
with .js and it should use forward slashes for directory separators if
any.
@@ -117,12 +133,12 @@ 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
`lib/plugin`. For example, to define a MasterPlugin class the following
code can be used:
const plugin = require('lib/plugin');
const libPlugin = require('@clusterio/lib/plugin');
class MasterPlugin extends plugin.BaseMasterPlugin {
class MasterPlugin extends libPlugin.BaseMasterPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
@@ -136,11 +152,12 @@ code can be used:
}
For the instance plugin it's exactly the same except "Master" is
replaced with "Instance". The available hooks that you can override is
documented on the base class [in lib/plugin.js](../lib/plugin.js).
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 be the base class. E.g.:
one, forward all arguments to the base class. E.g.:
constructor(...args) {
super(...args);
@@ -149,8 +166,8 @@ one forward all arguments to be the base class. E.g.:
}
The arguments passed may change, and attempting to modify them will
result in unpredicatable behaviour. The async init method always called
immediatly after the constructor so there's little reason to do this.
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
@@ -163,9 +180,9 @@ take advantage of it by subclassing `PluginConfigGroup`, setting the
and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in
the `info.js` export. For example in info.js:
const config = require("lib/config");
const libConfig = require("@clusterio/lib/config");
class MasterConfigGroup extends config.PluginConfigGroup { }
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
MasterConfigGroup.groupName = "foo_frobber";
MasterConfigGroup.define({
name: "level",
@@ -194,12 +211,26 @@ where appropriate. See [Configuration System](configuration-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:
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("foo_frobber plugin requires save patching.");
}
}
Communicating with Factorio
---------------------------
For pushing data into Factorio there's RCON which lets you send
arbitrary Lua command to invoke whatever code you want in the game.
This is done by calling 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 instance's server object.
For example:
async onStart() {
@@ -211,7 +242,7 @@ For example:
}
Because data into Factorio is streamed at a rate of 3-6 kB/s by default
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
@@ -222,7 +253,7 @@ 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
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() {
@@ -257,7 +288,7 @@ 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 was sent at a later point in time. For RCON,
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.
@@ -269,18 +300,18 @@ 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 send over it are
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.)
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 target it's sent to. Requests are pairs of request and
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 only that both parties of a link may innitiate one.
HTTP requests, in that both parties of a link may initiate one.
### Defining Events
@@ -292,7 +323,7 @@ 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 link.Event({
startFrobnication: new libLink.Event({
type: 'foo_frobber:start_frobnication',
links: ['master-slave', 'slave-instance'],
forwardTo: 'instance',
@@ -303,68 +334,81 @@ the event, for example the following could be defined in `info.js`:
},
This specifies an event that can be sent from the master to a slave,
and from a slave to an instance. And 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.
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.
The following properties are recognized by the Event constructor:
**type**:
The message type sent over the wire. This can be any string but
it must be unique across all plugins and it's recommended that it is
of format `plugin_name:message_name`. The suffix `_event` will be
appened to the type.
#### type
**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 slave, but can't be sent back the other way
unless `'slave-master'` is also present in the links array.
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.
**forwardTo**:
Target to forward event to. Can be either `'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.
#### links
**broadcastTo**:
Target to broadcast this message towards. Currently only
`'instance'` is supported and 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. This means that sending an 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.
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.
**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
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.
#### 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
#### 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.
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 and master-slave, slave-instance as the links.
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. If you need a
confirmation that the message was received use a request.
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 correspond to the handler invoked on the plugin
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`:
the event. For example, the following could be defined in `info.js`:
messages: {
reportFrobnication: new link.Request({
reportFrobnication: new libLink.Request({
type: 'foo_frobber:report_frobnication',
links: ['master-slave', 'slave-instance'],
forwardTo: 'instance',
@@ -382,47 +426,59 @@ 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
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
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 can be any string but
it must be unique across all plugins and it's recommended that it is
of format `plugin_name:message_name`. The suffix `_request` will be
appened to the type for the request message sent, and the suffix
`_response` will be appended to the type for the response.
#### type
**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 slave and the slave can reply to the master,
but the slave can't sent a request to the master unless
`'slave-master'` is also present in the links array.
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.
**forwardTo**:
Target to forward request to. Can be either `'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 at the links
that forward it.
#### links
**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
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.
**responseProperties**:
Same as the requestProperties only for the response sent back by the
target.
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.
#### 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
#### responseProperties
Same as the requestProperties only for the response sent back by the
target.
Sending Link Messages
@@ -430,10 +486,10 @@ 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 instance plugins the link is the instance itself
which is accessible through the `.instance` property on the plugin. The
`.info` property of the plugin class exposes the data exported from the
plugin's `info.js` module. In other words:
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() {
@@ -445,18 +501,63 @@ 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.
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.
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.
Collecting Statistics
---------------------
Clusterio comes with it's own Prometheus client implementation, one part
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 a plugins consists of defining
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('lib/prometheus');
const { Counter } = require('@clusterio/lib/prometheus');
const fooMetric = new Counter(
'clusterio_foo_frobber_foo_metric', "Measures the level of foo",
@@ -470,10 +571,10 @@ 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
For metrics that are per-instance, you must define an `instance_id` label and
set it accordingly, for example:
const { Counter } = require('lib/prometheus');
const { Counter } = require('@clusterio/lib/prometheus');
const barMetric = new Gauge(
'clusterio_foo_frobber_bar_metric', "Bar instance level",
@@ -488,10 +589,73 @@ 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
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 runned before the
metrics in the default registry is collected.
hook on both master and instance plugins that is run before the
metrics in the default registry are collected.
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:
const { Command, CommandTree } = require("@clusterio/lib/command");
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
Then commands are added to the the plugin's command tree:
const info = require("./info");
fooFrobberCommands.add(new Command({
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
yargs.positional("level", {
describe: "type of frobnication", type: "string"
});
}],
handler: async function(args, control) {
await info.messages.frobnicate.send(control, {
instance_name: "Console",
content: args.message,
});
},
}));
For a command the `definition` is the arguments to pass to
[yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler)
(see also
[yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt)
and
[yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt)
for setting up positional and optional arguments to commands). The
`handler` is an async function that's invoked when the command is
executed and it's passed the parsed command line arguments and a
reference to the `Control` class of clusterioctl. 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.
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");
class ControlPlugin extends libPlugin.BaseControlPlugin {
async addCommands(rootCommand) {
rootCommand.add(fooFrobberCommands);
}
}
module.exports = {
ControlPlugin,
}
+7
View File
@@ -0,0 +1,7 @@
{
"packages": [
"packages/*",
"plugins/*"
],
"version": "2.0.0-alpha.1"
}
-48
View File
@@ -1,48 +0,0 @@
const jwt = require('jsonwebtoken');
let authSecret = null;
function setAuthSecret(secret) {
authSecret = secret;
}
function middleware(req, res, next) {
// This should not happen
if (!authSecret) {
return res.status(500).send({ auth: false, message: 'Secret not set', endpoint: req.route.path });
}
var token = req.headers['x-access-token'];
if (!token) {
return res.status(401).send({ auth: false, message: 'No token provided.', endpoint: req.route.path });
}
jwt.verify(token, authSecret, function(err, decoded) {
if (err) {
return res.status(401).send({ auth: false, message: 'Failed to authenticate token.', endpoint: req.route.path });
}
next();
});
}
async function check(token) {
return new Promise((resolve, reject) => {
// This should not happen
if (!authSecret) {
resolve({ ok: false, msg: 'Secret not set' });
}
jwt.verify(token, authSecret, function(err, decoded) {
if (err) {
resolve({ok:false, msg:err})
} else {
resolve({ok:true, msg:"Successfully authenticated"});
}
});
});
}
module.exports = {
setAuthSecret,
middleware,
check,
};
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
...require('lib/factorio/locale'),
...require('lib/factorio/patch'),
...require('lib/factorio/server'),
};
-28
View File
@@ -1,28 +0,0 @@
/**
* Function for parsing factorio locale from a local factorio install
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
const ini = require("ini");
/**
* Gets factorios locale as an object, does not respect mods.
*
* @param {string} factorioDirectory
* @param {string} languageCode
* @param {function} callback
*/
async function getLocale(dataDir, languageCode) {
if (typeof dataDir !== "string") throw new TypeError("dataDir must be a string");
if (typeof languageCode !== "string") throw new TypeError("languageCode must be a string");
let localeFilePath = path.join(dataDir, "base", "locale", languageCode, "base.cfg");
let content = await fs.readFile(localeFilePath, "utf8");
return ini.parse(content);
}
module.exports = {
getLocale,
};
-2
View File
@@ -1,2 +0,0 @@
const fileOps = require("./fileOps");
module.exports = fileOps;
-5
View File
@@ -1,5 +0,0 @@
module.exports = {
...require('lib/link/link'),
...require('lib/link/messages'),
...require('lib/link/connectors'),
};
-558
View File
@@ -1,558 +0,0 @@
"use strict";
const schema = require("lib/schema");
const errors = require("lib/errors");
/**
* Represents a request sent over the link
*
* A request-response message that can be innitated by either party of the
* link.
*/
class Request {
/**
* Creates a request
*
* @param {string} type -
* message type used. Will have suffix '_request' for the request
* message and suffix '_response' for the response message.
* @param {Array<string>} links -
* Links this request is valid on. Takes an array of strings
* containing "<source>-<target>" specifications.
* @param {?string} forwardTo -
* 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 {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 {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, links, forwardTo = null, requestProperties = {}, responseProperties = {}
}) {
this.type = type;
this.links = links;
this.forwardTo = forwardTo;
this.requestType = type + '_request';
this.responseType = type + '_response';
if (forwardTo === 'instance') {
requestProperties = Object.assign(
{"instance_id": { type: "integer" }}, requestProperties
);
} else if (forwardTo !== "master" && forwardTo !== null) {
throw new Error(`Invalid forwardTo value ${forwardTo}`);
}
this._requestValidator = schema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.requestType },
"data": {
additionalProperties: false,
required: Object.keys(requestProperties),
properties: requestProperties,
},
},
});
this._responseValidator = schema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.responseType },
"data": {
anyOf: [
{
additionalProperties: false,
required: ["seq", ...Object.keys(responseProperties)],
properties: {
"seq": { type: "integer" },
...responseProperties,
},
},
{
additionalProperties: false,
required: ["seq", "error"],
properties: {
"seq": { type: "integer" },
"error": { type: "string" },
},
},
],
},
}
});
}
/**
* Attach to a link
*
* Set a the validator for the response on the given link if it's a
* source and sets the handler for the request if it is a target.
*
* If forwardTo was set to 'instance' the `forwardRequestToInstance`
* method on the link is used as the default handler.
*
* If forwardTo was set to 'master' the `forwardRequesToMaster` method
* on the link is used as the default handler.
*
* Does nothing if the link is neither a source nor a target for this
* request.
*
* @param {Link} link - The link to attach to.
* @param {Function} handler -
* Async function to invoke with link set as this to handle the
* request request. Only used on the target side.
* @throws {Error} if the handler is needed and not defined.
*/
attach(link, handler) {
// Source side.
if (this.links.includes(`${link.source}-${link.target}`)) {
link.setValidator(this.responseType, this._responseValidator);
}
// Target side. Note: Source and target is reversed here.
if (this.links.includes(`${link.target}-${link.source}`)) {
// Use forwarder if handler is not present.
if (!handler) {
if (this.forwardTo === 'instance') {
handler = link.forwardRequestToInstance;
} else if (this.forwardTo === "master") {
handler = link.forwardRequestToMaster;
}
}
if (!handler) {
throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`);
}
link.setHandler(this.requestType, message => {
handler.call(link, message, this).then(response => {
if (response === undefined) {
// XXX Should we allow implicit responses like this?
response = {};
}
if (!this._responseValidator({ seq: 0, type: this.responseType, response})) {
console.error(this._responseValidator.errors);
throw new Error(`Validation failed responding to ${this.requestType}`);
}
link.connector.send(this.responseType, { ...response, seq: message.seq });
}).catch(err => {
if (!(err instanceof errors.RequestError)) {
console.error(`Unexpected error while responding to ${this.requestType}`, err);
}
link.connector.send(this.responseType, { seq: message.seq, error: err.message });
});
}, this._requestValidator);
}
}
/**
* Send request over the given link
*
* Sends the given data over the link as the request payload and waits
* for the response.
*
* @returns {object} response data
*/
async send(link, data = {}) {
// XXX validate link target/source?
if (!this._requestValidator({ seq: 0, type: this.requestType, data})) {
console.error(this._requestValidator.errors);
throw new Error(`Validation failed sending ${this.requestType}`);
}
let seq = link.connector.send(this.requestType, data);
let responseMessage = await link.waitFor(this.responseType, { seq });
if (responseMessage.data.error) {
throw new errors.RequestError(responseMessage.data.error);
}
return responseMessage.data;
}
}
let messages = {}
// Connection requests
let wsLinks = ["master-control", "control-master", "master-slave", "slave-master"];
messages.shutdownConnection = new Request({
type: "shutdown_connection",
links: wsLinks,
});
messages.ping = new Request({
type: "ping",
links: wsLinks,
});
// Management requests
messages.listSlaves = new Request({
type: 'list_slaves',
links: ['control-master'],
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["agent", "version", "name", "id", "connected"],
properties: {
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
},
},
},
},
});
messages.listInstances = new Request({
type: 'list_instances',
links: ['control-master'],
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["name", "id", "assigned_slave"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
},
},
},
},
});
messages.setInstanceOutputSubscriptions = new Request({
type: 'set_instance_output_subscriptions',
links: ['control-master'],
requestProperties: {
"instance_ids": {
type: "array",
items: { type: "integer" },
},
},
});
messages.createInstance = new Request({
type: 'create_instance',
links: ['control-master'],
requestProperties: {
"serialized_config": { type: "object" },
},
});
messages.getInstanceConfig = new Request({
type: "get_instance_config",
links: ["control-master"],
requestProperties: {
"instance_id": { type: "integer" },
},
responseProperties: {
"serialized_config": { type: "object" },
},
});
messages.setInstanceConfigField = new Request({
type: "set_instance_config_field",
links: ["control-master"],
requestProperties: {
"instance_id": { type: "integer" },
"field": { type: "string" },
"value": { type: "string" },
},
});
messages.assignInstanceCommand = new Request({
type: 'assign_instance_command',
links: ['control-master'],
requestProperties: {
"instance_id": { type: "integer" },
"slave_id": { type: "integer" },
},
});
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'],
requestProperties: {
"instance_id": { type: "integer" },
},
forwardTo: 'instance',
});
messages.createSave = new Request({
type: 'create_save',
links: ['control-master', 'master-slave', 'slave-instance'],
forwardTo: 'instance',
});
messages.stopInstance = new Request({
type: 'stop_instance',
links: ['control-master', 'master-slave', 'slave-instance'],
forwardTo: 'instance',
});
messages.deleteInstance = new Request({
type: 'delete_instance',
links: ['control-master', 'master-slave'],
forwardTo: 'instance',
});
messages.sendRcon = new Request({
type: 'send_rcon',
links: ['control-master', 'master-slave', 'slave-instance'],
forwardTo: 'instance',
requestProperties: {
"command": { type: "string" },
},
responseProperties: {
"result": { type: "string" },
}
});
messages.getMetrics = new Request({
type: 'statistics_exporter:get_metrics',
links: ['master-slave', 'slave-instance'],
responseProperties: {
"results": { type: "array" },
},
});
/**
* Represents an event sent over the link
*
* A one way message without any response or recipt confirmation that
* can be innitiated by either party of the link.
*/
class Event {
/**
* Creates an event
*
* @param {string} type -
* message type used. Will have suffix '_event' appended to it for
* the messages sent through this event.
* @param {Array<string>} links -
* Links this event is valid on. Takes an array of strings
* containing "<source>-<target>" specifications.
* @param {?string} forwardTo -
* Optional target to forward this request to. Supported values are
* 'master' and 'instance'. If set to 'instance' an instance_id
* property is implicity added to eventProperties.
* @param {?string} broadcastTo -
* Optional target to broadcast this event to. Only 'instance' is
* currently supported. An event will only be broadcast towards
* 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 {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 = {} }) {
this.type = type;
this.links = links;
this.forwardTo = forwardTo;
this.broadcastTo = broadcastTo;
if (forwardTo === 'instance') {
eventProperties = Object.assign(
{"instance_id": { type: "integer" }}, eventProperties
);
} else if (forwardTo !== 'master' && forwardTo !== null) {
throw new Error(`Invalid forwardTo value ${forwardTo}`);
}
if (broadcastTo !== 'instance' && broadcastTo !== null) {
throw new Error(`Invalid broadcastTo value ${broadcastTo}`);
}
this.eventType = type + '_event';
this._eventValidator = schema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.eventType },
"data": {
additionalProperties: false,
required: Object.keys(eventProperties),
properties: eventProperties,
},
},
});
}
/**
* Attach to a link
*
* Set a the handler for the event on the given link if it's a target
* for the event.
*
* If forwardTo was set to 'master' the `forwardEventToMaster` method on
* the link is used as the default handler. If it was set to 'instance'
* the `forwardEventToInstance` method on the link is used by default.
*
* If broadcastTo was set to 'instance' the `broadcastEventToInstance`
* method on the link is invoked before the handler.
*
* Does nothing if the link is not a target for this event.
*
* @param {Link} link - The link to attach to.
* @param {Function} handler -
* Async function to invoke with link set as this to handle the
* event. Only used on the target side.
* @throws {Error} if the handler is needed and not defined.
*/
attach(link, handler) {
// Target side. Note: Source and target is reversed here.
if (this.links.includes(`${link.target}-${link.source}`)) {
// Use forwarder if handler is not present.
if (!handler) {
if (this.forwardTo === 'instance') {
handler = link.forwardEventToInstance;
} else if (this.forwardTo === 'master') {
handler = link.forwardEventToMaster;
}
}
if (
this.broadcastTo === 'instance'
&& [
'instance-slave', 'slave-master', 'control-master', 'master-slave'
].includes(`${link.target}-${link.source}`)
) {
if (!handler) {
handler = link.broadcastEventToInstance;
} else {
let originalHandler = handler;
handler = async (message, event) => {
await link.broadcastEventToInstance(message, event);
await originalHandler.call(link, message, event);
}
}
}
if (!handler) {
throw new Error(`Missing handler for ${this.eventType} on ${link.source}-${link.target} link`);
}
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);
});
}, this._eventValidator);
}
}
/**
* Send event over the given link
*
* Sends the given event data over the link.
*/
send(link, data = {}) {
if (!this._eventValidator({ seq: 0, type: this.eventType, data})) {
console.error(this._eventValidator.errors);
throw new Error(`Validation failed sending ${this.eventType}`);
}
link.connector.send(this.eventType, data);
}
}
messages.instanceStopped = new Event({
type: 'instance_stopped',
links: ['instance-slave'],
});
messages.updateInstances = new Event({
type: 'update_instances',
links: ['slave-master'],
eventProperties: {
"instances": {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["serialized_config"],
properties: {
"serialized_config": { type: "object" },
},
},
},
},
});
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" },
},
},
},
});
/**
* Attaches all requests and events to the given link
*
* Loops through all builtin messages defined and attaches all of them
* to the link. The handler used for the messageis looked up on the
* link instance as the concatenation of the name of the message, the
* name of message class, and 'Handler'.
*/
function attachAllMessages(link) {
for (let [name, message] of Object.entries(messages)) {
message.attach(link, link[name + message.constructor.name + 'Handler']);
}
}
module.exports = {
Request,
Event,
attachAllMessages,
messages,
}
-294
View File
@@ -1,294 +0,0 @@
/**
Provides tools to manage mods in a clusterio install
@module
*/
const asTable = require("as-table").configure({delimiter: ' | '});
const moment = require("moment");
const syncRequest = require("sync-request");
const fs = require("fs-extra");
const https = require('follow-redirects').https;
const factorioAPI = require("node-factorio-api");
const sLog = require("single-line-log").stdout;
const query = require('cli-interact').getYesNo;
const path = require("path");
const fileOps = require("lib/fileOps");
function ellipse(str, max){
return str.length > (max - 3) ? str.substring(0,max-3) + '...' : str;
}
// https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
function formatBytes(size, decimals) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
Lists mods for an instance as a table. Warning, handles printing internally.
@param {Instance} instance - Instance object, see slave.js
@example
var modManager = require("modManager");
modManager.listMods(config, instance);
// console.logs table of mods installed on instance
// if instance.name is shared:
// console.logs mods located in /sharedMods as a nice table
*/
async function listMods(config, instance) {
return new Promise((resolve, reject) => {
if (true) {
factorioAPI.init(false, instance.path("instanceMods"), instance.path("saves"), instance.server.version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(token => {
console.log("* Currently installed:");
factorioAPI.readModZips().then(list => {
console.log(asTable(list.map(mod => {
return {
name:(mod.title || mod.name),
version:mod.version,
date:mod.date,
factorio_ver:mod.factorio_version,
author:mod.author,
}
})));
});
// Had issues earlier due to bug in node-factorio-api, see https://github.com/Danacus/node-factorio-api/issues/2
// bug is resolved, thanks to @Danacus and @brotherpot
factorioAPI.getModsFromSaves().then(list => {
console.log("* Mods used in save:");
console.log(asTable(list[0].mods))
});
}).catch(reason => {
console.log("Failed to authenticate to factorio.com! Please check your connection and the username+token used for authentication in config.json");
resolve();
});
} else {
// list shared mods in /sharedMods/
factorioAPI.init(false, 'sharedMods/', '', instance.server.version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
console.log("\r\n")
console.log("* Mods shared by all instances:")
factorioAPI.getInstalledMods().then(mods => {
console.log(asTable(mods));
resolve();
});
}).catch(e => {
reject(new Error(e));
});
}
// get JSON data about GH releases
/*let res = syncRequest('GET', 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases', {"headers":{"User-Agent":"factorioClusterio"}});
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
let name = JSON.parse(res.getBody())[0].assets[0].name;
console.log(JSON.parse(res.getBody()))
*/
});
}
/**
Searches mods.factorio.com for mods matching a search term and prints
the results to console as a neat looking table
@param {string} searchTerm What to search for
*/
async function findMods(config, searchTerm) {
return new Promise((resolve, reject) => {
factorioAPI.init(false);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(token => {
factorioAPI.searchMods(searchTerm).then(mods => {
let result = asTable(mods.reverse().map(mod => {
return {
title:ellipse(mod.title, 50),
name:ellipse(mod.name, 30),
downloads:mod.downloads_count,
updated: moment(mod.updated_at).format("DD.MM.YY"),
latest_version: mod.latest_release.version,
owner:mod.owner,
//license: mod.license_name,
}
}));
resolve(result);
// console.log(result);
});
}).catch(e => {
if (e.contains("Insufficient information")) console.log("We are missing some parameters required for logging in! Make sure your factorio username and token are specified in config.json.");
reject(new Error(e));
});
});
}
async function addMod(config, term, instance) {
return new Promise((resolve, reject) => {
if (instance.name == "shared") {
var modDirectory = "sharedMods";
} else {
var modDirectory = instance.path("instanceMods");
}
if (term && term.length > 0) {
if (term.includes("mods.factorio.com/mods/")) { // https://mods.factorio.com/mods/Danielv123/ChooChoo
// treat it as a link download
factorioAPI.init(false, modDirectory, "", instance.server.version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
factorioAPI.searchMods(term.split("/")[term.split("/").length-1]).then(body => {
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
console.log("Mod downloaded");
resolve();
});
});
});
} else if (term.includes("github.com")) { // https://github.com/Danielv123/factorioClusterioMod
// treat it as a github releases download
let apiURL = "https://api.github.com/repos/" + term.split("/")[3] + "/" + term.split("/")[4] + "/releases"; // https://api.github.com/repos/Danielv123/factorioClusterioMod/releases
let res = syncRequest('GET', apiURL, {"headers":{"User-Agent":"factorioClusterio"}});
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
let name = JSON.parse(res.getBody())[0].assets[0].name;
if (url) {
console.log("Downloading " + url);
let file = fs.createWriteStream(path.join(modDirectory, name));
let bytesDownloaded = 0;
let downloadStartTime = Date.now();
https.get(url, function(response) {
let bytesToDownload = response.headers['content-length'];
response.pipe(file);
response.on("data", chunk => {
bytesDownloaded += chunk.length;
let downloadTimeElapsed = Date.now() - downloadStartTime;
let bytesPerSecond = bytesDownloaded / (downloadTimeElapsed/1000);
let timeToComplete = Math.floor((bytesToDownload - bytesDownloaded)/bytesPerSecond);
sLog("Downloading "+name+" "+(bytesDownloaded/bytesToDownload*100).toPrecision(3)+"% ("+formatBytes(bytesDownloaded)+" / "+formatBytes(bytesToDownload)+")\r\n"+
"about "+timeToComplete+" seconds remaining ("+formatBytes(bytesPerSecond)+"/s)");
}).on("end", x => {
sLog.clear();
console.log(" Downloaded in "+moment.utc(moment.duration(Date.now()-downloadStartTime).as('milliseconds')).format('mm:ss')); // because sLog.clear() doesn't seem to do the job of ending the line properly
resolve();
});
});
}
} else {
// treat it as a search and download the top result
factorioAPI.init(false, modDirectory, "", instance.server.version);
factorioAPI.authenticate({
username: config.username,
token: config.token,
require_ownership: false
}).then(() => {
factorioAPI.searchMods(term).then(body => {
console.log(body)
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
console.log("Mod downloaded");
resolve();
});
});
});
}
} else {
reject(new Error("Invalid search term"));
}
});
}
async function removeMod(config, term, instance, force = false) {
return new Promise((resolve, reject) => {
if (instance.name == "shared") {
var modDirectory = "sharedMods";
} else {
var modDirectory = instance.path("instanceMods");
}
if (term && typeof term == "string" && term.length > 0) {
let mods = fs.readdirSync(modDirectory);
let modsMatchingFilter = mods.filter(mod => mod.toLowerCase().includes(term.toLowerCase()));
if (modsMatchingFilter.length < 1) {
console.log("No mods found matching search");
} else if (modsMatchingFilter.length == 1) {
console.log("Deleting "+modsMatchingFilter[0]);
fs.unlinkSync(path.join(modDirectory, modsMatchingFilter[0]));
console.log("Deleted.");
} else if (modsMatchingFilter.length > 1) {
console.log("Found multiple mods");
modsMatchingFilter.forEach(mod => console.log(mod));
if (query("Delete all mods matching filter?")) {
let modsToDelete = modsMatchingFilter.length;
for (let i = 0; i < modsToDelete; i++) {
fs.unlinkSync(path.join(modDirectory, modsMatchingFilter[i]));
console.log("Deleted "+modsMatchingFilter[i]);
}
} else console.log("Did not delete mods.")
}
resolve();
} else {
reject(new Error("Invalid search term"));
}
});
}
// 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases'
/**
Download mod from github repo by looking for the first binary download in
the latest release, saving it to /sharedMods
@param {string} url a github repo URL, for example https://api.github.com/repos/Danielv123/factorioClusterioMod/releases. Must link to /releases
*/
function downloadFromGitURL(url) {
console.log("Downloading mod from "+url);
// get JSON data about releases
let res = syncRequest('GET', url, {"headers":{"User-Agent":"factorioClusterio"}});
let downloadUrl = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
let name = JSON.parse(res.getBody())[0].assets[0].name;
console.log(JSON.parse(res.getBody()))
if (downloadUrl) {
console.log(downloadUrl);
let file = fs.createWriteStream("sharedMods/"+name);
https.get(downloadUrl, function(response) {
response.pipe(file);
console.log("Downloaded "+name);
});
}
}
/*
function findUpdates() {
console.log("Downloading mods...");
// get JSON data about releases
let res = syncRequest('GET', 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases', {"headers":{"User-Agent":"factorioClusterio"}});
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
let name = JSON.parse(res.getBody())[0].assets[0].name;
console.log(JSON.parse(res.getBody()))
}*/
module.exports = {
listMods,
findMods,
addMod,
removeMod,
downloadFromGitURL,
}
-116
View File
@@ -1,116 +0,0 @@
var http = require('http');
var https = require('https');
var fs = require('fs-extra');
function makeDirectory(path){
return new Promise((resolve, reject) => {
fs.mkdirp(path, function(err){
if(err){
console.error("Unable to mkdirp "+path);
console.error(err);
resolve(false);
} else {
console.log("mkdirp "+path);
resolve(true);
}
});
});
}
function download(url, file, callback){
return new Promise((resolve, reject) => {
let pathArray = file.split("/");
pathArray.pop(); // remove filename from the end
if(pathArray.length >= 1){
// this might be an invalid path!
fs.stat(pathArray.join("/"), (err, stat) => {
if(err){
makeDirectory(pathArray.join("/")).then(()=>{
doDownload(url, file, callback);
});
} else {
doDownload(url, file, callback);
}
});
} else {
doDownload(url, file, callback);
}
function doDownload(url, file, callback){
var writeStream = fs.createWriteStream(file);
let protocol = http;
if(url.split(":")[0] == "https") protocol = https;
var request = protocol.get(url, function(response) {
response.pipe(writeStream);
response.on("end", () => {
if(callback && typeof callback == "function") callback(file);
console.log("Downloaded "+file+" from "+url);
resolve();
});
});
}
});
}
var directories = [
"sharedMods",
"instances",
];
let downloadList = [
// {
// url: "http://fbpviewer.trakos.pl/images/spritesheet.json",
// file: "static/pictures/spritesheet.js",
// callback: function(file){
// fs.writeFileSync("static/pictures/spritesheet.js", "export default\n"+fs.readFileSync("static/pictures/spritesheet.js"));
// },
// },
// {
// url: "http://fbpviewer.trakos.pl/images/spritesheet.png",
// file: "static/pictures/spritesheet.png",
// },
{
url: "https://code.jquery.com/jquery.js",
file: "static/external/jquery.js",
},
{
url: "https://code.jquery.com/ui/1.12.1/jquery-ui.js",
file: "static/external/jquery-ui.js",
},
{
url: "https://momentjs.com/downloads/moment.min.js",
file: "static/external/moment.min.js",
},
{
url: "https://craig.global.ssl.fastly.net/js/mousetrap/mousetrap.min.js",
file: "static/external/mousetrap.min.js",
},
{
url: "https://fonts.googleapis.com/css?family=Roboto",
file: "static/external/css/Roboto.css",
},
]
// initiate folder creations and downloads
function processMkdirRecursive(callback){
let item = directories.shift();
makeDirectory(item).then(()=>{
if(directories.length == 0){
if(callback) callback();
} else {
processMkdirRecursive(callback);
}
});
}
function processDownloadRecursive(callback){
let item = downloadList.shift();
download(item.url, item.file, item.callback).then(()=>{
if(downloadList.length == 0){
if(callback) callback();
} else {
processDownloadRecursive(callback);
}
});
}
if (module === require.main) {
processMkdirRecursive(processDownloadRecursive);
}
-44
View File
@@ -1,44 +0,0 @@
/**
Helper functions related to objects
@module
*/
/**
Deep clones an object, is actually just JSON.parse(JSON.stringify()) but looks cleaner.
@param {object} obj to clone
@returns {object}
@example
a = {"thingy":"with a value"};
b = a;
a == b; // true
c = objectOps.deepclone(a);
a == c; // false
*/
module.exports.deepclone = function(obj){
return JSON.parse(JSON.stringify(obj));
}
/**
Checks if a string is valid JSON using try/catch. If passed an object it will always return true.
@param {string} string to verify
@returns {boolean}
@example
objectOps.isJSON("99 bottles of beer"); // false
objectOps.isJSON("99"); // true
objectOps.isJSON({"thingy":"that is an object"}); // true
objectOps.isJSON(JSON.stringify({"thingy":"that is an object"})); // true
*/
module.exports.isJSON = function(string){
let stringIsJson = false;
let x
try {
x = JSON.parse(string);
} catch (e){
return false;
}
if(typeof x == "object" || typeof string == "object"){
stringIsJson = true;
}
return stringIsJson;
}
-5
View File
@@ -1,5 +0,0 @@
{
"name": "lib",
"description": "Internal library for factorioClusterio"
}
-192
View File
@@ -1,192 +0,0 @@
/**
* Plugin interfaces and utilities.
* @module
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
const errors = require("lib/errors");
/**
* Base class for instance plugins
*/
class BaseInstancePlugin {
constructor(info, instance, slave) {
/**
* The plugin's own info module
*/
this.info = info;
/**
* Instance the plugin started for
* @type {Instance}
*/
this.instance = instance;
/**
* Slave running the instance
*
* With the exepction of accessing the slave's config you should
* avoid ineracting with the slave object directly.
*
* @type {Slave}
*/
this.slave = slave;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics() { }
/**
* Called after the Factorio server is started
*/
async onStart() { }
/**
* Called before the Factorio server is stopped
*
* This will not be called if for example the Factorio server crashes or
* is killed.
*/
async onStop() { }
/**
* Called when the Factorio server exited
*/
onExit() { }
/**
* Called when the Factorio outputs a line
*
* Called when the Factorio server outputs a line on either stdout or
* stderr. The output is a parsed form of the line and contains the
* following properties (those marked with ? are optional):
*
* - source: Where the message came from, one of "stdout" and "stderr".
* - format: Timestamp format, one of "date", "seconds" and "none".
* - time?: Timestamp of the message. Not present if format is "none".
* - type: Type of message, one of "log", "action" and "generic".
* - level?: Log level for "log" type. i.e "Info" normally.
* - file?: File reported for "log" type.
* - action?: Kind of action for "action" type. i.e "CHAT" for chat.
* - message: Main content of the line.
*/
async onOutput(output) { }
}
class BaseMasterPlugin {
constructor(info, master, metrics) {
this.info = info;
this.master = master;
this.metrics = metrics;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns an async iterator of prometheus metric results or undefined.
*/
async onMetrics() { }
/**
* Called when the master server is shutting down
*/
async onExit() { }
}
/**
* Load plugin information
*
* Searches through the plugin directory and loads each plugin's info
* module. Once loaded the info modules will not be reloaded should this
* function be called again.
*
* @returns {Map<string, Object>} mapping of plugin name to info module.
*/
async function loadPluginInfos(baseDir) {
let plugins = [];
for (let pluginDir of await fs.readdir(baseDir)) {
let pluginInfo;
try {
// Note: Require path is relative to this module
pluginInfo = require(path.posix.join('..', baseDir, pluginDir, 'info'));
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
continue;
} else {
throw new errors.PluginError(pluginDir, err);
}
}
if (pluginInfo.name !== pluginDir) {
throw new errors.EnvironmentError(
`Plugin dir ${baseDir}/${pluginDir} does not match the name of the plugin (${pluginInfo.name})`
);
}
plugins.push(pluginInfo);
}
return plugins;
}
function attachPluginMessages(link, pluginInfo, plugin) {
let messageDefinitions = pluginInfo.messages || [];
for (let [name, messageFormat] of Object.entries(messageDefinitions)) {
let handler = name + messageFormat.constructor.name + 'Handler';
if (plugin === null || !plugin[handler]) {
messageFormat.attach(link);
} else {
messageFormat.attach(link, async function(message, format) {
return await plugin[handler](message, format, this);
});
}
}
}
module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
loadPluginInfos,
attachPluginMessages,
};
-1153
View File
File diff suppressed because it is too large. Load diff
+15 -56
View File
@@ -1,17 +1,14 @@
{
"name": "clusterio",
"version": "2.0.0-alpha",
"description": "Interserver communication for factorio",
"repository": "https://github.com/clusterio/factorioClusterio",
"main": "master.js",
"name": "root",
"private": true,
"scripts": {
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/*/test",
"fast-test": "FAST_TEST=y npm test",
"cover": "nyc npm test",
"fast-cover": "FAST_TEST=y nyc npm test",
"ci-cover": "nyc --reporter=lcovonly npm run-script test",
"lint": "eslint \"*.js\" packages plugins test",
"docs": "jsdoc --configure docs/jsdoc.json",
"install": "node lib/npmPostinstall",
"bundle-dependencies": "bundle-dependencies"
},
"nyc": {
@@ -20,61 +17,23 @@
"plugins/*/test/**"
]
},
"keywords": [
"factorio"
],
"author": "Danielv123",
"license": "MIT",
"engines": {
"node": ">=10.16 <11 || >= 11.13"
},
"dependencies": {
"as-table": "^1.0.31",
"base64url": "^3.0.0",
"bcrypt": "=3.0.6",
"bcrypt-promise": "^2.0.0",
"body-parser": "^1.18.2",
"chalk": "^3.0.0",
"cli-interact": "^0.1.9",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
"deepmerge": "^4.0.0",
"ejs": "^2.5.7",
"express": "^4.16.2",
"express-fileupload": "1.1.5",
"follow-redirects": "^1.5.0",
"fs-extra": "^8.1.0",
"ini": "^1.3.5",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"lib": "file:./lib",
"luamin": "^1.0.4",
"minimist": "^1.2.0",
"moment": "^2.22.2",
"needle": "^2.4.0",
"node-factorio-api": "^0.3.8",
"node-forge": "^0.7.1",
"rcon-client": "^4.2.0",
"request": "^2.83.0",
"sanitizer": "^0.1.3",
"semver": "^7.1.1",
"set-blocking": "^2.0.0",
"simple-git": "^1.96.0",
"single-line-log": "^1.1.2",
"sync-request": "^4.0.1",
"typescript-collections": "^1.3.2",
"ws": "^7.2.3",
"yargs": "^14.2.0"
"node": ">=10.16 <11 || >= 12"
},
"devDependencies": {
"html5-validator": "^1.2.1",
"@clusterio/ctl": "file:./packages/ctl",
"@clusterio/lib": "file:./packages/lib",
"@clusterio/master": "file:packages/master",
"@clusterio/slave": "file:./packages/slave",
"eslint": "^7.2.0",
"fs-extra": "^8.1.0",
"jsdoc": "^3.6.3",
"klaw": "^3.0.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"lerna": "^3.22.1",
"mocha": "^6.2.0",
"mocha-sinon": "^2.1.0",
"mocha.parallel": "^0.15.5",
"nock": "^11.3.0",
"nyc": "^14.1.1",
"sinon": "^7.4.1"
"phin": "^3.4.1",
"webpack": "^5.0.0"
}
}
+108
View File
@@ -0,0 +1,108 @@
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.
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.
* `--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.
#### `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.
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.
#### `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.
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.
#### `plugin list`
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.
#### `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.
See docs/configuration.md in the main repositiory for the available
configuration.
#### `config-control show <config-entry>`
Shows the value for a single config entry.
#### `config-control list`
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.
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
+784
View File
@@ -0,0 +1,784 @@
#!/usr/bin/env node
/**
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
"use strict";
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 libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
const libConfig = require("@clusterio/lib/config");
const libPlugin = require("@clusterio/lib/plugin");
const libCommand = require("@clusterio/lib/command");
const libSharedCommands = require("@clusterio/lib/shared_commands");
/**
* 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;
}
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));
},
}));
slaveCommands.add(new libCommand.Command({
definition: ["generate-token", "Generate token for a slave", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", demandOption: true });
}],
handler: async function(args, control) {
let response = await link.messages.generateSlaveToken.send(control, { slave_id: args.id });
console.log(response.token);
},
}));
slaveCommands.add(new libCommand.Command({
definition: ["create-config", "Create slave config", (yargs) => {
yargs.option("id", { type: "number", nargs: 1, describe: "Slave id", default: null });
yargs.option("name", { type: "string", nargs: 1, describe: "Slave name", default: null });
yargs.option("generate-token", {
type: "boolean", nargs: 0, describe: "Generate authentication token", default: false,
});
yargs.option("output", {
type: "string", nargs: 1, describe: "Path to output config (- for stdout)", default: "config-slave.json",
});
}],
handler: async function(args, control) {
let response = await libLink.messages.createSlaveConfig.send(control, {
id: args.id, name: args.name, generate_token: args.generateToken,
});
let content = JSON.stringify(response.serialized_config, null, 4);
if (args.output === "-") {
console.log(content);
} else {
console.log(`Writing ${args.output}`);
try {
await fs.outputFile(args.output, content, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
throw new libErrors.CommandError(`File ${args.output} already exists`);
}
throw err;
}
}
},
}));
const instanceCommands = new libCommand.CommandTree({
name: "instance", alias: ["i"], description: "Instance management",
});
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));
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["create <name>", "Create an instance", (yargs) => {
// XXX TODO: set any specific options?
yargs.positional("name", { describe: "Instance name", type: "string" });
yargs.options({
"id": { type: "number", nargs: 1, describe: "Instance id" },
});
}],
handler: async function(args, control) {
let instanceConfig = new libConfig.InstanceConfig();
await instanceConfig.init();
if (args.id) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let response = await libLink.messages.createInstance.send(control, { serialized_config });
},
}));
const instanceConfigCommands = new libCommand.CommandTree({
name: "config", alias: ["c"], description: "Instance config management",
});
instanceConfigCommands.add(new libCommand.Command({
definition: ["list <instance>", "List configuration for an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to list config for", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let response = await libLink.messages.getInstanceConfig.send(control, { instance_id: instanceId });
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)}`);
}
}
},
}));
instanceConfigCommands.add(new libCommand.Command({
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" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceConfigField.send(control, {
instance_id: instanceId,
field: args.field,
value: args.value,
});
},
}));
instanceConfigCommands.add(new libCommand.Command({
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" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceConfigProp.send(control, {
instance_id: instanceId,
field: args.field,
prop: args.prop,
value: JSON.parse(args.value),
});
},
}));
instanceCommands.add(instanceConfigCommands);
instanceCommands.add(new libCommand.Command({
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" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
let slaveId = await libCommand.resolveSlave(control, args.slave);
await libLink.messages.assignInstanceCommand.send(control, {
instance_id: instanceId,
slave_id: slaveId,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["create-save <instance>", "Create a new save on an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await libLink.messages.createSave.send(control, {
instance_id: instanceId,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await libLink.messages.exportData.send(control, {
instance_id: instanceId,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["start <instance>", "Start instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.options({
"save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await libLink.messages.startInstance.send(control, {
instance_id: instanceId,
save: args.save || null,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.positional("scenario", { describe: "Scenario to load", type: "string" });
yargs.options({
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await libLink.messages.loadScenario.send(control, {
instance_id: instanceId,
scenario: args.scenario || null,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["stop <instance>", "Stop instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to stop", type: "string" });
}],
handler: async function(args, control) {
let instanceId = await libCommand.resolveInstance(control, args.instance);
await libLink.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
let response = await libLink.messages.stopInstance.send(control, {
instance_id: instanceId,
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["delete <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
}],
handler: async function(args, control) {
let response = await libLink.messages.deleteInstance.send(control, {
instance_id: await libCommand.resolveInstance(control, args.instance),
});
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["send-rcon <instance> <command>", "Send RCON command", (yargs) => {
yargs.positional("instance", { describe: "Instance to send to", type: "string" });
yargs.positional("command", { describe: "command to send", type: "string" });
}],
handler: async function(args, control) {
let response = await libLink.messages.sendRcon.send(control, {
instance_id: await libCommand.resolveInstance(control, args.instance),
command: args.command,
});
// Factorio includes a newline in it's response output.
process.stdout.write(response.result);
},
}));
const permissionCommands = new libCommand.CommandTree({ name: "permission", description: "Permission inspection" });
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));
},
}));
const roleCommands = new libCommand.CommandTree({ name: "role", description: "Role management" });
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));
},
}));
roleCommands.add(new libCommand.Command({
definition: ["create <name>", "Create a new role", (yargs) => {
yargs.positional("name", { describe: "Name of role to create", type: "string" });
yargs.options({
"description": { describe: "Description for role", nargs: 1, type: "string", default: "" },
"permissions": { describe: "Permissions role grants", nargs: 1, array: true, type: "string", default: [] },
});
}],
handler: async function(args, control) {
let response = await libLink.messages.createRole.send(control, {
name: args.name,
description: args.description,
permissions: args.permissions,
});
console.log(`Created role ID ${response.id}`);
},
}));
roleCommands.add(new libCommand.Command({
definition: ["edit <role>", "Edit existing role", (yargs) => {
yargs.positional("role", { describe: "Role to edit", type: "string" });
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" },
"grant-default": { describe: "Add default permissions to role", nargs: 0, type: "boolean" },
});
}],
handler: async function(args, control) {
let role = await libCommand.retrieveRole(control, args.role);
if (args.name !== undefined) {
role.name = args.name;
}
if (args.description !== undefined) {
role.description = args.description;
}
if (args.permissions !== undefined) {
role.permissions = args.permissions;
}
await libLink.messages.updateRole.send(control, role);
if (args.grantDefault) {
await libLink.messages.grantDefaultRolePermissions.send(control, { id: role.id });
}
},
}));
roleCommands.add(new libCommand.Command({
definition: ["delete <role>", "Delete role", (yargs) => {
yargs.positional("role", { describe: "Role to delete", type: "string" });
}],
handler: async function(args, control) {
let role = await libCommand.retrieveRole(control, args.role);
await libLink.messages.deleteRole.send(control, { id: role.id });
},
}));
const userCommands = new libCommand.CommandTree({ name: "user", alias: ["u"], description: "User management" });
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));
},
}));
userCommands.add(new libCommand.Command({
definition: ["create <name>", "Create a user", (yargs) => {
yargs.positional("name", { describe: "Name of user to create", type: "string" });
}],
handler: async function(args, control) {
await libLink.messages.createUser.send(control, { name: args.name });
},
}));
userCommands.add(new libCommand.Command({
definition: ["set-admin <user>", "Promote or demote a user to admin", (yargs) => {
yargs.positional("user", { describe: "Name of user set admin status for", type: "string" });
yargs.options({
"revoke": { describe: "Revoke admin status", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
await libLink.messages.setUserAdmin.send(control, {
name: args.user, create: args.create, admin: !args.revoke,
});
},
}));
userCommands.add(new libCommand.Command({
definition: ["set-whitelisted <user>", "Add or remove user from the whitelist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set whitelist status for", type: "string" });
yargs.options({
"remove": { describe: "Remove from whitelist", nargs: 0, type: "boolean", default: false },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
await libLink.messages.setUserWhitelisted.send(control, {
name: args.user, create: args.create, whitelisted: !args.remove,
});
},
}));
userCommands.add(new libCommand.Command({
definition: ["set-banned <user>", "Ban or pardon user from banlist", (yargs) => {
yargs.positional("user", { describe: "Name of user to set ban status for", type: "string" });
yargs.options({
"pardon": { describe: "Remove from banlist", nargs: 0, type: "boolean", default: false },
"reason": { describe: "Ban reason", nargs: 1, type: "string", default: "" },
"create": { describe: "Create user if it does not exist", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args, control) {
await libLink.messages.setUserBanned.send(control, {
name: args.user, create: args.create, banned: !args.pardon, reason: args.reason,
});
},
}));
userCommands.add(new libCommand.Command({
definition: ["set-roles <user> [roles...]", "Replace user roles", (yargs) => {
yargs.positional("user", { describe: "Name of user to change roles for", type: "string" });
yargs.positional("roles", { describe: "roles to assign", type: "string" });
}],
handler: async function(args, control) {
let response = await libLink.messages.listRoles.send(control);
let resolvedRoles = [];
for (let roleName of args.roles) {
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
resolvedRoles.push(roleId);
} else {
let found = false;
for (let role of response.list) {
if (role.name === roleName) {
resolvedRoles.push(role.id);
found = true;
break;
}
}
if (!found) {
throw new libErrors.CommandError(`No role named ${roleName}`);
}
}
}
await libLink.messages.updateUserRoles.send(control, { name: args.user, roles: resolvedRoles });
},
}));
userCommands.add(new libCommand.Command({
definition: ["delete <user>", "Delete user", (yargs) => {
yargs.positional("user", { describe: "Name of user to delete", type: "string" });
}],
handler: async function(args, control) {
await libLink.messages.deleteUser.send(control, { name: args.user });
},
}));
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(() => {});
},
}));
/**
* Connector for control connection to master server
* @private
*/
class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
super(url, reconnectDelay);
this._token = token;
}
register() {
console.log("SOCKET | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "clusterioctl",
version: version,
});
}
}
/**
* Handles running the control
*
* Connects to the master server over WebSocket and sends commands to it.
* @static
*/
class Control extends libLink.Link {
constructor(connector, controlPlugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* 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);
}
}
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutputColored(output));
}
async debugWsMessageEventHandler(message) {
console.log("WS", message.data.direction, message.data.content);
}
async shutdown() {
this.connector.setTimeout(30);
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
throw err;
}
}
await this.connector.close(1001, "Control Quit");
}
}
async function startControl() {
yargs
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
default: "config-control.json",
defaultDescription: "auto",
type: "string",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
default: "plugin-list.json",
type: "string",
})
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
.command("control-config", "Manage Control config", libConfig.configCommand)
.wrap(yargs.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
// Parse the args first to get the configured plugin list.
let args = yargs.argv;
console.log(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
} catch (err) {
if (err.code !== "ENOENT") {
throw err;
}
}
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await libSharedCommands.handlePluginCommand(args, pluginList, args.pluginList);
return;
}
// 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 libPlugin.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);
}
}
// Reparse after commands have been added with help and strict checking.
args = yargs
.help()
.strict()
.argv
;
console.log(`Loading config from ${args.config}`);
let controlConfig = new libConfig.ControlConfig();
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");
await controlConfig.init();
} else {
throw err;
}
}
if (args._.length === 0) {
yargs.showHelp();
yargs.exit();
}
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await libConfig.handleConfigCommand(args, controlConfig, args.config);
return;
}
// Determine which command is being executed.
let commandPath = [...args._];
let targetCommand = rootCommands;
while (commandPath.length && targetCommand instanceof libCommand.CommandTree) {
targetCommand = targetCommand.get(commandPath.shift());
}
// 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.");
process.exitCode = 1;
return;
}
let controlConnector = new ControlConnector(
controlConfig.get("control.master_url"),
controlConfig.get("control.reconnect_delay"),
controlConfig.get("control.master_token")
);
let control = new Control(controlConnector, controlPlugins);
try {
await controlConnector.connect();
} catch(err) {
if (err instanceof libErrors.AuthenticationFailed) {
throw new libErrors.StartupError(err.message);
}
throw err;
}
process.on("SIGINT", () => {
console.log("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
console.error(err);
process.exit(1);
});
});
let keepOpen = Boolean(args.keepOpen);
try {
await targetCommand.run(args, control);
} catch (err) {
keepOpen = false;
if (err instanceof libErrors.CommandError) {
console.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof libErrors.RequestError) {
console.error(`Error sending request: ${err.message}`);
process.exitCode = 1;
} else {
throw err;
}
} finally {
if (!keepOpen) {
await control.shutdown();
}
}
}
module.exports = {
Control,
// for testing only
_formatOutputColored: formatOutputColored,
};
if (module === require.main) {
console.warn(`
+==========================================================+
I WARNING: This is the development branch for the 2.0 I
I version of clusterio. Expect things to break. I
+==========================================================+
`
);
startControl().catch(err => {
if (!(err instanceof libErrors.StartupError)) {
console.error(`
+----------------------------------------------------------------+
| Unexpected error occured while starting control, please report |
| it to https://github.com/clusterio/factorioClusterio/issues |
+----------------------------------------------------------------+`
);
} else {
console.error(`
+---------------------------------+
| Unable to to start clusterioctl |
+---------------------------------+`
);
}
console.error(err);
process.exitCode = 1;
});
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@clusterio/ctl",
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.1",
"main": "ctl.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusterioctl": "./ctl.js"
},
"keywords": [
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.1",
"as-table": "^1.0.31",
"chalk": "^3.0.0",
"fs-extra": "^8.1.0",
"yargs": "^14.2.0"
},
"publishConfig": {
"access": "public"
}
}
+8
View File
@@ -0,0 +1,8 @@
Common Library for 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.
+36 -23
View File
@@ -7,29 +7,27 @@ const path = require("path");
const yargs = require("yargs");
// Location of mod source files
const sourceDir = "mod";
// Paths to additional files to put into the mod's folder.
const additionalFiles = [
path.join("modules", "clusterio", "serialize.lua"),
];
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 },
'output-dir': { describe: "Path to output built mod", nargs: 1, type: 'string', default: "dist" },
"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
;
let info = JSON.parse(await fs.readFile(path.join(sourceDir, "info.json")));
await build(args);
}
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(".");
@@ -41,8 +39,12 @@ async function main() {
info.factorio_version = args.factorioVersion;
}
if (args.dependencies) {
info.dependencies = args.dependencies;
}
if (args.clean) {
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(args.outputDir)) {
let match = splitter.exec(entry);
if (match) {
@@ -62,29 +64,34 @@ async function main() {
if (args.pack) {
let zip = new JSZip();
let walker = klaw(sourceDir)
.on('data', item => {
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(sourceDir, item.path).replace(/\\/g, "/");
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');
await events.once(walker, "end");
for (let filePath of additionalFiles) {
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');
await events.once(writeStream, "finish");
} else {
let modDir = path.join(args.outputDir, modName);
@@ -93,10 +100,12 @@ async function main() {
await fs.remove(modDir);
}
console.log(`Building ${modDir}`);
await fs.copy(sourceDir, modDir);
for (let filePath of additionalFiles) {
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));
}
@@ -104,5 +113,9 @@ async function main() {
}
if (module === require.main) {
main().catch(err => { console.log(err) });
main().catch(err => { console.log(err); });
}
module.exports = {
build,
};
+248
View File
@@ -0,0 +1,248 @@
/**
* Command library
*
* Data types and utilites for commands to clusterioctl's CLI.
*
* @author Hornwitser
* @module lib/command
*/
"use strict";
const libLink = require("@clusterio/lib/link");
const libErrors = require("@clusterio/lib/errors");
/**
* Represents a command that can be runned by clusterioctl
* @static
*/
class Command {
/**
* Define an executable command.
*
* @param {Object} cmd - Command definiton.
* @param {Array} cmd.definiton -
* Arguments to pass to yargs .command method to define this
* command.
* @param {function(args, control)} handler -
* Async function invoked when the command is executed. Is given
* the parsed args and a reference to the {@link
* module:ctl/ctl.Control} instance.
*/
constructor({ definition, handler }) {
if (definition[0] instanceof Array) {
this.name = definition[0][0].split(" ")[0];
this.alias = definition[0].slice(1);
} else {
this.name = definition[0].split(" ")[0];
this.alias = [];
}
this._definition = definition;
this._handler = handler;
}
register(yargs) {
yargs.command(...this._definition);
}
async run(args, control) {
await this._handler(args, control);
}
}
/**
* A node in the command tree that can hold commands and sub trees
*
* Container which can contain {@link module:lib/command.Command}s and other
* CommandTrees. This is used by clusterioctl to hold the full tree of
* available commands, you may extend this tree by using a control plugin,
* see {@link module:lib/plugin.BaseControlPlugin#addCommands}
*
* @static
*/
class CommandTree {
/**
* Define a command containing sub commands.
*
* @param {Object} cmd - Command definition
* @param {string} cmd.name - Name of the command.
* @param {Array<string>} cmd.alias - Aliases for this tree.
* @param {description} cmd.description -
* Descripton to provide for this command tree node.
*/
constructor({ name, alias, description }) {
if (typeof name !== "string") {
throw new Error("name must be a string");
}
this.name = name;
this.alias = alias || [];
this.description = description;
this.subCommands = new Map();
}
/**
* Add a command or command tree to this tree
*
* @param {module:lib/command.Command|module:lib/command.CommandTree} command -
* The command to add to this command tree.
*/
add(command) {
if (this.subCommands.has(command.name)) {
throw new Error(`Command ${command.name} already exists`);
}
this.subCommands.set(command.name, command);
for (let alias of command.alias) {
if (this.subCommands.has(alias)) {
throw new Error(`Alias ${alias} for command ${command.name} already exists`);
}
this.subCommands.set(alias, command);
}
}
/**
* Get a command or command tree from this tree
*
* @param {string} name -
* The name of the command or command tree to retrieve.
* @return {?module:lib/command.Command|module:lib/command.CommandTree}
* The command to add to this command tree.
*/
get(name) {
return this.subCommands.get(name) || null;
}
/**
* Insert the registered commands into the given yargs parser
*
* Traverses the the tree of sub-commands recursively and adds all of
* them to the given yargs parser.
*
* @param {Object} yargs - yargs parser to register with.
*/
register(yargs) {
yargs.command([this.name].concat(this.alias), this.description, (yargs) => {
for (let [name, command] of this.subCommands) {
// Check if the entry is not an alias.
if (name === command.name) {
command.register(yargs);
}
}
}, () => {
yargs.showHelp();
yargs.exit();
});
}
}
/**
* Resolve a string into a slave ID
*
* Resolves a string with either an slave name or an id into an integer with
* the slave ID.
*
* @param {module:lib/link.Link} client - link to master server to query slave on.
* @param {string} slaveName - string with name or id of slave.
* @returns {number} slave ID.
* @static
*/
async function resolveSlave(client, slaveName) {
let slaveId;
if (/^-?\d+$/.test(slaveName)) {
slaveId = parseInt(slaveName, 10);
} else {
let response = await libLink.messages.listSlaves.send(client);
for (let slave of response.list) {
if (slave.name === slaveName) {
slaveId = slave.id;
break;
}
}
if (slaveId === undefined) {
throw new libErrors.CommandError(`No slave named ${slaveName}`);
}
}
return slaveId;
}
/**
* Resolve a string to an instance ID
*
* Resolves a string with either an instance name or an id into an integer
* with the instance ID.
*
* @param {module:lib/link.Link} client - link to master server to query instance on.
* @param {string} instanceName - string with name or id of instance.
* @returns {number} instance ID.
* @static
*/
async function resolveInstance(client, instanceName) {
let instanceId;
if (/^-?\d+$/.test(instanceName)) {
instanceId = parseInt(instanceName, 10);
} else {
let response = await libLink.messages.listInstances.send(client);
for (let instance of response.list) {
if (instance.name === instanceName) {
instanceId = instance.id;
break;
}
}
if (instanceId === undefined) {
throw new libErrors.CommandError(`No instance named ${instanceName}`);
}
}
return instanceId;
}
/**
* Retrieve role object from string
*
* Resolves a string with either a role name or an id into an object
* representing the role.
*
* @param {module:lib/link.Link} client - link to master server to query role on.
* @param {string} roleName - string with name or id of role.
* @returns {Object} Role info.
* @static
*/
async function retrieveRole(client, roleName) {
let response = await libLink.messages.listRoles.send(client);
let resolvedRole;
if (/^-?\d+$/.test(roleName)) {
let roleId = parseInt(roleName, 10);
for (let role of response.list) {
if (role.id === roleId) {
resolvedRole = role;
break;
}
}
} else {
for (let role of response.list) {
if (role.name === roleName) {
resolvedRole = role;
break;
}
}
}
if (!resolvedRole) {
throw new libErrors.CommandError(`No role named ${roleName}`);
}
return resolvedRole;
}
module.exports = {
Command,
CommandTree,
resolveSlave,
resolveInstance,
retrieveRole,
};
@@ -1,10 +1,10 @@
/**
* @file Configuration classes
* @author Hornwitser
*/
// Configuration classes
"use strict";
const { basicType } = require("lib/helpers");
const events = require("events");
const isDeepStrictEqual = require("../is_deep_strict_equal");
const { basicType } = require("@clusterio/lib/helpers");
/**
@@ -12,6 +12,9 @@ const { basicType } = require("lib/helpers");
*
* Exception class for when invalid values are attempted to be set on a
* field.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidValue extends Error { };
@@ -20,23 +23,31 @@ class InvalidValue extends Error { };
*
* Exception class for when a group or field that does not exist is
* attempted to be accessed.
*
* @extends Error
* @memberof module:lib/config
*/
class InvalidField extends Error { };
/**
* Split the given string on the first instance of separator
*
* Splits @p string on the first instance of @p separator and returns an
* Splits `string` on the first instance of `separator` and returns an
* array consisting of the string up to the separator and the string
* after the separator. Returns an array with the string and an empty
* string if the separator is not present.
*
* @param {string} separator - Separator to split string by.
* @param {string} string - String to split
* @returns {Array} string split on separator.
* @memberof module:lib/config
* @private
* @inner
*/
function splitOn(separator, string) {
let index = string.indexOf(separator);
if (index === -1) {
return [string, ''];
return [string, ""];
}
return [string.slice(0, index), string.slice(index + separator.length)];
}
@@ -46,11 +57,23 @@ function splitOn(separator, string) {
*
* Represents a collection of ConfigGroup instances that can be
* managed as one.
*
* This is an EventEmitter with the following events:
* - fieldChanged -
* invoked after a field has been updated in one of the config groups
* belonging to this config. Takes three parameters: group, field,
* prev.
*
* @extends events.EventEmitter
*
* @memberof module:lib/config
*/
class Config {
class Config extends events.EventEmitter {
constructor() {
super();
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`)
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`);
}
this._groups = new Map();
@@ -61,7 +84,7 @@ class Config {
for (let [name, GroupClass] of this.constructor._groups) {
if (!this._groups.has(name)) {
let group = new GroupClass();
await group.init();
await group.init(this);
this._groups.set(name, group);
}
}
@@ -88,8 +111,8 @@ class Config {
}
let group = new GroupClass();
await group.load(serializedGroup);
this._groups.set(GroupClass.groupName, group);
await group.load(this, serializedGroup);
this._groups.set(group.name, group);
}
await this.init();
@@ -97,6 +120,8 @@ class Config {
/**
* Serialize the config to a plain JavaScript object
*
* @returns {Object} JSON serializable representation of the config.
*/
serialize() {
let groups = [...this._unknownGroups.values()];
@@ -112,13 +137,16 @@ class Config {
*
* Updates all groups in the config with groups in the serialized
* config passed.
*
* @param {Object} serializedConfig - Output from .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
*/
update(serializedConfig) {
update(serializedConfig, notify) {
this._validate(serializedConfig);
for (let serializedGroup of serializedConfig.groups) {
let group = this._groups.get(serializedGroup.name);
if (group) {
group.update(serializedGroup);
group.update(serializedGroup, notify);
} else {
this._unknownGroups.set(serializedGroup.name, serializedGroup);
}
@@ -127,26 +155,47 @@ class Config {
/**
* Get the value for a config field
*
* @param {string} name - Name of field to get.
* @returns {*} the value for the field.
*/
get(name) {
let [group, field] = splitOn('.', name);
let [group, field] = splitOn(".", name);
return this.group(group).get(field);
}
/**
* Set the value for a config field
*
* @param {string} name - Name of field to set.
* @param {*} value - the value to set on field
*/
set(name, value) {
let [group, field] = splitOn('.', name);
return this.group(group).set(field, value);
let [group, field] = splitOn(".", name);
this.group(group).set(field, value);
}
/**
* Set property of an object value for a config field
*
* @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.
*/
setProp(name, prop, value) {
let [group, field] = splitOn(".", name);
this.group(group).setProp(field, prop, value);
}
/**
* Get the config group instance with the given name
*
* @param {string} name - Name of group to get.
* @returns {module:lib/config.ConfigGroup} config group.
*/
group(name) {
if (!this._initialized) {
throw new Error(`${this.constructor.name} instance is uninitialized`)
throw new Error(`${this.constructor.name} instance is uninitialized`);
}
let group = this._groups.get(name);
if (!group) {
@@ -192,6 +241,8 @@ class Config {
*
* Adds the ConfigGroup to this config as a group that will get
* initialized, loaded and serialized with this config class.
*
* @param {module:lib/config.ConfigGroup} group - class to register.
*/
static registerGroup(group) {
this._initSubclass();
@@ -215,6 +266,13 @@ class Config {
/**
* Collection of related config entries
*
* Type checks and stores a collection of related config entries. Attempt
* to set invalid values for fields will result in an error thrown while
* loading a config with invalid values will result in those being replaced
* by the innitial value for those fields.
*
* @memberof module:lib/config
*/
class ConfigGroup {
/**
@@ -223,13 +281,14 @@ 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.
*/
constructor(serializedGroup) {
constructor() {
if (!this.constructor._finalized) {
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`)
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`);
}
this._fields = new Map();
this._unknownFields = new Map();
this._config = null;
}
/**
@@ -237,8 +296,12 @@ 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() {
async init(config) {
this._config = config;
for (let [name, def] of this.constructor._definitions) {
if (this._fields.has(name)) {
continue;
@@ -246,7 +309,7 @@ class ConfigGroup {
let value = null;
if (typeof def.initial_value === "function") {
value = await def.initial_value()
value = await def.initial_value();
} else if (def.initial_value !== undefined) {
value = def.initial_value;
@@ -262,14 +325,30 @@ class ConfigGroup {
* Loads all the values from the passed serialized version of the
* 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().
*/
async load(serializedGroup) {
this.update(serializedGroup);
await this.init();
async load(config, serializedGroup) {
this.update(serializedGroup, false);
await this.init(config);
}
/**
* Name of the group
*
* Shortcut for `group.constructor.groupName`
*/
get name() {
return this.constructor.groupName;
}
/**
* Get value for field
*
* @param {string} name - Name of field to get.
* @returns {*} Value stored for the field.
*/
get(name) {
if (!this.constructor._definitions.has(name)) {
@@ -285,12 +364,15 @@ class ConfigGroup {
* The field must be defined for the config group and the value is
* type checked against the field definition.
*
* @throws {InvalidValue} if value is not allowed for the field.
* @param {string} name - Name of field to set.
* @param {*} value - Value to set for field.
* @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) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new Error(`No field named '${name}'`);
throw new InvalidField(`No field named '${name}'`);
}
// Empty strings are treates as null
@@ -299,7 +381,7 @@ class ConfigGroup {
}
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValue(`Expected one of [${def.enum.join(', ')}], not ${value}`);
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
if (basicType(value) === "string") {
@@ -313,27 +395,61 @@ class ConfigGroup {
value = Number.parseFloat(value);
}
} else if (def.type === "object" ) {
} else if (def.type === "object") {
try {
value = JSON.parse(value);
} catch (err) {
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`)
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
}
}
}
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`)
throw new InvalidValue(`Field ${name} cannot be null`);
}
} else {
if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
let prev = this._fields.get(name);
this._fields.set(name, value);
if (this._config && !isDeepStrictEqual(value, prev)) {
this._config.emit("fieldChanged", this, name, prev);
}
}
/**
* Set property of object field
*
* Update value of stored object field by setting the specified property
* on it. The field must be defined as an object for the config group.
*
* @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.
* @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) {
let def = this.constructor._definitions.get(name);
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
if (def.type !== "object") {
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
}
let prev = this._fields.get(name);
let updated = {...prev || {}};
updated[prop] = value;
this._fields.set(name, updated);
if (this._config && !isDeepStrictEqual(updated, prev)) {
this._config.emit("fieldChanged", this, name, prev);
}
}
/**
@@ -341,14 +457,18 @@ class ConfigGroup {
*
* Overwrites the values of all the fields in this config group that
* has a valid value in the passed serialized group with that value.
*
* @param {Object} serializedGroup -
* Result from a previous call to .serialize().
* @param {boolean} notify - Invoke fieldChanged events if true.
*/
update(serializedGroup) {
update(serializedGroup, notify) {
if (basicType(serializedGroup) !== "object") {
throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`);
}
if (serializedGroup.name !== this.constructor.groupName) {
throw new Error(`Expected group name ${this.constructor.groupName}, not ${serializedGroup.name}`);
if (serializedGroup.name !== this.name) {
throw new Error(`Expected group name ${this.name}, not ${serializedGroup.name}`);
}
if (basicType(serializedGroup.fields) !== "object") {
@@ -366,22 +486,27 @@ class ConfigGroup {
if (value !== null && basicType(value) !== def.type) {
continue;
}
} else {
if (basicType(value) !== def.type) {
continue;
}
} else if (basicType(value) !== def.type) {
continue;
}
if (def.enum && !def.enum.includes(value)) {
continue;
}
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);
}
}
}
/**
* Serialize the config to a plain JavaScript object
*
* @returns {Object} JSON serializable representation of the group.
*/
serialize() {
let fields = {};
@@ -394,7 +519,7 @@ class ConfigGroup {
}
return {
name: this.constructor.groupName,
name: this.name,
fields,
};
}
@@ -424,34 +549,34 @@ class ConfigGroup {
/**
* Define a new configuration field
*
* The following properties are supported:
* - type:
* @param {Object} item - Field definition.
* @param {string} item.type -
* string declaring the type of the config value, supports
* boolean, string, number and object.
* - name:
* @param {string} item.name -
* Name of the configuration field. Should follow the
* lower_case_underscore style.
* - title:
* @param {string} item.title -
* Text used to identify the config field in user interfaces.
* - description:
* @param {string} item.description -
* Text used to describe the config field in user interfaces.
* - enum (optional):
* @param {Array=} item.enum -
* Array of all values that are acceptible. Useful for making
* enumerated values.
* - optional (optional):
* @param {boolean=} item.optional -
* True if this field can be set to null. Defaults to false.
* - initial_value (optional):
* @param {*=} item.initial_value -
* Value this config field should take in a newly initialized
* config. This can also be an async function returning the
* value to use.
*/
static define(item) {
if (this._finalized) {
throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`)
throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`);
}
if (basicType(this.groupName) !== "string") {
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`)
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`);
}
this._initSubclass();
@@ -463,7 +588,7 @@ class ConfigGroup {
if (!itemKeys.includes(prop)) {
throw new Error(`${prop} is required when defining an field`);
}
})
});
// Validate properties
let validTypes = ["boolean", "string", "number", "object"];
@@ -522,10 +647,11 @@ class ConfigGroup {
throw new Error(`Config field ${item.name} has already been defined`);
}
let computed = Object.assign({
let computed = {
optional: false,
fullName: `${this.groupName}.${item.name}`,
}, item)
...item,
};
if (!computed.optional && computed.initial_value === undefined) {
throw new Error(`Non-optional field ${item.name} needs an initial_value`);
@@ -539,7 +665,7 @@ class ConfigGroup {
*/
static finalize() {
if (basicType(this.groupName) !== "string") {
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`)
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`);
}
this._initSubclass();
@@ -553,6 +679,9 @@ class ConfigGroup {
* Grouping that includes per plugin fields clusterio uses to manage
* plugins. This is currently only the enabled field. It is otherwise
* identical to the ConfigGroup class.
*
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class PluginConfigGroup extends ConfigGroup {
static _initSubclass() {
@@ -576,4 +705,4 @@ module.exports = {
Config,
ConfigGroup,
PluginConfigGroup,
}
};
@@ -1,19 +1,18 @@
/**
* @file Core definitions for the configuration system
* @author Hornwitser
*/
// Core definitions for the configuration system
"use strict";
const util = require("util");
const crypto = require("crypto");
const path = require("path");
const fs = require("fs-extra");
const classes = require("./classes");
/**
* Master config group for {@link module:lib/config.MasterConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class MasterGroup extends classes.ConfigGroup { }
MasterGroup.groupName = "master"
MasterGroup.groupName = "master";
MasterGroup.define({
name: "database_directory",
title: "Database directory",
@@ -21,18 +20,12 @@ MasterGroup.define({
type: "string",
initial_value: "database",
});
MasterGroup.define({
name: "factorio_directory",
description: "Path to directory to look for factorio installs",
type: "string",
initial_value: "factorio"
});
MasterGroup.define({
name: "http_port",
title: "HTTP Port",
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
type: "number",
optional: true
optional: true,
});
MasterGroup.define({
name: "https_port",
@@ -43,11 +36,11 @@ MasterGroup.define({
initial_value: 8443,
});
MasterGroup.define({
name: "web_root",
title: "Web Root",
description: "Sub-path to where the web interface is hosted. Needed when proxying the web interface.",
name: "external_address",
title: "External Address",
description: "Public facing address the master server is hosted on.",
type: "string",
initial_value: "/",
optional: true,
});
MasterGroup.define({
name: "tls_certificate",
@@ -65,6 +58,13 @@ MasterGroup.define({
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({
name: "auth_secret",
title: "Master Authentication Secret",
@@ -73,12 +73,7 @@ MasterGroup.define({
+" Should be a long string of random letters and numbers."
+" Do not share this.",
type: "string",
initial_value: async function() {
console.log("Generating new master authentication secret");
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
return bytes.toString("base64");
},
optional: true,
});
MasterGroup.define({
name: "heartbeat_interval",
@@ -87,12 +82,37 @@ MasterGroup.define({
type: "number",
initial_value: 60,
});
MasterGroup.define({
name: "connector_shutdown_timeout",
title: "Connector Shutdown Timeout",
description: "Timeout in seconds for connectors to properly disconnect on shutdown",
type: "number",
initial_value: 30,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
description: "ID of role assigned by default to new users",
type: "number",
optional: true,
initial_value: 1,
});
MasterGroup.finalize();
/**
* Master Config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class MasterConfig extends classes.Config { }
MasterConfig.registerGroup(MasterGroup);
/**
* Slave config group for {@link module:lib/config.SlaveConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class SlaveGroup extends classes.ConfigGroup {}
SlaveGroup.groupName = "slave";
SlaveGroup.define({
@@ -111,7 +131,7 @@ SlaveGroup.define({
name: "factorio_directory",
description: "Path to directory to look for factorio installs",
type: "string",
initial_value: "factorio"
initial_value: "factorio",
});
SlaveGroup.define({
name: "instances_directory",
@@ -146,12 +166,22 @@ SlaveGroup.define({
});
SlaveGroup.finalize();
/**
* Slave Config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class SlaveConfig extends classes.Config { }
SlaveConfig.registerGroup(SlaveGroup);
/**
* Instance config group for {@link module:lib/config.InstanceConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class InstanceGroup extends classes.ConfigGroup { }
InstanceGroup.groupName = "instance"
InstanceGroup.groupName = "instance";
InstanceGroup.define({
name: "name",
type: "string",
@@ -170,6 +200,11 @@ InstanceGroup.define({
});
InstanceGroup.finalize();
/**
* Factorio config group for {@link module:lib/config.InstanceConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class FactorioGroup extends classes.ConfigGroup { }
FactorioGroup.groupName = "factorio";
FactorioGroup.define({
@@ -196,6 +231,18 @@ FactorioGroup.define({
type: "string",
optional: true,
});
FactorioGroup.define({
name: "enable_save_patching",
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "enable_whitelist",
description: "Turn on whitelist for joining the server.",
type: "boolean",
initial_value: false,
});
FactorioGroup.define({
name: "settings",
description: "Settings overridden in server-settings.json",
@@ -205,13 +252,73 @@ FactorioGroup.define({
"auto_pause": false,
},
});
FactorioGroup.define({
name: "sync_adminlist",
description: "Synchronize adminlist with master server",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_whitelist",
description: "Synchronize whitelist with master server",
type: "boolean",
initial_value: true,
});
FactorioGroup.define({
name: "sync_banlist",
description: "Synchronize banlist with master server",
type: "boolean",
initial_value: true,
});
FactorioGroup.finalize();
/**
* Instance config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class InstanceConfig extends classes.Config { }
InstanceConfig.registerGroup(InstanceGroup);
InstanceConfig.registerGroup(FactorioGroup);
/**
* Control config group for {@link module:lib/config.ControlConfig}
* @extends module:lib/config.ConfigGroup
* @memberof module:lib/config
*/
class ControlGroup extends classes.ConfigGroup {}
ControlGroup.groupName = "control";
ControlGroup.define({
name: "master_url",
description: "URL to connect to the master server at",
type: "string",
optional: true,
});
ControlGroup.define({
name: "master_token",
description: "Token to authenticate to master server with.",
type: "string",
optional: true,
});
ControlGroup.define({
name: "reconnect_delay",
title: "Reconnect Delay",
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initial_value: 2,
});
ControlGroup.finalize();
/**
* Control config class
* @extends module:lib/config.Config
* @memberof module:lib/config
*/
class ControlConfig extends classes.Config { }
ControlConfig.registerGroup(ControlGroup);
function validateGroup(pluginInfo, groupName) {
if (!pluginInfo[groupName] instanceof classes.PluginConfigGroup) {
throw new Error(
@@ -228,6 +335,9 @@ function validateGroup(pluginInfo, groupName) {
/**
* Registers the config groups for the provided plugin infos
*
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
* @memberof module:lib/config
*/
function registerPluginConfigGroups(pluginInfos) {
for (let pluginInfo of pluginInfos) {
@@ -259,11 +369,14 @@ function registerPluginConfigGroups(pluginInfos) {
/**
* Lock configs from adding more groups and make them usable
*
* @memberof module:lib/config
*/
function finalizeConfigs() {
MasterConfig.finalize();
SlaveConfig.finalize();
InstanceConfig.finalize();
ControlConfig.finalize();
}
/**
@@ -271,6 +384,9 @@ function finalizeConfigs() {
*
* Can be passed to yargs.command to implement a config command. Use
* handleConfigCommand to do the requested action.
*
* @param {Object} yargs - yargs command builder.
* @memberof module:lib/config
*/
function configCommand(yargs) {
yargs
@@ -278,6 +394,7 @@ function configCommand(yargs) {
.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")
.help()
.strict()
;
}
@@ -286,6 +403,11 @@ function configCommand(yargs) {
* Handle yargs command
*
* Handle the actions that are made available by configCommand.
*
* @param {Object} args - yargs args object.
* @param {module:lib/config.Config} instance - Config instance.
* @param {string} configPath - Path to configuration file.
* @memberof module:lib/config
*/
async function handleConfigCommand(args, instance, configPath) {
let command = args._[1];
@@ -333,13 +455,15 @@ module.exports = {
SlaveGroup,
InstanceGroup,
FactorioGroup,
ControlGroup,
MasterConfig,
SlaveConfig,
InstanceConfig,
ControlConfig,
registerPluginConfigGroups,
finalizeConfigs,
configCommand,
handleConfigCommand,
}
};
@@ -1,9 +1,10 @@
/**
* @file Configuration System
* Configuration System
* @author Hornwitser
* @module lib/config
*/
"use strict";
module.exports = {
...require("./classes"),
...require("./definitions"),
}
};
+9 -10
View File
@@ -1,14 +1,14 @@
/**
* Implementation of master databases
*
* @module
* @module lib/database
* @author Hornwitser
*/
"use strict";
const fs = require("fs-extra");
const { basicType } = require("lib/helpers");
const { basicType } = require("@clusterio/lib/helpers");
/**
@@ -21,7 +21,7 @@ const { basicType } = require("lib/helpers");
function mapToObject(map) {
let obj = {};
for (let [key, value] of map) {
if (typeof key !== 'string') {
if (typeof key !== "string") {
throw new Error(
`Expected all keys to be string but got ${typeof key}`
);
@@ -55,7 +55,7 @@ async function loadJsonAsMap(filePath) {
return new Map(Object.entries(parsed));
} catch (err) {
if (err.code == "ENOENT") {
if (err.code === "ENOENT") {
// File does not exist, return empty map
return new Map();
@@ -105,11 +105,11 @@ async function loadJsonArrayAsMap(filePath) {
let map = new Map();
for (let element of parsed) {
if (basicType(element) !== "object") {
throw new Error(`Expected all elements to be objects`);
throw new Error("Expected all elements to be objects");
}
if (element.id === undefined) {
throw new Error(`Expected all elements to have an id property`);
throw new Error("Expected all elements to have an id property");
}
map.set(element.id, element);
@@ -117,7 +117,7 @@ async function loadJsonArrayAsMap(filePath) {
return map;
} catch (err) {
if (err.code == "ENOENT") {
if (err.code === "ENOENT") {
// File does not exist, return empty map
return new Map();
@@ -144,7 +144,6 @@ async function saveMapAsJsonArray(filePath, map) {
}
function checkName(name) {
if (typeof name !== "string") {
throw new Error("name must be a string");
@@ -232,7 +231,7 @@ class ItemDatabase {
checkName(name);
if (!this._items.has(name)) {
return 0
return 0;
}
return this._items.get(name);
@@ -287,4 +286,4 @@ module.exports = {
loadJsonArrayAsMap,
saveMapAsJsonArray,
ItemDatabase,
}
};
+39 -7
View File
@@ -1,12 +1,25 @@
/**
* Errors thrown in Clusterio
* @module
* @module lib/errors
*/
"use strict";
/**
* Thrown by commands when they fail
* @static
*/
class CommandError extends Error { }
/**
* Thrown from requests sent when an error occured handling it
* @static
*/
class RequestError extends Error { }
// Signal for messages that fail validation
/**
* Signal for messages that fail validation
* @static
*/
class InvalidMessage extends Error {
constructor(msg, errors) {
super(msg);
@@ -14,16 +27,34 @@ class InvalidMessage extends Error {
}
}
// Thrown when WebSocket authentication failed
/**
* Thrown from requests when the session it was sent on was lost
* @static
*/
class SessionLost extends Error { }
/**
* Thrown when WebSocket authentication failed
* @static
*/
class AuthenticationFailed extends Error { }
// Errror class for known errors occuring during startup
/**
* Errror class for known errors occuring during startup
* @static
*/
class StartupError extends Error { }
// Errors outside of our control.
/**
* Errors outside of our control
* @static
*/
class EnvironmentError extends Error { }
// Errors caused by plugins
/**
* Errors caused by plugins
* @static
*/
class PluginError extends Error {
constructor(pluginName, original) {
super(`PluginError: ${original.message}`);
@@ -36,8 +67,9 @@ module.exports = {
CommandError,
RequestError,
InvalidMessage,
SessionLost,
AuthenticationFailed,
StartupError,
EnvironmentError,
PluginError,
}
};
+366
View File
@@ -0,0 +1,366 @@
// Export of item icons and locale
"use strict";
const fs = require("fs-extra");
const path = require("path");
const ini = require("ini");
const Jimp = require("jimp");
const JSZip = require("jszip");
const libBuildMod = require("@clusterio/lib/build_mod");
/**
* Generate the export mod needed for exportData
*
* Packs the lua export mod located in lua/export into the mods folder for
* the server given, with dependencies generated for all the mods present in
* the server's mods folder.
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to generate the export mod for.
* @memberof module:lib/factorio
*/
async function generateExportMod(server) {
let dependencies = [];
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
let match = splitter.exec(entry);
if (match && match[1] !== "export") {
dependencies.push(`? ${match[1]}`);
}
}
await libBuildMod.build({
clean: false,
build: true,
pack: true,
// XXX fix path when moving lib/factorio to slave
sourceDir: path.join(__dirname, "..", "..", "slave", "lua", "export"),
outputDir: server.writePath("mods"),
bumpPatch: false,
factorioVersion: server.version.replace(/\.\d+$/, ""),
dependencies,
});
}
let zipCache = new Map();
async function loadZip(server, modVersions, mod) {
let modVersion = modVersions.get(mod);
if (!modVersion) {
throw new Error(`Got path for unknown mod ${mod}`);
}
let zipPath = server.writePath("mods", `${mod}_${modVersion}.zip`);
let zip = zipCache.get(zipPath);
if (!zip) {
zip = await JSZip.loadAsync(await fs.readFile(zipPath));
zipCache.set(zipPath, zip);
}
return zip.folder(`${mod}_${modVersion}`);
}
/**
* Load the given Factorio file path into a Buffer
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to load the file from.
* @param {Map<string, string>} modVersions - Mapping of mod to version used.
* @param {string} modPath - Factorio style path to the file to load.
* @returns {?Buffer} The content of the file or null if not found.
* @memberof module:lib/factorio
* @private
* @inner
*/
async function loadFile(server, modVersions, modPath) {
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
if (!match) {
throw new Error(`Bad mod path ${match}`);
}
let [, mod, filePath] = match;
if (["core", "base"].includes(mod)) {
try {
return await fs.readFile(server.dataPath(mod, filePath));
} catch (err) {
if (err.code === "ENOENT") {
return null;
}
throw err;
}
}
let zip = await loadZip(server, modVersions, mod);
let file = zip.file(filePath);
if (!file) {
return null;
}
return await file.async("nodebuffer");
}
/**
* Export item icons and data
*
* Assembles and packs the icons for the item prototypes given into a single
* spritesheet and json file with meta data.
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to generate the export mod for.
* @param {Map<string, string>} modVersions -
* Mapping of mod name to versions to get icons from.
* @param {Array<Object>} items - Array of item prototypes.
* @return {{itemSheet: Jimp, itemData: Map<string,Object>}}
* Item spritesheet and metadata.
* @memberof module:lib/factorio
* @private
* @inner
*/
async function exportItems(server, modVersions, items) {
// Size to render icons at
let size = 32;
// Width of spritesheet
let width = 1024;
let rows = Math.ceil(items.length / (width / size));
let iconSheet = await Jimp.create(width, rows * size);
let itemData = new Map();
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);
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);
if (icon) {
iconPos = pos;
simpleIcons.set(item.icon, pos);
let iconScale = size / item.icon_size;
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
}
}
}
}
if (iconPos !== undefined) {
itemData.set(item.name, {
x: iconPos * size % width,
y: Math.floor(iconPos / (width / size)) * size,
size,
localised_name: item.localised_name,
localised_description: item.localised_description,
});
}
if (icon) {
iconSheet.composite(icon, pos * size % width, Math.floor(pos / (width / size)) * size);
pos += 1;
}
}
iconSheet.crop(0, 0, width, Math.ceil(pos / (width / size)) * size);
return { iconSheet, itemData };
}
/**
* Exports the locale files for the base game and the given mods
*
* Parses and merges all the locales for the all the mods given through
* `modVersions` and `modOrder`.
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to export the locale from.
* @param {Map<string, string>} modVersions -
* Mapping of mod name to version to export locale from.
* @param {Array<string>} modOrder - Load order of the mods.
* @param {string} languageCode - Language to export locale for.
* @returns {Map<string, string>} merged locale information
* @memberof module:lib/factorio
* @private
* @inner
*/
async function exportLocale(server, modVersions, modOrder, languageCode) {
let mergedLocales = new Map();
function mergeLocale(locale) {
for (let [category, entries] of Object.entries(locale)) {
if (typeof entries === "string") {
mergedLocales.set(category, entries);
} else {
for (let [key, value] of Object.entries(entries)) {
mergedLocales.set(`${category}.${key}`, value);
}
}
}
}
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
for (let mod of modOrder) {
if (["core", "base", "export"].includes(mod)) {
continue;
}
let zip = await loadZip(server, modVersions, mod);
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
let content = await file.async("nodebuffer");
mergeLocale(ini.parse(content.toString("utf8")));
}
}
return mergedLocales;
}
/**
* Export the locale and item icons for the given factorio server
*
* @param {module:lib/factorio.FactorioServer} server -
* The server to export the data from.
* @returns {JSZip} zip file with exported data.
* @memberof module:lib/factorio
*/
async function exportData(server) {
await generateExportMod(server);
let items = [];
let modVersions = new Map();
let modOrder = [];
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);
if (match) {
modOrder.push(match[1]);
}
}
});
try {
await server.startScenario("base/freeplay");
await server.kill();
} finally {
await fs.unlink(server.writePath("mods", "export_0.0.0.zip"));
}
if (!items.length) {
throw new Error("No items got exported");
}
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
zipCache.clear();
let zip = new JSZip();
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
return zip;
}
module.exports = {
exportData,
// For testing only
_exportLocale: exportLocale,
};
+11
View File
@@ -0,0 +1,11 @@
/**
* Library for interfacing with Factorio Servers and saves.
* @module lib/factorio
* @author Hornwitser
*/
"use strict";
module.exports = {
...require("./export"),
...require("./patch"),
...require("./server"),
};
@@ -1,16 +1,13 @@
/**
* Library for patching Factorio saves with scenario code.
* @author Hornwitser
*/
// Library for patching Factorio saves with scenario code.
"use strict";
const events = require("events");
const fs = require("fs-extra");
const jszip = require("jszip");
const JSZip = require("jszip");
const path = require("path");
const semver = require("semver");
const hash = require("lib/hash");
const libHash = require("@clusterio/lib/hash");
const knownScenarios = {
@@ -30,7 +27,11 @@ const knownScenarios = {
* Returns the name of the folder that all files in the zip file is
* contained in. Throws an error if there are multiple such folders.
*
* @param {JSZip} zip - Zip to search through.
* @returns {string} name of the root folder.
* @memberof module:lib/factorio
* @private
* @inner
*/
function findRoot(zip) {
let root = null;
@@ -44,12 +45,12 @@ function findRoot(zip) {
if (root === null) {
root = pathRoot;
} else if (root !== pathRoot) {
throw new Error(`Zip contains multiple root folders`);
throw new Error("Zip contains multiple root folders");
}
});
if (root === null) {
throw new Error(`Empty zip file`);
throw new Error("Empty zip file");
}
return root;
@@ -60,16 +61,20 @@ function findRoot(zip) {
* Generates control.lua code for loading the Clusterio modules
*
* @param {Object} patchInfo - The patch info files's json content
* @returns {string} Generated control.lua code.
* @memberof module:lib/factorio
* @private
* @inner
*/
function generateLoader(patchInfo) {
let lines = [
'-- Auto generated scenario module loader created by Clusterio',
'-- Modifications to this file will be lost when loaded in Clusterio',
"-- Auto generated scenario module loader created by Clusterio",
"-- Modifications to this file will be lost when loaded in Clusterio",
`clusterio_patch_number = ${patchInfo.patch_number}`,
'',
"",
'local event_handler = require("event_handler")',
'',
'-- Scenario modules',
"",
"-- Scenario modules",
];
for (let moduleName of patchInfo["scenario"]["modules"]) {
@@ -77,8 +82,8 @@ function generateLoader(patchInfo) {
}
lines.push(...[
'',
'-- Clusterio modules',
"",
"-- Clusterio modules",
]);
for (let module of patchInfo["modules"]) {
@@ -94,9 +99,9 @@ function generateLoader(patchInfo) {
}
// End last line with a newline
lines.push('');
lines.push("");
return lines.join('\n');
return lines.join("\n");
}
/**
@@ -107,6 +112,9 @@ function generateLoader(patchInfo) {
* their dependents. Throws an error if this is not possible.
*
* @param {Array<Object>} modules - Array of modules to reorder
* @memberof module:lib/factorio
* @private
* @inner
*/
function reorderDependencies(modules) {
let index = 0;
@@ -161,8 +169,8 @@ function reorderDependencies(modules) {
// the module depends on a module that satisfy any of these conditions.
let remaining = new Map();
for (let modules of hold.values()) {
for (let module of modules) {
for (let heldModules of hold.values()) {
for (let module of heldModules) {
remaining.set(module.name, module);
}
}
@@ -182,7 +190,7 @@ function reorderDependencies(modules) {
if (cycle.includes(module.name)) {
cycle.push(module.name);
cycle.splice(0, cycle.indexOf(module.name))
cycle.splice(0, cycle.indexOf(module.name));
throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`);
}
@@ -202,9 +210,10 @@ function reorderDependencies(modules) {
*
* @param {string} savePath - Path to the Factorio save to patch.
* @param {Array<Object>} modules - Description of the modules to patch.
* @memberof module:lib/factorio
*/
async function patch(savePath, modules) {
let zip = await jszip.loadAsync(await fs.readFile(savePath));
let zip = await JSZip.loadAsync(await fs.readFile(savePath));
let root = zip.folder(findRoot(zip));
let patchInfoFile = root.file("clusterio.json");
@@ -216,7 +225,7 @@ async function patch(savePath, modules) {
// No info file present, try to detect if it's a known compatible scenario.
} else {
let controlStream = root.file("control.lua").nodeStream("nodebuffer");
let controlHash = await hash.hashStream(controlStream);
let controlHash = await libHash.hashStream(controlStream);
if (controlHash in knownScenarios) {
patchInfo = {
@@ -244,9 +253,9 @@ async function patch(savePath, modules) {
let moduleInfo = {
"name": module.name,
"files": [],
}
};
let dirs = [[module.path, '']];
let dirs = [[module.path, ""]];
while (dirs.length) {
let [dir, relativeDir] = dirs.pop();
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
@@ -275,7 +284,7 @@ async function patch(savePath, modules) {
root.file("clusterio.json", JSON.stringify(patchInfo, null, 4));
// Write back the save
let stream = zip.generateNodeStream({compression: "DEFLATE"});
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let pipe = stream.pipe(fs.createWriteStream(savePath));
await events.once(pipe, "finish");
}
@@ -287,4 +296,4 @@ module.exports = {
_findRoot: findRoot,
_generateLoader: generateLoader,
_reorderDependencies: reorderDependencies,
}
};
@@ -1,9 +1,4 @@
/**
* Factorio interfaces
*
* @module
*/
// Factorio Server interface
"use strict";
const fs = require("fs-extra");
const child_process = require("child_process");
@@ -14,12 +9,17 @@ const crypto = require("crypto");
const ini = require("ini");
const rconClient = require("rcon-client");
const errors = require("lib/errors");
const libErrors = require("@clusterio/lib/errors");
/**
* Determines the version of Factorio the datadir is pointing to by
* reading the changelog.txt in it.
*
* @param {string} changelogPath - Path to changelog.txt.
* @memberof module:lib/factorio
* @private
* @inner
*/
async function getVersion(changelogPath) {
let changelog;
@@ -47,6 +47,13 @@ async function getVersion(changelogPath) {
/**
* Comparison function for sorting Factorio version strings
*
* @param {string} a - Version to compare.
* @param {string} b - Version to compare.
* @returns {number} 1 if a < b, 0 if a = b and -1 if a > b.
* @memberof module:lib/factorio
* @private
* @inner
*/
function versionOrder(a, b) {
let aParts = a.split(".").map(s => Number.parseInt(s, 10));
@@ -70,6 +77,15 @@ function versionOrder(a, b) {
*
* Searches the given factorio dir for an installation of Factorio of
* the target version.
*
* @param {string} factorioDir - Path to Factorio installation dir(s).
* @param {string} targetVersion -
* Version to look for, supports the special value "latest" for the
* latest version available.
* @returns {Array} Array with path to data dir and version found.
* @memberof module:lib/factorio
* @private
* @inner
*/
async function findVersion(factorioDir, targetVersion) {
@@ -125,12 +141,15 @@ async function findVersion(factorioDir, targetVersion) {
* RFC 6335.
*
* @return {number} a number in the range 49152 to 65535.
* @memberof module:lib/factorio
* @private
* @inner
*/
function randomDynamicPort() {
const start = 49152;
const end = 65535 + 1;
return Math.floor(Math.random() * (end - start) + start)
return Math.floor(Math.random() * (end - start) + start);
}
/**
@@ -141,13 +160,16 @@ function randomDynamicPort() {
*
* @param {number} length - the length of the password to generate.
* @return {string} password of the given length
* @memberof module:lib/factorio
* @private
* @inner
*/
async function generatePassword(length) {
function validChar(byte) {
const ranges = ['az', 'AZ', '09'];
return ranges.some(range =>
const ranges = ["az", "AZ", "09"];
return ranges.some(range => (
range.codePointAt(0) <= byte && byte <= range.codePointAt(1)
);
));
}
let randomBytesAsync = util.promisify(crypto.randomBytes);
@@ -161,7 +183,7 @@ async function generatePassword(length) {
if (validChar(byte)) {
password += String.fromCharCode(byte);
if (password.length == length) {
if (password.length === length) {
return password;
}
}
@@ -179,6 +201,10 @@ async function generatePassword(length) {
* Data is streamed via the data method, and once the stream ends the
* end method should be called. This ensures an unterminated line at
* the end of the stream is passed to the callback.
*
* @memberof module:lib/factorio
* @private
* @inner
*/
class LineSplitter {
@@ -189,7 +215,7 @@ class LineSplitter {
*/
constructor(callback) {
this._callback = callback;
this._partial = null
this._partial = null;
}
/**
@@ -253,17 +279,20 @@ class LineSplitter {
* @param {string} source - Passed into the output structure as source
*
* @returns {object} - An object with interpeted data.
* @memberof module:lib/factorio
* @private
* @inner
*/
function parseOutput(line, source) {
let output = {
source,
received: Date.now(),
}
};
// There are three broad categories of output from Factorio, the first kind
// starts with the seconds since the server started and has a format of
// " 0.704 message"
const secRegex = /^ {0,3}(\d+\.\d+) (.*)$/
const secRegex = /^ {0,3}(\d+\.\d+) (.*)$/;
let secMatch = secRegex.exec(line);
if (secMatch) {
output.format = "seconds";
@@ -273,7 +302,7 @@ 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+): (.*)$/
const secLogRegex = /^(\w+) (\w+\.cpp:\d+): (.*)$/;
let secLogMatch = secLogRegex.exec(secContent);
if (secLogMatch) {
output.type = "log";
@@ -290,7 +319,7 @@ function parseOutput(line, source) {
// The second category of output starts with a date stamp of the format
// "yyyy-mm-dd hh:mm:ss message"
} else {
const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/
const dateRegex = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (.*)$/;
let dateMatch = dateRegex.exec(line);
if (dateMatch) {
output.format = "date";
@@ -299,7 +328,7 @@ function parseOutput(line, source) {
// A date output has two general formats. The first is an action
// followed by a message and has a format of "[ACTION] message"
const dateActionRegex = /^\[(\w+)\] (.*)$/
const dateActionRegex = /^\[(\w+)\] (.*)$/;
let dateActionMatch = dateActionRegex.exec(dateContent);
if (dateActionMatch) {
output.type = "action";
@@ -335,24 +364,47 @@ function parseOutput(line, source) {
const outputHeuristics = [
// Message indicating the RCON interface has started
{
filter: { type: 'log', message: /^Starting RCON interface/ },
filter: { type: "log", message: /^Starting RCON interface/ },
action: function(output) {
this._startRcon().catch((err) => { this.emit('error', err); });
this._startRcon().catch((err) => { this.emit("error", err); });
},
},
// Message indicating the server is done starting up
{
filter: { type: 'log', message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
filter: { type: "log", message: /^updateTick\(\d+\) changing state from\(CreatingGame\) to\(InGame\)$/ },
action: function(output) {
this._notifyGameReady().catch((err) => { this.emit('error', err); });
}
this._notifyGameReady().catch((err) => { this.emit("error", err); });
},
},
// Message indicating the server has started an autosave
{
filter: {
type: "log",
message: /^Saving to _autosave\d+ \((non-)?blocking\)\.$/,
},
action: function(output) {
let name = /^Saving to (_autosave\d+) /.exec(output.message)[1];
this.emit("_autosave", name);
},
},
// Message indicating the server has started saving
{
filter: {
type: "log",
message: /^Saving game as /,
},
action: function(output) {
this.emit("_saving");
},
},
// Message indicating the server has finished saving
{
filter: {
type: 'log',
type: "log",
message: [
"Saving finished",
@@ -361,26 +413,33 @@ const outputHeuristics = [
],
},
action: function(output) {
this.emit('_saved');
}
this.emit("_saved");
},
},
// Message indicating the server is shutting down
{
filter: {
type: 'generic',
message: /^Quitting: /
type: "generic",
message: /^Quitting: /,
},
action: function(output) {
this.emit('_quitting');
}
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.") {
this._server.kill();
}
},
},
// Messages that might be tha cause of an unexpected shutdown.
{
filter: {
type: 'log',
level: 'Error',
type: "log",
level: "Error",
message: [
"MultiplayerManager failed: \"Binding IPv4 socket failed: Permission denied\"",
"MultiplayerManager failed: Host address is already in use.",
@@ -392,8 +451,8 @@ const outputHeuristics = [
},
{
filter: {
type: 'log',
level: 'Error',
type: "log",
level: "Error",
message: [
"Can't bind socket: Address already in use",
"Can't bind socket: Permission denied",
@@ -413,13 +472,17 @@ const outputHeuristics = [
* stopping and sending commands to the server. It does not deal with
* creating or managing servers, or downloading Factorio.
*
* This is an events emitter with the following events:
* stdout - invoked when Factorio outputs a line to stdout
* stderr - invoked when Factorio outputs a line to stderr
* output - invoked with parsed output from Factorio
* rcon-ready - invoked when the RCON client has connected
* game-ready - invoked when the server is finished starting up
* exit - invoked when the sterver has exited
* This is an EventEmitter with the following events:
* - stdout - invoked when Factorio outputs a line to stdout
* - stderr - invoked when Factorio outputs a line to stderr
* - output - invoked with parsed output from Factorio
* - rcon-ready - invoked when the RCON client has connected
* - game-ready - invoked when the server is finished starting up
* - autosave-start - invoked when the server starts an autosave
* - autosave-fnished - invoked when the autosave finished
* - exit - invoked when the sterver has exited
* @extends events.EventEmitter
* @memberof module:lib/factorio
*/
class FactorioServer extends events.EventEmitter {
/**
@@ -428,6 +491,12 @@ 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 {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.
*/
constructor(factorioDir, writeDir, options) {
super();
@@ -440,9 +509,14 @@ class FactorioServer extends events.EventEmitter {
this._dataDir = null;
this._targetVersion = options.version || "latest";
/** UDP port used for hosting the Factorio game server on */
this.gamePort = options.gamePort || randomDynamicPort();
/** TCP port used for RCON on the Factorio game server */
this.rconPort = options.rconPort || randomDynamicPort();
/** Password used for RCON on the Factorio game server */
this.rconPassword = options.rconPassword;
/** Enable player whitelist */
this.enableWhitelist = options.enableWhitelist;
this._state = "new";
this._server = null;
this._rconClient = null;
@@ -452,8 +526,22 @@ class FactorioServer extends events.EventEmitter {
// Array of possible causes for an unexpected shutdown
this._unexpected = [];
this._stdout = new LineSplitter((line) => { this._handleOutput(line, 'stdout'); });
this._stderr = new LineSplitter((line) => { this._handleOutput(line, 'stderr'); });
this._stdout = new LineSplitter((line) => { this._handleOutput(line, "stdout"); });
this._stderr = new LineSplitter((line) => { this._handleOutput(line, "stderr"); });
// Track autosaving
this._runningAutosave = null;
this.on("_autosave", function(name) {
this.emit("autosave-start", name);
this._runningAutosave = name;
});
this.on("_saved", function() {
if (this._runningAutosave) {
this.emit("autosave-finished", this._runningAutosave);
this._runningAutosave = null;
}
});
}
_check(expectedStates) {
@@ -465,40 +553,40 @@ class FactorioServer extends events.EventEmitter {
}
async _handleIpc(line) {
let channelEnd = line.indexOf('?');
let channelEnd = line.indexOf("?");
if (channelEnd === -1) {
throw new Error(`Malformed IPC line "${line.toString()}"`);
}
let channel = line
.slice(6, channelEnd)
.toString('utf-8')
.replace(/\\x([0-9a-f]{2})/g, (match, p1) =>
.toString("utf-8")
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
String.fromCharCode(parseInt(p1, 16))
)
))
;
let type = line.slice(channelEnd + 1, channelEnd + 2).toString('utf-8');
let type = line.slice(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
if (type === 'j') {
content = JSON.parse(line.slice(channelEnd + 2).toString('utf-8'));
if (type === "j") {
content = JSON.parse(line.slice(channelEnd + 2).toString("utf-8"));
} else if (type === 'f') {
let fileName = line.slice(channelEnd + 2).toString('utf-8');
let filePath = this.writePath('script-output', fileName);
} else if (type === "f") {
let fileName = line.slice(channelEnd + 2).toString("utf-8");
let filePath = this.writePath("script-output", fileName);
// Prevent malicious names
if (/[\\/\0]/.test(fileName)) {
throw new Error(`Invalid IPC file name '${fileName}'`)
throw new Error(`Invalid IPC file name '${fileName}'`);
}
if (fileName.endsWith('.json')) {
content = JSON.parse(await fs.readFile(filePath, 'utf-8'));
if (fileName.endsWith(".json")) {
content = JSON.parse(await fs.readFile(filePath, "utf-8"));
await fs.unlink(filePath);
} else {
let format = fileName.slice(fileName.lastIndexOf('.') + 1);
throw new Error(`Unknown IPC file format '${format}'`)
let format = fileName.slice(fileName.lastIndexOf(".") + 1);
throw new Error(`Unknown IPC file format '${format}'`);
}
} else {
@@ -512,13 +600,13 @@ class FactorioServer extends events.EventEmitter {
_handleOutput(line, source) {
if (line.slice(0, 6).equals(Buffer.from("\f$ipc:"))) {
this._handleIpc(line).catch(err => this.emit('error', err));
this._handleIpc(line).catch(err => this.emit("error", err));
return;
}
this.emit(source, line);
let output = parseOutput(line.toString('utf-8'), source);
let output = parseOutput(line.toString("utf-8"), source);
heuristicLoop: for (let heuristic of outputHeuristics) {
for (let [name, expected] of Object.entries(heuristic.filter)) {
if (!Object.hasOwnProperty.call(output, name)) {
@@ -533,10 +621,8 @@ class FactorioServer extends events.EventEmitter {
if (!expected.includes(output[name])) {
continue heuristicLoop;
}
} else {
if (expected !== output[name]) {
continue heuristicLoop;
}
} else if (expected !== output[name]) {
continue heuristicLoop;
}
}
@@ -544,7 +630,7 @@ class FactorioServer extends events.EventEmitter {
heuristic.action.call(this, output);
}
this.emit('output', output);
this.emit("output", output);
}
async _startRcon() {
@@ -565,9 +651,13 @@ class FactorioServer extends events.EventEmitter {
};
this._rconClient = new rconClient.Rcon(config);
this._rconClient.on('error', (err) => this.emit('error', err));
this._rconClient.on('authenticated', () => { this._rconReady = true; this.emit('rcon-ready'); });
this._rconClient.on('end', () => {/* XXX TODO */});
this._rconClient.on("error", () => { /* Ignore */ });
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 */ });
});
await this._rconClient.connect();
}
@@ -575,6 +665,7 @@ class FactorioServer extends events.EventEmitter {
* Initialize class instance
*
* Must be called before instances of this class can be used.
* @throws {Error} if the requested version of Factorio was not found.
*/
async init() {
this._check(["new"]);
@@ -584,44 +675,56 @@ class FactorioServer extends events.EventEmitter {
}
/**
* The version of Factorio that was detected
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the
* constructor.
*/
get version() {
return this._version;
}
/**
* The pid of the server process, or null if not running
*/
get pid() {
if (!this._server) {
return null;
}
return this._server.pid;
}
_attachStdio() {
this._server.stdout.on('data', chunk => { this._stdout.data(chunk); });
this._server.stdout.on('close', () => { this._stdout.end(); });
this._server.stderr.on('data', chunk => { this._stderr.data(chunk); });
this._server.stderr.on('close', () => { this._stderr.end(); });
this._server.stdout.on("data", chunk => { this._stdout.data(chunk); });
this._server.stdout.on("close", () => { this._stdout.end(); });
this._server.stderr.on("data", chunk => { this._stderr.data(chunk); });
this._server.stderr.on("close", () => { this._stderr.end(); });
}
_watchExit() {
this._server.on('exit', (code, signal) => {
this._server.on("exit", (code, signal) => {
if (this._state !== "stopping") {
if (this._unexpected.length === 0) {
if (signal === 'SIGKILL') {
this.emit('error', new errors.EnvironmentError(
if (signal === "SIGKILL") {
this.emit("error", new libErrors.EnvironmentError(
"Factorio server was unexpectedly killed, is the system low on memory?"
));
} else {
let msg;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
msg = `Factorio server was unexpectedly shut by down by signal ${signal}`;
}
this.emit('error', new Error(msg));
this.emit("error", new Error(msg));
}
} else if (this._unexpected.length === 1) {
this.emit('error', new Error(this._unexpected[0]));
this.emit("error", new Error(this._unexpected[0]));
} else {
this.emit('error', new Error(
this.emit("error", new Error(
"Factorio server unexpectedly shut down. Possible causes:\n- "+
this._unexpected.join("\n- ")
));
@@ -635,6 +738,7 @@ class FactorioServer extends events.EventEmitter {
this._rconReady = false;
this._gameReady = false;
this._unexpected = [];
this._runningAutosave = null;
this.emit("exit");
});
@@ -642,12 +746,12 @@ class FactorioServer extends events.EventEmitter {
async _notifyGameReady() {
this._gameReady = true;
this.emit('game-ready');
this.emit("game-ready");
}
async _waitForReady() {
if (!this._gameReady) {
await events.once(this, 'game-ready');
await events.once(this, "game-ready");
}
}
@@ -666,14 +770,14 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath('..', 'bin', 'x64', 'factorio'),
this.dataPath("..", "bin", "x64", "factorio"),
[
'--config', this.writePath('config.ini'),
'--create', this.writePath('saves', name),
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
],
{
detached: true,
stdio: 'pipe',
stdio: "pipe",
}
);
@@ -681,7 +785,7 @@ class FactorioServer extends events.EventEmitter {
this._server.once("exit", () => this.emit("exit"));
try {
let [code, signal] = await events.once(this._server, 'exit');
let [code, signal] = await events.once(this._server, "exit");
if (code !== 0) {
throw new Error(`Factorio exited with status ${code}`);
}
@@ -704,17 +808,18 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath('..', 'bin', 'x64', 'factorio'),
this.dataPath("..", "bin", "x64", "factorio"),
[
'--config', this.writePath('config.ini'),
'--start-server', this.writePath('saves', save),
'--port', this.gamePort,
'--rcon-port', this.rconPort,
'--rcon-password', this.rconPassword,
"--config", this.writePath("config.ini"),
"--start-server", this.writePath("saves", save),
"--port", this.gamePort,
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
],
{
detached: true,
stdio: 'pipe',
stdio: "pipe",
}
);
@@ -737,17 +842,18 @@ class FactorioServer extends events.EventEmitter {
await this._writeConfigIni();
this._server = child_process.spawn(
this.dataPath('..', 'bin', 'x64', 'factorio'),
this.dataPath("..", "bin", "x64", "factorio"),
[
'--config', this.writePath('config.ini'),
'--start-server-load-scenario', scenario,
'--port', this.gamePort,
'--rcon-port', this.rconPort,
'--rcon-password', this.rconPassword,
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
"--port", this.gamePort,
"--rcon-port", this.rconPort,
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
],
{
detached: true,
stdio: 'pipe',
stdio: "pipe",
}
);
@@ -770,13 +876,13 @@ class FactorioServer extends events.EventEmitter {
* @returns {string} response from server.
*/
async sendRcon(message, expectEmpty) {
this._check(["running"])
this._check(["running", "stopping"]);
if (!this._rconReady) {
await events.once(this, 'rcon-ready');
await events.once(this, "rcon-ready");
}
let response = await this._rconClient.send(message);
if (expectEmpty && response !== '') {
if (expectEmpty && response !== "") {
throw new Error(`Expected empty response but got "${response}"`);
}
return response;
@@ -791,20 +897,44 @@ class FactorioServer extends events.EventEmitter {
async stop() {
this._check(["running"]);
// On Windows we need to save the map as there's no graceful shutdown.
if (this._rconClient && process.platform === "win32") {
let saved = events.once(this, '_saved');
await this.sendRcon("/server-save");
await saved;
}
let hanged = true;
function setAlive(output) { hanged = false; }
let timeoutId = setTimeout(() => {
if (hanged) {
console.error("Factorio appears to have hanged, sending SIGKILL");
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._server.kill("SIGKILL");
}
}, 5000);
this._state = "stopping";
// On Windows we need to save the map as there's no graceful shutdown.
if (this._rconClient && process.platform === "win32") {
// Not using events.once here as that will reject on error.
let saved = new Promise(resolve => this.once("_saved", resolve));
this.on("_saving", setAlive);
try {
await this.sendRcon("/server-save");
await saved;
} catch (err) {
// Ignore
}
clearTimeout(timeoutId);
this.off("_saving", setAlive);
}
if (this._rconClient) {
// If RCON is not yet fully connected that operation needs to
// complete before the RCON connection can be closed cleanly.
if (!this._rconReady) {
await events.once(this, 'rcon-ready');
await events.once(this, "rcon-ready");
}
await this._rconClient.end();
@@ -823,29 +953,31 @@ class FactorioServer extends events.EventEmitter {
// stop if it's stuck with an infinite lua code loop. In either
// case there's no recovering from it.
if (process.platform !== "win32") {
let hanged = true;
function setAlive(output) { hanged = false; }
this.on('_quitting', setAlive);
let timeoutId = setTimeout(() => {
if (hanged) {
console.error("Factorio appears to have hanged, sending SIGKILL");
this._server.kill('SIGKILL');
}
}, 5000);
await events.once(this._server, 'exit');
this.on("_quitting", setAlive);
await events.once(this._server, "exit");
clearTimeout(timeoutId);
this.off('_quitting', setAlive);
this.off("_quitting", setAlive);
// On windows the process is terminated immediately, but to keep
// ordering wait until after the exit event here.
} else {
await events.once(this._server, 'exit');
await events.once(this._server, "exit");
}
}
/**
* Kill the server
*
* Terminates the server without any cleanup or saving.
*/
async kill() {
this._check(["running", "stopping"]);
this._state = "stopping";
this._server.kill("SIGKILL");
await events.once(this._server, "exit");
}
/**
* Disable achievements on the running server
*
@@ -860,14 +992,14 @@ class FactorioServer extends events.EventEmitter {
this._check(["running"]);
let check = await this.sendRcon("/sc rcon.print('disabled')");
if (check === 'disabled\n') {
if (check === "disabled\n") {
return false;
}
// Factorio will print a warning to the console and ask for the command
// to be runned again before disabling achievements and allowing commands.
check = await this.sendRcon("/sc rcon.print('disabled')");
if (check === 'disabled\n') {
if (check === "disabled\n") {
return true;
}
@@ -880,6 +1012,9 @@ class FactorioServer extends events.EventEmitter {
* Creates a path using path.join with the given parts that's relative to
* the data directory of the Factorio server. Not valid before init has
* been called.
*
* @param {...string} parts - Extra parts to add to the data path.
* @returns {string} Data directory path.
*/
dataPath(...parts) {
return path.join(this._dataDir, ...parts);
@@ -890,6 +1025,9 @@ class FactorioServer extends events.EventEmitter {
*
* Creates a path using path.join with the given parts that's relative to
* the write directory of the Factorio server.
*
* @param {...string} parts - Extra parts to add to the write path.
* @returns {string} Write directory path.
*/
writePath(...parts) {
return path.join(this._writeDir, ...parts);
@@ -911,7 +1049,7 @@ class FactorioServer extends events.EventEmitter {
path: {
"read-data": this.dataPath(),
"write-data": this.writePath(),
}
},
});
await fs.outputFile(this.writePath("config.ini"), content);
}
@@ -1,3 +1,7 @@
/**
* @module lib/file_ops
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
@@ -30,4 +34,4 @@ async function getNewestFile(directory, filter = (name) => true) {
module.exports = {
getNewestFile,
}
};
+15 -8
View File
@@ -1,21 +1,27 @@
const crypto = require('crypto');
const fs = require('fs');
/**
* Hashing functions
* @module lib/hash
*/
"use strict";
const crypto = require("crypto");
const fs = require("fs");
/**
* Returns a promise that resolves to the SHA1 hash of the stream given
*
* @param stream - Node stream of the content to hash.
* @param {Stream} stream - Node stream of the content to hash.
* @returns {Promise<string>} hash of the stream.
*/
function hashStream(stream) {
return new Promise(function(resolve, reject) {
let hasher = crypto.createHash('sha1')
hasher.setEncoding('hex')
hasher.on('finish', function() {
let hasher = crypto.createHash("sha1");
hasher.setEncoding("hex");
hasher.on("finish", function() {
resolve(hasher.read());
});
stream.on('error', function(error) {
stream.on("error", function(error) {
// the docs doesn't say anything about what's passed on error
reject(error);
});
@@ -28,6 +34,7 @@ function hashStream(stream) {
* Returns a promise that resolves to the SHA1 hash of the file given by path
*
* @param {string} path - Path to the file to hash.
* @returns {Promise<string>} hash of the file given.
*/
function hashFile(path) {
return hashStream(fs.createReadStream(path));
@@ -36,4 +43,4 @@ function hashFile(path) {
module.exports = {
hashStream,
hashFile,
}
};
+4 -3
View File
@@ -1,7 +1,8 @@
/**
* Collection of small utilites that are useful in multiple places.
* @module
* @module lib/helpers
*/
"use strict";
/**
* Return a string describing the type of the value passed
@@ -9,7 +10,7 @@
* Works the same as typeof, excpet that null and array types get their
* own string.
*
* @param value - value to return the type of.
* @param {*} value - value to return the type of.
* @returns {string} basic type of the value passed.
*/
function basicType(value) {
@@ -20,4 +21,4 @@ function basicType(value) {
module.exports = {
basicType,
}
};
+7
View File
@@ -0,0 +1,7 @@
"use strict";
// eslint-disable-next-line no-process-env
if (process.env.APP_ENV === "browser") {
module.exports = require("fast-deep-equal/es6");
} else {
module.exports = require("util").isDeepStrictEqual;
}
@@ -1,11 +1,18 @@
// Connection adapters for Links
"use strict";
const events = require("events");
const WebSocket = require("ws");
const WebSocket = require("isomorphic-ws");
const schema = require("lib/schema");
const errors = require("lib/errors");
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
/**
* Base connector for links
*
* @extends events.EventEmitter
* @memberof module:lib/link
*/
class WebSocketBaseConnector extends events.EventEmitter {
constructor() {
super();
@@ -36,7 +43,7 @@ class WebSocketBaseConnector extends events.EventEmitter {
}
_doHeartbeat() {
this._check(["connected"]);
this._check(["connected", "closing"]);
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
console.log("SOCKET | closing after heartbeat timed out");
this._socket.close(1008, "Heartbeat timeout");
@@ -53,10 +60,10 @@ class WebSocketBaseConnector extends events.EventEmitter {
}
_processHeartbeat(message) {
if (!schema.heartbeat(message)) {
if (!libSchema.heartbeat(message)) {
console.log("SOCKET | closing after received invalid heartbeat:", message);
this._socket.close(1002, "Invalid heartbeat");
console.log(schema.heartbeat.errors);
console.log(libSchema.heartbeat.errors);
return;
}
@@ -87,10 +94,14 @@ class WebSocketBaseConnector extends events.EventEmitter {
/**
* Send a message over the socket
*
* @returns the sequence number of the message sent
* @param {string} type - Message type to send.
* @param {Object} data - Data to send with message.
* @returns {number} the sequence number of the message sent
*/
send(type, data) {
this._check(["handshake", "connected", "closing"]);
if (!["handshake", "connected", "closing"].includes(this._state)) {
throw new libErrors.SessionLost("No session");
}
let message = { seq: this._seq, type, data };
this._sendBuffer.push(message);
if (["connected", "closing"].includes(this._state)) {
@@ -108,10 +119,27 @@ class WebSocketBaseConnector extends events.EventEmitter {
setClosing() {
this._state = "closing";
}
/**
* True if the connection is established and active
*/
get connected() {
return this._state === "connected";
}
/**
* True if the connection is closing down or closed
*/
get closing() {
return ["closing", "closed"].includes(this._state);
}
}
/**
* Connector for master server clients
*
* @extends module:lib/link.WebSocketBaseConnector
* @memberof module:lib/link
*/
class WebSocketClientConnector extends WebSocketBaseConnector {
constructor(url, reconnectDelay) {
@@ -129,6 +157,8 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._sessionToken = null;
this._connected = false;
this._reconnectDelay = reconnectDelay;
this._timeout = 15 * 60;
this._startedReconnect = null;
}
_check(expectedStates) {
@@ -139,11 +169,23 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
}
setTimeout(timeout) {
this._timeout = timeout;
if (this._reconnectId && this.timeout < this._reconnectDelay / 2) {
clearTimeout(this._reconnectId);
this.reconnect();
}
}
/**
* Send a handshake message over the socket
*
* Used in the register function in order to send the register handshake
* message over the WebSocket.
*
* @param {string} type - Message type to send.
* @param {Object} data - Data to send with message.
*/
sendHandshake(type, data) {
this._check(["handshake"]);
@@ -154,8 +196,11 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
* Close the connection with the given reason.
*
* Sends a close frame and disconnects the connector.
*
* @param {number} code - WebSocket close code.
* @param {string} reason - WebSocket close reason.
*/
close(code, reason) {
async close(code, reason) {
if (this._state === "new") {
return;
}
@@ -168,6 +213,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this.stopHeartbeat();
this._state = "closing";
this._socket.close(code, reason);
await events.once(this, "close");
}
/**
@@ -180,7 +226,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._doConnect();
// Wait for the connection to be ready
await events.once(this, "connected");
await events.once(this, "connect");
}
async _doConnect() {
@@ -188,13 +234,19 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
url.pathname += "api/socket";
// Open WebSocket to master
console.log(`SOCKET | connecting to ${url}`)
console.log(`SOCKET | connecting to ${url}`);
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,
});
// 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,
});
}
this._attachSocketHandlers();
}
@@ -207,8 +259,23 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
return;
}
if (this._startedReconnect === null) {
this._startedReconnect = Date.now();
}
if (this._startedReconnect + this._timeout * 1000 < Date.now()) {
console.log("SOCKET | Timed out trying to reconnect");
this._heartbeatInterval = null;
this._lastReceivedSeq = null;
this._sessionToken = null;
this._sendBuffer.length = 0;
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`)
console.log(`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`);
this._reconnectId = setTimeout(() => {
this._reconnectId = null;
this._doConnect();
@@ -216,11 +283,11 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
_attachSocketHandlers() {
this._socket.on("close", (code, reason) => {
console.log(`SOCKET | Close (code: ${code}, reason: ${reason})`);
this._socket.onclose = event => {
console.log(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
// Authentication failed
if (code === 4003) {
this.emit("error", new errors.AuthenticationFailed("Authentication failed"));
if (event.code === 4003) {
this.emit("error", new libErrors.AuthenticationFailed(event.reason));
this._state = "closing";
}
@@ -229,55 +296,33 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._sessionToken = null;
this._sendBuffer.length = 0;
this._state = "new";
if (this._connected) {
this.emit("close");
this._connected = false;
}
this._connected = false;
this.emit("close");
} else {
this._state = "handshake";
this.reconnect();
if (this._connected) {
this.emit("dropped");
this._connected = false;
this.emit("drop");
}
}
this.stopHeartbeat();
});
};
this._socket.on("error", err => {
this._socket.onerror = event => {
// It's assumed that close is always called by ws
console.error("SOCKET | Error:", err);
});
console.error("SOCKET | Error:", event);
};
/*
this._socket.on("unexpected-response", (req, res) => {
let data = "";
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
console.error(`SOCKET | Unexpeced Response ${res.statusCode}`);
console.error(data);
this._socket.emit("error", new Error(`Unexpected Response ${res.statusCode}`));
});
});
*/
this._socket.on("open", () => {
this._socket.onopen = () => {
console.log("SOCKET | Open");
});
this._socket.on("ping", data => {
console.log(`SOCKET | Ping (data: ${data}`);
});
this._socket.on("pong", data => {
console.log(`SOCKET | Pong (data: ${data}`);
});
};
// Handle messages
this._socket.on("message", data => {
let message = JSON.parse(data);
this._socket.onmessage = event => {
let message = JSON.parse(event.data);
if (this._state === "handshake") {
this._processHandshake(message);
@@ -296,19 +341,20 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
} else {
throw new Error(`Received message in unexpected state ${this._state}`);
}
});
};
}
_processHandshake(message) {
if (!schema.serverHandshake(message)) {
if (!libSchema.serverHandshake(message)) {
console.log("SOCKET | closing after received invalid handshake:", message);
this._socket.close(1002, "Invalid handshake");
return;
}
let { seq, type, data } = message;
if (type == 'hello') {
if (type === "hello") {
console.log(`SOCKET | received hello from master version ${data.version}`);
this.emit("hello", data);
if (this._sessionToken) {
console.log("SOCKET | Attempting resume");
this.sendHandshake("resume", {
@@ -319,14 +365,18 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this.register();
}
} else if (type === 'ready') {
} else if (type === "ready") {
console.log("SOCKET | received ready from master");
this._state = "connected";
this._sessionToken = data.session_token;
this._heartbeatInterval = data.heartbeat_interval;
this.startHeartbeat();
for (let bufferedMessage of this._sendBuffer) {
this._socket.send(JSON.stringify(bufferedMessage));
}
this._startedReconnect = null;
this._connected = true;
this.emit("connected");
this.emit("connect");
} else if (type === "continue") {
console.log("SOCKET | resuming existing session");
@@ -334,11 +384,12 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
this._heartbeatInterval = data.heartbeat_interval;
this.startHeartbeat();
this._dropSendBufferSeq(data.last_seq);
for (let message of this._sendBuffer) {
this._socket.send(JSON.stringify(message));
for (let bufferedMessage of this._sendBuffer) {
this._socket.send(JSON.stringify(bufferedMessage));
}
this._startedReconnect = null;
this._connected = true;
this.emit("connected");
this.emit("connect");
} else if (type === "invalidate") {
this._heartbeatInterval = null;
@@ -361,6 +412,12 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
}
}
/**
* Connector for in-memory local links
*
* @extends events.EventEmitter
* @memberof module:lib/link
*/
class VirtualConnector extends events.EventEmitter {
constructor() {
super();
@@ -388,10 +445,12 @@ class VirtualConnector extends events.EventEmitter {
/**
* Send a message to the other end of the connector
*
* @returns the sequence number of the message sent
* @param {string} type - Message type to send.
* @param {Object} data - Data to send with message.
* @returns {number} the sequence number of the message sent
*/
send(type, data) {
this.other.emit('message', { seq: this._seq, type, data });
this.other.emit("message", { seq: this._seq, type, data });
return this._seq++;
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Library for message based communication between nodes
* @module lib/link
* @author Hornwitser
*/
"use strict";
module.exports = {
...require("./link"),
...require("./messages"),
...require("./connectors"),
};
+36 -18
View File
@@ -1,8 +1,9 @@
// Implementation of Link class
"use strict";
const schema = require("lib/schema");
const errors = require("lib/errors");
const messages = require("lib/link/messages");
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
const messages = require("./messages");
// Some definitions for the terminology used here:
// link: Either side of a master - client connection
@@ -15,6 +16,8 @@ const messages = require("lib/link/messages");
/**
* Common interface for server and client connections
*
* @memberof module:lib/link
*/
class Link {
constructor(source, target, connector) {
@@ -27,17 +30,17 @@ class Link {
this._validators = new Map();
// Process messages received by the connector
connector.on('message', payload => {
connector.on("message", payload => {
try {
this.processMessage(payload);
} catch (err) {
if (err instanceof errors.InvalidMessage) {
console.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`)
if (err instanceof libErrors.InvalidMessage) {
console.error(`Invalid message on ${this.source}-${this.target} link: ${err.message}`);
if (err.errors) {
console.error(err.errors);
}
} else {
this.connector.emit('error', err);
this.connector.emit("error", err);
}
}
});
@@ -45,7 +48,17 @@ class Link {
connector.on("invalidate", () => {
for (let waiterType of this._waiters.values()) {
for (let waiter of waiterType) {
waiter.reject(new Error("Session lost"));
waiter.reject(new libErrors.SessionLost("Session Lost"));
}
}
this._waiters.clear();
});
connector.on("close", () => {
for (let waiterType of this._waiters.values()) {
for (let waiter of waiterType) {
waiter.reject(new libErrors.SessionLost("Session Closed"));
}
}
@@ -59,22 +72,23 @@ class Link {
* Validates and invokes the handler and/or waiters for a message that has
* been received. An unhandled message is considered to be an error.
*
* @throws {InvalidMessage}
* @param {Object} message - Message to process.
* @throws {module:lib/errors.InvalidMessage}
* if validation failed, is missing, the message is invalid, or no
* handlers/waiters were invoked to handle the message.
*/
processMessage(message) {
if (!schema.message(message)) {
throw new errors.InvalidMessage("Malformed", schema.message.errors);
if (!libSchema.message(message)) {
throw new libErrors.InvalidMessage("Malformed", libSchema.message.errors);
}
let validator = this._validators.get(message.type);
if (!validator) {
throw new errors.InvalidMessage(`No validator for ${message.type} on ${this.source}-${this.target}`);
throw new libErrors.InvalidMessage(`No validator for ${message.type} on ${this.source}-${this.target}`);
}
if (!validator(message)) {
throw new errors.InvalidMessage(`Validation failed for ${message.type}`, validator.errors);
throw new libErrors.InvalidMessage(`Validation failed for ${message.type}`, validator.errors);
}
let hadHandlers = this._processHandler(message);
@@ -82,13 +96,14 @@ class Link {
if (!hadWaiters && !hadHandlers) {
// XXX console.error(`Unhandled message: ${JSON.stringify(message, null, 4)}`);
throw new errors.InvalidMessage(`Unhandled message ${message.type}`);
throw new libErrors.InvalidMessage(`Unhandled message ${message.type}`);
}
}
/**
* Invoke the handler for this message if it exists
*
* @param {Object} message - Message to invoke handlers on.
* @returns {Boolean} true if a handler was invoked.
*/
_processHandler(message) {
@@ -104,6 +119,7 @@ class Link {
/**
* Invokes the waiters for this message if any
*
* @param {Object} message - Message to invoke waiters on.
* @returns {Boolean} true if a waiter was triggered.
*/
_processWaiters(message) {
@@ -183,6 +199,9 @@ class Link {
* as any properties specified in data. For example specifying { foo:
* 21 } as the data will cause it to wait until a message with the right
* type and a foo property value of 21 in the data payload.
*
* @param {string} type - Message type to wait for.
* @param {Object} data - Properties to match in the message data.
*/
async waitFor(type, data) {
if (!this._validators.has(type)) {
@@ -208,14 +227,14 @@ class Link {
async pingRequestHandler() { }
/**
* Prepare connection for close
* Prepare connection for disconnect
*
* Waits for all pending requests on the connection to resolve.
* Sub-classes should implement this handler with code that prevents
* additional requests to be sent out before calling the super class
* method.
*/
async shutdownConnectionRequestHandler() {
async prepareDisconnectRequestHandler() {
let promises = [];
for (let waiterType of this._waiters.values()) {
for (let waiter of waiterType) {
@@ -224,11 +243,10 @@ class Link {
}
await Promise.all(promises);
this.connector.setClosing();
}
}
module.exports = {
Link,
}
};
+975
View File
@@ -0,0 +1,975 @@
// Message definitions for links
"use strict";
const libSchema = require("@clusterio/lib/schema");
const libErrors = require("@clusterio/lib/errors");
/**
* Represents a message that can be sent over the link
*
* @memberof module:lib/link
*/
class Message {
/**
* Name of the plugin this message belongs to, or null.
* @returns {?string} - plugin name or null if not from a plugin.
*/
get plugin() {
if (this._plugin === undefined) {
let index = this.type.indexOf(":");
this._plugin = index === -1 ? null : this.type.slice(0, index);
}
return this._plugin;
}
}
/**
* Represents a request sent over the link
*
* A request-response message that can be innitated by either party of the
* link.
*
* @extends module:lib/link.Message
* @memberof module:lib/link
*/
class Request extends Message {
/**
* Creates a request
*
* @param {string} type -
* message type used. Will have suffix '_request' for the request
* message and suffix '_response' for the response message.
* @param {Array<string>} links -
* Links this request is valid on. Takes an array of strings
* containing "<source>-<target>" specifications.
* @param {?string} permission -
* Permission required to send this request. Only applies to
* requests sent from control to master.
* @param {?string} forwardTo -
* 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 {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 {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 = {},
}) {
super();
this.type = type;
this.links = links;
this.permission = permission || null;
this.forwardTo = forwardTo;
this.handlerSuffix = "RequestHandler";
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`);
}
if (permission && !links.includes("control-master")) {
throw new Error(`permission is not allowed on ${this.type} request as it is not over control-master link`);
}
if (forwardTo === "instance") {
requestProperties = {
"instance_id": { type: "integer" },
...requestProperties,
};
} else if (forwardTo !== "master" && forwardTo !== null) {
throw new Error(`Invalid forwardTo value ${forwardTo}`);
}
this._requestValidator = libSchema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.requestType },
"data": {
additionalProperties: false,
required: Object.keys(requestProperties),
properties: requestProperties,
},
},
});
this._responseValidator = libSchema.compile({
$schema: "http://json-schema.org/draft-07/schema#",
properties: {
"type": { const: this.responseType },
"data": {
anyOf: [
{
additionalProperties: false,
required: ["seq", ...Object.keys(responseProperties)],
properties: {
"seq": { type: "integer" },
...responseProperties,
},
},
{
additionalProperties: false,
required: ["seq", "error"],
properties: {
"seq": { type: "integer" },
"error": { type: "string" },
},
},
],
},
},
});
}
/**
* Attach to a link
*
* Set a the validator for the response on the given link if it's a
* source and sets the handler for the request if it is a target.
*
* If forwardTo was set to 'instance' the `forwardRequestToInstance`
* method on the link is used as the default handler.
*
* If forwardTo was set to 'master' the `forwardRequesToMaster` method
* on the link is used as the default handler.
*
* Does nothing if the link is neither a source nor a target for this
* request.
*
* @param {Link} link - The link to attach to.
* @param {Function} handler -
* Async function to invoke with link set as this to handle the
* request request. Only used on the target side.
* @throws {Error} if the handler is needed and not defined.
*/
attach(link, handler) {
// Source side.
if (this.links.includes(`${link.source}-${link.target}`)) {
link.setValidator(this.responseType, this._responseValidator);
}
// Target side. Note: Source and target is reversed here.
if (this.links.includes(`${link.target}-${link.source}`)) {
// Use forwarder if handler is not present.
if (!handler) {
if (this.forwardTo === "instance") {
handler = link.forwardRequestToInstance;
} else if (this.forwardTo === "master") {
handler = link.forwardRequestToMaster;
}
}
if (!handler) {
throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`);
}
// 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);
};
}
link.setHandler(this.requestType, message => {
handler.call(link, message, this).then(response => {
if (response === undefined) {
// XXX Should we allow implicit responses like this?
response = {};
}
if (!this._responseValidator({ seq: 0, type: this.responseType, response })) {
console.error(this._responseValidator.errors);
throw new Error(`Validation failed responding to ${this.requestType}`);
}
link.connector.send(this.responseType, { ...response, seq: message.seq });
}).catch(err => {
if (!(err instanceof libErrors.RequestError)) {
console.error(`Unexpected error while responding to ${this.requestType}`, err);
}
link.connector.send(this.responseType, { seq: message.seq, error: err.message });
});
}, this._requestValidator);
}
}
/**
* Send request over the given link
*
* Sends the given data over the link as the request payload and waits
* for the response.
*
* @param {module:lib/link.Link} link - Link to send request over.
* @param {Object} data - Data to send with the request.
* @returns {object} response data
*/
async send(link, data = {}) {
// XXX validate link target/source?
if (!this._requestValidator({ seq: 0, type: this.requestType, data })) {
console.error(this._requestValidator.errors);
throw new Error(`Validation failed sending ${this.requestType}`);
}
let seq = link.connector.send(this.requestType, data);
let responseMessage = await link.waitFor(this.responseType, { seq });
if (responseMessage.data.error) {
throw new libErrors.RequestError(responseMessage.data.error);
}
return responseMessage.data;
}
}
let messages = {};
// Connection requests
let wsLinks = ["master-control", "control-master", "master-slave", "slave-master"];
messages.prepareDisconnect = new Request({
type: "prepare_disconnect",
links: wsLinks,
permission: null,
});
messages.prepareMasterDisconnect = new Request({
type: "prepare_master_disconnect",
links: ["slave-instance"],
});
messages.ping = new Request({
type: "ping",
links: wsLinks,
permission: null,
});
messages.debugDumpWs = new Request({
type: "debug_dump_ws",
links: ["control-master"],
permission: "core.debug.dump_ws",
});
// Management requests
messages.listSlaves = new Request({
type: "list_slaves",
links: ["control-master"],
permission: "core.slave.list",
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["agent", "version", "name", "id", "connected"],
properties: {
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
},
},
},
},
});
messages.listInstances = new Request({
type: "list_instances",
links: ["control-master"],
permission: "core.instance.list",
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["name", "id", "assigned_slave", "status"],
properties: {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
},
},
},
});
messages.generateSlaveToken = new Request({
type: "generate_slave_token",
links: ["control-master"],
permission: "core.slave.generate_token",
requestProperties: {
"slave_id": { type: "integer" },
},
responseProperties: {
"token": { type: "string" },
},
});
messages.createSlaveConfig = new Request({
type: "create_slave_config",
links: ["control-master"],
permission: "core.slave.create_config",
requestProperties: {
"id": { type: ["integer", "null"] },
"name": { type: ["string", "null"] },
"generate_token": { type: "boolean" },
},
responseProperties: {
"serialized_config": { type: "object" },
},
});
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"],
permission: "core.instance.create",
requestProperties: {
"serialized_config": { type: "object" },
},
});
messages.getInstanceConfig = new Request({
type: "get_instance_config",
links: ["control-master"],
permission: "core.instance.get_config",
requestProperties: {
"instance_id": { type: "integer" },
},
responseProperties: {
"serialized_config": { type: "object" },
},
});
messages.setInstanceConfigField = new Request({
type: "set_instance_config_field",
links: ["control-master"],
permission: "core.instance.update_config",
requestProperties: {
"instance_id": { type: "integer" },
"field": { type: "string" },
"value": { type: "string" },
},
});
messages.setInstanceConfigProp = new Request({
type: "set_instance_config_prop",
links: ["control-master"],
permission: "core.instance.update_config",
requestProperties: {
"instance_id": { type: "integer" },
"field": { type: "string" },
"prop": { type: "string" },
"value": {},
},
});
messages.assignInstanceCommand = new Request({
type: "assign_instance_command",
links: ["control-master"],
permission: "core.instance.assign",
requestProperties: {
"instance_id": { type: "integer" },
"slave_id": { type: "integer" },
},
});
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"],
permission: "core.instance.start",
requestProperties: {
"save": { type: ["string", "null"] },
},
forwardTo: "instance",
});
messages.createSave = new Request({
type: "create_save",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.create_save",
forwardTo: "instance",
});
messages.loadScenario = new Request({
type: "load_scenario",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.load_scenario",
forwardTo: "instance",
requestProperties: {
"scenario": { type: "string" },
},
});
messages.exportData = new Request({
type: "export_data",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.export_data",
forwardTo: "instance",
});
messages.stopInstance = new Request({
type: "stop_instance",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.stop",
forwardTo: "instance",
});
messages.deleteInstance = new Request({
type: "delete_instance",
links: ["control-master", "master-slave"],
permission: "core.instance.delete",
forwardTo: "instance",
});
messages.sendRcon = new Request({
type: "send_rcon",
links: ["control-master", "master-slave", "slave-instance"],
permission: "core.instance.send_rcon",
forwardTo: "instance",
requestProperties: {
"command": { type: "string" },
},
responseProperties: {
"result": { type: "string" },
},
});
messages.listPermissions = new Request({
type: "list_permissions",
links: ["control-master"],
permission: "core.permission.list",
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["name", "title", "description"],
properties: {
"name": { type: "string" },
"title": { type: "string" },
"description": { type: "string" },
},
},
},
},
});
messages.listRoles = new Request({
type: "list_roles",
links: ["control-master"],
permission: "core.role.list",
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["id", "name", "description", "permissions"],
properties: {
"id": { type: "integer" },
"name": { type: "string" },
"description": { type: "string" },
"permissions": { type: "array", items: { type: "string" }},
},
},
},
},
});
messages.createRole = new Request({
type: "create_role",
links: ["control-master"],
permission: "core.role.create",
requestProperties: {
"name": { type: "string" },
"description": { type: "string" },
"permissions": { type: "array", items: { type: "string" }},
},
responseProperties: {
"id": { type: "integer" },
},
});
messages.updateRole = new Request({
type: "update_role",
links: ["control-master"],
permission: "core.role.update",
requestProperties: {
"id": { type: "integer" },
"name": { type: "string" },
"description": { type: "string" },
"permissions": { type: "array", items: { type: "string" }},
},
});
messages.grantDefaultRolePermissions = new Request({
type: "grant_default_role_permissions",
links: ["control-master"],
permission: "core.role.update",
requestProperties: {
"id": { type: "integer" },
},
});
messages.deleteRole = new Request({
type: "delete_role",
links: ["control-master"],
permission: "core.role.delete",
requestProperties: {
"id": { type: "integer" },
},
});
messages.listUsers = new Request({
type: "list_users",
links: ["control-master"],
permission: "core.user.list",
responseProperties: {
"list": {
type: "array",
items: {
additionalProperties: false,
required: ["name", "roles", "instances"],
properties: {
"name": { type: "string" },
"roles": { type: "array", items: { type: "integer" }},
"is_admin": { type: "boolean" },
"is_banned": { type: "boolean" },
"is_whitelisted": { type: "boolean" },
"instances": { type: "array", items: { type: "integer" }},
},
},
},
},
});
messages.createUser = new Request({
type: "create_user",
links: ["control-master"],
permission: "core.user.create",
requestProperties: {
"name": { type: "string" },
},
});
messages.updateUserRoles = new Request({
type: "update_user_roles",
links: ["control-master"],
permission: "core.user.update_roles",
requestProperties: {
"name": { type: "string" },
"roles": { type: "array", items: { type: "integer" }},
},
});
messages.setUserAdmin = new Request({
type: "set_user_admin",
links: ["control-master"],
permission: "core.user.set_admin",
requestProperties: {
"name": { type: "string" },
"create": { type: "boolean" },
"admin": { type: "boolean" },
},
});
messages.setUserWhitelisted = new Request({
type: "set_user_whitelisted",
links: ["control-master"],
permission: "core.user.set_whitelisted",
requestProperties: {
"name": { type: "string" },
"create": { type: "boolean" },
"whitelisted": { type: "boolean" },
},
});
messages.setUserBanned = new Request({
type: "set_user_banned",
links: ["control-master"],
permission: "core.user.set_banned",
requestProperties: {
"name": { type: "string" },
"create": { type: "boolean" },
"banned": { type: "boolean" },
"reason": { type: "string" },
},
});
messages.deleteUser = new Request({
type: "delete_user",
links: ["control-master"],
permission: "core.user.delete",
requestProperties: {
"name": { type: "string" },
},
});
messages.getMetrics = new Request({
type: "statistics_exporter:get_metrics",
links: ["master-slave", "slave-instance"],
responseProperties: {
"results": { type: "array" },
},
});
/**
* Represents an event sent over the link
*
* A one way message without any response or recipt confirmation that
* can be innitiated by either party of the link.
*
* @extends module:lib/link.Message
* @memberof module:lib/link
*/
class Event extends Message {
/**
* Creates an event
*
* @param {string} type -
* message type used. Will have suffix '_event' appended to it for
* the messages sent through this event.
* @param {Array<string>} links -
* Links this event is valid on. Takes an array of strings
* containing "<source>-<target>" specifications.
* @param {?string} forwardTo -
* Optional target to forward this request to. Supported values are
* 'master' and 'instance'. If set to 'instance' an instance_id
* property is implicity added to eventProperties.
* @param {?string} broadcastTo -
* Optional target to broadcast this event to. Only 'instance' is
* currently supported. An event will only be broadcast towards
* 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 {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 = {} }) {
super();
this.type = type;
this.links = links;
this.forwardTo = forwardTo;
this.broadcastTo = broadcastTo;
this.handlerSuffix = "EventHandler";
if (forwardTo === "instance") {
eventProperties = {
"instance_id": { type: "integer" },
...eventProperties,
};
} else if (forwardTo !== "master" && forwardTo !== null) {
throw new Error(`Invalid forwardTo value ${forwardTo}`);
}
if (broadcastTo !== "instance" && broadcastTo !== null) {
throw new Error(`Invalid broadcastTo value ${broadcastTo}`);
}
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),
properties: eventProperties,
},
},
});
}
/**
* Attach to a link
*
* Set a the handler for the event on the given link if it's a target
* for the event.
*
* If forwardTo was set to 'master' the `forwardEventToMaster` method on
* the link is used as the default handler. If it was set to 'instance'
* the `forwardEventToInstance` method on the link is used by default.
*
* If broadcastTo was set to 'instance' the `broadcastEventToInstance`
* method on the link is invoked before the handler.
*
* Does nothing if the link is not a target for this event.
*
* @param {Link} link - The link to attach to.
* @param {Function} handler -
* Async function to invoke with link set as this to handle the
* event. Only used on the target side.
* @throws {Error} if the handler is needed and not defined.
*/
attach(link, handler) {
// Target side. Note: Source and target is reversed here.
if (this.links.includes(`${link.target}-${link.source}`)) {
// Use forwarder if handler is not present.
if (!handler) {
if (this.forwardTo === "instance") {
handler = link.forwardEventToInstance;
} else if (this.forwardTo === "master") {
handler = link.forwardEventToMaster;
}
}
if (
this.broadcastTo === "instance"
&& [
"instance-slave", "slave-master", "control-master", "master-slave",
].includes(`${link.target}-${link.source}`)
) {
if (!handler) {
handler = link.broadcastEventToInstance;
} else {
let originalHandler = handler;
handler = async (message, event) => {
await link.broadcastEventToInstance(message, event);
await originalHandler.call(link, message, event);
};
}
}
if (!handler) {
throw new Error(`Missing handler for ${this.eventType} on ${link.source}-${link.target} link`);
}
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);
});
}, this._eventValidator);
}
}
/**
* Send event over the given link
*
* Sends the given event data over the link.
*
* @param {module:lib/link.Link} link - Link to send event over.
* @param {Object} data - Data to send with the event.
*/
send(link, data = {}) {
if (!this._eventValidator({ seq: 0, type: this.eventType, data })) {
console.error(this._eventValidator.errors);
throw new Error(`Validation failed sending ${this.eventType}`);
}
link.connector.send(this.eventType, data);
}
}
messages.debugWsMessage = new Event({
type: "debug_ws_message",
links: ["master-control"],
eventProperties: {
"direction": { type: "string" },
"content": { type: "string" },
},
});
messages.instanceInitialized = new Event({
type: "instance_initialized",
links: ["instance-slave"],
eventProperties: {
"instance_id": { type: "integer" },
"plugins": {
type: "object",
additionalProperties: { type: "string" },
},
},
});
messages.instanceStatusChanged = new Event({
type: "instance_status_changed",
links: ["instance-slave", "slave-master"],
eventProperties: {
"instance_id": { type: "integer" },
"status": { enum: [
"stopped", "starting", "running", "creating_save", "exporting_data",
]},
},
});
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"],
eventProperties: {
"event": { type: "string" },
},
});
messages.syncUserLists = new Event({
type: "sync_user_lists",
links: ["master-slave"],
eventProperties: {
"adminlist": {
type: "array",
items: { type: "string" },
},
"banlist": {
type: "array",
items: {
type: "array",
additionalItems: false,
items: [{ type: "string" }, { type: "string" }],
},
},
"whitelist": {
type: "array",
items: { type: "string" },
},
},
});
messages.banlistUpdate = new Event({
type: "banlist_update",
links: ["master-slave", "slave-instance"],
broadcastTo: "instance",
eventProperties: {
"name": { type: "string" },
"banned": { type: "boolean" },
"reason": { type: "string" },
},
});
messages.adminlistUpdate = new Event({
type: "adminlist_update",
links: ["master-slave", "slave-instance"],
eventProperties: {
"name": { type: "string" },
"admin": { type: "boolean" },
},
broadcastTo: "instance",
});
messages.whitelistUpdate = new Event({
type: "whitelist_update",
links: ["master-slave", "slave-instance"],
eventProperties: {
"name": { type: "string" },
"whitelisted": { type: "boolean" },
},
broadcastTo: "instance",
});
messages.playerEvent = new Event({
type: "player_event",
links: ["instance-slave", "slave-master"],
forwardTo: "master",
eventProperties: {
"instance_id": { type: "integer" },
"type": { type: "string", enum: ["join", "leave"] },
"name": { type: "string" },
},
});
/**
* Attaches all requests and events to the given link
*
* Loops through all builtin messages defined and attaches all of them
* to the link. The handler used for the messageis looked up on the
* link instance as the concatenation of the name of the message, the
* name of message class, and 'Handler'.
*
* @param {module:lib/link.Link} link - Link to attach messages to.
* @memberof module:lib/link
*/
function attachAllMessages(link) {
for (let [name, message] of Object.entries(messages)) {
message.attach(link, link[name + message.handlerSuffix]);
}
}
module.exports = {
Message,
Request,
Event,
attachAllMessages,
messages,
};
+10 -1
View File
@@ -1,5 +1,14 @@
/**
* Utilities for dealing with Lua
* @module lib/lua_tools
*/
"use strict";
/**
* Escapes a string for inclusion into a lua string
*
* @param {string} content - String to escape.
* @returns {string} Escaped string.
*/
function escapeString(content) {
return content
@@ -14,4 +23,4 @@ function escapeString(content) {
module.exports = {
escapeString,
}
};
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.1",
"repository": "https://github.com/clusterio/factorioClusterio",
"license": "MIT",
"keywords": [
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"ajv": "^6.12.6",
"fs-extra": "^8.1.0",
"ini": "^1.3.5",
"isomorphic-ws": "^4.0.1",
"jimp": "^0.12.1",
"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",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"publishConfig": {
"access": "public"
}
}
+491
View File
@@ -0,0 +1,491 @@
/**
* Plugin interfaces and utilities.
* @module lib/plugin
*/
"use strict";
const path = require("path");
const libErrors = require("@clusterio/lib/errors");
/**
* Conceptual base for master and instance plugins.
* @typedef {
* module:lib/plugin.BaseMasterPlugin
* |module:lib/plugin.BaseInstancePlugin
* |module:lib/plugin.BaseControlPlugin
* } module:lib/plugin.BasePlugin
*/
/**
* Base class for instance plugins
*
* Instance plugins are subclasses of this class which get instantiated by
* the slave when it brings up an instance with the plugin enabled in the
* config. To be discovered the class must be exported under the name
* `InstancePlugin` in the module specified by the `instanceEntrypoint` in
* the plugin's info.js file.
*
* Instances may be started and stopped many times, and many instances may
* be running at the same time, each of which will have their own instance
* of the InstancePlugin class.
*
* @static
*/
class BaseInstancePlugin {
constructor(info, instance, slave) {
/**
* The plugin's own info module
*/
this.info = info;
/**
* Instance the plugin started for
* @type {module:slave/slave~Instance}
*/
this.instance = instance;
/**
* Slave running the instance
*
* With the exepction of accessing the slave's config you should
* avoid ineracting with the slave object directly.
*
* @type {module:slave/slave~Slave}
*/
this.slave = slave;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the value of a config field changed.
*
* Invoked after the value of the config field given by `field` has
* changed.
*
* @param {module:lib/config.ConfigGroup} group -
* The group who's field got changed on.
* @param {string} field - Name of the field that changed.
* @param {*} prev - The previous value of the field.
*/
async onInstanceConfigFieldChanged(group, field, prev) { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns {*} an async iterator of prometheus metric results or undefined.
*/
async onMetrics() { }
/**
* Called after the Factorio server is started
*/
async onStart() { }
/**
* Called before the Factorio server is stopped
*
* This will not be called if for example the Factorio server crashes or
* is killed.
*/
async onStop() { }
/**
* Called when the instance exits
*
* Invoked when the instance is shut down. This may occur after init
* has been called if an error occurs during startup. Note that if
* the plugin's init() throws this method will still be invoked.
*/
onExit() { }
/**
* Called when the Factorio outputs a line
*
* Called when the Factorio server outputs a line on either stdout or
* 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 -
* Where the message came from, one of "stdout" and "stderr".
* @param {string} output.format -
* Timestamp format, one of "date", "seconds" and "none".
* @param {string=} output.time -
* Timestamp of the message. Not present if format is "none".
* @param {string} output.type -
* Type of message, one of "log", "action" and "generic".
* @param {string=} output.level -
* Log level for "log" type. i.e "Info" normally.
* @param {string=} output.file - File reported for "log" type.
* @param {string=} output.action -
* Kind of action for "action" type. i.e "CHAT" for chat.
* @param {string} output.message - Main content of the line.
*/
async onOutput(output) { }
/**
* 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:
*
* ##### connect
*
* Invoked when a new connection to the master has been established, or
* the existing one that have previously been dropped is re-established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the slave 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 on the slave and sent all in one go when the connection is
* re-established again.
*
* ##### 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, and close
*/
onMasterConnectionEvent(event) { }
/**
* Called when the master is preparing to disconnect from the slave
*
* Invoked when the master server has requested the disconnection from
* the slave. This typically happens when it is in the process of
* shutting down.
*
* Plugins must stop sending messages to the master, or are forwarded
* via the master server after the prepare disconnect has been handled.
*
* @param {module:master/master~SlaveConnection} connection -
* The connection to the slave preparing to disconnect.
*/
async onPrepareMasterDisconnect(connection) { }
/**
* Called when a player joins or leaves the game
*
* Invoked when a player either joins or leaves the instance.
*
* @param {Object} event - Information about the event.
* @param {string} event.type - Either "join" or "leave".
* @param {string} event.name - Name of the player that joined/left.
*/
async onPlayerEvent(event) { }
}
/**
* Base class for master plugins
*
* Master plugins are subclasses of this class which get instantiated by
* the master server on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `MasterPlugin`
* in the module specified by the `masterEntrypoint` in the plugin's info.js
* file.
*
* @static
*/
class BaseMasterPlugin {
constructor(info, master, metrics) {
this.info = info;
this.master = master;
this.metrics = metrics;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the status of an instance changes
*
* Invoked when the master server has received notice from a slave that
* the running status of an instance has changed. The possible statuses
* that can be notified about are:
* - `stopped`: Instance is no longer running.
* - `starting`: Instance is in the process of starting up.
* - `running`: Instance startup completed and is now running.
* - `creating_save`: Instance is in the process of creating a save.
* - `exporting_data`: Instance is in the process of exporting item
* icons and locale data.
*
* On master startup all known instances gets the status `unknown` if
* they are assigned to a slave, when the slave then connects to the
* master and informs of the current status of its instances this hook
* is invoked for all those instances. You can detect this situation by
* checking if prev equals `unknown`.
*
* Note that it's possible for status change notification to get lost in
* the case of network outages if reconnect fails to re-establish the
* session between the master server and the slave.
*
* @param {Object} instance - the instance that changed.
* @param {string} prev - the previous status of the instance.
*/
async onInstanceStatusChanged(instance, prev) { }
/**
* Called before collecting Prometheus metrics
*
* Invoked before the default metrics of prometheus is collected. Note
* that since this is done while replying to /metrics this should not
* take a long time, otherwise the requst will time out.
*
* Although you can return the results of collecting your prometheus
* collectors directly, all gauges in the default registry is already
* automatically collected. It's therefore recommended to only update
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
* collecting the values.
*
* @returns {*} an async iterator of prometheus metric results or undefined.
*/
async onMetrics() { }
/**
* Called when the master server is shutting down
*/
async onShutdown() { }
/**
* 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:
*
* ##### connect
*
* Invoked when a new connection to the slave has been established, or
* an existing one that had previously been dropped is re-established.
*
* ##### drop
*
* Invoked when a connection loss is detected between the master and a
* slave. Plugins should respond to this event by throtteling messages
* it is sending to the given slave connection to an absolute minimum.
*
* Messages sent over a dropped slave connection will get queued up in
* memory on the master and sent all in one go when the connection is
* re-established again.
*
* ##### 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
*/
onSlaveConnectionEvent(connection, event) { }
/**
* Called when a slave is preparing to disconnect from the master
*
* Invoked when a slave has requested the disconnection from the master.
* This typically happens when it is in the process of shutting down.
*
* Plugins must stop sending messages to the slave in question after the
* prepare disconnect has been handled.
*
* @param {module:master/master~SlaveConnection} connection -
* The connection to the slave preparing to disconnect.
*/
async onPrepareSlaveDisconnect(connection) { }
/**
* Called when a player joins or leaves an instance
*
* Invoked when a player either joins or leaves an instance in the
* cluster.
*
* @param {Object} instance - the instance it occured on.
* @param {Object} event - Information about the event.
* @param {string} event.type - Either "join" or "leave".
* @param {string} event.name - Name of the player that joined/left.
*/
async onPlayerEvent(instance, event) { }
/**
* Broadcast event to all connected slaves
*
* Sends the given event to all slaves connected to the master server.
* This does not include slaves that are in the process of closing the
* connection, which typically happens when they are shutting down.
*
* @param {module:lib/link.Event} event - Event to send
* @param {Object} data - Data ta pass with the event.
*/
broadcastEventToSlaves(event, data={}) {
for (let slaveConnection of this.master.slaveConnections.values()) {
if (
!slaveConnection.connector.closing
&& (!event.plugin || slaveConnection.plugins.has(event.plugin))
) {
event.send(slaveConnection, data);
}
}
}
}
/**
* Base class for clusterioctl plugins
*
* Control plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `ControlPlugin` in the module
* specified by the `controlEntrypoint` in the plugin's info.js file.
*
* @static
*/
class BaseControlPlugin {
constructor(info) {
/**
* The plugin's own info module
*/
this.info = info;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called to add commands to the command line interface.
*
* Invoked by clusterioctl to let plugins add commands. `rootCommand` is
* the top level command node which the plugin should add its own {@link
* module:lib/command.CommandTree} to.
*
* @param {module:lib/command.CommandTree} rootCommand -
* Root of the clusterioctl command tree.
*/
async addCommands(rootCommand) { }
}
/**
* Load plugin information
*
* Loads plugin info modules for the paths to the given plugins. Once
* loaded the info modules will not be reloaded should this function be
* called again.
*
* @param {Map<string, string>} pluginList -
* Mapping of plugin name to require path for the plugins to load.
* @returns {Array<Object>} Array of plugin info modules.
* @static
*/
async function loadPluginInfos(pluginList) {
let plugins = [];
for (let [pluginName, pluginPath] of pluginList) {
let pluginInfo;
let pluginPackage;
try {
pluginInfo = require(path.posix.join(pluginPath, "info"));
pluginPackage = require(path.posix.join(pluginPath, "package.json"));
} catch (err) {
throw new libErrors.PluginError(pluginName, err);
}
if (pluginInfo.name !== pluginName) {
throw new libErrors.EnvironmentError(
`Expected plugin at ${pluginPath} to be named ${pluginName} but got ${pluginInfo.name}`
);
}
pluginInfo.requirePath = pluginPath;
pluginInfo.version = pluginPackage.version;
plugins.push(pluginInfo);
}
return plugins;
}
/**
* Attach plugin messages
*
* Attaches all messages defined in the `.message` property of the
* `pluginInfo` passed with handlers taken from `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.
*/
function attachPluginMessages(link, pluginInfo, plugin) {
let messageDefinitions = pluginInfo.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}:"`);
}
let handler = name + messageFormat.handlerSuffix;
if (plugin === null || !plugin[handler]) {
messageFormat.attach(link);
} else {
messageFormat.attach(link, async function(message, format) {
return await plugin[handler](message, format, this);
});
}
}
}
/**
* Invokes the given hook on all plugins
*
* @param {Map<string, Object>} plugins -
* Mapping of plugin names to plugins to invoke the hook on.
* @param {string} hook - Name of hook to invoke.
* @param {...*} args - Arguments to pass on to the hook.
* @returns {Array} Non-empty return values from the hooks.
*/
async function invokeHook(plugins, hook, ...args) {
let results = [];
for (let [name, plugin] of plugins) {
try {
let result = await plugin[hook](...args);
if (result !== undefined) {
results.push(result);
}
} catch (err) {
console.error(`Ignoring error from plugin ${name} in ${hook}:`, err);
}
}
return results;
}
module.exports = {
BaseInstancePlugin,
BaseMasterPlugin,
BaseControlPlugin,
loadPluginInfos,
attachPluginMessages,
invokeHook,
};
@@ -1,10 +1,11 @@
"use strict";
class Metric {
constructor(type, name, help, labels = []) {
if (typeof name !== 'string') {
if (typeof name !== "string") {
throw new Error("Expected name to be a string");
}
if (typeof help !== 'string') {
if (typeof help !== "string") {
throw new Error("Expected help to be a string");
}
@@ -12,9 +13,9 @@ class Metric {
throw new Error(`Invalid name '${name}'`);
}
for (let name of labels) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`Invalid label '${name}'`);
for (let label of labels) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(label)) {
throw new Error(`Invalid label '${label}'`);
}
}
@@ -46,7 +47,7 @@ function escapeLabelValue(value) {
}
function labelsToKey(labels, metricLabels) {
let items = []
let items = [];
if (labels.length === 1 && typeof labels[0] === "object") {
labels = labels[0];
for (let name of metricLabels) {
@@ -73,7 +74,7 @@ function labelsToKey(labels, metricLabels) {
}
if (labels.length > metricLabels.length) {
throw new Error(`Extra positional label`);
throw new Error("Extra positional label");
}
for (let i=0; i < metricLabels.length; ++i) {
@@ -86,35 +87,54 @@ function labelsToKey(labels, metricLabels) {
}
}
return items.join(',');
return items.join(",");
}
function keyToLabels(key) {
let labels = new Map();
if (key !== '') for (let pair of key.split(',')) {
let [name, value] = pair.split('=', 2);
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
);
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
.replace(/\\n/g, "\n")
.replace(/\\\\/g, "\\")
);
}
}
return labels;
}
function removeMatchingLabels(mapping, labels) {
for (let key of mapping.keys()) {
let candidate = keyToLabels(key);
let hasLabels = Object.entries(labels).every(([name, value]) => (
candidate.get(name) === value
));
if (hasLabels) {
mapping.delete(key);
}
}
}
class ValueCollector extends Collector {
constructor(type, name, help, options, childClass) {
let labels = [];
let register = true;
let callback = null;
for (let [name, value] of Object.entries(options)) {
if (name === 'labels') { labels = value; }
else if (name === 'register') { register = value; }
else if (name === 'callback') { callback = value; }
else { throw new Error(`Unrecognized option '${name}'`); }
for (let [key, value] of Object.entries(options)) {
if (key === "labels") {
labels = value;
} else if (key === "register") {
register = value;
} else if (key === "callback") {
callback = value;
} else {
throw new Error(`Unrecognized option '${key}'`);
}
}
// Make sure we don't register to the default registry if metric throws.
@@ -151,7 +171,7 @@ class ValueCollector extends Collector {
remove(...labels) {
let key = labelsToKey(labels, this.metric.labels);
if (key === '') {
if (key === "") {
throw new Error("labels cannot be empty");
}
this._children.delete(key);
@@ -163,18 +183,6 @@ class ValueCollector extends Collector {
throw new Error("labels cannot be empty");
}
function removeMatchingLabels(mapping, labels) {
for (let key of mapping.keys()) {
let candidate = keyToLabels(key);
let hasLabels = Object.entries(labels).every(([name, value]) => {
return candidate.get(name) === value;
});
if (hasLabels) {
mapping.delete(key)
}
}
}
removeMatchingLabels(this._values, labels);
removeMatchingLabels(this._children, labels);
}
@@ -208,7 +216,7 @@ class CounterChild extends ValueCollectorChild {
throw new Error("Expected value to be a positive number");
}
this._values.set(this._key, this._values.get(this._key) + value)
this._values.set(this._key, this._values.get(this._key) + value);
}
}
@@ -224,19 +232,19 @@ class Counter extends ValueCollector {
class GaugeChild extends ValueCollectorChild {
inc(value = 1) {
this._values.set(this._key, this._values.get(this._key) + value)
this._values.set(this._key, this._values.get(this._key) + value);
}
dec(value = 1) {
this._values.set(this._key, this._values.get(this._key) - value)
this._values.set(this._key, this._values.get(this._key) - value);
}
set(value) {
this._values.set(this._key, value)
this._values.set(this._key, value);
}
setToCurrentTime() {
this._values.set(this._key, Date.now() / 1000)
this._values.set(this._key, Date.now() / 1000);
}
}
@@ -309,12 +317,12 @@ function escapeHelp(help) {
function formatValue(value) {
if (value === Infinity) {
return '+Inf';
} else if (value === -Infinity) {
return '-Inf';
} else {
return value.toString();
return "+Inf";
}
if (value === -Infinity) {
return "-Inf";
}
return value.toString();
}
async function* expositionLines(resultsIterator) {
@@ -323,14 +331,14 @@ async function* expositionLines(resultsIterator) {
if (first) {
first = false;
} else {
yield `\n`
yield "\n";
}
yield `# HELP ${result.metric.name} ${escapeHelp(result.metric.help)}\n`;
yield `# TYPE ${result.metric.name} ${result.metric.type}\n`;
for (let [key, value] of result.samples) {
if (key === '') {
if (key === "") {
yield `${result.metric.name} ${formatValue(value)}\n`;
} else {
yield `${result.metric.name}{${key}} ${formatValue(value)}\n`;
@@ -340,25 +348,30 @@ async function* expositionLines(resultsIterator) {
}
async function exposition(resultsIterator = defaultRegistry.collect()) {
let exposition = '';
let lines = "";
for await (let line of expositionLines(resultsIterator)) {
exposition += line;
lines += line;
}
return exposition;
return lines;
}
// HTTP Content-Type for the exposition format that's implemented
exposition.contentType = 'text/plain; version=0.0.4';
exposition.contentType = "text/plain; version=0.0.4";
function serializeResult(result, options = {}) {
let addLabels = null;
let metricName = result.metric.name;
let metricHelp = result.metric.help;
for (let [name, value] of Object.entries(options)) {
if (name === 'addLabels') { addLabels = value; }
else if (name === 'metricName') { metricName = value; }
else if (name === 'metricHelp') { metricHelp = value; }
else { throw new Error(`Unrecognized option '${name}'`); }
if (name === "addLabels") {
addLabels = value;
} else if (name === "metricName") {
metricName = value;
} else if (name === "metricHelp") {
metricHelp = value;
} else {
throw new Error(`Unrecognized option '${name}'`);
}
}
let samples;
@@ -370,8 +383,11 @@ function serializeResult(result, options = {}) {
samples = new Map();
let key = labelsToKey([addLabels], [...Object.keys(addLabels)]);
for (let [labels, value] of result.samples.entries()) {
if (labels === '') { samples.set(key, value); }
else { samples.set(`${labels},${key}`, value); }
if (labels === "") {
samples.set(key, value);
} else {
samples.set(`${labels},${key}`, value);
}
}
}
@@ -395,12 +411,13 @@ function deserializeResult(serializedResult) {
serializedResult.metric.labels,
),
samples: new Map(serializedResult.samples),
}
};
}
let defaultCollectors = {};
defaultCollectors.processCpuSecondsTotal = new Gauge(
'process_cpu_seconds', "Total user and system CPU time spent in seconds.",
"process_cpu_seconds_total",
"Total user and system CPU time spent in seconds.",
{
callback: async function() {
let usage = process.cpuUsage();
@@ -409,8 +426,28 @@ defaultCollectors.processCpuSecondsTotal = new Gauge(
},
);
defaultCollectors.processResidentMemoryBytes = new Gauge(
"process_resident_memory_bytes",
"Resident memory size in bytes.",
{
callback: function() {
this.set(process.memoryUsage().rss);
},
},
);
defaultCollectors.processHeapBytes = new Gauge(
"process_heap_bytes",
"Process heap size in bytes.",
{
callback: function() {
this.set(process.memoryUsage().heapUsed);
},
},
);
defaultCollectors.processStartTimeSeconds = new Gauge(
'process_start_time_seconds',
"process_start_time_seconds",
"Start time of the process since unix epoch in seconds."
);
defaultCollectors.processStartTimeSeconds.setToCurrentTime();
+50 -7
View File
@@ -1,4 +1,9 @@
const ajv = new (require('ajv'))({
/**
* JSON schemas used for validating link messages
* @module lib/schema
*/
"use strict";
const ajv = new (require("ajv"))({
verbose: true,
format: "full",
extendRefs: "fail",
@@ -24,8 +29,20 @@ const messageSchema = {
},
};
/**
* Validator for message schemas
* @function
* @param {Object} obj - Object to validate
* @returns {boolean} True if object passed validation.
*/
const message = ajv.compile(messageSchema);
/**
* Validator for heartbeat messages
* @function
* @param {Object} obj - Object to validate
* @returns {boolean} True if object passed validation.
*/
const heartbeat = ajv.compile({
$schema: "http://json-schema.org/draft-07/schema#",
title: "Heartbeat",
@@ -47,21 +64,31 @@ const heartbeat = ajv.compile({
},
});
/**
* Validator for server sent part of handshake
* @function
* @param {Object} obj - Object to validate
* @returns {boolean} True if object passed validation.
*/
const serverHandshake = ajv.compile({
$schema: "http://json-schema.org/draft-07/schema#",
title: "Server Handshake",
description: "Message sent from the server to a client during the handshake",
allOf: [ messageSchema ],
allOf: [messageSchema],
anyOf: [
{
properties: {
"type": { const: "hello" },
"data": {
additionalProperties: false,
required: ["version"],
required: ["version", "plugins"],
properties: {
"version": { type: "string" },
}
"plugins": {
type: "object",
additionalProperties: { type: "string" },
},
},
},
},
},
@@ -100,24 +127,34 @@ const serverHandshake = ajv.compile({
],
});
/**
* Validator for client sent part of handshake
* @function
* @param {Object} obj - Object to validate
* @returns {boolean} True if object passed validation.
*/
const clientHandshake = ajv.compile({
$schema: "http://json-schema.org/draft-07/schema#",
title: "Client Message",
description: "Message sent from a client to the server",
allOf: [ messageSchema ],
allOf: [messageSchema],
anyOf: [
{
properties: {
"type": { const: "register_slave" },
"data": {
additionalProperties: false,
required: ["token", "agent", "version", "name", "id"],
required: ["token", "agent", "version", "name", "id", "plugins"],
properties: {
"token": { type: "string" },
"agent": { type: "string" },
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"plugins": {
type: "object",
additionalProperties: { type: "string" },
},
},
},
},
@@ -132,7 +169,7 @@ const clientHandshake = ajv.compile({
"token": { type: "string" },
"agent": { type: "string" },
"version": { type: "string" },
}
},
},
},
},
@@ -154,6 +191,12 @@ const clientHandshake = ajv.compile({
module.exports = {
/**
* Compile JSON schema into validator
* @function
* @param {Object} schema - JSON schema to create validator for.
* @returns {Function} Validator for the schema.
*/
compile: ajv.compile.bind(ajv),
message,
+86
View File
@@ -0,0 +1,86 @@
/**
* Implementation of commands shared between master/slave/ctl.
* @module lib/shared_commands
*/
"use strict";
const fs = require("fs-extra");
const path = require("path");
/**
* Yargs plugin command
*
* Can be passed to yargs.command to implement a plugin list management
* command. Use handlePluginCommand to do the requested action.
*
* @param {Object} yargs - yargs command builder.
*/
function pluginCommand(yargs) {
yargs
.command("add <path>", "Add plugin by require path")
.command("remove <name>", "Remove plugin by name")
.command("list", "List all plugins and their path")
.demandCommand(1, "You need to specify a command to run")
.help()
.strict()
;
}
/**
* Handle yargs plugin command
*
* Handle the actions that are made available by pluginCommand.
*
* @param {Object} args - yargs args object.
* @param {Map<string, string>} pluginList - Current list of plugins.
* @param {string} pluginListPath - Path to plugin list config file.
*/
async function handlePluginCommand(args, pluginList, pluginListPath) {
let command = args._[1];
if (command === "add") {
let pluginPath = args.path;
if (/^\.\.?[\/\\]/.test(pluginPath)) {
pluginPath = path.resolve(pluginPath);
}
let pluginInfo;
try {
pluginInfo = require(path.posix.join(pluginPath, "info"));
} catch (err) {
console.error(`Unable to import plugin info from ${args.path}`);
console.error(err);
process.exitCode = 1;
return;
}
if (pluginList.has(pluginInfo.name)) {
console.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}`);
} else if (command === "remove") {
if (!pluginList.delete(args.name)) {
console.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}`);
} else if (command === "list") {
for (let [pluginName, pluginPath] of pluginList) {
console.log(`${pluginName} - ${pluginPath}`);
}
}
}
module.exports = {
pluginCommand,
handlePluginCommand,
};
+469
View File
@@ -0,0 +1,469 @@
/**
* User and permissions library
*
* Defines data types for users, roles and permissions.
*
* @author Hornwitser
* @module lib/users
*/
"use strict";
const jwt = require("jsonwebtoken");
/**
* Represents a permission that can be granted
* @static
*/
class Permission {
constructor(name, title, description, grantByDefault) {
this.name = name;
this.title = title;
this.description = description;
this.grantByDefault = grantByDefault;
}
}
const permissions = new Map();
/**
* Define a new user permission for Clusterio
*
* Defines a permission for use in Clusterio. Defined permissions can be
* granted to roles, and checked on users. Name should be `lower_case`
* style and start with the plugin name followed by a dot.
*
* @param {Object} def - The definition for the permission.
* @param {string} def.name - The internal name for the permission.
* @param {string} def.title - User facing name for permission.
* @param {string} def.description - User facing description.
* @param {boolean} def.grantByDefault -
* If true this role is granted by the generated Player role by default.
*
*/
function definePermission({ name, title, description, grantByDefault=false }) {
if (typeof name !== "string" || !name.length) {
throw new Error("Expected name to be a non-empty string");
}
if (typeof title !== "string" || !title.length) {
throw new Error("Expected title to be a non-empty string");
}
if (typeof description !== "string" || !description.length) {
throw new Error("Expected description to be a non-empty string");
}
if (typeof grantByDefault !== "boolean") {
throw new Error("Expected grantByDefault to be a boolean");
}
if (permissions.has(name)) {
throw new Error(`Permission '${name}' is already defined`);
}
permissions.set(name, new Permission(name, title, description, grantByDefault));
}
/**
* Represents a collection of granted permissions
* @static
*/
class Role {
constructor(serializedRole) {
this.id = serializedRole.id;
this.name = serializedRole.name;
this.description = serializedRole.description;
this.permissions = new Set(serializedRole.permissions);
}
serialize() {
return {
id: this.id,
name: this.name,
description: this.description,
permissions: [...this.permissions],
};
}
grantDefaultPermissions() {
for (let permission of permissions.values()) {
if (permission.grantByDefault) {
this.permissions.add(permission.name);
}
}
}
}
/**
* Ensure the default admin role has full access
*
* Ensures the role with id 0 exisits and has the core.admin permission.
*
* @param {Map<number, module:lib/user.Role>} roles - role storage to modify.
* @returns {module:lib/permission.Role} the default admin role
* @static
*/
function ensureDefaultAdminRole(roles) {
let admin = roles.get(0);
if (!admin) {
admin = new Role({ id: 0, name: "Admin", description: "Cluster wide administrator." });
roles.set(0, admin);
}
admin.permissions.add("core.admin");
return admin;
}
/**
* Ensure the default player role has default accesses
*
* Ensures the role with id 1 exists and has all the permissions that are
* assigned by default. Note that this may not be the same role as the one
* that is assigned to new users by default.
*
* @param {Map<number, module:lib/user.Role>} roles - role storage to modify.
* @static
*/
function ensureDefaultPlayerRole(roles) {
let player = roles.get(1);
if (!player) {
player = new Role({ id: 1, name: "Player", description: "Default player role." });
roles.set(1, player);
}
player.grantDefaultPermissions();
}
/**
* Represeents a user in the cluster
*
* Holds data about a Factorio user in the cluster.
* @static
*/
class User {
constructor(serializedUser, loadedRoles) {
/**
* Factorio user name.
* @type {string}
*/
this.name = serializedUser.name;
/**
* Instances this user is online on.
* @type {Set<number>}
*/
this.instances = new Set();
/**
* Unix time in seconds the user token must be issued after to be valid.
* @type {number}
*/
this.tokenValidAfter = serializedUser.token_valid_after || 0;
/**
* True if the user is promoted to admin on the Factorio instances.
* @type {boolean}
*/
this.isAdmin = Boolean(serializedUser.is_admin);
/**
* True if the user is whitelisted on the Factorio instances.
* @type {boolean}
*/
this.isWhitelisted = Boolean(serializedUser.is_whitelisted);
/**
* True if the user is banned from Factorio instances.
* @type {boolean}
*/
this.isBanned = Boolean(serializedUser.is_banned);
/**
* Reason for being banned. Ignored if isBanned is false.
* @type {string}
*/
this.banReason = serializedUser.ban_reason || "";
/**
* Roles this user has
* @type {Set<module:lib/permissions.Role>}
*/
this.roles = new Set();
if (serializedUser.roles) {
for (let roleId of serializedUser.roles) {
let role = loadedRoles.get(roleId);
if (role) {
this.roles.add(role);
}
}
}
}
serialize() {
let serialized = {
name: this.name,
};
if (this.roles.size) {
serialized.roles = [...this.roles].map(role => role.id);
}
if (this.tokenValidAfter) {
serialized.token_valid_after = this.tokenValidAfter;
}
if (this.isAdmin) {
serialized.is_admin = true;
}
if (this.isWhitelisted) {
serialized.is_whitelisted = true;
}
if (this.isBanned) {
serialized.is_banned = true;
}
if (this.banReason) {
serialized.ban_reason = this.banReason;
}
return serialized;
}
/**
* Generate access token for this user
*
* @param {string} secret - Secret to sign token with.
* @returns {string} JWT access token for the user.
*/
createToken(secret) {
return jwt.sign({ aud: "user", user: this.name }, secret);
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
/**
* Check if a given permission is granted
*
* Checks the roles the user is member of for one that grants the given
* permission. If the permission is not granted for the user a
* "Permission denied" error is thrown.
*
* @param {string} permission - The permission to check for.
* @throws {Error} if the user does noh have the given permission.
*/
checkPermission(permission) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let role of this.roles) {
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new Error("Permission denied");
}
notifyJoin(instance_id) {
this.instances.add(instance_id);
User.onlineUsers.add(this);
}
notifyLeave(instance_id) {
this.instances.delete(instance_id);
if (!this.instances.size) {
User.onlineUsers.delete(this);
}
}
}
/**
* Set of users currently online in the cluster.
* @type {module:lib/users.User}
*/
User.onlineUsers = new Set();
// Definitions for the built in permissions used in Clusterio.
// description should answer "this permission allows you to ___"
definePermission({
name: "core.admin",
title: "Administrator",
description: "Bypass all permission checks.",
});
definePermission({
name: "core.control.connect",
title: "Connect to WebSocket",
description: "Connect to the master's WebSocket control interface.",
grantByDefault: true,
});
definePermission({
name: "core.slave.list",
title: "List slaves",
description: "Get the full list of slaves in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.slave.generate_token",
title: "Generate slave token",
description: "Generate tokens for slaves to connect to the cluster with.",
});
definePermission({
name: "core.slave.create_config",
title: "Create slave config",
description: "Create slave configs via the master server.",
});
definePermission({
name: "core.instance.list",
title: "List instances",
description: "Get the full list of instances in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.instance.create",
title: "Create instance",
description: "Create new instances on the master server.",
});
definePermission({
name: "core.instance.get_config",
title: "Get instance config",
description: "Get the config of instances.",
});
definePermission({
name: "core.instance.update_config",
title: "Modify instance config",
description: "Modify the config or entries of the config for instances.",
});
definePermission({
name: "core.instance.assign",
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",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.export_data",
title: "Export locale and icons",
description: "Export the the locale and icons from an instance and upload it to the master.",
});
definePermission({
name: "core.instance.start",
title: "Start instance",
description: "Start instances.",
});
definePermission({
name: "core.instance.load_scenario",
title: "Load scenario",
description: "Start instances by loading a scenario.",
});
definePermission({
name: "core.instance.stop",
title: "Stop instance",
description: "Stop instances.",
});
definePermission({
name: "core.instance.delete",
title: "Delete instance",
description: "Delete instances (includes config and all files stored like saves and logs).",
});
definePermission({
name: "core.instance.send_rcon",
title: "Send RCON",
description: "Send arbitrary RCON commands to instances.",
});
definePermission({
name: "core.permission.list",
title: "List permissions",
description: "Get the full list of permissions in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.role.list",
title: "List roles",
description: "Get the full list of roles and their permissions in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.role.create",
title: "Create role",
description: "Create new roles with permissions.",
});
definePermission({
name: "core.role.update",
title: "Update role",
description: "Edit existing roles including permissions they grant.",
});
definePermission({
name: "core.role.delete",
title: "Delete role",
description: "Permanently delete roles.",
});
definePermission({
name: "core.user.list",
title: "List users",
description: "Get the full list of users in the cluster.",
});
definePermission({
name: "core.user.create",
title: "Create users",
description: "Create user acounts with a given name.",
});
definePermission({
name: "core.user.update_roles",
title: "Update user roles",
description: "Add or remove any role to any user.",
});
definePermission({
name: "core.user.set_admin",
title: "Set user admin status",
description: "Promote or demote any user to Factorio admin.",
});
definePermission({
name: "core.user.set_banned",
title: "Set user ban status",
description: "Ban or unban any user.",
});
definePermission({
name: "core.user.set_whitelisted",
title: "Set user whitelist status",
description: "Add or remove any user to/from the whitelist.",
});
definePermission({
name: "core.user.delete",
title: "Delete user",
description: "Delete users and all data stored for them.",
});
definePermission({
name: "core.debug.dump_ws",
title: "Dump WebSocket",
description: "Dump all WebSocket communicatation from the master.",
});
module.exports = {
Permission,
Role,
User,
permissions,
definePermission,
ensureDefaultAdminRole,
ensureDefaultPlayerRole,
};
+128
View File
@@ -0,0 +1,128 @@
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.
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.
* `--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.
#### `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.
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.
#### `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.
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.
#### `plugin list`
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.
#### `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.
See docs/configuration.md in the main repositiory for the available
configuration entries.
#### `config show <config-entry>`
Shows the value for a single config entry.
#### `config list`
Lists up all configuration entries with their currently configured values.
### `bootstrap`
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.
#### `bootstrap create-ctl-config <name>`
Creates a clusterioctl config for the given user with url and token set
up for connecting to the cluster.
### `run`
Runs the master server.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
+1842
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@clusterio/master",
"description": "Implementation of Clusterio master server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.1",
"main": "master.js",
"scripts": {
"build": "webpack-cli --env.production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriomaster": "./master.js"
},
"keywords": [
"factorio"
],
"engines": {
"node": ">=10.16 <11 || >= 12"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.1",
"body-parser": "^1.18.2",
"compression": "^1.7.2",
"cookie-parser": "^1.4.3",
"express": "^4.16.2",
"express-fileupload": "1.2.0",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
"node-factorio-api": "^0.3.8",
"node-forge": "^0.10.0",
"set-blocking": "^2.0.0",
"ws": "^7.2.3",
"yargs": "^14.2.0"
},
"devDependencies": {
"@ant-design/icons": "^4.2.2",
"@babel/core": "^7.11.6",
"@babel/preset-react": "^7.10.4",
"antd": "^4.6.5",
"babel-loader": "^8.1.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^4.3.0",
"events": "^3.2.0",
"fast-deep-equal": "^3.1.3",
"null-loader": "^4.0.0",
"path-browserify": "^1.0.1",
"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-merge": "^5.2.0"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,3 +1,4 @@
"use strict";
const forge = require("node-forge");
const fs = require("fs-extra");
const util = require("util");
@@ -12,12 +13,12 @@ module.exports = async function({
doLogging = false,
} = {}){
function log(t) {
if(doLogging) console.log("lib/generateSSLcert.js | "+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});
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();
@@ -41,4 +42,4 @@ module.exports = async function({
await fs.outputFile(sslCertPath, forge.pki.certificateToPem(cert));
log("Generated certificate in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
}
};
+16
View File
@@ -0,0 +1,16 @@
"use strict";
// Routes used in the web interface and served by the master server
module.exports = [
"/",
"/instances",
"/instances/:id/view",
"/slaves",
"/slaves/:id/view",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/permissions",
"/permissions/:id/view",
"/settings",
];
+6
View File
@@ -0,0 +1,6 @@
// 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;
+77
View File
@@ -0,0 +1,77 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom";
import libConfig from "@clusterio/lib/config";
import App from "./components/App";
import { Control, ControlConnector } from "./util/websocket";
async function loadScript(url) {
let script = document.createElement("script");
script.src = url;
script.type = "text/javascript";
script.async = true;
let result = new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
});
document.head.appendChild(script);
return result;
}
async function loadPluginInfos() {
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");
pluginList = [];
}
let pluginInfos = [];
await __webpack_init_sharing__("default");
for (let plugin of pluginList) {
try {
await loadScript(`${webRoot}plugin/${plugin.name}/remoteEntry.js`);
let container = window[`plugin_${plugin.name}`];
await container.init(__webpack_share_scopes__.default);
let info = (await container.get("./info"))();
info.container = container;
pluginInfos.push(info);
} catch (err) {
console.log(`Failed to load plugin info for ${plugin.name}`);
console.log(err);
}
}
return pluginInfos;
}
async function load() {
let pluginInfos = await loadPluginInfos();
libConfig.registerPluginConfigGroups(pluginInfos);
libConfig.finalizeConfigs();
let wsUrl = new URL(window.webRoot, document.location);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
let controlConnector = new ControlConnector(wsUrl, 10, localStorage.getItem("master_token"));
// XXX come up with a better way of sharing this
window.control = new Control(controlConnector, []);
try {
await controlConnector.connect();
} catch (err) {
// XXX: What now?
console.log(err);
}
ReactDOM.render(<App />, document.getElementById("root"));
}
load().catch(console.log);
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import { BrowserRouter } from "react-router-dom";
import basename from "../basename";
import Layout from "../layout";
export default function App() {
return (
<BrowserRouter basename={basename}>
<Layout />
</BrowserRouter>
);
}
@@ -0,0 +1,289 @@
import React, { Component } from "react";
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import notify from "../util/notify";
const { Panel } = Collapse;
/**
* Display data in table. Allows for editing data by clicking rows in the table.
* Basic usage: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
*
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
* columns - Array of objects, optional, same as <Table />. Note the lowercase c
* 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
*/
class DataTable extends Component {
constructor(props) {
super(props);
if (!this.props.DataFunction) throw new Error("DataFunction not defined");
this.state = {
pagination: {
pageSize: 100,
current: 1,
},
filters: this.props.Filters,
sorter: {},
tableData: [],
showEditModal: false,
record: {},
};
}
componentDidMount() {
this.getData();
}
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 = 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
tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data,
pagination: { ...pagination, ...data.pagination },
sorter,
filters,
});
}
showEditModal(record, index) {
if (!this.state.showEditModal) {
this.setState({
record: record,
showEditModal: true,
});
}
}
hideEditModal() {
this.setState({
showEditModal: false,
inserting: false,
record: {},
});
}
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
await this.props.Editable.insert(values);
}
this.setState({
showEditModal: false,
inserting: false,
record: {},
});
// Force refresh of data
this.getData();
}
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
for (let key in record) {
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)
console.log("Deleted record", status);
if (status.ok) notify("success", status.msg, "success");
if (!status.ok) notify("error", status.msg, "error");
// Force refresh of data
this.getData();
}
render() {
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.
*/
let externalOnRow = {
...(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);
}
},
onDoubleClick: event => {
if (externalOnRow.onDoubleClick) externalOnRow.onDoubleClick(event);
},
onContextMenu: event => {
if (externalOnRow.onContextMenu) externalOnRow.onContextMenu(event);
},
onMouseEnter: event => {
if (externalOnRow.onMouseEnter) externalOnRow.onMouseEnter(event);
},
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
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>,
});
return <>
<Row>
<Col span={24}>
<style>
{`
.editableTable .ant-table-tbody > tr {
cursor:pointer
}
`}
</style>
<Table
className={this.props.Editable && "editableTable"}
{...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,
}))}
dataSource={this.state.tableData}
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
onChange={this.getData.bind(this)}
/>
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
showEditModal: true,
inserting: true,
})}>
<PlusOutlined />Add
</Button>}
</Col>
<Modal
visible={this.state.showEditModal}
onCancel={this.hideEditModal.bind(this)}
footer={null}
title={
<h2>{this.state.inserting ? "Legg til" : "Rediger"}</h2>
}
>
{this.state.showEditModal && <Form
labelCol={{
span: 6,
}}
wrapperCol={{
span: 12,
}}
layout="horizontal"
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>;
})
}
<Form.Item>
<Button type="primary" htmlType="submit">
{this.state.inserting ? "Legg til" : "Lagre"}
</Button>
</Form.Item>
</Form>}
</Modal>
</Row>
</>;
}
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
let columns = [];
data.forEach(row => {
Object.keys(row).filter(x => x !== "key").forEach(key => {
let existing = columns.find(col => col.key === key);
if (!existing) {
columns.push({
key: key,
title: key,
dataIndex: key,
});
}
});
});
return columns;
}
}
export default DataTable;
+25
View File
@@ -0,0 +1,25 @@
@import '~antd/dist/antd.dark.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
[data-theme="compact"] p, p {
margin: 0;
}
.row-links{
cursor: pointer;
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clusterio</title>
<script>
window.webRoot = "__WEB_ROOT__";
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="__WEB_ROOT__bundle.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
import("./bootstrap");
+166
View File
@@ -0,0 +1,166 @@
import { Layout, Menu } from "antd";
import { Route, Link, withRouter } from "react-router-dom";
import React, { Component } from "react";
import { Dashboard } from "../pages/dashboard";
import { InstancesTable } from "../pages/instances/instancesTable";
import { SlavesTable } from "../pages/slaves/slavesTable";
import { UsersTable } from "../pages/users/usersTable";
import { RolesTable } from "../pages/roles/rolesTable";
import { PermissionsTable } from "../pages/permissions/permissionsTable";
import { InstanceView } from "../pages/instances/instance";
import { UserView } from "../pages/users/user";
import { RoleView } from "../pages/roles/role";
import { Settings } from "../pages/settings/settings";
const { Content, Footer, Sider } = Layout;
const { SubMenu } = Menu;
class SiteLayout extends Component {
constructor(props) {
super(props);
const { location } = props;
this.state = {
collapsed: false,
setcollapsed: false,
tags: [],
setTags: [],
location,
siteSettings: {},
factoryModel: [],
};
}
onCollapse() {
this.setState({ collapsed: !this.state.collapsed });
}
titleCase(text) {
return text.substring(0, 1).toUpperCase() + text.substring(1);
}
replaceParametersInUrl({ endpoint, params }) {
let path = endpoint;
console.log("endpoint", params);
Object.keys(params).forEach(key => {
path = path.replace(":" + key, params[key]);
});
return path;
}
render() {
return (
<Layout style={{ minHeight: "100vh" }}>
{document.location.search !== "?hideExtras" ? (
<Sider
collapsible
collapsed={this.state.collapsed}
onCollapse={this.onCollapse.bind(this)}
width={250}
breakpoint="lg"
>
<p></p>
<div style={{ width: "100%", textAlign: "center" }}>
{" "}
{this.state.collapsed ? (
<img
alt=""
src="/smal_logo.png"
style={{ height: "48px", margin: 8 }}
/>
) : (
<img
alt=""
src="/logo.png"
style={{ width: "200px", margin: 8 }}
/>
)}
</div>
<p></p>
<p></p>
<Menu
theme="dark"
defaultSelectedKeys={[
this.state.location.pathname.split("/")[
this.state.location.pathname.split("/").length - 1
],
]}
defaultOpenKeys={(() => {
let x = this.state.location.pathname.split("/");
x.shift();
return x;
})()}
mode="inline"
>
<Menu.Item key="slaves">
<span>Slaves</span>
<Link to="/slaves" />
</Menu.Item>
<Menu.Item key="instances">
<span>Instances</span>
<Link to="/instances" />
</Menu.Item>
<Menu.Item key="users">
<span>Users</span>
<Link to="/users" />
</Menu.Item>
<Menu.Item key="roles">
<span>Roles</span>
<Link to="/roles" />
</Menu.Item>
<Menu.Item key="permissions">
<span>Permissions</span>
<Link to="/permissions" />
</Menu.Item>
<Menu.Item key="settings">
<span>Settings</span>
<Link to="/settings" />
</Menu.Item>
</Menu>
</Sider>
) : (
""
)}
<Layout style={{
// background: "#F4F4F4"
}}>
<Content
style={{
margin: "24px 16px",
padding: 24,
// background: "#fff",
minHeight: 280,
}}
>
<Route exact path="/" component={Dashboard} />
<Route exact path="/instances" component={InstancesTable} />
<Route exact path="/instances/:id/view" component={InstanceView} />
<Route exact path="/slaves" component={SlavesTable} />
<Route exact path="/slaves/:id/view" component={SlavesTable} />
<Route exact path="/users" component={UsersTable} />
<Route exact path="/users/:id/view" component={UserView} />
<Route exact path="/roles" component={RolesTable} />
<Route exact path="/roles/:id/view" component={RoleView} />
<Route exact path="/permissions" component={PermissionsTable} />
<Route exact path="/permissions/:id/view" component={PermissionsTable} />
<Route exact path="/settings" component={Settings} />
</Content>
<Footer style={{
// background: "#F4F4F4",
textAlign: "center",
}}>
Clusterio 2.0 web interface https://github.com/clusterio
</Footer>
</Layout>
</Layout>
);
}
}
export default withRouter(SiteLayout);
+7
View File
@@ -0,0 +1,7 @@
import React, {Component} from "react";
export class Dashboard extends Component {
render(){
return <h2>Dashboard</h2>;
}
}
@@ -0,0 +1,454 @@
import React, { Component } from "react";
import { Card, Button, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd";
import {
listSlaves,
listInstances,
startInstance,
stopInstance,
setInstanceOutputSubscriptions,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
sendRcon,
} from "../../util/wslink";
import DataTable from "../../components/data-table";
import notify from "../../util/notify";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import EditOutlined from "@ant-design/icons/EditOutlined";
import PlusOutlined from "@ant-design/icons/PlusOutlined";
const { Option } = Select;
export class InstanceView extends Component {
constructor(props) {
super(props);
this.state = {
instance: {},
slaves: [],
logLines: [{ message: "Logviewer. Follows the latest logs if you scroll to the bottom. Does not store logs past the lifetime of this tab" }],
};
this.messagesEndRef = React.createRef();
}
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
let id = this.props.match.params.id;
await this.getData();
await setInstanceOutputSubscriptions({
instance_id: Number(id),
});
window.instanceOutputEventHandler = ({ instance_id, output }) => {
if (instance_id === Number(id)) {
this.setState({
logLines: [...this.state.logLines, output],
});
}
// Automatically scroll to bottom on new line.
// this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
};
}
async getData() {
let id = this.props.match.params.id;
let instances = await listInstances();
let slaves = await listSlaves();
this.setState({
instance: instances.find(x => x.id === Number(id)),
slaves,
});
let config = await getInstanceConfig({ instance_id: Number(id) });
console.log(config);
this.setState({
config,
});
}
async start() {
this.setState({
starting: true,
});
try {
await startInstance({
instance_id: this.state.instance.id,
save: null,
});
} catch (e) {
notify("Error starting instance", "error", e.message);
this.setState({
starting: false,
});
return;
}
notify("Instance started");
this.setState({
starting: false,
});
this.getData();
}
async stop() {
this.setState({
stopping: true,
});
try {
await stopInstance({
instance_id: this.state.instance.id,
});
} catch (e) {
notify("Error stopping instance", "error", e.message);
this.setState({
stopping: false,
});
return;
}
notify("Instance stopped");
this.setState({
stopping: false,
});
this.getData();
}
async createSave() {
this.setState({
creatingSave: true,
});
try {
await createSave({
instance_id: this.state.instance.id,
});
} catch (e) {
notify("Error creating save", "error", e.message);
this.setState({
creatingSave: false,
});
return;
}
notify("Save created", "success");
this.setState({
creatingSave: false,
});
}
async onConfigEditFinish(values) {
console.log("Completed config edit with", values);
for (let k in values) {
if (values[k] !== this.getInstanceConfigAsKvObject(this.state.config)[k]) {
let value = values[k];
if (typeof value === "boolean") value = value.toString();
console.log("Setting", k, value);
await setInstanceConfigField({
instance_id: this.state.instance.id,
field: k,
value: value,
});
}
}
notify("Set configuration fields", "success");
this.setState({
editingConfig: false,
});
this.getData();
}
render() {
console.log(this.state);
let { instance, slaves } = this.state;
let slave = slaves && slaves.find(x => x.id === instance.assigned_slave);
return <Card>
<h2>{instance.name}</h2>
<p>ID: {instance.id}</p>
<p>Slave: {slave && slave.name}</p>
{instance.status !== "running" && <Button
loading={this.state.starting}
type="primary"
onClick={this.start.bind(this)}
>
Start server
</Button>}
{instance.status !== "stopped" && <Button
loading={this.state.stopping}
type="primary"
onClick={this.stop.bind(this)}
>
Stop server
</Button>}
{instance.status !== "running" && <Button
loading={this.state.creatingSave}
type="primary"
onClick={this.createSave.bind(this)}
>
Create save
</Button>}
<Popover
content={
<Select style={{
width: "200px",
}}
onChange={async newSlaveId => {
let result = await assignInstance({ instance_id: instance.id, slave_id: newSlaveId });
notify("Assigned to slave", "success");
this.getData();
}}
>
{this.state.slaves.map(newSlave => <Option
defaultValue={instance.assigned_slave} value={newSlave.id} key={newSlave.id}
>
{newSlave.name}
</Option>)}
</Select>
}
trigger="click"
type={instance.status === "unassigned" ? "primary" : "default"}
>
<Button>
Assign to slave
</Button>
</Popover>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteInstance({ instance_id: instance.id });
notify("Deleted instance " + instance.id, "success");
this.navigate("/instances");
}}
>
Delete this instance permanently
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
<style>
{/* Scroll sticks to bottom of terminal window after manually scrolling to the bottom */}
{`#scroller * {
/* don't allow the children of the scrollable element to be selected as an anchor node */
overflow-anchor: none;
}
#anchor {
/* allow the final child to be selected as an anchor node */
overflow-anchor: auto;
/* anchor nodes are required to have non-zero area */
height: 1px;
}`}
</style>
<div id="scroller" style={{
height: "300px",
overflowY: "scroll",
}}>
{this.state.logLines.map(item => <p>{item.time} {item.level} {item.file}: {item.message}</p>)}
<div id="anchor" ref={this.messagesEndRef}></div>
</div>
{/* rcon input field */}
<Form
onFinish={async values => {
await sendRcon({ instance_id: instance.id, command: values.command });
}}
>
<Form.Item
label="RCON: "
name="command"
>
<Input />
</Form.Item>
</Form>
{this.state.config && this.renderConfig(this.state.config)}
</Card>;
}
getInstanceConfigAsKvObject(config) {
let object = {};
for (let group of config.serialized_config.groups) {
for (let fieldName of Object.keys(group.fields)) {
object[group.name + "." + fieldName] = group.fields[fieldName];
}
}
return object;
}
renderConfig(config) {
const layout = {
labelCol: {
span: 6,
},
wrapperCol: {
span: 14,
},
};
const tailLayout = {
wrapperCol: {
offset: 6,
span: 14,
},
};
return <Card>
<Form
{...layout}
initialValues={this.getInstanceConfigAsKvObject(config)}
onFinish={this.onConfigEditFinish.bind(this)}
>
{this.state.editingConfig ?
<Form.Item>
<Button type="primary" htmlType="submit">
Save config changes
</Button>
</Form.Item>
:
<Button onClick={() => this.setState({ editingConfig: true })}>
<EditOutlined /> Edit config
</Button>
}
<Row>
{config && config.serialized_config.groups.map(group => <>
<Col span={24} lg={12}>
<Divider
// orientation="left"
>{group.name}</Divider>
{/* {Object.keys(group.fields).map(field => <>
<span>{field}: </span>
<span>{JSON.stringify(group.fields[field])}</span>
<br />
</>)} */}
{console.log(group)}
{Object.keys(group.fields).map(field => <ConfigFormItem field={field} group={group} editingConfig={this.state.editingConfig} />)}
</Col>
</>)}
</Row>
</Form>
</Card>;
}
}
function renderDisplayField(value) {
if (typeof value === "object") {
return JSON.stringify(value);
}
if (typeof value === "boolean") {
return <Checkbox />;
}
return value.toString();
}
function renderFormField(value) {
if (typeof value === "object") {
return <ObjectInput />;
}
if (typeof value === "boolean") {
return <Checkbox />;
}
return <Input />;
}
class ObjectInput extends Component {
constructor(props) {
super(props);
console.log(this.props);
/*
value: {
auto_pause: false,
tags: ["clusterio"]
},
id: "factorio.settings",
onChange: f()
*/
this.state = {};
}
onChange(e, key) {
if (e.target.type === "checkbox") {
// if (e.target.checked !== undefined) {
// Its a checkbox, set value to true/false
let value = { ...this.props.value };
value[key] = e.target.checked || false;
this.props.onChange(value);
} else if (e.target.type === "text") {
// } else if (e.target.value !== undefined) {
// its probably a text input
let value = { ...this.props.value };
value[key] = e.target.value || "";
this.props.onChange(value);
}
}
render() {
let { value } = this.props;
let ret = [];
for (let key in value) {
if (typeof value[key] === "boolean") {
ret.push(<Checkbox
key={key}
checked={value[key]}
onChange={e => this.onChange(e, key)}
>
{key}
</Checkbox>
);
} else if (typeof value[key] === "string") {
ret.push(<div
key={key}
><span>{key}</span><Input
value={value[key]}
onChange={e => this.onChange(e, key)}
/></div>
);
}
}
ret.push(<Popover
key="addMore"
content={<>
<Input onChange={e => this.setState({ newInput: e.target.value })} />
<Button
onClick={e => {
this.onChange({ target: { value: "", type: "text" } }, this.state.newInput);
this.setState({
newInput: undefined,
addPopoverVisible: false,
});
}}
><PlusOutlined />Add property</Button>
</>}
trigger="click"
visible={this.state.addPopoverVisible}
handleVisibleChange={visible => this.setState({ addPopoverVisible: visible })}
>
<Button
onClick={e => this.setState({ addPopoverVisible: true })}
>
<PlusOutlined />Add property
</Button>
</Popover>);
return <Space direction="vertical">{ret}</Space>;
}
}
class ConfigFormItem extends Component {
render() {
let { field, group, editingConfig } = this.props;
return <Form.Item
label={field}
name={group.name + "." + field}
valuePropName={typeof group.fields[field] === "boolean" ? "checked" : "value" /* Boolean fields should be rendered as checkboxes */}
>
{editingConfig ?
renderFormField(group.fields[field])
:
renderDisplayField(group.fields[field])
}
</Form.Item>;
}
}
@@ -0,0 +1,42 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, createInstance } from "../../util/wslink";
import DataTable from "../../components/data-table";
const config = require("@clusterio/lib/config");
export class InstancesTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Instances</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
let instanceConfig = new config.InstanceConfig();
await instanceConfig.init();
console.log("Base config", instanceConfig);
instanceConfig.set("instance.name", args.name);
let serialized_config = instanceConfig.serialize();
let response = await createInstance(serialized_config);
console.log("Created instance", response);
},
}}
DataFunction={listInstances}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/instances/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
@@ -0,0 +1,26 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, listPermissions } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class PermissionsTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Permissions</h2>
<DataTable
DataFunction={listPermissions}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/permissions/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
+118
View File
@@ -0,0 +1,118 @@
import React, { Component } from "react";
import { Card, Button, Popover, Select, Transfer } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import {
listSlaves,
listInstances,
startInstance,
stopInstance,
setInstanceOutputSubscriptions,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
sendRcon,
deleteUser,
listUsers,
listRoles,
setRoles,
listPermissions,
updateRole,
} from "../../util/wslink";
import DataTable from "../../components/data-table";
import notify from "../../util/notify";
const { Option } = Select;
export class RoleView extends Component {
constructor(props) {
super(props);
this.state = {
role: {},
};
this.messagesEndRef = React.createRef();
}
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
await this.getData();
}
async getData() {
let id = this.props.match.params.id;
let roles = await listRoles();
let permissions = await listPermissions();
this.setState({
role: roles.find(x => x.id === Number(id)),
permissions,
});
}
async handleChange(targetKeys) {
this.setState({
role: {
...this.state.role,
permissions: targetKeys,
},
});
let response = await updateRole({
...this.state.role,
permissions: targetKeys,
});
console.log("Updated user roles", response);
}
render() {
console.log(this.state);
let { role, permissions } = this.state;
return <Card>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteUser({ role: role.name });
notify("Deleted role " + role.name, "success");
this.navigate("/roles");
}}
>
Delete this role permanently
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
<h2>{role.name}</h2>
<h3>Assign roles</h3>
<style>
{`
.ant-transfer-list{
width: calc( 50% - 34px );
height: 500px;
}
`}
</style>
<Transfer
titles={["Available", "Assigned"]}
dataSource={permissions}
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={role.permissions}
onChange={this.handleChange.bind(this)}
render={item => item.name}
/>
</Card>;
}
}
@@ -0,0 +1,39 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class RolesTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Roles</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
},{
dataIndex: "description",
title: "Description",
}],
insert: async args => {
let response = await createRole({name: args.name, description: args.description});
console.log("Created role",response);
},
}}
DataFunction={listRoles}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/roles/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
@@ -0,0 +1,70 @@
import React, { Component } from "react";
import { Card, Button, Input, Form } from "antd";
const layout = {
labelCol: {
span: 4,
},
wrapperCol: {
span: 16,
},
};
const tailLayout = {
wrapperCol: {
offset: 4,
span: 16,
},
};
export class Settings extends Component {
constructor(props) {
super(props);
this.state = {
role: {},
};
}
navigate(url) {
this.props.history.push(url);
}
render() {
console.log(this.state);
return <Card>
<h2>Web interface settings</h2>
<Form
{...layout}
name="basic"
initialValues={{
master_token: localStorage.getItem("master_token"),
}}
onFinish={values => {
console.log("Saving settings",values);
for (let key of Object.keys(values)){
localStorage.setItem(key, values[key].replace("https://", "wss://").replace("http://", "ws://"));
}
// Force refresh to apply changes
document.location = document.location;
}}
>
<Form.Item
label="User auth token"
name="master_token"
rules={[
{
required: true,
message: "Please input your user auth token from config-control.json",
},
]}
>
<Input />
</Form.Item>
<Form.Item {...tailLayout}>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Form>
</Card>;
}
}
@@ -0,0 +1,26 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class SlavesTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Slaves</h2>
<DataTable
DataFunction={listSlaves}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/slaves/${record.id}/view`);
},
}),
}}
/>
</Card>;
}
}
+95
View File
@@ -0,0 +1,95 @@
import React, { Component } from "react";
import { Card, Button, Popover, Select, Transfer } from "antd";
import {
deleteUser,
listUsers,
listRoles,
setRoles,
} from "../../util/wslink";
import DataTable from "../../components/data-table";
import notify from "../../util/notify";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
const { Option } = Select;
export class UserView extends Component {
constructor(props) {
super(props);
this.state = {
user: {},
};
this.messagesEndRef = React.createRef();
}
navigate(url) {
this.props.history.push(url);
}
async componentDidMount() {
await this.getData();
}
async getData() {
let name = this.props.match.params.id;
let users = await listUsers();
let roles = await listRoles();
this.setState({
user: users.find(x => x.name === name),
roles,
});
}
async handleChange(targetKeys) {
this.setState({
user: {
...this.state.user,
roles: targetKeys,
},
});
let response = await setRoles({
name: this.state.user.name,
roles: targetKeys,
});
console.log("Updated user roles",response);
};
render() {
console.log(this.state);
let { user, roles } = this.state;
return <Card>
<h2>{user.name}</h2>
<h3>Assign roles</h3>
<Transfer
titles={["Available","Assigned"]}
dataSource={roles}
showSearch
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
targetKeys={user.roles}
onChange={this.handleChange.bind(this)}
render={item => item.name}
/>
<Popover
content={
<Button
type="primary"
danger
onClick={async () => {
await deleteUser({ name: user.name });
notify("Deleted user " + user.name, "success");
this.navigate("/users");
}}
>
Delete this user from the cluster
</Button>
}
trigger="click"
>
<Button danger style={{ float: "right", fontSize: "16px" }}>
<DeleteOutlined />
</Button>
</Popover>
</Card>;
}
}
@@ -0,0 +1,36 @@
import React, { Component } from "react";
import { Card } from "antd";
import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink";
import DataTable from "../../components/data-table";
export class UsersTable extends Component {
navigate(url) {
this.props.history.push(url);
}
render() {
return <Card>
<h2>Slaves</h2>
<DataTable
AddRecord={{
fields: [{
dataIndex: "name",
title: "Name",
}],
insert: async args => {
let response = await createUser({name: args.name});
console.log("Created user",response);
},
}}
DataFunction={listUsers}
TableProps={{
onRow: (record, rowIndex) => ({
onClick: event => {
this.navigate(`/users/${record.name}/view`);
},
}),
}}
/>
</Card>;
}
}
File renamed without changes.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+10
View File
@@ -0,0 +1,10 @@
import { notification } from "antd";
export default function notify(message, type = "info", description = undefined) {
notification[type]({
message: typeof message === "string" ? message : "ERROR: See console",
description,
placement: "bottomRight",
});
if (typeof message !== "string") console.error(message);
}
+100
View File
@@ -0,0 +1,100 @@
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
/**
* Format a parsed Factorio output message
*
* Formats a parsed Factorio output from lib/factorio back into the
* text string it was parsed from.
*
* @param {Object} output - Factorio server output.
* @returns {string} origial output text.
* @private
*/
function formatOutput(output) {
let time = "";
if (output.format === "seconds") {
time = `${output.time.padStart(8)} `;
} else if (output.format === "date") {
time = `${output.time} `;
}
let info = "";
if (output.type === "log") {
info = `${output.level} ${output.file}: `;
} else if (output.type === "action") {
info = `[${output.action}] `;
}
return `${time}${info}${output.message}`;
}
/**
* Connector for control connection to master server
* @private
*/
export class ControlConnector extends libLink.WebSocketClientConnector {
constructor(url, reconnectDelay, token) {
super(url, reconnectDelay);
this._token = token;
}
register() {
console.log("SOCKET | registering control");
this.sendHandshake("register_control", {
token: this._token,
agent: "web",
version: "2.0.0-alpha",
});
}
}
/**
* Handles running the control
*
* Connects to the master server over WebSocket and sends commands to it.
* @static
*/
export class Control extends libLink.Link {
constructor(connector, controlPlugins) {
super("control", "master", connector);
libLink.attachAllMessages(this);
/**
* 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);
}
}
async instanceOutputEventHandler(message) {
let { instance_id, output } = message.data;
console.log(formatOutput(output));
if (window.instanceOutputEventHandler) {
window.instanceOutputEventHandler({instance_id, output});
}
}
async debugWsMessageEventHandler(message) {
console.log("WS", message.data.direction, message.data.content);
}
async shutdown() {
this.connector.setTimeout(30);
try {
await libLink.messages.prepareDisconnect.send(this);
} catch (err) {
if (!(err instanceof libErrors.SessionLost)) {
throw err;
}
}
await this.connector.close(1001, "Control Quit");
}
}
+116
View File
@@ -0,0 +1,116 @@
import * as errors from "@clusterio/lib/errors";
import * as link from "@clusterio/lib/link";
// Note: control is created as in bootstrap.jsx as a global.
let listSlaves = createListFunction("listSlaves");
let listInstances = createListFunction("listInstances");
let listUsers = createListFunction("listUsers", "name");
let listRoles = createListFunction("listRoles");
let listPermissions = createListFunction("listPermissions", "name");
async function startInstance({ instance_id, save = null }) {
return await link.messages.startInstance.send(control, { instance_id, save });
};
async function stopInstance({ instance_id }) {
return await link.messages.stopInstance.send(control, { instance_id });
};
async function createInstance(serialized_config) {
return await link.messages.createInstance.send(control, { serialized_config });
};
async function deleteInstance({ instance_id }) {
return await link.messages.deleteInstance.send(control, { instance_id });
};
async function assignInstance({ instance_id, slave_id }) {
return await link.messages.assignInstanceCommand.send(control, {
instance_id,
slave_id,
});
};
async function createSave({ instance_id }) {
return await link.messages.createSave.send(control, { instance_id });
};
async function getInstanceConfig({ instance_id }) {
return await link.messages.getInstanceConfig.send(control, { instance_id });
};
async function setInstanceConfigField({ instance_id, field, value }) {
return await link.messages.setInstanceConfigField.send(control, {
instance_id,
field,
value: typeof value === "object" ? JSON.stringify(value) : value,
});
};
async function setInstanceConfigProp({ instance_id, field, prop, value }) {
return await link.messages.setInstanceConfigProp.send(control, {
instance_id,
field,
prop,
value, // JSON Object
});
};
async function sendRcon({ instance_id, command }) {
return await link.messages.sendRcon.send(control, { instance_id, command });
};
async function setInstanceOutputSubscriptions({ instance_id }) {
return await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instance_id] });
};
async function createUser({ name }) {
return await link.messages.createUser.send(control, { name });
};
async function deleteUser({ name }) {
return await link.messages.deleteUser.send(control, { name });
};
async function setRoles({ name, roles }) {
return await link.messages.updateUserRoles.send(control, {
name,
roles,
});
};
async function createRole({ name, description, permissions = [] }) {
return await link.messages.createRole.send(control, { name, description, permissions });
};
async function updateRole({ id, name, description, permissions }) {
return await link.messages.updateRole.send(control, {
id, name, description, permissions,
});
};
export {
listSlaves,
listInstances,
listUsers,
createUser,
deleteUser,
listRoles,
setRoles,
createRole,
updateRole,
listPermissions,
startInstance,
stopInstance,
createInstance,
deleteInstance,
assignInstance,
createSave,
getInstanceConfig,
setInstanceConfigField,
setInstanceConfigProp,
sendRcon,
setInstanceOutputSubscriptions,
};
function createListFunction(name, key) {
return async () => (
(await link.messages[name].send(control)).list.map(addKey(key || "id"))
);
}
function addKey(key) {
return function (el) {
return {
...el,
key: el[key],
};
};
}
+71
View File
@@ -0,0 +1,71 @@
"use strict";
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = (env = {}) => ({
mode: env.production ? "production" : "development",
devtool: env.production ? "source-map" : "eval-source-map",
performance: {
maxAssetSize: 2**21,
maxEntrypointSize: 2**21,
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
}),
// Make sure ant-design icons use the ES variant
new webpack.NormalModuleReplacementPlugin(
/@ant-design\/icons\/[A-Z]/,
function(resource) {
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
}
),
],
module: {
rules: [
{
test: /node_modules.fs-extra/,
use: "null-loader",
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
sourceMap: !env.production,
},
},
],
},
{
test: /\.js$/,
resolve: {
// XXX Required due to bug in babel-runtime
// see: https://github.com/babel/babel/issues/12058
fullySpecified: false,
},
},
{
test: /\.jsx$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-react"],
},
},
},
],
},
resolve: {
extensions: [".js", ".json", ".jsx"],
fallback: {
"events": require.resolve("events/"),
"path": require.resolve("path-browserify"),
},
},
});
+29
View File
@@ -0,0 +1,29 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("./webpack.common");
module.exports = (env = {}) => merge(common(env), {
entry: "./web/index.jsx",
devServer: {
contentBase: "./static",
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "static"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "master",
shared: {
"@clusterio/lib": { singleton: true },
"ajv": {},
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
},
}),
],
});
+111
View File
@@ -0,0 +1,111 @@
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.
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.
* `--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.
#### `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.
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.
#### `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.
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.
#### `plugin list`
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.
#### `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.
See docs/configuration.md in the main repositiory for the available
configuration.
#### `config show <config-entry>`
Shows the value for a single config entry.
#### `config list`
Lists up all configuration entries with their currently configured values.
### `run`
Runs the slave.
## See Also
[The Clusterio
repository](https://github.com/clusterio/factorioClusterio)
for instructions on how to set up a cluster.
File renamed without changes.
File renamed without changes.
@@ -5,5 +5,8 @@
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"factorio_version": "0.17"
"factorio_version": "0.17",
"additional_files": [
["..", "..", "modules", "clusterio", "serialize.lua"]
]
}
+8
View File
@@ -0,0 +1,8 @@
local function send_json(channel, data)
data = game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
script.on_init(function()
send_json("mod_list", game.active_mods)
end)
@@ -0,0 +1,101 @@
local item_types = {
-- Item categories
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"deconstruction-item",
"copy-paste-tool",
"upgrade-item",
"module",
"rail-planner",
"tool",
"armor",
"mining-tool",
"repair-tool",
-- Fluids
"fluid",
}
local function is_array(t)
local count = 0
for k in pairs(t) do
if type(k) ~= "number" then
return false
end
count = count + 1
end
for i = 1, count do
if not t[i] then
return false
end
end
return count ~= 0
end
-- no game.table_to_json here :(
local function table_to_json(data)
if type(data) == "number" then
return string.format("%.17g", data)
elseif type(data) == "string" then
data = data:gsub('([\x00-\x1f\\"])', function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
return '"' .. data .. '"'
elseif data == nil then
return "null"
elseif data == true then
return "true"
elseif data == false then
return "false"
elseif type(data) == "table" then
local r
if is_array(data) then
r = {"["}
for i=1, #data do
r[#r+1] = table_to_json(data[i])
r[#r+1] = ","
end
-- overwrite last comma, empty table is an object
r[#r] = "]"
else
r = {"{"}
for k, v in pairs(data) do
r[#r+1] = table_to_json(tostring(k))
r[#r+1] = ":"
r[#r+1] = table_to_json(v)
r[#r+1] = ","
end
r[#r + (r[#r] == "," and 0 or 1)] = "}"
end
return table.concat(r)
end
end
local function send_json(channel, data)
data = table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
for _, group in ipairs(item_types) do
for _, item in pairs(data.raw[group]) do
send_json("item_export", item)
end
end
+8
View File
@@ -0,0 +1,8 @@
{
"name": "export",
"version": "0.0.0",
"title": "Icon exporter",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/factorioClusterio",
"description": "Exports item prototypes on stdout during the data stage."
}
Loaded 100 of 3880 files, more files were not shown because too many files have changed in this diff. Show more